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

Compare changes

Choose any two refs to compare.

Changed files
+5853 -3934
.air
.tangled
.zed
api
appview
cmd
genjwks
punchcardPopulate
docs
eventconsumer
cursor
jetstream
knotserver
lexicons
log
nix
rbac
spindle
workflow
+1 -1
.air/appview.toml
··· 5 5 6 6 exclude_regex = [".*_templ.go"] 7 7 include_ext = ["go", "templ", "html", "css"] 8 - exclude_dir = ["target", "atrium"] 8 + exclude_dir = ["target", "atrium", "nix"]
+4
.gitignore
··· 14 14 .DS_Store 15 15 .env 16 16 *.rdb 17 + .envrc 18 + # Created if following hacking.md 19 + genjwks.out 20 + /nix/vm-data
+12
.prettierrc.json
··· 1 + { 2 + "overrides": [ 3 + { 4 + "files": ["*.html"], 5 + "options": { 6 + "parser": "go-template" 7 + } 8 + } 9 + ], 10 + "bracketSameLine": true, 11 + "htmlWhitespaceSensitivity": "ignore" 12 + }
+2
.tangled/workflows/build.yml
··· 2 2 - event: ["push", "pull_request"] 3 3 branch: ["master"] 4 4 5 + engine: nixery 6 + 5 7 dependencies: 6 8 nixpkgs: 7 9 - go
+3 -12
.tangled/workflows/fmt.yml
··· 2 2 - event: ["push", "pull_request"] 3 3 branch: ["master"] 4 4 5 - dependencies: 6 - nixpkgs: 7 - - go 8 - - alejandra 5 + engine: nixery 9 6 10 7 steps: 11 - - name: "nix fmt" 8 + - name: "Check formatting" 12 9 command: | 13 - alejandra -c nix/**/*.nix flake.nix 14 - 15 - - name: "go fmt" 16 - command: | 17 - unformatted=$(gofmt -l .) 18 - test -z "$unformatted" || (echo "$unformatted" && exit 1) 19 - 10 + nix run .#fmt -- --ci
+2
.tangled/workflows/test.yml
··· 2 2 - event: ["push", "pull_request"] 3 3 branch: ["master"] 4 4 5 + engine: nixery 6 + 5 7 dependencies: 6 8 nixpkgs: 7 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 - }
+287 -722
api/tangled/cbor_gen.go
··· 2728 2728 2729 2729 return nil 2730 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 2731 func (t *Pipeline_ManualTriggerData) MarshalCBOR(w io.Writer) error { 2905 2732 if t == nil { 2906 2733 _, err := w.Write(cbg.CborNull) ··· 3916 3743 3917 3744 return nil 3918 3745 } 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 3746 func (t *Pipeline_TriggerMetadata) MarshalCBOR(w io.Writer) error { 4137 3747 if t == nil { 4138 3748 _, err := w.Write(cbg.CborNull) ··· 4609 4219 4610 4220 cw := cbg.NewCborWriter(w) 4611 4221 4612 - if _, err := cw.Write([]byte{165}); err != nil { 4222 + if _, err := cw.Write([]byte{164}); err != nil { 4223 + return err 4224 + } 4225 + 4226 + // t.Raw (string) (string) 4227 + if len("raw") > 1000000 { 4228 + return xerrors.Errorf("Value in field \"raw\" was too long") 4229 + } 4230 + 4231 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("raw"))); err != nil { 4232 + return err 4233 + } 4234 + if _, err := cw.WriteString(string("raw")); err != nil { 4235 + return err 4236 + } 4237 + 4238 + if len(t.Raw) > 1000000 { 4239 + return xerrors.Errorf("Value in field t.Raw was too long") 4240 + } 4241 + 4242 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Raw))); err != nil { 4243 + return err 4244 + } 4245 + if _, err := cw.WriteString(string(t.Raw)); err != nil { 4613 4246 return err 4614 4247 } 4615 4248 ··· 4652 4285 return err 4653 4286 } 4654 4287 4655 - // t.Steps ([]*tangled.Pipeline_Step) (slice) 4656 - if len("steps") > 1000000 { 4657 - return xerrors.Errorf("Value in field \"steps\" was too long") 4288 + // t.Engine (string) (string) 4289 + if len("engine") > 1000000 { 4290 + return xerrors.Errorf("Value in field \"engine\" was too long") 4658 4291 } 4659 4292 4660 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("steps"))); err != nil { 4293 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("engine"))); err != nil { 4661 4294 return err 4662 4295 } 4663 - if _, err := cw.WriteString(string("steps")); err != nil { 4296 + if _, err := cw.WriteString(string("engine")); err != nil { 4664 4297 return err 4665 4298 } 4666 4299 4667 - if len(t.Steps) > 8192 { 4668 - return xerrors.Errorf("Slice value in field t.Steps was too long") 4300 + if len(t.Engine) > 1000000 { 4301 + return xerrors.Errorf("Value in field t.Engine was too long") 4669 4302 } 4670 4303 4671 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Steps))); err != nil { 4304 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Engine))); err != nil { 4672 4305 return err 4673 4306 } 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 { 4307 + if _, err := cw.WriteString(string(t.Engine)); err != nil { 4690 4308 return err 4691 4309 } 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 4310 return nil 4733 4311 } 4734 4312 ··· 4757 4335 4758 4336 n := extra 4759 4337 4760 - nameBuf := make([]byte, 12) 4338 + nameBuf := make([]byte, 6) 4761 4339 for i := uint64(0); i < n; i++ { 4762 4340 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 4763 4341 if err != nil { ··· 4773 4351 } 4774 4352 4775 4353 switch string(nameBuf[:nameLen]) { 4776 - // t.Name (string) (string) 4354 + // t.Raw (string) (string) 4355 + case "raw": 4356 + 4357 + { 4358 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4359 + if err != nil { 4360 + return err 4361 + } 4362 + 4363 + t.Raw = string(sval) 4364 + } 4365 + // t.Name (string) (string) 4777 4366 case "name": 4778 4367 4779 4368 { ··· 4804 4393 } 4805 4394 4806 4395 } 4807 - // t.Steps ([]*tangled.Pipeline_Step) (slice) 4808 - case "steps": 4396 + // t.Engine (string) (string) 4397 + case "engine": 4809 4398 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 - 4399 + { 4400 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4401 + if err != nil { 4402 + return err 4903 4403 } 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 4404 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 - } 4405 + t.Engine = string(sval) 4953 4406 } 4954 4407 4955 4408 default: ··· 6059 5512 } 6060 5513 6061 5514 cw := cbg.NewCborWriter(w) 6062 - fieldCount := 7 5515 + fieldCount := 6 6063 5516 6064 5517 if t.Body == nil { 6065 5518 fieldCount-- ··· 6189 5642 return err 6190 5643 } 6191 5644 6192 - // t.IssueId (int64) (int64) 6193 - if len("issueId") > 1000000 { 6194 - return xerrors.Errorf("Value in field \"issueId\" was too long") 6195 - } 6196 - 6197 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("issueId"))); err != nil { 6198 - return err 6199 - } 6200 - if _, err := cw.WriteString(string("issueId")); err != nil { 6201 - return err 6202 - } 6203 - 6204 - if t.IssueId >= 0 { 6205 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.IssueId)); err != nil { 6206 - return err 6207 - } 6208 - } else { 6209 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.IssueId-1)); err != nil { 6210 - return err 6211 - } 6212 - } 6213 - 6214 5645 // t.CreatedAt (string) (string) 6215 5646 if len("createdAt") > 1000000 { 6216 5647 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 6342 5773 6343 5774 t.Title = string(sval) 6344 5775 } 6345 - // t.IssueId (int64) (int64) 6346 - case "issueId": 6347 - { 6348 - maj, extra, err := cr.ReadHeader() 6349 - if err != nil { 6350 - return err 6351 - } 6352 - var extraI int64 6353 - switch maj { 6354 - case cbg.MajUnsignedInt: 6355 - extraI = int64(extra) 6356 - if extraI < 0 { 6357 - return fmt.Errorf("int64 positive overflow") 6358 - } 6359 - case cbg.MajNegativeInt: 6360 - extraI = int64(extra) 6361 - if extraI < 0 { 6362 - return fmt.Errorf("int64 negative overflow") 6363 - } 6364 - extraI = -1 - extraI 6365 - default: 6366 - return fmt.Errorf("wrong type for int64 field: %d", maj) 6367 - } 6368 - 6369 - t.IssueId = int64(extraI) 6370 - } 6371 5776 // t.CreatedAt (string) (string) 6372 5777 case "createdAt": 6373 5778 ··· 6397 5802 } 6398 5803 6399 5804 cw := cbg.NewCborWriter(w) 6400 - fieldCount := 7 6401 - 6402 - if t.CommentId == nil { 6403 - fieldCount-- 6404 - } 5805 + fieldCount := 6 6405 5806 6406 5807 if t.Owner == nil { 6407 5808 fieldCount-- ··· 6544 5945 } 6545 5946 } 6546 5947 6547 - // t.CommentId (int64) (int64) 6548 - if t.CommentId != nil { 6549 - 6550 - if len("commentId") > 1000000 { 6551 - return xerrors.Errorf("Value in field \"commentId\" was too long") 6552 - } 6553 - 6554 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commentId"))); err != nil { 6555 - return err 6556 - } 6557 - if _, err := cw.WriteString(string("commentId")); err != nil { 6558 - return err 6559 - } 6560 - 6561 - if t.CommentId == nil { 6562 - if _, err := cw.Write(cbg.CborNull); err != nil { 6563 - return err 6564 - } 6565 - } else { 6566 - if *t.CommentId >= 0 { 6567 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.CommentId)); err != nil { 6568 - return err 6569 - } 6570 - } else { 6571 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.CommentId-1)); err != nil { 6572 - return err 6573 - } 6574 - } 6575 - } 6576 - 6577 - } 6578 - 6579 5948 // t.CreatedAt (string) (string) 6580 5949 if len("createdAt") > 1000000 { 6581 5950 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 6717 6086 t.Owner = (*string)(&sval) 6718 6087 } 6719 6088 } 6720 - // t.CommentId (int64) (int64) 6721 - case "commentId": 6722 - { 6723 - 6724 - b, err := cr.ReadByte() 6725 - if err != nil { 6726 - return err 6727 - } 6728 - if b != cbg.CborNull[0] { 6729 - if err := cr.UnreadByte(); err != nil { 6730 - return err 6731 - } 6732 - maj, extra, err := cr.ReadHeader() 6733 - if err != nil { 6734 - return err 6735 - } 6736 - var extraI int64 6737 - switch maj { 6738 - case cbg.MajUnsignedInt: 6739 - extraI = int64(extra) 6740 - if extraI < 0 { 6741 - return fmt.Errorf("int64 positive overflow") 6742 - } 6743 - case cbg.MajNegativeInt: 6744 - extraI = int64(extra) 6745 - if extraI < 0 { 6746 - return fmt.Errorf("int64 negative overflow") 6747 - } 6748 - extraI = -1 - extraI 6749 - default: 6750 - return fmt.Errorf("wrong type for int64 field: %d", maj) 6751 - } 6752 - 6753 - t.CommentId = (*int64)(&extraI) 6754 - } 6755 - } 6756 6089 // t.CreatedAt (string) (string) 6757 6090 case "createdAt": 6758 6091 ··· 8423 7756 8424 7757 return nil 8425 7758 } 7759 + func (t *String) MarshalCBOR(w io.Writer) error { 7760 + if t == nil { 7761 + _, err := w.Write(cbg.CborNull) 7762 + return err 7763 + } 7764 + 7765 + cw := cbg.NewCborWriter(w) 7766 + 7767 + if _, err := cw.Write([]byte{165}); err != nil { 7768 + return err 7769 + } 7770 + 7771 + // t.LexiconTypeID (string) (string) 7772 + if len("$type") > 1000000 { 7773 + return xerrors.Errorf("Value in field \"$type\" was too long") 7774 + } 7775 + 7776 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 7777 + return err 7778 + } 7779 + if _, err := cw.WriteString(string("$type")); err != nil { 7780 + return err 7781 + } 7782 + 7783 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.string"))); err != nil { 7784 + return err 7785 + } 7786 + if _, err := cw.WriteString(string("sh.tangled.string")); err != nil { 7787 + return err 7788 + } 7789 + 7790 + // t.Contents (string) (string) 7791 + if len("contents") > 1000000 { 7792 + return xerrors.Errorf("Value in field \"contents\" was too long") 7793 + } 7794 + 7795 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("contents"))); err != nil { 7796 + return err 7797 + } 7798 + if _, err := cw.WriteString(string("contents")); err != nil { 7799 + return err 7800 + } 7801 + 7802 + if len(t.Contents) > 1000000 { 7803 + return xerrors.Errorf("Value in field t.Contents was too long") 7804 + } 7805 + 7806 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Contents))); err != nil { 7807 + return err 7808 + } 7809 + if _, err := cw.WriteString(string(t.Contents)); err != nil { 7810 + return err 7811 + } 7812 + 7813 + // t.Filename (string) (string) 7814 + if len("filename") > 1000000 { 7815 + return xerrors.Errorf("Value in field \"filename\" was too long") 7816 + } 7817 + 7818 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("filename"))); err != nil { 7819 + return err 7820 + } 7821 + if _, err := cw.WriteString(string("filename")); err != nil { 7822 + return err 7823 + } 7824 + 7825 + if len(t.Filename) > 1000000 { 7826 + return xerrors.Errorf("Value in field t.Filename was too long") 7827 + } 7828 + 7829 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Filename))); err != nil { 7830 + return err 7831 + } 7832 + if _, err := cw.WriteString(string(t.Filename)); err != nil { 7833 + return err 7834 + } 7835 + 7836 + // t.CreatedAt (string) (string) 7837 + if len("createdAt") > 1000000 { 7838 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 7839 + } 7840 + 7841 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 7842 + return err 7843 + } 7844 + if _, err := cw.WriteString(string("createdAt")); err != nil { 7845 + return err 7846 + } 7847 + 7848 + if len(t.CreatedAt) > 1000000 { 7849 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 7850 + } 7851 + 7852 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 7853 + return err 7854 + } 7855 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 7856 + return err 7857 + } 7858 + 7859 + // t.Description (string) (string) 7860 + if len("description") > 1000000 { 7861 + return xerrors.Errorf("Value in field \"description\" was too long") 7862 + } 7863 + 7864 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil { 7865 + return err 7866 + } 7867 + if _, err := cw.WriteString(string("description")); err != nil { 7868 + return err 7869 + } 7870 + 7871 + if len(t.Description) > 1000000 { 7872 + return xerrors.Errorf("Value in field t.Description was too long") 7873 + } 7874 + 7875 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Description))); err != nil { 7876 + return err 7877 + } 7878 + if _, err := cw.WriteString(string(t.Description)); err != nil { 7879 + return err 7880 + } 7881 + return nil 7882 + } 7883 + 7884 + func (t *String) UnmarshalCBOR(r io.Reader) (err error) { 7885 + *t = String{} 7886 + 7887 + cr := cbg.NewCborReader(r) 7888 + 7889 + maj, extra, err := cr.ReadHeader() 7890 + if err != nil { 7891 + return err 7892 + } 7893 + defer func() { 7894 + if err == io.EOF { 7895 + err = io.ErrUnexpectedEOF 7896 + } 7897 + }() 7898 + 7899 + if maj != cbg.MajMap { 7900 + return fmt.Errorf("cbor input should be of type map") 7901 + } 7902 + 7903 + if extra > cbg.MaxLength { 7904 + return fmt.Errorf("String: map struct too large (%d)", extra) 7905 + } 7906 + 7907 + n := extra 7908 + 7909 + nameBuf := make([]byte, 11) 7910 + for i := uint64(0); i < n; i++ { 7911 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7912 + if err != nil { 7913 + return err 7914 + } 7915 + 7916 + if !ok { 7917 + // Field doesn't exist on this type, so ignore it 7918 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 7919 + return err 7920 + } 7921 + continue 7922 + } 7923 + 7924 + switch string(nameBuf[:nameLen]) { 7925 + // t.LexiconTypeID (string) (string) 7926 + case "$type": 7927 + 7928 + { 7929 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7930 + if err != nil { 7931 + return err 7932 + } 7933 + 7934 + t.LexiconTypeID = string(sval) 7935 + } 7936 + // t.Contents (string) (string) 7937 + case "contents": 7938 + 7939 + { 7940 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7941 + if err != nil { 7942 + return err 7943 + } 7944 + 7945 + t.Contents = string(sval) 7946 + } 7947 + // t.Filename (string) (string) 7948 + case "filename": 7949 + 7950 + { 7951 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7952 + if err != nil { 7953 + return err 7954 + } 7955 + 7956 + t.Filename = string(sval) 7957 + } 7958 + // t.CreatedAt (string) (string) 7959 + case "createdAt": 7960 + 7961 + { 7962 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7963 + if err != nil { 7964 + return err 7965 + } 7966 + 7967 + t.CreatedAt = string(sval) 7968 + } 7969 + // t.Description (string) (string) 7970 + case "description": 7971 + 7972 + { 7973 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7974 + if err != nil { 7975 + return err 7976 + } 7977 + 7978 + t.Description = string(sval) 7979 + } 7980 + 7981 + default: 7982 + // Field doesn't exist on this type, so ignore it 7983 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 7984 + return err 7985 + } 7986 + } 7987 + } 7988 + 7989 + return nil 7990 + }
-1
api/tangled/issuecomment.go
··· 19 19 type RepoIssueComment struct { 20 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"` 21 21 Body string `json:"body" cborgen:"body"` 22 - CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"` 23 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 24 23 Issue string `json:"issue" cborgen:"issue"` 25 24 Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"`
-1
api/tangled/repoissue.go
··· 20 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"` 21 21 Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 - IssueId int64 `json:"issueId" cborgen:"issueId"` 24 23 Owner string `json:"owner" cborgen:"owner"` 25 24 Repo string `json:"repo" cborgen:"repo"` 26 25 Title string `json:"title" cborgen:"title"`
+4 -18
api/tangled/tangledpipeline.go
··· 29 29 Submodules bool `json:"submodules" cborgen:"submodules"` 30 30 } 31 31 32 - // Pipeline_Dependency is a "dependency" in the sh.tangled.pipeline schema. 33 - type Pipeline_Dependency struct { 34 - Packages []string `json:"packages" cborgen:"packages"` 35 - Registry string `json:"registry" cborgen:"registry"` 36 - } 37 - 38 32 // Pipeline_ManualTriggerData is a "manualTriggerData" in the sh.tangled.pipeline schema. 39 33 type Pipeline_ManualTriggerData struct { 40 34 Inputs []*Pipeline_Pair `json:"inputs,omitempty" cborgen:"inputs,omitempty"` ··· 61 55 Ref string `json:"ref" cborgen:"ref"` 62 56 } 63 57 64 - // Pipeline_Step is a "step" in the sh.tangled.pipeline schema. 65 - type Pipeline_Step struct { 66 - Command string `json:"command" cborgen:"command"` 67 - Environment []*Pipeline_Pair `json:"environment,omitempty" cborgen:"environment,omitempty"` 68 - Name string `json:"name" cborgen:"name"` 69 - } 70 - 71 58 // Pipeline_TriggerMetadata is a "triggerMetadata" in the sh.tangled.pipeline schema. 72 59 type Pipeline_TriggerMetadata struct { 73 60 Kind string `json:"kind" cborgen:"kind"` ··· 87 74 88 75 // Pipeline_Workflow is a "workflow" in the sh.tangled.pipeline schema. 89 76 type Pipeline_Workflow struct { 90 - Clone *Pipeline_CloneOpts `json:"clone" cborgen:"clone"` 91 - Dependencies []*Pipeline_Dependency `json:"dependencies" cborgen:"dependencies"` 92 - Environment []*Pipeline_Pair `json:"environment" cborgen:"environment"` 93 - Name string `json:"name" cborgen:"name"` 94 - Steps []*Pipeline_Step `json:"steps" cborgen:"steps"` 77 + Clone *Pipeline_CloneOpts `json:"clone" cborgen:"clone"` 78 + Engine string `json:"engine" cborgen:"engine"` 79 + Name string `json:"name" cborgen:"name"` 80 + Raw string `json:"raw" cborgen:"raw"` 95 81 }
+25
api/tangled/tangledstring.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.string 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + StringNSID = "sh.tangled.string" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.string", &String{}) 17 + } // 18 + // RECORDTYPE: String 19 + type String struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.string" cborgen:"$type,const=sh.tangled.string"` 21 + Contents string `json:"contents" cborgen:"contents"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Description string `json:"description" cborgen:"description"` 24 + Filename string `json:"filename" cborgen:"filename"` 25 + }
+1
appview/cache/session/store.go
··· 31 31 PkceVerifier string 32 32 DpopAuthserverNonce string 33 33 DpopPrivateJwk string 34 + ReturnUrl string 34 35 } 35 36 36 37 type SessionStore struct {
+3
appview/config/config.go
··· 16 16 AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"` 17 17 Dev bool `env:"DEV, default=false"` 18 18 DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` 19 + 20 + // temporarily, to add users to default spindle 21 + AppPassword string `env:"APP_PASSWORD"` 19 22 } 20 23 21 24 type OAuthConfig struct {
+1 -1
appview/db/collaborators.go
··· 50 50 } 51 51 52 52 func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) { 53 - rows, err := e.Query(`select repo_at from collaborators where did = ?`, collaborator) 53 + rows, err := e.Query(`select repo_at from collaborators where subject_did = ?`, collaborator) 54 54 if err != nil { 55 55 return nil, err 56 56 }
+64 -26
appview/db/db.go
··· 27 27 } 28 28 29 29 func Make(dbPath string) (*DB, error) { 30 - db, err := sql.Open("sqlite3", dbPath) 30 + // https://github.com/mattn/go-sqlite3#connection-string 31 + opts := []string{ 32 + "_foreign_keys=1", 33 + "_journal_mode=WAL", 34 + "_synchronous=NORMAL", 35 + "_auto_vacuum=incremental", 36 + } 37 + 38 + db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 31 39 if err != nil { 32 40 return nil, err 33 41 } 34 - _, err = db.Exec(` 35 - pragma journal_mode = WAL; 36 - pragma synchronous = normal; 37 - pragma foreign_keys = on; 38 - pragma temp_store = memory; 39 - pragma mmap_size = 30000000000; 40 - pragma page_size = 32768; 41 - pragma auto_vacuum = incremental; 42 - pragma busy_timeout = 5000; 42 + 43 + ctx := context.Background() 43 44 45 + conn, err := db.Conn(ctx) 46 + if err != nil { 47 + return nil, err 48 + } 49 + defer conn.Close() 50 + 51 + _, err = conn.ExecContext(ctx, ` 44 52 create table if not exists registrations ( 45 53 id integer primary key autoincrement, 46 54 domain text not null unique, ··· 443 451 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) 444 452 ); 445 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 + 446 469 create table if not exists migrations ( 447 470 id integer primary key autoincrement, 448 471 name text unique 449 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); 450 477 `) 451 478 if err != nil { 452 479 return nil, err 453 480 } 454 481 455 482 // run migrations 456 - runMigration(db, "add-description-to-repos", func(tx *sql.Tx) error { 483 + runMigration(conn, "add-description-to-repos", func(tx *sql.Tx) error { 457 484 tx.Exec(` 458 485 alter table repos add column description text check (length(description) <= 200); 459 486 `) 460 487 return nil 461 488 }) 462 489 463 - runMigration(db, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 490 + runMigration(conn, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 464 491 // add unconstrained column 465 492 _, err := tx.Exec(` 466 493 alter table public_keys ··· 483 510 return nil 484 511 }) 485 512 486 - runMigration(db, "add-rkey-to-comments", func(tx *sql.Tx) error { 513 + runMigration(conn, "add-rkey-to-comments", func(tx *sql.Tx) error { 487 514 _, err := tx.Exec(` 488 515 alter table comments drop column comment_at; 489 516 alter table comments add column rkey text; ··· 491 518 return err 492 519 }) 493 520 494 - runMigration(db, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 521 + runMigration(conn, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 495 522 _, err := tx.Exec(` 496 523 alter table comments add column deleted text; -- timestamp 497 524 alter table comments add column edited text; -- timestamp ··· 499 526 return err 500 527 }) 501 528 502 - runMigration(db, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 529 + runMigration(conn, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 503 530 _, err := tx.Exec(` 504 531 alter table pulls add column source_branch text; 505 532 alter table pulls add column source_repo_at text; ··· 508 535 return err 509 536 }) 510 537 511 - runMigration(db, "add-source-to-repos", func(tx *sql.Tx) error { 538 + runMigration(conn, "add-source-to-repos", func(tx *sql.Tx) error { 512 539 _, err := tx.Exec(` 513 540 alter table repos add column source text; 514 541 `) ··· 519 546 // NOTE: this cannot be done in a transaction, so it is run outside [0] 520 547 // 521 548 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys 522 - db.Exec("pragma foreign_keys = off;") 523 - runMigration(db, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 549 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 550 + runMigration(conn, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 524 551 _, err := tx.Exec(` 525 552 create table pulls_new ( 526 553 -- identifiers ··· 575 602 `) 576 603 return err 577 604 }) 578 - db.Exec("pragma foreign_keys = on;") 605 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 579 606 580 607 // run migrations 581 - runMigration(db, "add-spindle-to-repos", func(tx *sql.Tx) error { 608 + runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error { 582 609 tx.Exec(` 583 610 alter table repos add column spindle text; 584 611 `) ··· 586 613 }) 587 614 588 615 // recreate and add rkey + created columns with default constraint 589 - runMigration(db, "rework-collaborators-table", func(tx *sql.Tx) error { 616 + runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error { 590 617 // create new table 591 618 // - repo_at instead of repo integer 592 619 // - rkey field ··· 603 630 repo_at text not null, 604 631 605 632 -- meta 606 - created text default (strftime('%y-%m-%dt%h:%m:%sz', 'now')), 633 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 607 634 608 635 -- constraints 609 636 foreign key (repo_at) references repos(at_uri) on delete cascade ··· 640 667 return err 641 668 }) 642 669 670 + runMigration(conn, "add-rkey-to-issues", func(tx *sql.Tx) error { 671 + _, err := tx.Exec(` 672 + alter table issues add column rkey text not null default ''; 673 + 674 + -- get last url section from issue_at and save to rkey column 675 + update issues 676 + set rkey = replace(issue_at, rtrim(issue_at, replace(issue_at, '/', '')), ''); 677 + `) 678 + return err 679 + }) 680 + 643 681 return &DB{db}, nil 644 682 } 645 683 646 684 type migrationFn = func(*sql.Tx) error 647 685 648 - func runMigration(d *sql.DB, name string, migrationFn migrationFn) error { 649 - tx, err := d.Begin() 686 + func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error { 687 + tx, err := c.BeginTx(context.Background(), nil) 650 688 if err != nil { 651 689 return err 652 690 } ··· 713 751 kind := rv.Kind() 714 752 715 753 // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)` 716 - if kind == reflect.Slice || kind == reflect.Array { 754 + if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 717 755 if rv.Len() == 0 { 718 756 // always false 719 757 return "1 = 0" ··· 733 771 func (f filter) Arg() []any { 734 772 rv := reflect.ValueOf(f.arg) 735 773 kind := rv.Kind() 736 - if kind == reflect.Slice || kind == reflect.Array { 774 + if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 737 775 if rv.Len() == 0 { 738 776 return nil 739 777 }
+1 -1
appview/db/follow.go
··· 53 53 return err 54 54 } 55 55 56 - func GetFollowerFollowing(e Execer, did string) (int, int, error) { 56 + func GetFollowerFollowingCount(e Execer, did string) (int, int, error) { 57 57 followers, following := 0, 0 58 58 err := e.QueryRow( 59 59 `SELECT
+208 -17
appview/db/issues.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "fmt" 6 + mathrand "math/rand/v2" 7 + "strings" 5 8 "time" 6 9 7 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 + "tangled.sh/tangled.sh/core/api/tangled" 8 12 "tangled.sh/tangled.sh/core/appview/pagination" 9 13 ) 10 14 ··· 13 17 RepoAt syntax.ATURI 14 18 OwnerDid string 15 19 IssueId int 16 - IssueAt string 20 + Rkey string 17 21 Created time.Time 18 22 Title string 19 23 Body string ··· 42 46 Edited *time.Time 43 47 } 44 48 49 + func (i *Issue) AtUri() syntax.ATURI { 50 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.OwnerDid, tangled.RepoIssueNSID, i.Rkey)) 51 + } 52 + 53 + func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { 54 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 55 + if err != nil { 56 + created = time.Now() 57 + } 58 + 59 + body := "" 60 + if record.Body != nil { 61 + body = *record.Body 62 + } 63 + 64 + return Issue{ 65 + RepoAt: syntax.ATURI(record.Repo), 66 + OwnerDid: record.Owner, 67 + Rkey: rkey, 68 + Created: created, 69 + Title: record.Title, 70 + Body: body, 71 + Open: true, // new issues are open by default 72 + } 73 + } 74 + 75 + func ResolveIssueFromAtUri(e Execer, issueUri syntax.ATURI) (syntax.ATURI, int, error) { 76 + ownerDid := issueUri.Authority().String() 77 + issueRkey := issueUri.RecordKey().String() 78 + 79 + var repoAt string 80 + var issueId int 81 + 82 + query := `select repo_at, issue_id from issues where owner_did = ? and rkey = ?` 83 + err := e.QueryRow(query, ownerDid, issueRkey).Scan(&repoAt, &issueId) 84 + if err != nil { 85 + return "", 0, err 86 + } 87 + 88 + return syntax.ATURI(repoAt), issueId, nil 89 + } 90 + 91 + func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (Comment, error) { 92 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 93 + if err != nil { 94 + created = time.Now() 95 + } 96 + 97 + ownerDid := did 98 + if record.Owner != nil { 99 + ownerDid = *record.Owner 100 + } 101 + 102 + issueUri, err := syntax.ParseATURI(record.Issue) 103 + if err != nil { 104 + return Comment{}, err 105 + } 106 + 107 + repoAt, issueId, err := ResolveIssueFromAtUri(e, issueUri) 108 + if err != nil { 109 + return Comment{}, err 110 + } 111 + 112 + comment := Comment{ 113 + OwnerDid: ownerDid, 114 + RepoAt: repoAt, 115 + Rkey: rkey, 116 + Body: record.Body, 117 + Issue: issueId, 118 + CommentId: mathrand.IntN(1000000), 119 + Created: &created, 120 + } 121 + 122 + return comment, nil 123 + } 124 + 45 125 func NewIssue(tx *sql.Tx, issue *Issue) error { 46 126 defer tx.Rollback() 47 127 ··· 67 147 issue.IssueId = nextId 68 148 69 149 res, err := tx.Exec(` 70 - insert into issues (repo_at, owner_did, issue_id, title, body) 71 - values (?, ?, ?, ?, ?) 72 - `, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body) 150 + insert into issues (repo_at, owner_did, rkey, issue_at, issue_id, title, body) 151 + values (?, ?, ?, ?, ?, ?, ?) 152 + `, issue.RepoAt, issue.OwnerDid, issue.Rkey, issue.AtUri(), issue.IssueId, issue.Title, issue.Body) 73 153 if err != nil { 74 154 return err 75 155 } ··· 87 167 return nil 88 168 } 89 169 90 - func SetIssueAt(e Execer, repoAt syntax.ATURI, issueId int, issueAt string) error { 91 - _, err := e.Exec(`update issues set issue_at = ? where repo_at = ? and issue_id = ?`, issueAt, repoAt, issueId) 92 - return err 93 - } 94 - 95 170 func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 96 171 var issueAt string 97 172 err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt) ··· 104 179 return ownerDid, err 105 180 } 106 181 107 - func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { 182 + func GetIssuesPaginated(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { 108 183 var issues []Issue 109 184 openValue := 0 110 185 if isOpen { ··· 117 192 select 118 193 i.id, 119 194 i.owner_did, 195 + i.rkey, 120 196 i.issue_id, 121 197 i.created, 122 198 i.title, ··· 136 212 select 137 213 id, 138 214 owner_did, 215 + rkey, 139 216 issue_id, 140 217 created, 141 218 title, 142 219 body, 143 220 open, 144 221 comment_count 145 - from 222 + from 146 223 numbered_issue 147 - where 224 + where 148 225 row_num between ? and ?`, 149 226 repoAt, openValue, page.Offset+1, page.Offset+page.Limit) 150 227 if err != nil { ··· 156 233 var issue Issue 157 234 var createdAt string 158 235 var metadata IssueMetadata 159 - err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 236 + err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 160 237 if err != nil { 161 238 return nil, err 162 239 } ··· 178 255 return issues, nil 179 256 } 180 257 258 + func GetIssuesWithLimit(e Execer, limit int, filters ...filter) ([]Issue, error) { 259 + issues := make([]Issue, 0, limit) 260 + 261 + var conditions []string 262 + var args []any 263 + for _, filter := range filters { 264 + conditions = append(conditions, filter.Condition()) 265 + args = append(args, filter.Arg()...) 266 + } 267 + 268 + whereClause := "" 269 + if conditions != nil { 270 + whereClause = " where " + strings.Join(conditions, " and ") 271 + } 272 + limitClause := "" 273 + if limit != 0 { 274 + limitClause = fmt.Sprintf(" limit %d ", limit) 275 + } 276 + 277 + query := fmt.Sprintf( 278 + `select 279 + i.id, 280 + i.owner_did, 281 + i.repo_at, 282 + i.issue_id, 283 + i.created, 284 + i.title, 285 + i.body, 286 + i.open 287 + from 288 + issues i 289 + %s 290 + order by 291 + i.created desc 292 + %s`, 293 + whereClause, limitClause) 294 + 295 + rows, err := e.Query(query, args...) 296 + if err != nil { 297 + return nil, err 298 + } 299 + defer rows.Close() 300 + 301 + for rows.Next() { 302 + var issue Issue 303 + var issueCreatedAt string 304 + err := rows.Scan( 305 + &issue.ID, 306 + &issue.OwnerDid, 307 + &issue.RepoAt, 308 + &issue.IssueId, 309 + &issueCreatedAt, 310 + &issue.Title, 311 + &issue.Body, 312 + &issue.Open, 313 + ) 314 + if err != nil { 315 + return nil, err 316 + } 317 + 318 + issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt) 319 + if err != nil { 320 + return nil, err 321 + } 322 + issue.Created = issueCreatedTime 323 + 324 + issues = append(issues, issue) 325 + } 326 + 327 + if err := rows.Err(); err != nil { 328 + return nil, err 329 + } 330 + 331 + return issues, nil 332 + } 333 + 334 + func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 335 + return GetIssuesWithLimit(e, 0, filters...) 336 + } 337 + 181 338 // timeframe here is directly passed into the sql query filter, and any 182 339 // timeframe in the past should be negative; e.g.: "-3 months" 183 340 func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) { ··· 187 344 `select 188 345 i.id, 189 346 i.owner_did, 347 + i.rkey, 190 348 i.repo_at, 191 349 i.issue_id, 192 350 i.created, ··· 219 377 err := rows.Scan( 220 378 &issue.ID, 221 379 &issue.OwnerDid, 380 + &issue.Rkey, 222 381 &issue.RepoAt, 223 382 &issue.IssueId, 224 383 &issueCreatedAt, ··· 262 421 } 263 422 264 423 func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { 265 - query := `select id, owner_did, created, title, body, open from issues where repo_at = ? and issue_id = ?` 424 + query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 266 425 row := e.QueryRow(query, repoAt, issueId) 267 426 268 427 var issue Issue 269 428 var createdAt string 270 - err := row.Scan(&issue.ID, &issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open) 429 + err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 271 430 if err != nil { 272 431 return nil, err 273 432 } ··· 282 441 } 283 442 284 443 func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) { 285 - query := `select id, owner_did, issue_id, created, title, body, open, issue_at from issues where repo_at = ? and issue_id = ?` 444 + query := `select id, owner_did, rkey, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?` 286 445 row := e.QueryRow(query, repoAt, issueId) 287 446 288 447 var issue Issue 289 448 var createdAt string 290 - err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &issue.IssueAt) 449 + err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open) 291 450 if err != nil { 292 451 return nil, nil, err 293 452 } ··· 464 623 deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 465 624 where repo_at = ? and issue_id = ? and comment_id = ? 466 625 `, repoAt, issueId, commentId) 626 + return err 627 + } 628 + 629 + func UpdateCommentByRkey(e Execer, ownerDid, rkey, newBody string) error { 630 + _, err := e.Exec( 631 + ` 632 + update comments 633 + set body = ?, 634 + edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 635 + where owner_did = ? and rkey = ? 636 + `, newBody, ownerDid, rkey) 637 + return err 638 + } 639 + 640 + func DeleteCommentByRkey(e Execer, ownerDid, rkey string) error { 641 + _, err := e.Exec( 642 + ` 643 + update comments 644 + set body = "", 645 + deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 646 + where owner_did = ? and rkey = ? 647 + `, ownerDid, rkey) 648 + return err 649 + } 650 + 651 + func UpdateIssueByRkey(e Execer, ownerDid, rkey, title, body string) error { 652 + _, err := e.Exec(`update issues set title = ?, body = ? where owner_did = ? and rkey = ?`, title, body, ownerDid, rkey) 653 + return err 654 + } 655 + 656 + func DeleteIssueByRkey(e Execer, ownerDid, rkey string) error { 657 + _, err := e.Exec(`delete from issues where owner_did = ? and rkey = ?`, ownerDid, rkey) 467 658 return err 468 659 } 469 660
-62
appview/db/migrations/20250305_113405.sql
··· 1 - -- Simplified SQLite Database Migration Script for Issues and Comments 2 - 3 - -- Migration for issues table 4 - CREATE TABLE issues_new ( 5 - id integer primary key autoincrement, 6 - owner_did text not null, 7 - repo_at text not null, 8 - issue_id integer not null, 9 - title text not null, 10 - body text not null, 11 - open integer not null default 1, 12 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 13 - issue_at text, 14 - unique(repo_at, issue_id), 15 - foreign key (repo_at) references repos(at_uri) on delete cascade 16 - ); 17 - 18 - -- Migrate data to new issues table 19 - INSERT INTO issues_new ( 20 - id, owner_did, repo_at, issue_id, 21 - title, body, open, created, issue_at 22 - ) 23 - SELECT 24 - id, owner_did, repo_at, issue_id, 25 - title, body, open, created, issue_at 26 - FROM issues; 27 - 28 - -- Drop old issues table 29 - DROP TABLE issues; 30 - 31 - -- Rename new issues table 32 - ALTER TABLE issues_new RENAME TO issues; 33 - 34 - -- Migration for comments table 35 - CREATE TABLE comments_new ( 36 - id integer primary key autoincrement, 37 - owner_did text not null, 38 - issue_id integer not null, 39 - repo_at text not null, 40 - comment_id integer not null, 41 - comment_at text not null, 42 - body text not null, 43 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 44 - unique(issue_id, comment_id), 45 - foreign key (repo_at, issue_id) references issues(repo_at, issue_id) on delete cascade 46 - ); 47 - 48 - -- Migrate data to new comments table 49 - INSERT INTO comments_new ( 50 - id, owner_did, issue_id, repo_at, 51 - comment_id, comment_at, body, created 52 - ) 53 - SELECT 54 - id, owner_did, issue_id, repo_at, 55 - comment_id, comment_at, body, created 56 - FROM comments; 57 - 58 - -- Drop old comments table 59 - DROP TABLE comments; 60 - 61 - -- Rename new comments table 62 - ALTER TABLE comments_new RENAME TO comments;
-66
appview/db/migrations/validate.sql
··· 1 - -- Validation Queries for Database Migration 2 - 3 - -- 1. Verify Issues Table Structure 4 - PRAGMA table_info(issues); 5 - 6 - -- 2. Verify Comments Table Structure 7 - PRAGMA table_info(comments); 8 - 9 - -- 3. Check Total Row Count Consistency 10 - SELECT 11 - 'Issues Row Count' AS check_type, 12 - (SELECT COUNT(*) FROM issues) AS row_count 13 - UNION ALL 14 - SELECT 15 - 'Comments Row Count' AS check_type, 16 - (SELECT COUNT(*) FROM comments) AS row_count; 17 - 18 - -- 4. Verify Unique Constraint on Issues 19 - SELECT 20 - repo_at, 21 - issue_id, 22 - COUNT(*) as duplicate_count 23 - FROM issues 24 - GROUP BY repo_at, issue_id 25 - HAVING duplicate_count > 1; 26 - 27 - -- 5. Verify Foreign Key Integrity for Comments 28 - SELECT 29 - 'Orphaned Comments' AS check_type, 30 - COUNT(*) AS orphaned_count 31 - FROM comments c 32 - LEFT JOIN issues i ON c.repo_at = i.repo_at AND c.issue_id = i.issue_id 33 - WHERE i.id IS NULL; 34 - 35 - -- 6. Check Foreign Key Constraint 36 - PRAGMA foreign_key_list(comments); 37 - 38 - -- 7. Sample Data Integrity Check 39 - SELECT 40 - 'Sample Issues' AS check_type, 41 - repo_at, 42 - issue_id, 43 - title, 44 - created 45 - FROM issues 46 - LIMIT 5; 47 - 48 - -- 8. Sample Comments Data Integrity Check 49 - SELECT 50 - 'Sample Comments' AS check_type, 51 - repo_at, 52 - issue_id, 53 - comment_id, 54 - body, 55 - created 56 - FROM comments 57 - LIMIT 5; 58 - 59 - -- 9. Verify Constraint on Comments (Issue ID and Comment ID Uniqueness) 60 - SELECT 61 - issue_id, 62 - comment_id, 63 - COUNT(*) as duplicate_count 64 - FROM comments 65 - GROUP BY issue_id, comment_id 66 - HAVING duplicate_count > 1;
+22 -3
appview/db/pulls.go
··· 310 310 return pullId - 1, err 311 311 } 312 312 313 - func GetPulls(e Execer, filters ...filter) ([]*Pull, error) { 313 + func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*Pull, error) { 314 314 pulls := make(map[int]*Pull) 315 315 316 316 var conditions []string ··· 323 323 whereClause := "" 324 324 if conditions != nil { 325 325 whereClause = " where " + strings.Join(conditions, " and ") 326 + } 327 + limitClause := "" 328 + if limit != 0 { 329 + limitClause = fmt.Sprintf(" limit %d ", limit) 326 330 } 327 331 328 332 query := fmt.Sprintf(` ··· 344 348 from 345 349 pulls 346 350 %s 347 - `, whereClause) 351 + order by 352 + created desc 353 + %s 354 + `, whereClause, limitClause) 348 355 349 356 rows, err := e.Query(query, args...) 350 357 if err != nil { ··· 412 419 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 413 420 submissionsQuery := fmt.Sprintf(` 414 421 select 415 - id, pull_id, round_number, patch, source_rev 422 + id, pull_id, round_number, patch, created, source_rev 416 423 from 417 424 pull_submissions 418 425 where ··· 438 445 for submissionsRows.Next() { 439 446 var s PullSubmission 440 447 var sourceRev sql.NullString 448 + var createdAt string 441 449 err := submissionsRows.Scan( 442 450 &s.ID, 443 451 &s.PullId, 444 452 &s.RoundNumber, 445 453 &s.Patch, 454 + &createdAt, 446 455 &sourceRev, 447 456 ) 448 457 if err != nil { 449 458 return nil, err 450 459 } 460 + 461 + createdTime, err := time.Parse(time.RFC3339, createdAt) 462 + if err != nil { 463 + return nil, err 464 + } 465 + s.Created = createdTime 451 466 452 467 if sourceRev.Valid { 453 468 s.SourceRev = sourceRev.String ··· 511 526 }) 512 527 513 528 return orderedByPullId, nil 529 + } 530 + 531 + func GetPulls(e Execer, filters ...filter) ([]*Pull, error) { 532 + return GetPullsWithLimit(e, 0, filters...) 514 533 } 515 534 516 535 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) {
+7 -7
appview/db/reaction.go
··· 11 11 12 12 const ( 13 13 Like ReactionKind = "๐Ÿ‘" 14 - Unlike = "๐Ÿ‘Ž" 15 - Laugh = "๐Ÿ˜†" 16 - Celebration = "๐ŸŽ‰" 17 - Confused = "๐Ÿซค" 18 - Heart = "โค๏ธ" 19 - Rocket = "๐Ÿš€" 20 - Eyes = "๐Ÿ‘€" 14 + Unlike ReactionKind = "๐Ÿ‘Ž" 15 + Laugh ReactionKind = "๐Ÿ˜†" 16 + Celebration ReactionKind = "๐ŸŽ‰" 17 + Confused ReactionKind = "๐Ÿซค" 18 + Heart ReactionKind = "โค๏ธ" 19 + Rocket ReactionKind = "๐Ÿš€" 20 + Eyes ReactionKind = "๐Ÿ‘€" 21 21 ) 22 22 23 23 func (rk ReactionKind) String() string {
+10 -11
appview/db/repos.go
··· 19 19 Knot string 20 20 Rkey string 21 21 Created time.Time 22 - AtUri string 23 22 Description string 24 23 Spindle string 25 24 ··· 391 390 var description, spindle sql.NullString 392 391 393 392 row := e.QueryRow(` 394 - select did, name, knot, created, at_uri, description, spindle 393 + select did, name, knot, created, description, spindle, rkey 395 394 from repos 396 395 where did = ? and name = ? 397 396 `, ··· 400 399 ) 401 400 402 401 var createdAt string 403 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &description, &spindle); err != nil { 402 + if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &description, &spindle, &repo.Rkey); err != nil { 404 403 return nil, err 405 404 } 406 405 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 421 420 var repo Repo 422 421 var nullableDescription sql.NullString 423 422 424 - row := e.QueryRow(`select did, name, knot, created, at_uri, description from repos where at_uri = ?`, atUri) 423 + row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 425 424 426 425 var createdAt string 427 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &nullableDescription); err != nil { 426 + if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 428 427 return nil, err 429 428 } 430 429 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 444 443 `insert into repos 445 444 (did, name, knot, rkey, at_uri, description, source) 446 445 values (?, ?, ?, ?, ?, ?, ?)`, 447 - repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri, repo.Description, repo.Source, 446 + repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, 448 447 ) 449 448 return err 450 449 } ··· 467 466 var repos []Repo 468 467 469 468 rows, err := e.Query( 470 - `select did, name, knot, rkey, description, created, at_uri, source 469 + `select did, name, knot, rkey, description, created, source 471 470 from repos 472 471 where did = ? and source is not null and source != '' 473 472 order by created desc`, ··· 484 483 var nullableDescription sql.NullString 485 484 var nullableSource sql.NullString 486 485 487 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource) 486 + err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 488 487 if err != nil { 489 488 return nil, err 490 489 } ··· 521 520 var nullableSource sql.NullString 522 521 523 522 row := e.QueryRow( 524 - `select did, name, knot, rkey, description, created, at_uri, source 523 + `select did, name, knot, rkey, description, created, source 525 524 from repos 526 525 where did = ? and name = ? and source is not null and source != ''`, 527 526 did, name, 528 527 ) 529 528 530 - err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource) 529 + err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 531 530 if err != nil { 532 531 return nil, err 533 532 } ··· 556 555 return err 557 556 } 558 557 559 - func UpdateSpindle(e Execer, repoAt, spindle string) error { 558 + func UpdateSpindle(e Execer, repoAt string, spindle *string) error { 560 559 _, err := e.Exec( 561 560 `update repos set spindle = ? where at_uri = ?`, spindle, repoAt) 562 561 return err
+73 -6
appview/db/star.go
··· 47 47 // Get a star record 48 48 func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) { 49 49 query := ` 50 - select starred_by_did, repo_at, created, rkey 50 + select starred_by_did, repo_at, created, rkey 51 51 from stars 52 52 where starred_by_did = ? and repo_at = ?` 53 53 row := e.QueryRow(query, starredByDid, repoAt) ··· 119 119 } 120 120 121 121 repoQuery := fmt.Sprintf( 122 - `select starred_by_did, repo_at, created, rkey 122 + `select starred_by_did, repo_at, created, rkey 123 123 from stars 124 124 %s 125 125 order by created desc ··· 187 187 var stars []Star 188 188 189 189 rows, err := e.Query(` 190 - select 190 + select 191 191 s.starred_by_did, 192 192 s.repo_at, 193 193 s.rkey, ··· 196 196 r.name, 197 197 r.knot, 198 198 r.rkey, 199 - r.created, 200 - r.at_uri 199 + r.created 201 200 from stars s 202 201 join repos r on s.repo_at = r.at_uri 203 202 `) ··· 222 221 &repo.Knot, 223 222 &repo.Rkey, 224 223 &repoCreatedAt, 225 - &repo.AtUri, 226 224 ); err != nil { 227 225 return nil, err 228 226 } ··· 246 244 247 245 return stars, nil 248 246 } 247 + 248 + // GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week 249 + func GetTopStarredReposLastWeek(e Execer) ([]Repo, error) { 250 + // first, get the top repo URIs by star count from the last week 251 + query := ` 252 + with recent_starred_repos as ( 253 + select distinct repo_at 254 + from stars 255 + where created >= datetime('now', '-7 days') 256 + ), 257 + repo_star_counts as ( 258 + select 259 + s.repo_at, 260 + count(*) as star_count 261 + from stars s 262 + join recent_starred_repos rsr on s.repo_at = rsr.repo_at 263 + group by s.repo_at 264 + ) 265 + select rsc.repo_at 266 + from repo_star_counts rsc 267 + order by rsc.star_count desc 268 + limit 8 269 + ` 270 + 271 + rows, err := e.Query(query) 272 + if err != nil { 273 + return nil, err 274 + } 275 + defer rows.Close() 276 + 277 + var repoUris []string 278 + for rows.Next() { 279 + var repoUri string 280 + err := rows.Scan(&repoUri) 281 + if err != nil { 282 + return nil, err 283 + } 284 + repoUris = append(repoUris, repoUri) 285 + } 286 + 287 + if err := rows.Err(); err != nil { 288 + return nil, err 289 + } 290 + 291 + if len(repoUris) == 0 { 292 + return []Repo{}, nil 293 + } 294 + 295 + // get full repo data 296 + repos, err := GetRepos(e, 0, FilterIn("at_uri", repoUris)) 297 + if err != nil { 298 + return nil, err 299 + } 300 + 301 + // sort repos by the original trending order 302 + repoMap := make(map[string]Repo) 303 + for _, repo := range repos { 304 + repoMap[repo.RepoAt().String()] = repo 305 + } 306 + 307 + orderedRepos := make([]Repo, 0, len(repoUris)) 308 + for _, uri := range repoUris { 309 + if repo, exists := repoMap[uri]; exists { 310 + orderedRepos = append(orderedRepos, repo) 311 + } 312 + } 313 + 314 + return orderedRepos, nil 315 + }
+252
appview/db/strings.go
··· 1 + package db 2 + 3 + import ( 4 + "bytes" 5 + "database/sql" 6 + "errors" 7 + "fmt" 8 + "io" 9 + "strings" 10 + "time" 11 + "unicode/utf8" 12 + 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + "tangled.sh/tangled.sh/core/api/tangled" 15 + ) 16 + 17 + type String struct { 18 + Did syntax.DID 19 + Rkey string 20 + 21 + Filename string 22 + Description string 23 + Contents string 24 + Created time.Time 25 + Edited *time.Time 26 + } 27 + 28 + func (s *String) StringAt() syntax.ATURI { 29 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey)) 30 + } 31 + 32 + type StringStats struct { 33 + LineCount uint64 34 + ByteCount uint64 35 + } 36 + 37 + func (s String) Stats() StringStats { 38 + lineCount, err := countLines(strings.NewReader(s.Contents)) 39 + if err != nil { 40 + // non-fatal 41 + // TODO: log this? 42 + } 43 + 44 + return StringStats{ 45 + LineCount: uint64(lineCount), 46 + ByteCount: uint64(len(s.Contents)), 47 + } 48 + } 49 + 50 + func (s String) Validate() error { 51 + var err error 52 + 53 + if utf8.RuneCountInString(s.Filename) > 140 { 54 + err = errors.Join(err, fmt.Errorf("filename too long")) 55 + } 56 + 57 + if utf8.RuneCountInString(s.Description) > 280 { 58 + err = errors.Join(err, fmt.Errorf("description too long")) 59 + } 60 + 61 + if len(s.Contents) == 0 { 62 + err = errors.Join(err, fmt.Errorf("contents is empty")) 63 + } 64 + 65 + return err 66 + } 67 + 68 + func (s *String) AsRecord() tangled.String { 69 + return tangled.String{ 70 + Filename: s.Filename, 71 + Description: s.Description, 72 + Contents: s.Contents, 73 + CreatedAt: s.Created.Format(time.RFC3339), 74 + } 75 + } 76 + 77 + func StringFromRecord(did, rkey string, record tangled.String) String { 78 + created, err := time.Parse(record.CreatedAt, time.RFC3339) 79 + if err != nil { 80 + created = time.Now() 81 + } 82 + return String{ 83 + Did: syntax.DID(did), 84 + Rkey: rkey, 85 + Filename: record.Filename, 86 + Description: record.Description, 87 + Contents: record.Contents, 88 + Created: created, 89 + } 90 + } 91 + 92 + func AddString(e Execer, s String) error { 93 + _, err := e.Exec( 94 + `insert into strings ( 95 + did, 96 + rkey, 97 + filename, 98 + description, 99 + content, 100 + created, 101 + edited 102 + ) 103 + values (?, ?, ?, ?, ?, ?, null) 104 + on conflict(did, rkey) do update set 105 + filename = excluded.filename, 106 + description = excluded.description, 107 + content = excluded.content, 108 + edited = case 109 + when 110 + strings.content != excluded.content 111 + or strings.filename != excluded.filename 112 + or strings.description != excluded.description then ? 113 + else strings.edited 114 + end`, 115 + s.Did, 116 + s.Rkey, 117 + s.Filename, 118 + s.Description, 119 + s.Contents, 120 + s.Created.Format(time.RFC3339), 121 + time.Now().Format(time.RFC3339), 122 + ) 123 + return err 124 + } 125 + 126 + func GetStrings(e Execer, limit int, filters ...filter) ([]String, error) { 127 + var all []String 128 + 129 + var conditions []string 130 + var args []any 131 + for _, filter := range filters { 132 + conditions = append(conditions, filter.Condition()) 133 + args = append(args, filter.Arg()...) 134 + } 135 + 136 + whereClause := "" 137 + if conditions != nil { 138 + whereClause = " where " + strings.Join(conditions, " and ") 139 + } 140 + 141 + limitClause := "" 142 + if limit != 0 { 143 + limitClause = fmt.Sprintf(" limit %d ", limit) 144 + } 145 + 146 + query := fmt.Sprintf(`select 147 + did, 148 + rkey, 149 + filename, 150 + description, 151 + content, 152 + created, 153 + edited 154 + from strings 155 + %s 156 + order by created desc 157 + %s`, 158 + whereClause, 159 + limitClause, 160 + ) 161 + 162 + rows, err := e.Query(query, args...) 163 + 164 + if err != nil { 165 + return nil, err 166 + } 167 + defer rows.Close() 168 + 169 + for rows.Next() { 170 + var s String 171 + var createdAt string 172 + var editedAt sql.NullString 173 + 174 + if err := rows.Scan( 175 + &s.Did, 176 + &s.Rkey, 177 + &s.Filename, 178 + &s.Description, 179 + &s.Contents, 180 + &createdAt, 181 + &editedAt, 182 + ); err != nil { 183 + return nil, err 184 + } 185 + 186 + s.Created, err = time.Parse(time.RFC3339, createdAt) 187 + if err != nil { 188 + s.Created = time.Now() 189 + } 190 + 191 + if editedAt.Valid { 192 + e, err := time.Parse(time.RFC3339, editedAt.String) 193 + if err != nil { 194 + e = time.Now() 195 + } 196 + s.Edited = &e 197 + } 198 + 199 + all = append(all, s) 200 + } 201 + 202 + if err := rows.Err(); err != nil { 203 + return nil, err 204 + } 205 + 206 + return all, nil 207 + } 208 + 209 + func DeleteString(e Execer, filters ...filter) error { 210 + var conditions []string 211 + var args []any 212 + for _, filter := range filters { 213 + conditions = append(conditions, filter.Condition()) 214 + args = append(args, filter.Arg()...) 215 + } 216 + 217 + whereClause := "" 218 + if conditions != nil { 219 + whereClause = " where " + strings.Join(conditions, " and ") 220 + } 221 + 222 + query := fmt.Sprintf(`delete from strings %s`, whereClause) 223 + 224 + _, err := e.Exec(query, args...) 225 + return err 226 + } 227 + 228 + func countLines(r io.Reader) (int, error) { 229 + buf := make([]byte, 32*1024) 230 + bufLen := 0 231 + count := 0 232 + nl := []byte{'\n'} 233 + 234 + for { 235 + c, err := r.Read(buf) 236 + if c > 0 { 237 + bufLen += c 238 + } 239 + count += bytes.Count(buf[:c], nl) 240 + 241 + switch { 242 + case err == io.EOF: 243 + /* handle last line not having a newline at the end */ 244 + if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { 245 + count++ 246 + } 247 + return count, nil 248 + case err != nil: 249 + return 0, err 250 + } 251 + } 252 + }
+1 -1
appview/db/timeline.go
··· 162 162 163 163 followStatMap := make(map[string]FollowStats) 164 164 for _, s := range subjects { 165 - followers, following, err := GetFollowerFollowing(e, s) 165 + followers, following, err := GetFollowerFollowingCount(e, s) 166 166 if err != nil { 167 167 return nil, err 168 168 }
+241 -8
appview/ingester.go
··· 5 5 "encoding/json" 6 6 "fmt" 7 7 "log/slog" 8 + "strings" 8 9 "time" 9 10 10 11 "github.com/bluesky-social/indigo/atproto/syntax" ··· 14 15 "tangled.sh/tangled.sh/core/api/tangled" 15 16 "tangled.sh/tangled.sh/core/appview/config" 16 17 "tangled.sh/tangled.sh/core/appview/db" 18 + "tangled.sh/tangled.sh/core/appview/pages/markup" 17 19 "tangled.sh/tangled.sh/core/appview/spindleverify" 18 20 "tangled.sh/tangled.sh/core/idresolver" 19 21 "tangled.sh/tangled.sh/core/rbac" ··· 61 63 case tangled.ActorProfileNSID: 62 64 err = i.ingestProfile(e) 63 65 case tangled.SpindleMemberNSID: 64 - err = i.ingestSpindleMember(e) 66 + err = i.ingestSpindleMember(ctx, e) 65 67 case tangled.SpindleNSID: 66 - err = i.ingestSpindle(e) 68 + err = i.ingestSpindle(ctx, e) 69 + case tangled.StringNSID: 70 + err = i.ingestString(e) 71 + case tangled.RepoIssueNSID: 72 + err = i.ingestIssue(ctx, e) 73 + case tangled.RepoIssueCommentNSID: 74 + err = i.ingestIssueComment(e) 67 75 } 68 76 l = i.Logger.With("nsid", e.Commit.Collection) 69 77 } 70 78 71 79 if err != nil { 72 - l.Error("error ingesting record", "err", err) 80 + l.Debug("error ingesting record", "err", err) 73 81 } 74 82 75 - return err 83 + return nil 76 84 } 77 85 } 78 86 ··· 334 342 return nil 335 343 } 336 344 337 - func (i *Ingester) ingestSpindleMember(e *models.Event) error { 345 + func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error { 338 346 did := e.Did 339 347 var err error 340 348 ··· 357 365 return fmt.Errorf("failed to enforce permissions: %w", err) 358 366 } 359 367 360 - memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject) 368 + memberId, err := i.IdResolver.ResolveIdent(ctx, record.Subject) 361 369 if err != nil { 362 370 return err 363 371 } ··· 385 393 if err != nil { 386 394 return fmt.Errorf("failed to update ACLs: %w", err) 387 395 } 396 + 397 + l.Info("added spindle member") 388 398 case models.CommitOperationDelete: 389 399 rkey := e.Commit.RKey 390 400 ··· 431 441 if err = i.Enforcer.E.SavePolicy(); err != nil { 432 442 return fmt.Errorf("failed to save ACLs: %w", err) 433 443 } 444 + 445 + l.Info("removed spindle member") 434 446 } 435 447 436 448 return nil 437 449 } 438 450 439 - func (i *Ingester) ingestSpindle(e *models.Event) error { 451 + func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error { 440 452 did := e.Did 441 453 var err error 442 454 ··· 469 481 return err 470 482 } 471 483 472 - err = spindleverify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev) 484 + err = spindleverify.RunVerification(ctx, instance, did, i.Config.Core.Dev) 473 485 if err != nil { 474 486 l.Error("failed to add spindle to db", "err", err, "instance", instance) 475 487 return err ··· 549 561 550 562 return nil 551 563 } 564 + 565 + func (i *Ingester) ingestString(e *models.Event) error { 566 + did := e.Did 567 + rkey := e.Commit.RKey 568 + 569 + var err error 570 + 571 + l := i.Logger.With("handler", "ingestString", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 572 + l.Info("ingesting record") 573 + 574 + ddb, ok := i.Db.Execer.(*db.DB) 575 + if !ok { 576 + return fmt.Errorf("failed to index string record, invalid db cast") 577 + } 578 + 579 + switch e.Commit.Operation { 580 + case models.CommitOperationCreate, models.CommitOperationUpdate: 581 + raw := json.RawMessage(e.Commit.Record) 582 + record := tangled.String{} 583 + err = json.Unmarshal(raw, &record) 584 + if err != nil { 585 + l.Error("invalid record", "err", err) 586 + return err 587 + } 588 + 589 + string := db.StringFromRecord(did, rkey, record) 590 + 591 + if err = string.Validate(); err != nil { 592 + l.Error("invalid record", "err", err) 593 + return err 594 + } 595 + 596 + if err = db.AddString(ddb, string); err != nil { 597 + l.Error("failed to add string", "err", err) 598 + return err 599 + } 600 + 601 + return nil 602 + 603 + case models.CommitOperationDelete: 604 + if err := db.DeleteString( 605 + ddb, 606 + db.FilterEq("did", did), 607 + db.FilterEq("rkey", rkey), 608 + ); err != nil { 609 + l.Error("failed to delete", "err", err) 610 + return fmt.Errorf("failed to delete string record: %w", err) 611 + } 612 + 613 + return nil 614 + } 615 + 616 + return nil 617 + } 618 + 619 + func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error { 620 + did := e.Did 621 + rkey := e.Commit.RKey 622 + 623 + var err error 624 + 625 + l := i.Logger.With("handler", "ingestIssue", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 626 + l.Info("ingesting record") 627 + 628 + ddb, ok := i.Db.Execer.(*db.DB) 629 + if !ok { 630 + return fmt.Errorf("failed to index issue record, invalid db cast") 631 + } 632 + 633 + switch e.Commit.Operation { 634 + case models.CommitOperationCreate: 635 + raw := json.RawMessage(e.Commit.Record) 636 + record := tangled.RepoIssue{} 637 + err = json.Unmarshal(raw, &record) 638 + if err != nil { 639 + l.Error("invalid record", "err", err) 640 + return err 641 + } 642 + 643 + issue := db.IssueFromRecord(did, rkey, record) 644 + 645 + sanitizer := markup.NewSanitizer() 646 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(issue.Title)); st == "" { 647 + return fmt.Errorf("title is empty after HTML sanitization") 648 + } 649 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(issue.Body)); sb == "" { 650 + return fmt.Errorf("body is empty after HTML sanitization") 651 + } 652 + 653 + tx, err := ddb.BeginTx(ctx, nil) 654 + if err != nil { 655 + l.Error("failed to begin transaction", "err", err) 656 + return err 657 + } 658 + 659 + err = db.NewIssue(tx, &issue) 660 + if err != nil { 661 + l.Error("failed to create issue", "err", err) 662 + return err 663 + } 664 + 665 + return nil 666 + 667 + case models.CommitOperationUpdate: 668 + raw := json.RawMessage(e.Commit.Record) 669 + record := tangled.RepoIssue{} 670 + err = json.Unmarshal(raw, &record) 671 + if err != nil { 672 + l.Error("invalid record", "err", err) 673 + return err 674 + } 675 + 676 + body := "" 677 + if record.Body != nil { 678 + body = *record.Body 679 + } 680 + 681 + sanitizer := markup.NewSanitizer() 682 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(record.Title)); st == "" { 683 + return fmt.Errorf("title is empty after HTML sanitization") 684 + } 685 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" { 686 + return fmt.Errorf("body is empty after HTML sanitization") 687 + } 688 + 689 + err = db.UpdateIssueByRkey(ddb, did, rkey, record.Title, body) 690 + if err != nil { 691 + l.Error("failed to update issue", "err", err) 692 + return err 693 + } 694 + 695 + return nil 696 + 697 + case models.CommitOperationDelete: 698 + if err := db.DeleteIssueByRkey(ddb, did, rkey); err != nil { 699 + l.Error("failed to delete", "err", err) 700 + return fmt.Errorf("failed to delete issue record: %w", err) 701 + } 702 + 703 + return nil 704 + } 705 + 706 + return fmt.Errorf("unknown operation: %s", e.Commit.Operation) 707 + } 708 + 709 + func (i *Ingester) ingestIssueComment(e *models.Event) error { 710 + did := e.Did 711 + rkey := e.Commit.RKey 712 + 713 + var err error 714 + 715 + l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 716 + l.Info("ingesting record") 717 + 718 + ddb, ok := i.Db.Execer.(*db.DB) 719 + if !ok { 720 + return fmt.Errorf("failed to index issue comment record, invalid db cast") 721 + } 722 + 723 + switch e.Commit.Operation { 724 + case models.CommitOperationCreate: 725 + raw := json.RawMessage(e.Commit.Record) 726 + record := tangled.RepoIssueComment{} 727 + err = json.Unmarshal(raw, &record) 728 + if err != nil { 729 + l.Error("invalid record", "err", err) 730 + return err 731 + } 732 + 733 + comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record) 734 + if err != nil { 735 + l.Error("failed to parse comment from record", "err", err) 736 + return err 737 + } 738 + 739 + sanitizer := markup.NewSanitizer() 740 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(comment.Body)); sb == "" { 741 + return fmt.Errorf("body is empty after HTML sanitization") 742 + } 743 + 744 + err = db.NewIssueComment(ddb, &comment) 745 + if err != nil { 746 + l.Error("failed to create issue comment", "err", err) 747 + return err 748 + } 749 + 750 + return nil 751 + 752 + case models.CommitOperationUpdate: 753 + raw := json.RawMessage(e.Commit.Record) 754 + record := tangled.RepoIssueComment{} 755 + err = json.Unmarshal(raw, &record) 756 + if err != nil { 757 + l.Error("invalid record", "err", err) 758 + return err 759 + } 760 + 761 + sanitizer := markup.NewSanitizer() 762 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(record.Body)); sb == "" { 763 + return fmt.Errorf("body is empty after HTML sanitization") 764 + } 765 + 766 + err = db.UpdateCommentByRkey(ddb, did, rkey, record.Body) 767 + if err != nil { 768 + l.Error("failed to update issue comment", "err", err) 769 + return err 770 + } 771 + 772 + return nil 773 + 774 + case models.CommitOperationDelete: 775 + if err := db.DeleteCommentByRkey(ddb, did, rkey); err != nil { 776 + l.Error("failed to delete", "err", err) 777 + return fmt.Errorf("failed to delete issue comment record: %w", err) 778 + } 779 + 780 + return nil 781 + } 782 + 783 + return fmt.Errorf("unknown operation: %s", e.Commit.Operation) 784 + }
+41 -95
appview/issues/issues.go
··· 7 7 "net/http" 8 8 "slices" 9 9 "strconv" 10 + "strings" 10 11 "time" 11 12 12 13 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 14 "github.com/bluesky-social/indigo/atproto/data" 14 - "github.com/bluesky-social/indigo/atproto/syntax" 15 15 lexutil "github.com/bluesky-social/indigo/lex/util" 16 16 "github.com/go-chi/chi/v5" 17 17 ··· 21 21 "tangled.sh/tangled.sh/core/appview/notify" 22 22 "tangled.sh/tangled.sh/core/appview/oauth" 23 23 "tangled.sh/tangled.sh/core/appview/pages" 24 + "tangled.sh/tangled.sh/core/appview/pages/markup" 24 25 "tangled.sh/tangled.sh/core/appview/pagination" 25 26 "tangled.sh/tangled.sh/core/appview/reporesolver" 26 27 "tangled.sh/tangled.sh/core/idresolver" ··· 73 74 return 74 75 } 75 76 76 - issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt, issueIdInt) 77 + issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt(), issueIdInt) 77 78 if err != nil { 78 79 log.Println("failed to get issue and comments", err) 79 80 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 80 81 return 81 82 } 82 83 83 - reactionCountMap, err := db.GetReactionCountMap(rp.db, syntax.ATURI(issue.IssueAt)) 84 + reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 84 85 if err != nil { 85 86 log.Println("failed to get issue reactions") 86 87 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") ··· 88 89 89 90 userReactions := map[db.ReactionKind]bool{} 90 91 if user != nil { 91 - userReactions = db.GetReactionStatusMap(rp.db, user.Did, syntax.ATURI(issue.IssueAt)) 92 + userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 92 93 } 93 94 94 95 issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) ··· 96 97 log.Println("failed to resolve issue owner", err) 97 98 } 98 99 99 - identsToResolve := make([]string, len(comments)) 100 - for i, comment := range comments { 101 - identsToResolve[i] = comment.OwnerDid 102 - } 103 - resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve) 104 - didHandleMap := make(map[string]string) 105 - for _, identity := range resolvedIds { 106 - if !identity.Handle.IsInvalidHandle() { 107 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 108 - } else { 109 - didHandleMap[identity.DID.String()] = identity.DID.String() 110 - } 111 - } 112 - 113 100 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 114 101 LoggedInUser: user, 115 102 RepoInfo: f.RepoInfo(user), 116 - Issue: *issue, 103 + Issue: issue, 117 104 Comments: comments, 118 105 119 106 IssueOwnerHandle: issueOwnerIdent.Handle.String(), 120 - DidHandleMap: didHandleMap, 121 107 122 108 OrderedReactionKinds: db.OrderedReactionKinds, 123 109 Reactions: reactionCountMap, ··· 142 128 return 143 129 } 144 130 145 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 131 + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 146 132 if err != nil { 147 133 log.Println("failed to get issue", err) 148 134 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") ··· 174 160 Rkey: tid.TID(), 175 161 Record: &lexutil.LexiconTypeDecoder{ 176 162 Val: &tangled.RepoIssueState{ 177 - Issue: issue.IssueAt, 163 + Issue: issue.AtUri().String(), 178 164 State: closed, 179 165 }, 180 166 }, ··· 186 172 return 187 173 } 188 174 189 - err = db.CloseIssue(rp.db, f.RepoAt, issueIdInt) 175 + err = db.CloseIssue(rp.db, f.RepoAt(), issueIdInt) 190 176 if err != nil { 191 177 log.Println("failed to close issue", err) 192 178 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") ··· 218 204 return 219 205 } 220 206 221 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 207 + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 222 208 if err != nil { 223 209 log.Println("failed to get issue", err) 224 210 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") ··· 235 221 isIssueOwner := user.Did == issue.OwnerDid 236 222 237 223 if isCollaborator || isIssueOwner { 238 - err := db.ReopenIssue(rp.db, f.RepoAt, issueIdInt) 224 + err := db.ReopenIssue(rp.db, f.RepoAt(), issueIdInt) 239 225 if err != nil { 240 226 log.Println("failed to reopen issue", err) 241 227 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") ··· 279 265 280 266 err := db.NewIssueComment(rp.db, &db.Comment{ 281 267 OwnerDid: user.Did, 282 - RepoAt: f.RepoAt, 268 + RepoAt: f.RepoAt(), 283 269 Issue: issueIdInt, 284 270 CommentId: commentId, 285 271 Body: body, ··· 292 278 } 293 279 294 280 createdAt := time.Now().Format(time.RFC3339) 295 - commentIdInt64 := int64(commentId) 296 281 ownerDid := user.Did 297 - issueAt, err := db.GetIssueAt(rp.db, f.RepoAt, issueIdInt) 282 + issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt) 298 283 if err != nil { 299 284 log.Println("failed to get issue at", err) 300 285 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 301 286 return 302 287 } 303 288 304 - atUri := f.RepoAt.String() 289 + atUri := f.RepoAt().String() 305 290 client, err := rp.oauth.AuthorizedClient(r) 306 291 if err != nil { 307 292 log.Println("failed to get authorized client", err) ··· 316 301 Val: &tangled.RepoIssueComment{ 317 302 Repo: &atUri, 318 303 Issue: issueAt, 319 - CommentId: &commentIdInt64, 320 304 Owner: &ownerDid, 321 305 Body: body, 322 306 CreatedAt: createdAt, ··· 358 342 return 359 343 } 360 344 361 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 345 + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 362 346 if err != nil { 363 347 log.Println("failed to get issue", err) 364 348 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 365 349 return 366 350 } 367 351 368 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 352 + comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 369 353 if err != nil { 370 354 http.Error(w, "bad comment id", http.StatusBadRequest) 371 355 return 372 356 } 373 357 374 - identity, err := rp.idResolver.ResolveIdent(r.Context(), comment.OwnerDid) 375 - if err != nil { 376 - log.Println("failed to resolve did") 377 - return 378 - } 379 - 380 - didHandleMap := make(map[string]string) 381 - if !identity.Handle.IsInvalidHandle() { 382 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 383 - } else { 384 - didHandleMap[identity.DID.String()] = identity.DID.String() 385 - } 386 - 387 358 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 388 359 LoggedInUser: user, 389 360 RepoInfo: f.RepoInfo(user), 390 - DidHandleMap: didHandleMap, 391 361 Issue: issue, 392 362 Comment: comment, 393 363 }) ··· 417 387 return 418 388 } 419 389 420 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 390 + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 421 391 if err != nil { 422 392 log.Println("failed to get issue", err) 423 393 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 424 394 return 425 395 } 426 396 427 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 397 + comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 428 398 if err != nil { 429 399 http.Error(w, "bad comment id", http.StatusBadRequest) 430 400 return ··· 479 449 repoAt := record["repo"].(string) 480 450 issueAt := record["issue"].(string) 481 451 createdAt := record["createdAt"].(string) 482 - commentIdInt64 := int64(commentIdInt) 483 452 484 453 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 485 454 Collection: tangled.RepoIssueCommentNSID, ··· 490 459 Val: &tangled.RepoIssueComment{ 491 460 Repo: &repoAt, 492 461 Issue: issueAt, 493 - CommentId: &commentIdInt64, 494 462 Owner: &comment.OwnerDid, 495 463 Body: newBody, 496 464 CreatedAt: createdAt, ··· 503 471 } 504 472 505 473 // optimistic update for htmx 506 - didHandleMap := map[string]string{ 507 - user.Did: user.Handle, 508 - } 509 474 comment.Body = newBody 510 475 comment.Edited = &edited 511 476 ··· 513 478 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 514 479 LoggedInUser: user, 515 480 RepoInfo: f.RepoInfo(user), 516 - DidHandleMap: didHandleMap, 517 481 Issue: issue, 518 482 Comment: comment, 519 483 }) ··· 539 503 return 540 504 } 541 505 542 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 506 + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 543 507 if err != nil { 544 508 log.Println("failed to get issue", err) 545 509 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") ··· 554 518 return 555 519 } 556 520 557 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 521 + comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 558 522 if err != nil { 559 523 http.Error(w, "bad comment id", http.StatusBadRequest) 560 524 return ··· 572 536 573 537 // optimistic deletion 574 538 deleted := time.Now() 575 - err = db.DeleteComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 539 + err = db.DeleteComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 576 540 if err != nil { 577 541 log.Println("failed to delete comment") 578 542 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") ··· 598 562 } 599 563 600 564 // optimistic update for htmx 601 - didHandleMap := map[string]string{ 602 - user.Did: user.Handle, 603 - } 604 565 comment.Body = "" 605 566 comment.Deleted = &deleted 606 567 ··· 608 569 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 609 570 LoggedInUser: user, 610 571 RepoInfo: f.RepoInfo(user), 611 - DidHandleMap: didHandleMap, 612 572 Issue: issue, 613 573 Comment: comment, 614 574 }) 615 - return 616 575 } 617 576 618 577 func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { ··· 641 600 return 642 601 } 643 602 644 - issues, err := db.GetIssues(rp.db, f.RepoAt, isOpen, page) 603 + issues, err := db.GetIssuesPaginated(rp.db, f.RepoAt(), isOpen, page) 645 604 if err != nil { 646 605 log.Println("failed to get issues", err) 647 606 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 648 607 return 649 608 } 650 609 651 - identsToResolve := make([]string, len(issues)) 652 - for i, issue := range issues { 653 - identsToResolve[i] = issue.OwnerDid 654 - } 655 - resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve) 656 - didHandleMap := make(map[string]string) 657 - for _, identity := range resolvedIds { 658 - if !identity.Handle.IsInvalidHandle() { 659 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 660 - } else { 661 - didHandleMap[identity.DID.String()] = identity.DID.String() 662 - } 663 - } 664 - 665 610 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 666 611 LoggedInUser: rp.oauth.GetUser(r), 667 612 RepoInfo: f.RepoInfo(user), 668 613 Issues: issues, 669 - DidHandleMap: didHandleMap, 670 614 FilteringByOpen: isOpen, 671 615 Page: page, 672 616 }) 673 - return 674 617 } 675 618 676 619 func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { ··· 697 640 return 698 641 } 699 642 643 + sanitizer := markup.NewSanitizer() 644 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" { 645 + rp.pages.Notice(w, "issues", "Title is empty after HTML sanitization") 646 + return 647 + } 648 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" { 649 + rp.pages.Notice(w, "issues", "Body is empty after HTML sanitization") 650 + return 651 + } 652 + 700 653 tx, err := rp.db.BeginTx(r.Context(), nil) 701 654 if err != nil { 702 655 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") ··· 704 657 } 705 658 706 659 issue := &db.Issue{ 707 - RepoAt: f.RepoAt, 660 + RepoAt: f.RepoAt(), 661 + Rkey: tid.TID(), 708 662 Title: title, 709 663 Body: body, 710 664 OwnerDid: user.Did, ··· 722 676 rp.pages.Notice(w, "issues", "Failed to create issue.") 723 677 return 724 678 } 725 - atUri := f.RepoAt.String() 726 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 679 + atUri := f.RepoAt().String() 680 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 727 681 Collection: tangled.RepoIssueNSID, 728 682 Repo: user.Did, 729 - Rkey: tid.TID(), 683 + Rkey: issue.Rkey, 730 684 Record: &lexutil.LexiconTypeDecoder{ 731 685 Val: &tangled.RepoIssue{ 732 - Repo: atUri, 733 - Title: title, 734 - Body: &body, 735 - Owner: user.Did, 736 - IssueId: int64(issue.IssueId), 686 + Repo: atUri, 687 + Title: title, 688 + Body: &body, 689 + Owner: user.Did, 737 690 }, 738 691 }, 739 692 }) 740 693 if err != nil { 741 694 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 695 rp.pages.Notice(w, "issues", "Failed to create issue.") 750 696 return 751 697 }
-16
appview/knots/knots.go
··· 334 334 repoByMember[r.Did] = append(repoByMember[r.Did], r) 335 335 } 336 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 337 k.Pages.Knot(w, pages.KnotParams{ 353 338 LoggedInUser: user, 354 - DidHandleMap: didHandleMap, 355 339 Registration: reg, 356 340 Members: members, 357 341 Repos: repoByMember,
+16 -21
appview/middleware/middleware.go
··· 5 5 "fmt" 6 6 "log" 7 7 "net/http" 8 + "net/url" 8 9 "slices" 9 10 "strconv" 10 11 "strings" 11 - "time" 12 12 13 13 "github.com/bluesky-social/indigo/atproto/identity" 14 14 "github.com/go-chi/chi/v5" ··· 46 46 func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 47 47 return func(next http.Handler) http.Handler { 48 48 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 + returnURL := "/" 50 + if u, err := url.Parse(r.Header.Get("Referer")); err == nil { 51 + returnURL = u.RequestURI() 52 + } 53 + 54 + loginURL := fmt.Sprintf("/login?return_url=%s", url.QueryEscape(returnURL)) 55 + 49 56 redirectFunc := func(w http.ResponseWriter, r *http.Request) { 50 - http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) 57 + http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect) 51 58 } 52 59 if r.Header.Get("HX-Request") == "true" { 53 60 redirectFunc = func(w http.ResponseWriter, _ *http.Request) { 54 - w.Header().Set("HX-Redirect", "/login") 61 + w.Header().Set("HX-Redirect", loginURL) 55 62 w.WriteHeader(http.StatusOK) 56 63 } 57 64 } ··· 167 174 } 168 175 } 169 176 170 - func StripLeadingAt(next http.Handler) http.Handler { 171 - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 172 - path := req.URL.EscapedPath() 173 - if strings.HasPrefix(path, "/@") { 174 - req.URL.RawPath = "/" + strings.TrimPrefix(path, "/@") 175 - } 176 - next.ServeHTTP(w, req) 177 - }) 178 - } 179 - 180 177 func (mw Middleware) ResolveIdent() middlewareFunc { 181 178 excluded := []string{"favicon.ico"} 182 179 ··· 188 185 return 189 186 } 190 187 188 + didOrHandle = strings.TrimPrefix(didOrHandle, "@") 189 + 191 190 id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) 192 191 if err != nil { 193 192 // invalid did or handle 194 - log.Println("failed to resolve did/handle:", err) 193 + log.Printf("failed to resolve did/handle '%s': %s\n", didOrHandle, err) 195 194 mw.pages.Error404(w) 196 195 return 197 196 } ··· 222 221 return 223 222 } 224 223 225 - ctx := context.WithValue(req.Context(), "knot", repo.Knot) 226 - ctx = context.WithValue(ctx, "repoAt", repo.AtUri) 227 - ctx = context.WithValue(ctx, "repoDescription", repo.Description) 228 - ctx = context.WithValue(ctx, "repoSpindle", repo.Spindle) 229 - ctx = context.WithValue(ctx, "repoAddedAt", repo.Created.Format(time.RFC3339)) 224 + ctx := context.WithValue(req.Context(), "repo", repo) 230 225 next.ServeHTTP(w, req.WithContext(ctx)) 231 226 }) 232 227 } ··· 251 246 return 252 247 } 253 248 254 - pr, err := db.GetPull(mw.db, f.RepoAt, prIdInt) 249 + pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt) 255 250 if err != nil { 256 251 log.Println("failed to get pull and comments", err) 257 252 return ··· 292 287 return 293 288 } 294 289 295 - fullName := f.OwnerHandle() + "/" + f.RepoName 290 + fullName := f.OwnerHandle() + "/" + f.Name 296 291 297 292 if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 298 293 if r.URL.Query().Get("go-get") == "1" {
+158 -2
appview/oauth/handler/handler.go
··· 1 1 package oauth 2 2 3 3 import ( 4 + "bytes" 5 + "context" 4 6 "encoding/json" 5 7 "fmt" 6 8 "log" 7 9 "net/http" 8 10 "net/url" 9 11 "strings" 12 + "time" 10 13 11 14 "github.com/go-chi/chi/v5" 12 15 "github.com/gorilla/sessions" 13 16 "github.com/lestrrat-go/jwx/v2/jwk" 14 17 "github.com/posthog/posthog-go" 15 18 "tangled.sh/icyphox.sh/atproto-oauth/helpers" 19 + tangled "tangled.sh/tangled.sh/core/api/tangled" 16 20 sessioncache "tangled.sh/tangled.sh/core/appview/cache/session" 17 21 "tangled.sh/tangled.sh/core/appview/config" 18 22 "tangled.sh/tangled.sh/core/appview/db" ··· 23 27 "tangled.sh/tangled.sh/core/idresolver" 24 28 "tangled.sh/tangled.sh/core/knotclient" 25 29 "tangled.sh/tangled.sh/core/rbac" 30 + "tangled.sh/tangled.sh/core/tid" 26 31 ) 27 32 28 33 const ( ··· 104 109 func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) { 105 110 switch r.Method { 106 111 case http.MethodGet: 107 - o.pages.Login(w, pages.LoginParams{}) 112 + returnURL := r.URL.Query().Get("return_url") 113 + o.pages.Login(w, pages.LoginParams{ 114 + ReturnUrl: returnURL, 115 + }) 108 116 case http.MethodPost: 109 117 handle := r.FormValue("handle") 110 118 ··· 189 197 DpopAuthserverNonce: parResp.DpopAuthserverNonce, 190 198 DpopPrivateJwk: string(dpopKeyJson), 191 199 State: parResp.State, 200 + ReturnUrl: r.FormValue("return_url"), 192 201 }) 193 202 if err != nil { 194 203 log.Println("failed to save oauth request:", err) ··· 244 253 return 245 254 } 246 255 256 + if iss != oauthRequest.AuthserverIss { 257 + log.Println("mismatched iss:", iss, "!=", oauthRequest.AuthserverIss, "for state:", state) 258 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 259 + return 260 + } 261 + 247 262 self := o.oauth.ClientMetadata() 248 263 249 264 oauthClient, err := client.NewClient( ··· 294 309 295 310 log.Println("session saved successfully") 296 311 go o.addToDefaultKnot(oauthRequest.Did) 312 + go o.addToDefaultSpindle(oauthRequest.Did) 297 313 298 314 if !o.config.Core.Dev { 299 315 err = o.posthog.Enqueue(posthog.Capture{ ··· 305 321 } 306 322 } 307 323 308 - http.Redirect(w, r, "/", http.StatusFound) 324 + returnUrl := oauthRequest.ReturnUrl 325 + if returnUrl == "" { 326 + returnUrl = "/" 327 + } 328 + 329 + http.Redirect(w, r, returnUrl, http.StatusFound) 309 330 } 310 331 311 332 func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) { ··· 330 351 return nil, err 331 352 } 332 353 return pubKey, nil 354 + } 355 + 356 + func (o *OAuthHandler) addToDefaultSpindle(did string) { 357 + // use the tangled.sh app password to get an accessJwt 358 + // and create an sh.tangled.spindle.member record with that 359 + 360 + defaultSpindle := "spindle.tangled.sh" 361 + appPassword := o.config.Core.AppPassword 362 + 363 + spindleMembers, err := db.GetSpindleMembers( 364 + o.db, 365 + db.FilterEq("instance", "spindle.tangled.sh"), 366 + db.FilterEq("subject", did), 367 + ) 368 + if err != nil { 369 + log.Printf("failed to get spindle members for did %s: %v", did, err) 370 + return 371 + } 372 + 373 + if len(spindleMembers) != 0 { 374 + log.Printf("did %s is already a member of the default spindle", did) 375 + return 376 + } 377 + 378 + // TODO: hardcoded tangled handle and did for now 379 + tangledHandle := "tangled.sh" 380 + tangledDid := "did:plc:wshs7t2adsemcrrd4snkeqli" 381 + 382 + if appPassword == "" { 383 + log.Println("no app password configured, skipping spindle member addition") 384 + return 385 + } 386 + 387 + log.Printf("adding %s to default spindle", did) 388 + 389 + resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid) 390 + if err != nil { 391 + log.Printf("failed to resolve tangled.sh DID %s: %v", tangledDid, err) 392 + return 393 + } 394 + 395 + pdsEndpoint := resolved.PDSEndpoint() 396 + if pdsEndpoint == "" { 397 + log.Printf("no PDS endpoint found for tangled.sh DID %s", tangledDid) 398 + return 399 + } 400 + 401 + sessionPayload := map[string]string{ 402 + "identifier": tangledHandle, 403 + "password": appPassword, 404 + } 405 + sessionBytes, err := json.Marshal(sessionPayload) 406 + if err != nil { 407 + log.Printf("failed to marshal session payload: %v", err) 408 + return 409 + } 410 + 411 + sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 412 + sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 413 + if err != nil { 414 + log.Printf("failed to create session request: %v", err) 415 + return 416 + } 417 + sessionReq.Header.Set("Content-Type", "application/json") 418 + 419 + client := &http.Client{Timeout: 30 * time.Second} 420 + sessionResp, err := client.Do(sessionReq) 421 + if err != nil { 422 + log.Printf("failed to create session: %v", err) 423 + return 424 + } 425 + defer sessionResp.Body.Close() 426 + 427 + if sessionResp.StatusCode != http.StatusOK { 428 + log.Printf("failed to create session: HTTP %d", sessionResp.StatusCode) 429 + return 430 + } 431 + 432 + var session struct { 433 + AccessJwt string `json:"accessJwt"` 434 + } 435 + if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 436 + log.Printf("failed to decode session response: %v", err) 437 + return 438 + } 439 + 440 + record := tangled.SpindleMember{ 441 + LexiconTypeID: "sh.tangled.spindle.member", 442 + Subject: did, 443 + Instance: defaultSpindle, 444 + CreatedAt: time.Now().Format(time.RFC3339), 445 + } 446 + 447 + recordBytes, err := json.Marshal(record) 448 + if err != nil { 449 + log.Printf("failed to marshal spindle member record: %v", err) 450 + return 451 + } 452 + 453 + payload := map[string]interface{}{ 454 + "repo": tangledDid, 455 + "collection": tangled.SpindleMemberNSID, 456 + "rkey": tid.TID(), 457 + "record": json.RawMessage(recordBytes), 458 + } 459 + 460 + payloadBytes, err := json.Marshal(payload) 461 + if err != nil { 462 + log.Printf("failed to marshal request payload: %v", err) 463 + return 464 + } 465 + 466 + url := pdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 467 + req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 468 + if err != nil { 469 + log.Printf("failed to create HTTP request: %v", err) 470 + return 471 + } 472 + 473 + req.Header.Set("Content-Type", "application/json") 474 + req.Header.Set("Authorization", "Bearer "+session.AccessJwt) 475 + 476 + resp, err := client.Do(req) 477 + if err != nil { 478 + log.Printf("failed to add user to default spindle: %v", err) 479 + return 480 + } 481 + defer resp.Body.Close() 482 + 483 + if resp.StatusCode != http.StatusOK { 484 + log.Printf("failed to add user to default spindle: HTTP %d", resp.StatusCode) 485 + return 486 + } 487 + 488 + log.Printf("successfully added %s to default spindle", did) 333 489 } 334 490 335 491 func (o *OAuthHandler) addToDefaultKnot(did string) {
+13 -3
appview/oauth/oauth.go
··· 103 103 if err != nil { 104 104 return nil, false, fmt.Errorf("error parsing expiry time: %w", err) 105 105 } 106 - if expiry.Sub(time.Now()) <= 5*time.Minute { 106 + if time.Until(expiry) <= 5*time.Minute { 107 107 privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 108 108 if err != nil { 109 109 return nil, false, err ··· 224 224 s.service = service 225 225 } 226 226 } 227 + 228 + // Specify the Duration in seconds for the expiry of this token 229 + // 230 + // The time of expiry is calculated as time.Now().Unix() + exp 227 231 func WithExp(exp int64) ServiceClientOpt { 228 232 return func(s *ServiceClientOpts) { 229 - s.exp = exp 233 + s.exp = time.Now().Unix() + exp 230 234 } 231 235 } 232 236 ··· 266 270 return nil, err 267 271 } 268 272 273 + // force expiry to atleast 60 seconds in the future 274 + sixty := time.Now().Unix() + 60 275 + if opts.exp < sixty { 276 + opts.exp = sixty 277 + } 278 + 269 279 resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm) 270 280 if err != nil { 271 281 return nil, err ··· 305 315 redirectURIs := makeRedirectURIs(clientURI) 306 316 307 317 if o.config.Core.Dev { 308 - clientURI = fmt.Sprintf("http://127.0.0.1:3000") 318 + clientURI = "http://127.0.0.1:3000" 309 319 redirectURIs = makeRedirectURIs(clientURI) 310 320 311 321 query := url.Values{}
+29 -6
appview/pages/funcmap.go
··· 1 1 package pages 2 2 3 3 import ( 4 + "context" 4 5 "crypto/hmac" 5 6 "crypto/sha256" 6 7 "encoding/hex" ··· 18 19 19 20 "github.com/dustin/go-humanize" 20 21 "github.com/go-enry/go-enry/v2" 21 - "github.com/microcosm-cc/bluemonday" 22 22 "tangled.sh/tangled.sh/core/appview/filetree" 23 23 "tangled.sh/tangled.sh/core/appview/pages/markup" 24 24 ) ··· 27 27 return template.FuncMap{ 28 28 "split": func(s string) []string { 29 29 return strings.Split(s, "\n") 30 + }, 31 + "resolve": func(s string) string { 32 + identity, err := p.resolver.ResolveIdent(context.Background(), s) 33 + 34 + if err != nil { 35 + return s 36 + } 37 + 38 + if identity.Handle.IsInvalidHandle() { 39 + return "handle.invalid" 40 + } 41 + 42 + return "@" + identity.Handle.String() 30 43 }, 31 44 "truncateAt30": func(s string) string { 32 45 if len(s) <= 30 { ··· 74 87 "negf64": func(a float64) float64 { 75 88 return -a 76 89 }, 77 - "cond": func(cond interface{}, a, b string) string { 90 + "cond": func(cond any, a, b string) string { 78 91 if cond == nil { 79 92 return b 80 93 } ··· 167 180 return html.UnescapeString(s) 168 181 }, 169 182 "nl2br": func(text string) template.HTML { 170 - return template.HTML(strings.Replace(template.HTMLEscapeString(text), "\n", "<br>", -1)) 183 + return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(text), "\n", "<br>")) 171 184 }, 172 185 "unwrapText": func(text string) string { 173 186 paragraphs := strings.Split(text, "\n\n") ··· 193 206 } 194 207 return v.Slice(0, min(n, v.Len())).Interface() 195 208 }, 196 - 197 209 "markdown": func(text string) template.HTML { 198 - rctx := &markup.RenderContext{RendererType: markup.RendererTypeDefault} 199 - return template.HTML(bluemonday.UGCPolicy().Sanitize(rctx.RenderMarkdown(text))) 210 + p.rctx.RendererType = markup.RendererTypeDefault 211 + htmlString := p.rctx.RenderMarkdown(text) 212 + sanitized := p.rctx.SanitizeDefault(htmlString) 213 + return template.HTML(sanitized) 214 + }, 215 + "description": func(text string) template.HTML { 216 + p.rctx.RendererType = markup.RendererTypeDefault 217 + htmlString := p.rctx.RenderMarkdown(text) 218 + sanitized := p.rctx.SanitizeDescription(htmlString) 219 + return template.HTML(sanitized) 200 220 }, 201 221 "isNil": func(t any) bool { 202 222 // returns false for other "zero" values ··· 236 256 }, 237 257 "cssContentHash": CssContentHash, 238 258 "fileTree": filetree.FileTree, 259 + "pathEscape": func(s string) string { 260 + return url.PathEscape(s) 261 + }, 239 262 "pathUnescape": func(s string) string { 240 263 u, _ := url.PathUnescape(s) 241 264 return u
+61 -31
appview/pages/markup/markdown.go
··· 9 9 "path" 10 10 "strings" 11 11 12 - "github.com/microcosm-cc/bluemonday" 12 + chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 13 + "github.com/alecthomas/chroma/v2/styles" 13 14 "github.com/yuin/goldmark" 15 + highlighting "github.com/yuin/goldmark-highlighting/v2" 14 16 "github.com/yuin/goldmark/ast" 15 17 "github.com/yuin/goldmark/extension" 16 18 "github.com/yuin/goldmark/parser" ··· 40 42 repoinfo.RepoInfo 41 43 IsDev bool 42 44 RendererType RendererType 45 + Sanitizer Sanitizer 43 46 } 44 47 45 48 func (rctx *RenderContext) RenderMarkdown(source string) string { 46 49 md := goldmark.New( 47 - goldmark.WithExtensions(extension.GFM), 50 + goldmark.WithExtensions( 51 + extension.GFM, 52 + highlighting.NewHighlighting( 53 + highlighting.WithFormatOptions( 54 + chromahtml.Standalone(false), 55 + chromahtml.WithClasses(true), 56 + ), 57 + highlighting.WithCustomStyle(styles.Get("catppuccin-latte")), 58 + ), 59 + extension.NewFootnote( 60 + extension.WithFootnoteIDPrefix([]byte("footnote")), 61 + ), 62 + ), 48 63 goldmark.WithParserOptions( 49 64 parser.WithAutoHeadingID(), 50 65 ), ··· 145 160 } 146 161 } 147 162 148 - func (rctx *RenderContext) Sanitize(html string) string { 149 - policy := bluemonday.UGCPolicy() 150 - 151 - // video 152 - policy.AllowElements("video") 153 - policy.AllowAttrs("controls").OnElements("video") 154 - policy.AllowElements("source") 155 - policy.AllowAttrs("src", "type").OnElements("source") 156 - 157 - // centering content 158 - policy.AllowElements("center") 163 + func (rctx *RenderContext) SanitizeDefault(html string) string { 164 + return rctx.Sanitizer.SanitizeDefault(html) 165 + } 159 166 160 - policy.AllowAttrs("align", "style", "width", "height").Globally() 161 - policy.AllowStyles( 162 - "margin", 163 - "padding", 164 - "text-align", 165 - "font-weight", 166 - "text-decoration", 167 - "padding-left", 168 - "padding-right", 169 - "padding-top", 170 - "padding-bottom", 171 - "margin-left", 172 - "margin-right", 173 - "margin-top", 174 - "margin-bottom", 175 - ) 176 - return policy.Sanitize(html) 167 + func (rctx *RenderContext) SanitizeDescription(html string) string { 168 + return rctx.Sanitizer.SanitizeDescription(html) 177 169 } 178 170 179 171 type MarkdownTransformer struct { ··· 189 181 switch a.rctx.RendererType { 190 182 case RendererTypeRepoMarkdown: 191 183 switch n := n.(type) { 184 + case *ast.Heading: 185 + a.rctx.anchorHeadingTransformer(n) 192 186 case *ast.Link: 193 187 a.rctx.relativeLinkTransformer(n) 194 188 case *ast.Image: ··· 197 191 } 198 192 case RendererTypeDefault: 199 193 switch n := n.(type) { 194 + case *ast.Heading: 195 + a.rctx.anchorHeadingTransformer(n) 200 196 case *ast.Image: 201 197 a.rctx.imageFromKnotAstTransformer(n) 202 198 a.rctx.camoImageLinkAstTransformer(n) ··· 211 207 212 208 dst := string(link.Destination) 213 209 214 - if isAbsoluteUrl(dst) { 210 + if isAbsoluteUrl(dst) || isFragment(dst) || isMail(dst) { 215 211 return 216 212 } 217 213 ··· 252 248 img.Destination = []byte(rctx.imageFromKnotTransformer(dst)) 253 249 } 254 250 251 + func (rctx *RenderContext) anchorHeadingTransformer(h *ast.Heading) { 252 + idGeneric, exists := h.AttributeString("id") 253 + if !exists { 254 + return // no id, nothing to do 255 + } 256 + id, ok := idGeneric.([]byte) 257 + if !ok { 258 + return 259 + } 260 + 261 + // create anchor link 262 + anchor := ast.NewLink() 263 + anchor.Destination = fmt.Appendf(nil, "#%s", string(id)) 264 + anchor.SetAttribute([]byte("class"), []byte("anchor")) 265 + 266 + // create icon text 267 + iconText := ast.NewString([]byte("#")) 268 + anchor.AppendChild(anchor, iconText) 269 + 270 + // set class on heading 271 + h.SetAttribute([]byte("class"), []byte("heading")) 272 + 273 + // append anchor to heading 274 + h.AppendChild(h, anchor) 275 + } 276 + 255 277 // actualPath decides when to join the file path with the 256 278 // current repository directory (essentially only when the link 257 279 // destination is relative. if it's absolute then we assume the ··· 271 293 } 272 294 return parsed.IsAbs() 273 295 } 296 + 297 + func isFragment(link string) bool { 298 + return strings.HasPrefix(link, "#") 299 + } 300 + 301 + func isMail(link string) bool { 302 + return strings.HasPrefix(link, "mailto:") 303 + }
+117
appview/pages/markup/sanitizer.go
··· 1 + package markup 2 + 3 + import ( 4 + "maps" 5 + "regexp" 6 + "slices" 7 + "strings" 8 + 9 + "github.com/alecthomas/chroma/v2" 10 + "github.com/microcosm-cc/bluemonday" 11 + ) 12 + 13 + type Sanitizer struct { 14 + defaultPolicy *bluemonday.Policy 15 + descriptionPolicy *bluemonday.Policy 16 + } 17 + 18 + func NewSanitizer() Sanitizer { 19 + return Sanitizer{ 20 + defaultPolicy: defaultPolicy(), 21 + descriptionPolicy: descriptionPolicy(), 22 + } 23 + } 24 + 25 + func (s *Sanitizer) SanitizeDefault(html string) string { 26 + return s.defaultPolicy.Sanitize(html) 27 + } 28 + func (s *Sanitizer) SanitizeDescription(html string) string { 29 + return s.descriptionPolicy.Sanitize(html) 30 + } 31 + 32 + func defaultPolicy() *bluemonday.Policy { 33 + policy := bluemonday.UGCPolicy() 34 + 35 + // Allow generally safe attributes 36 + generalSafeAttrs := []string{ 37 + "abbr", "accept", "accept-charset", 38 + "accesskey", "action", "align", "alt", 39 + "aria-describedby", "aria-hidden", "aria-label", "aria-labelledby", 40 + "axis", "border", "cellpadding", "cellspacing", "char", 41 + "charoff", "charset", "checked", 42 + "clear", "cols", "colspan", "color", 43 + "compact", "coords", "datetime", "dir", 44 + "disabled", "enctype", "for", "frame", 45 + "headers", "height", "hreflang", 46 + "hspace", "ismap", "label", "lang", 47 + "maxlength", "media", "method", 48 + "multiple", "name", "nohref", "noshade", 49 + "nowrap", "open", "prompt", "readonly", "rel", "rev", 50 + "rows", "rowspan", "rules", "scope", 51 + "selected", "shape", "size", "span", 52 + "start", "summary", "tabindex", "target", 53 + "title", "type", "usemap", "valign", "value", 54 + "vspace", "width", "itemprop", 55 + } 56 + 57 + generalSafeElements := []string{ 58 + "h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "br", "b", "i", "strong", "em", "a", "pre", "code", "img", "tt", 59 + "div", "ins", "del", "sup", "sub", "p", "ol", "ul", "table", "thead", "tbody", "tfoot", "blockquote", "label", 60 + "dl", "dt", "dd", "kbd", "q", "samp", "var", "hr", "ruby", "rt", "rp", "li", "tr", "td", "th", "s", "strike", "summary", 61 + "details", "caption", "figure", "figcaption", 62 + "abbr", "bdo", "cite", "dfn", "mark", "small", "span", "time", "video", "wbr", 63 + } 64 + 65 + policy.AllowAttrs(generalSafeAttrs...).OnElements(generalSafeElements...) 66 + 67 + // video 68 + policy.AllowAttrs("src", "autoplay", "controls").OnElements("video") 69 + 70 + // checkboxes 71 + policy.AllowAttrs("type").Matching(regexp.MustCompile(`^checkbox$`)).OnElements("input") 72 + policy.AllowAttrs("checked", "disabled", "data-source-position").OnElements("input") 73 + 74 + // for code blocks 75 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`chroma`)).OnElements("pre") 76 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`anchor|footnote-ref|footnote-backref`)).OnElements("a") 77 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8") 78 + policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span") 79 + 80 + // centering content 81 + policy.AllowElements("center") 82 + 83 + policy.AllowAttrs("align", "style", "width", "height").Globally() 84 + policy.AllowStyles( 85 + "margin", 86 + "padding", 87 + "text-align", 88 + "font-weight", 89 + "text-decoration", 90 + "padding-left", 91 + "padding-right", 92 + "padding-top", 93 + "padding-bottom", 94 + "margin-left", 95 + "margin-right", 96 + "margin-top", 97 + "margin-bottom", 98 + ) 99 + 100 + return policy 101 + } 102 + 103 + func descriptionPolicy() *bluemonday.Policy { 104 + policy := bluemonday.NewPolicy() 105 + policy.AllowStandardURLs() 106 + 107 + // allow italics and bold. 108 + policy.AllowElements("i", "b", "em", "strong") 109 + 110 + // allow code. 111 + policy.AllowElements("code") 112 + 113 + // allow links 114 + policy.AllowAttrs("href", "target", "rel").OnElements("a") 115 + 116 + return policy 117 + }
+129 -51
appview/pages/pages.go
··· 24 24 "tangled.sh/tangled.sh/core/appview/pages/markup" 25 25 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 26 26 "tangled.sh/tangled.sh/core/appview/pagination" 27 + "tangled.sh/tangled.sh/core/idresolver" 27 28 "tangled.sh/tangled.sh/core/patchutil" 28 29 "tangled.sh/tangled.sh/core/types" 29 30 ··· 31 32 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 32 33 "github.com/alecthomas/chroma/v2/lexers" 33 34 "github.com/alecthomas/chroma/v2/styles" 35 + "github.com/bluesky-social/indigo/atproto/identity" 34 36 "github.com/bluesky-social/indigo/atproto/syntax" 35 37 "github.com/go-git/go-git/v5/plumbing" 36 38 "github.com/go-git/go-git/v5/plumbing/object" ··· 44 46 t map[string]*template.Template 45 47 46 48 avatar config.AvatarConfig 49 + resolver *idresolver.Resolver 47 50 dev bool 48 51 embedFS embed.FS 49 52 templateDir string // Path to templates on disk for dev mode 50 53 rctx *markup.RenderContext 51 54 } 52 55 53 - func NewPages(config *config.Config) *Pages { 56 + func NewPages(config *config.Config, res *idresolver.Resolver) *Pages { 54 57 // initialized with safe defaults, can be overriden per use 55 58 rctx := &markup.RenderContext{ 56 59 IsDev: config.Core.Dev, 57 60 CamoUrl: config.Camo.Host, 58 61 CamoSecret: config.Camo.SharedSecret, 62 + Sanitizer: markup.NewSanitizer(), 59 63 } 60 64 61 65 p := &Pages{ ··· 65 69 avatar: config.Avatar, 66 70 embedFS: Files, 67 71 rctx: rctx, 72 + resolver: res, 68 73 templateDir: "appview/pages", 69 74 } 70 75 ··· 255 260 return p.executeOrReload(name, w, "layouts/repobase", params) 256 261 } 257 262 263 + func (p *Pages) Favicon(w io.Writer) error { 264 + return p.executePlain("favicon", w, nil) 265 + } 266 + 258 267 type LoginParams struct { 268 + ReturnUrl string 259 269 } 260 270 261 271 func (p *Pages) Login(w io.Writer, params LoginParams) error { 262 272 return p.executePlain("user/login", w, params) 263 273 } 264 274 265 - type SignupParams struct{} 275 + func (p *Pages) Signup(w io.Writer) error { 276 + return p.executePlain("user/signup", w, nil) 277 + } 266 278 267 - func (p *Pages) CompleteSignup(w io.Writer, params SignupParams) error { 268 - return p.executePlain("user/completeSignup", w, params) 279 + func (p *Pages) CompleteSignup(w io.Writer) error { 280 + return p.executePlain("user/completeSignup", w, nil) 269 281 } 270 282 271 283 type TermsOfServiceParams struct { ··· 287 299 type TimelineParams struct { 288 300 LoggedInUser *oauth.User 289 301 Timeline []db.TimelineEvent 290 - DidHandleMap map[string]string 302 + Repos []db.Repo 291 303 } 292 304 293 305 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 294 - return p.execute("timeline", w, params) 306 + return p.execute("timeline/timeline", w, params) 295 307 } 296 308 297 309 type SettingsParams struct { ··· 315 327 316 328 type KnotParams struct { 317 329 LoggedInUser *oauth.User 318 - DidHandleMap map[string]string 319 330 Registration *db.Registration 320 331 Members []string 321 332 Repos map[string][]db.Repo ··· 372 383 Spindle db.Spindle 373 384 Members []string 374 385 Repos map[string][]db.Repo 375 - DidHandleMap map[string]string 376 386 } 377 387 378 388 func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { ··· 405 415 ProfileTimeline *db.ProfileTimeline 406 416 Card ProfileCard 407 417 Punchcard db.Punchcard 408 - 409 - DidHandleMap map[string]string 410 418 } 411 419 412 420 type ProfileCard struct { 413 421 UserDid string 414 422 UserHandle string 415 423 FollowStatus db.FollowStatus 416 - AvatarUri string 417 424 Followers int 418 425 Following int 419 426 ··· 428 435 LoggedInUser *oauth.User 429 436 Repos []db.Repo 430 437 Card ProfileCard 431 - 432 - DidHandleMap map[string]string 433 438 } 434 439 435 440 func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error { ··· 458 463 LoggedInUser *oauth.User 459 464 Profile *db.Profile 460 465 AllRepos []PinnedRepo 461 - DidHandleMap map[string]string 462 466 } 463 467 464 468 type PinnedRepo struct { ··· 517 521 } 518 522 519 523 p.rctx.RepoInfo = params.RepoInfo 524 + p.rctx.RepoInfo.Ref = params.Ref 520 525 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 521 526 522 527 if params.ReadmeFileName != "" { 523 - var htmlString string 524 528 ext := filepath.Ext(params.ReadmeFileName) 525 529 switch ext { 526 530 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 527 - htmlString = p.rctx.Sanitize(htmlString) 528 - htmlString = p.rctx.RenderMarkdown(params.Readme) 529 531 params.Raw = false 530 - params.HTMLReadme = template.HTML(htmlString) 532 + htmlString := p.rctx.RenderMarkdown(params.Readme) 533 + sanitized := p.rctx.SanitizeDefault(htmlString) 534 + params.HTMLReadme = template.HTML(sanitized) 531 535 default: 532 536 params.Raw = true 533 537 } ··· 666 670 p.rctx.RepoInfo = params.RepoInfo 667 671 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 668 672 htmlString := p.rctx.RenderMarkdown(params.Contents) 669 - params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString)) 673 + sanitized := p.rctx.SanitizeDefault(htmlString) 674 + params.RenderedContents = template.HTML(sanitized) 670 675 } 671 676 } 672 677 673 - if params.Lines < 5000 { 674 - c := params.Contents 675 - formatter := chromahtml.New( 676 - chromahtml.InlineCode(false), 677 - chromahtml.WithLineNumbers(true), 678 - chromahtml.WithLinkableLineNumbers(true, "L"), 679 - chromahtml.Standalone(false), 680 - chromahtml.WithClasses(true), 681 - ) 678 + c := params.Contents 679 + formatter := chromahtml.New( 680 + chromahtml.InlineCode(false), 681 + chromahtml.WithLineNumbers(true), 682 + chromahtml.WithLinkableLineNumbers(true, "L"), 683 + chromahtml.Standalone(false), 684 + chromahtml.WithClasses(true), 685 + ) 682 686 683 - lexer := lexers.Get(filepath.Base(params.Path)) 684 - if lexer == nil { 685 - lexer = lexers.Fallback 686 - } 687 + lexer := lexers.Get(filepath.Base(params.Path)) 688 + if lexer == nil { 689 + lexer = lexers.Fallback 690 + } 687 691 688 - iterator, err := lexer.Tokenise(nil, c) 689 - if err != nil { 690 - return fmt.Errorf("chroma tokenize: %w", err) 691 - } 692 + iterator, err := lexer.Tokenise(nil, c) 693 + if err != nil { 694 + return fmt.Errorf("chroma tokenize: %w", err) 695 + } 692 696 693 - var code bytes.Buffer 694 - err = formatter.Format(&code, style, iterator) 695 - if err != nil { 696 - return fmt.Errorf("chroma format: %w", err) 697 - } 698 - 699 - params.Contents = code.String() 697 + var code bytes.Buffer 698 + err = formatter.Format(&code, style, iterator) 699 + if err != nil { 700 + return fmt.Errorf("chroma format: %w", err) 700 701 } 701 702 703 + params.Contents = code.String() 702 704 params.Active = "overview" 703 705 return p.executeRepo("repo/blob", w, params) 704 706 } ··· 777 779 RepoInfo repoinfo.RepoInfo 778 780 Active string 779 781 Issues []db.Issue 780 - DidHandleMap map[string]string 781 782 Page pagination.Page 782 783 FilteringByOpen bool 783 784 } ··· 791 792 LoggedInUser *oauth.User 792 793 RepoInfo repoinfo.RepoInfo 793 794 Active string 794 - Issue db.Issue 795 + Issue *db.Issue 795 796 Comments []db.Comment 796 797 IssueOwnerHandle string 797 - DidHandleMap map[string]string 798 798 799 799 OrderedReactionKinds []db.ReactionKind 800 800 Reactions map[db.ReactionKind]int ··· 848 848 849 849 type SingleIssueCommentParams struct { 850 850 LoggedInUser *oauth.User 851 - DidHandleMap map[string]string 852 851 RepoInfo repoinfo.RepoInfo 853 852 Issue *db.Issue 854 853 Comment *db.Comment ··· 880 879 RepoInfo repoinfo.RepoInfo 881 880 Pulls []*db.Pull 882 881 Active string 883 - DidHandleMap map[string]string 884 882 FilteringBy db.PullState 885 883 Stacks map[string]db.Stack 886 884 Pipelines map[string]db.Pipeline ··· 913 911 LoggedInUser *oauth.User 914 912 RepoInfo repoinfo.RepoInfo 915 913 Active string 916 - DidHandleMap map[string]string 917 914 Pull *db.Pull 918 915 Stack db.Stack 919 916 AbandonedPulls []*db.Pull ··· 933 930 934 931 type RepoPullPatchParams struct { 935 932 LoggedInUser *oauth.User 936 - DidHandleMap map[string]string 937 933 RepoInfo repoinfo.RepoInfo 938 934 Pull *db.Pull 939 935 Stack db.Stack ··· 951 947 952 948 type RepoPullInterdiffParams struct { 953 949 LoggedInUser *oauth.User 954 - DidHandleMap map[string]string 955 950 RepoInfo repoinfo.RepoInfo 956 951 Pull *db.Pull 957 952 Round int ··· 1140 1135 func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error { 1141 1136 params.Active = "pipelines" 1142 1137 return p.executeRepo("repo/pipelines/workflow", w, params) 1138 + } 1139 + 1140 + type PutStringParams struct { 1141 + LoggedInUser *oauth.User 1142 + Action string 1143 + 1144 + // this is supplied in the case of editing an existing string 1145 + String db.String 1146 + } 1147 + 1148 + func (p *Pages) PutString(w io.Writer, params PutStringParams) error { 1149 + return p.execute("strings/put", w, params) 1150 + } 1151 + 1152 + type StringsDashboardParams struct { 1153 + LoggedInUser *oauth.User 1154 + Card ProfileCard 1155 + Strings []db.String 1156 + } 1157 + 1158 + func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error { 1159 + return p.execute("strings/dashboard", w, params) 1160 + } 1161 + 1162 + type StringTimelineParams struct { 1163 + LoggedInUser *oauth.User 1164 + Strings []db.String 1165 + } 1166 + 1167 + func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error { 1168 + return p.execute("strings/timeline", w, params) 1169 + } 1170 + 1171 + type SingleStringParams struct { 1172 + LoggedInUser *oauth.User 1173 + ShowRendered bool 1174 + RenderToggle bool 1175 + RenderedContents template.HTML 1176 + String db.String 1177 + Stats db.StringStats 1178 + Owner identity.Identity 1179 + } 1180 + 1181 + func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { 1182 + var style *chroma.Style = styles.Get("catpuccin-latte") 1183 + 1184 + if params.ShowRendered { 1185 + switch markup.GetFormat(params.String.Filename) { 1186 + case markup.FormatMarkdown: 1187 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 1188 + htmlString := p.rctx.RenderMarkdown(params.String.Contents) 1189 + sanitized := p.rctx.SanitizeDefault(htmlString) 1190 + params.RenderedContents = template.HTML(sanitized) 1191 + } 1192 + } 1193 + 1194 + c := params.String.Contents 1195 + formatter := chromahtml.New( 1196 + chromahtml.InlineCode(false), 1197 + chromahtml.WithLineNumbers(true), 1198 + chromahtml.WithLinkableLineNumbers(true, "L"), 1199 + chromahtml.Standalone(false), 1200 + chromahtml.WithClasses(true), 1201 + ) 1202 + 1203 + lexer := lexers.Get(filepath.Base(params.String.Filename)) 1204 + if lexer == nil { 1205 + lexer = lexers.Fallback 1206 + } 1207 + 1208 + iterator, err := lexer.Tokenise(nil, c) 1209 + if err != nil { 1210 + return fmt.Errorf("chroma tokenize: %w", err) 1211 + } 1212 + 1213 + var code bytes.Buffer 1214 + err = formatter.Format(&code, style, iterator) 1215 + if err != nil { 1216 + return fmt.Errorf("chroma format: %w", err) 1217 + } 1218 + 1219 + params.String.Contents = code.String() 1220 + return p.execute("strings/string", w, params) 1143 1221 } 1144 1222 1145 1223 func (p *Pages) Static() http.Handler {
+26
appview/pages/templates/favicon.html
··· 1 + {{ define "favicon" }} 2 + <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"> 3 + <style> 4 + .favicon-text { 5 + fill: #000000; 6 + stroke: none; 7 + } 8 + 9 + @media (prefers-color-scheme: dark) { 10 + .favicon-text { 11 + fill: #ffffff; 12 + stroke: none; 13 + } 14 + } 15 + </style> 16 + 17 + <g style="display:inline"> 18 + <path d="M0-2.117h62.177v25.135H0z" style="display:inline;fill:none;fill-opacity:1;stroke-width:.396875" transform="translate(11.01 6.9)"/> 19 + <path d="M3.64 22.787c-1.697 0-2.943-.45-3.74-1.35-.77-.9-1.156-2.094-1.156-3.585 0-.36.013-.72.038-1.08.052-.385.129-.873.232-1.464L.44 6.826h-5.089l.733-4.394h3.2c.822 0 1.439-.168 1.85-.502.437-.334.72-.938.848-1.812l.771-4.703h5.243L6.84 2.432h7.787l-.733 4.394H6.107L4.257 17.93l.77.27 6.015-4.742 2.775 3.161-2.313 2.005c-.822.694-1.568 1.31-2.236 1.85-.668.515-1.31.952-1.927 1.311a7.406 7.406 0 0 1-1.774.733c-.59.18-1.233.27-1.927.27z" 20 + aria-label="tangled.sh" 21 + class="favicon-text" 22 + style="font-size:16.2278px;font-family:'IBM Plex Mono';-inkscape-font-specification:'IBM Plex Mono, Normal';display:inline;fill-opacity:1" 23 + transform="translate(11.01 6.9)"/> 24 + </g> 25 + </svg> 26 + {{ end }}
+3 -4
appview/pages/templates/knots/dashboard.html
··· 38 38 <div> 39 39 <div class="flex justify-between items-center"> 40 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> 41 + {{ template "user/fragments/picHandleLink" . }} 42 + <span class="ml-2 font-mono text-gray-500">{{.}}</span> 44 43 </div> 45 44 </div> 46 45 <div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700"> ··· 48 47 {{ range $repos }} 49 48 <div class="flex gap-2 items-center"> 50 49 {{ i "book-marked" "size-4" }} 51 - <a href="/{{ .Did }}/{{ .Name }}"> 50 + <a href="/{{ resolve .Did }}/{{ .Name }}"> 52 51 {{ .Name }} 53 52 </a> 54 53 </div>
-12
appview/pages/templates/layouts/base.html
··· 24 24 {{ block "mainLayout" . }} 25 25 <div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4"> 26 26 {{ block "contentLayout" . }} 27 - <div class="col-span-1 md:col-span-2"> 28 - {{ block "contentLeft" . }} {{ end }} 29 - </div> 30 27 <main class="col-span-1 md:col-span-8"> 31 28 {{ block "content" . }}{{ end }} 32 29 </main> 33 - <div class="col-span-1 md:col-span-2"> 34 - {{ block "contentRight" . }} {{ end }} 35 - </div> 36 30 {{ end }} 37 31 38 32 {{ block "contentAfterLayout" . }} 39 - <div class="col-span-1 md:col-span-2"> 40 - {{ block "contentAfterLeft" . }} {{ end }} 41 - </div> 42 33 <main class="col-span-1 md:col-span-8"> 43 34 {{ block "contentAfter" . }}{{ end }} 44 35 </main> 45 - <div class="col-span-1 md:col-span-2"> 46 - {{ block "contentAfterRight" . }} {{ end }} 47 - </div> 48 36 {{ end }} 49 37 </div> 50 38 {{ end }}
+42 -8
appview/pages/templates/layouts/footer.html
··· 1 1 {{ define "layouts/footer" }} 2 - <div class="w-full p-4 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 - <div class="container mx-auto text-center text-gray-600 dark:text-gray-400 text-sm"> 4 - <div class="mb-2"> 5 - <a href="/terms" class="hover:text-gray-900 dark:hover:text-gray-200 underline">Terms of Service</a> 6 - &nbsp;โ€ข&nbsp; 7 - <a href="/privacy" class="hover:text-gray-900 dark:hover:text-gray-200 underline">Privacy Policy</a> 2 + <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 + <div class="container mx-auto max-w-7xl px-4"> 4 + <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 + <div class="mb-4 md:mb-0"> 6 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 7 + tangled<sub>alpha</sub> 8 + </a> 9 + </div> 10 + 11 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 + <div class="flex flex-col gap-1"> 16 + <div class="{{ $headerStyle }}">legal</div> 17 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 8 19 </div> 9 - <div> 10 - <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> 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> 11 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> 12 45 </div> 46 + </div> 13 47 </div> 14 48 {{ end }}
+16 -21
appview/pages/templates/layouts/repobase.html
··· 5 5 {{ if .RepoInfo.Source }} 6 6 <p class="text-sm"> 7 7 <div class="flex items-center"> 8 - {{ i "git-fork" "w-3 h-3 mr-1"}} 8 + {{ i "git-fork" "w-3 h-3 mr-1 shrink-0" }} 9 9 forked from 10 10 {{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }} 11 11 <a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a> ··· 20 20 </div> 21 21 22 22 <div class="flex items-center gap-2 z-auto"> 23 + <a 24 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 25 + href="/{{ .RepoInfo.FullName }}/feed.atom" 26 + > 27 + {{ i "rss" "size-4" }} 28 + </a> 23 29 {{ template "repo/fragments/repoStar" .RepoInfo }} 24 - {{ if .RepoInfo.DisableFork }} 25 - <button 26 - class="btn text-sm no-underline hover:no-underline flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" 27 - disabled 28 - title="Empty repositories cannot be forked" 29 - > 30 - {{ i "git-fork" "w-4 h-4" }} 31 - fork 32 - </button> 33 - {{ else }} 34 - <a 35 - class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 36 - hx-boost="true" 37 - href="/{{ .RepoInfo.FullName }}/fork" 38 - > 39 - {{ i "git-fork" "w-4 h-4" }} 40 - fork 41 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 42 - </a> 43 - {{ end }} 30 + <a 31 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 32 + hx-boost="true" 33 + href="/{{ .RepoInfo.FullName }}/fork" 34 + > 35 + {{ i "git-fork" "w-4 h-4" }} 36 + fork 37 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 38 + </a> 44 39 </div> 45 40 </div> 46 41 {{ template "repo/fragments/repoDescription" . }}
+38 -18
appview/pages/templates/layouts/topbar.html
··· 2 2 <nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 3 3 <div class="flex justify-between p-0 items-center"> 4 4 <div id="left-items"> 5 - <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 5 + <a href="/" hx-boost="true" class="flex gap-2 font-bold italic"> 6 6 tangled<sub>alpha</sub> 7 7 </a> 8 8 </div> 9 - <div class="hidden md:flex gap-4 items-center"> 10 - <a href="https://chat.tangled.sh" class="inline-flex gap-1 items-center"> 11 - {{ i "message-circle" "size-4" }} discord 12 - </a> 13 9 14 - <a href="https://web.libera.chat/#tangled" class="inline-flex gap-1 items-center"> 15 - {{ i "hash" "size-4" }} irc 16 - </a> 17 - 18 - <a href="https://tangled.sh/@tangled.sh/core" class="inline-flex gap-1 items-center"> 19 - {{ i "code" "size-4" }} source 20 - </a> 21 - </div> 22 - <div id="right-items" class="flex items-center gap-4"> 10 + <div id="right-items" class="flex items-center gap-2"> 23 11 {{ with .LoggedInUser }} 24 - <a href="/repo/new" hx-boost="true" class="btn-create hover:no-underline hover:text-white"> 25 - {{ i "plus" "w-4 h-4" }} 26 - </a> 12 + {{ block "newButton" . }} {{ end }} 27 13 {{ block "dropDown" . }} {{ end }} 28 14 {{ else }} 29 15 <a href="/login">login</a> 16 + <span class="text-gray-500 dark:text-gray-400">or</span> 17 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 18 + join now {{ i "arrow-right" "size-4" }} 19 + </a> 30 20 {{ end }} 31 21 </div> 32 22 </div> 33 23 </nav> 34 24 {{ end }} 35 25 26 + {{ define "newButton" }} 27 + <details class="relative inline-block text-left nav-dropdown"> 28 + <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 29 + {{ i "plus" "w-4 h-4" }} new 30 + </summary> 31 + <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 32 + <a href="/repo/new" class="flex items-center gap-2"> 33 + {{ i "book-plus" "w-4 h-4" }} 34 + new repository 35 + </a> 36 + <a href="/strings/new" class="flex items-center gap-2"> 37 + {{ i "line-squiggle" "w-4 h-4" }} 38 + new string 39 + </a> 40 + </div> 41 + </details> 42 + {{ end }} 43 + 36 44 {{ define "dropDown" }} 37 - <details class="relative inline-block text-left"> 45 + <details class="relative inline-block text-left nav-dropdown"> 38 46 <summary 39 47 class="cursor-pointer list-none flex items-center" 40 48 > ··· 46 54 > 47 55 <a href="/{{ $user }}">profile</a> 48 56 <a href="/{{ $user }}?tab=repos">repositories</a> 57 + <a href="/strings/{{ $user }}">strings</a> 49 58 <a href="/knots">knots</a> 50 59 <a href="/spindles">spindles</a> 51 60 <a href="/settings">settings</a> ··· 57 66 </a> 58 67 </div> 59 68 </details> 69 + 70 + <script> 71 + document.addEventListener('click', function(event) { 72 + const dropdowns = document.querySelectorAll('.nav-dropdown'); 73 + dropdowns.forEach(function(dropdown) { 74 + if (!dropdown.contains(event.target)) { 75 + dropdown.removeAttribute('open'); 76 + } 77 + }); 78 + }); 79 + </script> 60 80 {{ end }}
+3 -1
appview/pages/templates/legal/privacy.html
··· 1 1 {{ define "title" }} privacy policy {{ end }} 2 2 {{ define "content" }} 3 3 <div class="max-w-4xl mx-auto px-4 py-8"> 4 - <div class="prose prose-gray dark:prose-invert max-w-none"> 4 + <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 5 + <div class="prose prose-gray dark:prose-invert max-w-none"> 5 6 <h1>Privacy Policy</h1> 6 7 7 8 <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p> ··· 125 126 126 127 <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400"> 127 128 <p>This Privacy Policy complies with the EU General Data Protection Regulation (GDPR) and other applicable data protection laws.</p> 129 + </div> 128 130 </div> 129 131 </div> 130 132 </div>
+3 -1
appview/pages/templates/legal/terms.html
··· 2 2 3 3 {{ define "content" }} 4 4 <div class="max-w-4xl mx-auto px-4 py-8"> 5 - <div class="prose prose-gray dark:prose-invert max-w-none"> 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"> 6 7 <h1>Terms of Service</h1> 7 8 8 9 <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p> ··· 63 64 64 65 <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400"> 65 66 <p>These terms are effective as of the last updated date shown above and will remain in effect except with respect to any changes in their provisions in the future, which will be in effect immediately after being posted on this page.</p> 67 + </div> 66 68 </div> 67 69 </div> 68 70 </div>
+1 -1
appview/pages/templates/repo/commit.html
··· 118 118 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 119 119 {{ template "repo/fragments/diffOpts" .DiffOpts }} 120 120 </div> 121 - <div class="sticky top-0 flex-grow max-h-screen"> 121 + <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 122 122 {{ template "repo/fragments/diffChangedFiles" .Diff }} 123 123 </div> 124 124 {{end}}
+1 -1
appview/pages/templates/repo/compare/compare.html
··· 49 49 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 50 50 {{ template "repo/fragments/diffOpts" .DiffOpts }} 51 51 </div> 52 - <div class="sticky top-0 flex-grow max-h-screen"> 52 + <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 53 53 {{ template "repo/fragments/diffChangedFiles" .Diff }} 54 54 </div> 55 55 {{end}}
+5 -7
appview/pages/templates/repo/empty.html
··· 32 32 <div class="py-6 w-fit flex flex-col gap-4"> 33 33 <p>This is an empty repository. To get started:</p> 34 34 {{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }} 35 - <p><span class="{{$bullet}}">1</span>Add a public key to your account from the <a href="/settings" class="underline">settings</a> page</p> 36 - <p><span class="{{$bullet}}">2</span>Configure your remote to <span class="font-mono p-1 rounded bg-gray-100 dark:bg-gray-700 ">git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}<span></p> 37 - <p><span class="{{$bullet}}">3</span>Push!</p> 35 + 36 + <p><span class="{{$bullet}}">1</span>First, generate a new <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Generating-Your-SSH-Public-Key" class="underline">SSH key pair</a>.</p> 37 + <p><span class="{{$bullet}}">2</span>Then add the public key to your account from the <a href="/settings" class="underline">settings</a> page.</p> 38 + <p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p> 39 + <p><span class="{{$bullet}}">4</span>Push!</p> 38 40 </div> 39 41 </div> 40 42 {{ else }} ··· 42 44 {{ end }} 43 45 </main> 44 46 {{ end }} 45 - 46 - {{ define "repoAfter" }} 47 - {{ template "repo/fragments/cloneInstructions" . }} 48 - {{ end }}
+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 }}
+4 -4
appview/pages/templates/repo/fragments/fileTree.html
··· 3 3 <details open> 4 4 <summary class="cursor-pointer list-none pt-1"> 5 5 <span class="tree-directory inline-flex items-center gap-2 "> 6 - {{ i "folder" "size-4 fill-current" }} 7 - <span class="filename text-black dark:text-white">{{ .Name }}</span> 6 + {{ i "folder" "flex-shrink-0 size-4 fill-current" }} 7 + <span class="filename truncate text-black dark:text-white">{{ .Name }}</span> 8 8 </span> 9 9 </summary> 10 10 <div class="ml-1 pl-2 border-l border-gray-200 dark:border-gray-700"> ··· 15 15 </details> 16 16 {{ else if .Name }} 17 17 <div class="tree-file flex items-center gap-2 pt-1"> 18 - {{ i "file" "size-4" }} 19 - <a href="#file-{{ .Path }}" class="filename text-black dark:text-white no-underline hover:underline">{{ .Name }}</a> 18 + {{ i "file" "flex-shrink-0 size-4" }} 19 + <a href="#file-{{ .Path }}" class="filename truncate text-black dark:text-white no-underline hover:underline">{{ .Name }}</a> 20 20 </div> 21 21 {{ else }} 22 22 {{ range $child := .Children }}
+1 -1
appview/pages/templates/repo/fragments/interdiffFiles.html
··· 1 1 {{ define "repo/fragments/interdiffFiles" }} 2 2 {{ $fileTree := fileTree .AffectedFiles }} 3 - <section class="mt-4 px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm min-h-full text-sm"> 3 + <section class="px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm min-h-full text-sm"> 4 4 <div class="diff-stat"> 5 5 <div class="flex gap-2 items-center"> 6 6 <strong class="text-sm uppercase dark:text-gray-200">files</strong>
+1 -1
appview/pages/templates/repo/fragments/repoDescription.html
··· 1 1 {{ define "repo/fragments/repoDescription" }} 2 2 <span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML"> 3 3 {{ if .RepoInfo.Description }} 4 - {{ .RepoInfo.Description }} 4 + {{ .RepoInfo.Description | description }} 5 5 {{ else }} 6 6 <span class="italic">this repo has no description</span> 7 7 {{ end }}
+70 -63
appview/pages/templates/repo/index.html
··· 14 14 {{ end }} 15 15 <div class="flex items-center justify-between pb-5"> 16 16 {{ block "branchSelector" . }}{{ end }} 17 - <div class="flex md:hidden items-center gap-4"> 18 - <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex items-center text-sm gap-1"> 17 + <div class="flex md:hidden items-center gap-2"> 18 + <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex items-center text-sm gap-1 font-bold"> 19 19 {{ i "git-commit-horizontal" "w-4" "h-4" }} {{ .TotalCommits }} 20 20 </a> 21 - <a href="/{{ .RepoInfo.FullName }}/branches" class="inline-flex items-center text-sm gap-1"> 21 + <a href="/{{ .RepoInfo.FullName }}/branches" class="inline-flex items-center text-sm gap-1 font-bold"> 22 22 {{ i "git-branch" "w-4" "h-4" }} {{ len .Branches }} 23 23 </a> 24 - <a href="/{{ .RepoInfo.FullName }}/tags" class="inline-flex items-center text-sm gap-1"> 24 + <a href="/{{ .RepoInfo.FullName }}/tags" class="inline-flex items-center text-sm gap-1 font-bold"> 25 25 {{ i "tags" "w-4" "h-4" }} {{ len .Tags }} 26 26 </a> 27 + {{ template "repo/fragments/cloneDropdown" . }} 27 28 </div> 28 29 </div> 29 30 <div class="grid grid-cols-1 md:grid-cols-2 gap-2"> ··· 47 48 48 49 49 50 {{ define "branchSelector" }} 50 - <div class="flex gap-2 items-center items-stretch justify-center"> 51 - <select 52 - onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)" 53 - class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700" 54 - > 55 - <optgroup label="branches ({{len .Branches}})" class="bold text-sm"> 56 - {{ range .Branches }} 57 - <option 58 - value="{{ .Reference.Name }}" 59 - class="py-1" 60 - {{ if eq .Reference.Name $.Ref }} 61 - selected 62 - {{ end }} 63 - > 64 - {{ .Reference.Name }} 65 - </option> 66 - {{ end }} 67 - </optgroup> 68 - <optgroup label="tags ({{len .Tags}})" class="bold text-sm"> 69 - {{ range .Tags }} 70 - <option 71 - value="{{ .Reference.Name }}" 72 - class="py-1" 73 - {{ if eq .Reference.Name $.Ref }} 74 - selected 75 - {{ end }} 76 - > 77 - {{ .Reference.Name }} 78 - </option> 79 - {{ else }} 80 - <option class="py-1" disabled>no tags found</option> 81 - {{ end }} 82 - </optgroup> 83 - </select> 84 - <div class="flex items-center gap-2"> 51 + <div class="flex gap-2 items-center justify-between w-full"> 52 + <div class="flex gap-2 items-center"> 53 + <select 54 + onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)" 55 + class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700" 56 + > 57 + <optgroup label="branches ({{len .Branches}})" class="bold text-sm"> 58 + {{ range .Branches }} 59 + <option 60 + value="{{ .Reference.Name }}" 61 + class="py-1" 62 + {{ if eq .Reference.Name $.Ref }} 63 + selected 64 + {{ end }} 65 + > 66 + {{ .Reference.Name }} 67 + </option> 68 + {{ end }} 69 + </optgroup> 70 + <optgroup label="tags ({{len .Tags}})" class="bold text-sm"> 71 + {{ range .Tags }} 72 + <option 73 + value="{{ .Reference.Name }}" 74 + class="py-1" 75 + {{ if eq .Reference.Name $.Ref }} 76 + selected 77 + {{ end }} 78 + > 79 + {{ .Reference.Name }} 80 + </option> 81 + {{ else }} 82 + <option class="py-1" disabled>no tags found</option> 83 + {{ end }} 84 + </optgroup> 85 + </select> 86 + <div class="flex items-center gap-2"> 85 87 {{ $isOwner := and .LoggedInUser .RepoInfo.Roles.IsOwner }} 86 88 {{ $isCollaborator := and .LoggedInUser .RepoInfo.Roles.IsCollaborator }} 87 89 {{ if and (or $isOwner $isCollaborator) .ForkInfo .ForkInfo.IsFork }} ··· 115 117 <span>sync</span> 116 118 </button> 117 119 {{ end }} 118 - <a 119 - href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}" 120 - class="btn flex items-center gap-2 no-underline hover:no-underline" 121 - title="Compare branches or tags" 122 - > 123 - {{ i "git-compare" "w-4 h-4" }} 124 - </a> 120 + <a 121 + href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}" 122 + class="btn flex items-center gap-2 no-underline hover:no-underline" 123 + title="Compare branches or tags" 124 + > 125 + {{ i "git-compare" "w-4 h-4" }} 126 + </a> 127 + </div> 125 128 </div> 126 - </div> 129 + 130 + <!-- Clone dropdown in top right --> 131 + <div class="hidden md:flex items-center "> 132 + {{ template "repo/fragments/cloneDropdown" . }} 133 + </div> 134 + </div> 127 135 {{ end }} 128 136 129 137 {{ define "fileTree" }} ··· 131 139 {{ $linkstyle := "no-underline hover:underline dark:text-white" }} 132 140 133 141 {{ range .Files }} 134 - <div class="grid grid-cols-2 gap-4 items-center py-1"> 135 - <div class="col-span-1"> 142 + <div class="grid grid-cols-3 gap-4 items-center py-1"> 143 + <div class="col-span-2"> 136 144 {{ $link := printf "/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) .Name }} 137 145 {{ $icon := "folder" }} 138 146 {{ $iconStyle := "size-4 fill-current" }} ··· 144 152 {{ end }} 145 153 <a href="{{ $link }}" class="{{ $linkstyle }}"> 146 154 <div class="flex items-center gap-2"> 147 - {{ i $icon $iconStyle }}{{ .Name }} 155 + {{ i $icon $iconStyle "flex-shrink-0" }} 156 + <span class="truncate">{{ .Name }}</span> 148 157 </div> 149 158 </a> 150 159 </div> 151 160 152 - <div class="text-xs col-span-1 text-right"> 161 + <div class="text-sm col-span-1 text-right"> 153 162 {{ with .LastCommit }} 154 163 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a> 155 164 {{ end }} ··· 210 219 </div> 211 220 212 221 <!-- commit info bar --> 213 - <div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center"> 222 + <div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center flex-wrap"> 214 223 {{ $verified := $.VerifiedCommits.IsVerified .Hash.String }} 215 224 {{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }} 216 225 {{ if $verified }} ··· 280 289 </a> 281 290 <div class="flex flex-col gap-1"> 282 291 {{ range .BranchesTrunc }} 283 - <div class="text-base flex items-center justify-between"> 284 - <div class="flex items-center gap-2"> 292 + <div class="text-base flex items-center justify-between overflow-hidden"> 293 + <div class="flex items-center gap-2 min-w-0 flex-1"> 285 294 <a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Reference.Name | urlquery }}" 286 - class="inline no-underline hover:underline dark:text-white"> 295 + class="inline-block truncate no-underline hover:underline dark:text-white"> 287 296 {{ .Reference.Name }} 288 297 </a> 289 298 {{ if .Commit }} 290 - <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 291 - <span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .Commit.Committer.When }}</span> 299 + <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท'] shrink-0"></span> 300 + <span class="whitespace-nowrap text-xs text-gray-500 dark:text-gray-400 shrink-0">{{ template "repo/fragments/time" .Commit.Committer.When }}</span> 292 301 {{ end }} 293 302 {{ if .IsDefault }} 294 - <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 295 - <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-xs font-mono">default</span> 303 + <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท'] shrink-0"></span> 304 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-xs font-mono shrink-0">default</span> 296 305 {{ end }} 297 306 </div> 298 307 {{ if ne $.Ref .Reference.Name }} 299 308 <a href="/{{ $.RepoInfo.FullName }}/compare/{{ $.Ref | urlquery }}...{{ .Reference.Name | urlquery }}" 300 - class="text-xs flex gap-2 items-center" 309 + class="text-xs flex gap-2 items-center shrink-0 ml-2" 301 310 title="Compare branches or tags"> 302 311 {{ i "git-compare" "w-3 h-3" }} compare 303 312 </a> 304 - {{end}} 313 + {{ end }} 305 314 </div> 306 315 {{ end }} 307 316 </div> ··· 362 371 {{- end -}}</article> 363 372 </section> 364 373 {{- end -}} 365 - 366 - {{ template "repo/fragments/cloneInstructions" . }} 367 374 {{ end }}
+1 -2
appview/pages/templates/repo/issues/fragments/issueComment.html
··· 2 2 {{ with .Comment }} 3 3 <div id="comment-container-{{.CommentId}}"> 4 4 <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 - {{ $owner := index $.DidHandleMap .OwnerDid }} 6 - {{ template "user/fragments/picHandleLink" $owner }} 5 + {{ template "user/fragments/picHandleLink" .OwnerDid }} 7 6 8 7 <!-- show user "hats" --> 9 8 {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
+3 -3
appview/pages/templates/repo/issues/issue.html
··· 11 11 {{ define "repoContent" }} 12 12 <header class="pb-4"> 13 13 <h1 class="text-2xl"> 14 - {{ .Issue.Title }} 14 + {{ .Issue.Title | description }} 15 15 <span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span> 16 16 </h1> 17 17 </header> ··· 54 54 "Kind" $kind 55 55 "Count" (index $.Reactions $kind) 56 56 "IsReacted" (index $.UserReacted $kind) 57 - "ThreadAt" $.Issue.IssueAt) 57 + "ThreadAt" $.Issue.AtUri) 58 58 }} 59 59 {{ end }} 60 60 </div> ··· 70 70 {{ if gt $index 0 }} 71 71 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 72 72 {{ end }} 73 - {{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "DidHandleMap" $.DidHandleMap "Issue" $.Issue "Comment" .)}} 73 + {{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "Issue" $.Issue "Comment" .)}} 74 74 </div> 75 75 {{ end }} 76 76 </section>
+2 -3
appview/pages/templates/repo/issues/issues.html
··· 45 45 href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 46 class="no-underline hover:underline" 47 47 > 48 - {{ .Title }} 48 + {{ .Title | description }} 49 49 <span class="text-gray-500">#{{ .IssueId }}</span> 50 50 </a> 51 51 </div> ··· 65 65 </span> 66 66 67 67 <span class="ml-1"> 68 - {{ $owner := index $.DidHandleMap .OwnerDid }} 69 - {{ template "user/fragments/picHandleLink" $owner }} 68 + {{ template "user/fragments/picHandleLink" .OwnerDid }} 70 69 </span> 71 70 72 71 <span class="before:content-['ยท']">
+2 -2
appview/pages/templates/repo/pipelines/fragments/pipelineSymbol.html
··· 23 23 </div> 24 24 {{ else if $allFail }} 25 25 <div class="flex gap-1 items-center"> 26 - {{ i "x" "size-4 text-red-600" }} 26 + {{ i "x" "size-4 text-red-500" }} 27 27 <span>0/{{ $total }}</span> 28 28 </div> 29 29 {{ else if $allTimeout }} 30 30 <div class="flex gap-1 items-center"> 31 - {{ i "clock-alert" "size-4 text-orange-400" }} 31 + {{ i "clock-alert" "size-4 text-orange-500" }} 32 32 <span>0/{{ $total }}</span> 33 33 </div> 34 34 {{ else }}
+1 -1
appview/pages/templates/repo/pipelines/fragments/workflowSymbol.html
··· 19 19 {{ $color = "text-gray-600 dark:text-gray-500" }} 20 20 {{ else if eq $kind "timeout" }} 21 21 {{ $icon = "clock-alert" }} 22 - {{ $color = "text-orange-400 dark:text-orange-300" }} 22 + {{ $color = "text-orange-400 dark:text-orange-500" }} 23 23 {{ else }} 24 24 {{ $icon = "x" }} 25 25 {{ $color = "text-red-600 dark:text-red-500" }}
+5 -1
appview/pages/templates/repo/pipelines/workflow.html
··· 19 19 20 20 {{ define "sidebar" }} 21 21 {{ $active := .Workflow }} 22 + 23 + {{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }} 24 + {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }} 25 + 22 26 {{ with .Pipeline }} 23 27 {{ $id := .Id }} 24 28 <div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 25 29 {{ range $name, $all := .Statuses }} 26 30 <a href="/{{ $.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 27 31 <div 28 - class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }}bg-gray-100/50 dark:bg-gray-700/50{{ end }}"> 32 + class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 29 33 {{ $lastStatus := $all.Latest }} 30 34 {{ $kind := $lastStatus.Status.String }} 31 35
+3 -3
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 1 1 {{ define "repo/pulls/fragments/pullHeader" }} 2 2 <header class="pb-4"> 3 3 <h1 class="text-2xl dark:text-white"> 4 - {{ .Pull.Title }} 4 + {{ .Pull.Title | description }} 5 5 <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span> 6 6 </h1> 7 7 </header> ··· 17 17 {{ $icon = "git-merge" }} 18 18 {{ end }} 19 19 20 + {{ $owner := resolve .Pull.OwnerDid }} 20 21 <section class="mt-2"> 21 22 <div class="flex items-center gap-2"> 22 23 <div ··· 28 29 </div> 29 30 <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 30 31 opened by 31 - {{ $owner := index $.DidHandleMap .Pull.OwnerDid }} 32 - {{ template "user/fragments/picHandleLink" $owner }} 32 + {{ template "user/fragments/picHandleLink" .Pull.OwnerDid }} 33 33 <span class="select-none before:content-['\00B7']"></span> 34 34 {{ template "repo/fragments/time" .Pull.Created }} 35 35
+1 -1
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
··· 9 9 </div> 10 10 <span class="truncate text-sm text-gray-800 dark:text-gray-200"> 11 11 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 12 - {{ .Title }} 12 + {{ .Title | description }} 13 13 </span> 14 14 </div> 15 15
+1 -1
appview/pages/templates/repo/pulls/interdiff.html
··· 68 68 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 69 69 {{ template "repo/fragments/diffOpts" .DiffOpts }} 70 70 </div> 71 - <div class="sticky top-0 flex-grow max-h-screen"> 71 + <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 72 72 {{ template "repo/fragments/interdiffFiles" .Interdiff }} 73 73 </div> 74 74 {{end}}
+1 -1
appview/pages/templates/repo/pulls/patch.html
··· 73 73 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 74 74 {{ template "repo/fragments/diffOpts" .DiffOpts }} 75 75 </div> 76 - <div class="sticky top-0 flex-grow max-h-screen"> 76 + <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 77 77 {{ template "repo/fragments/diffChangedFiles" .Diff }} 78 78 </div> 79 79 {{end}}
+4 -5
appview/pages/templates/repo/pulls/pull.html
··· 47 47 <!-- round summary --> 48 48 <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 49 49 <span class="gap-1 flex items-center"> 50 - {{ $owner := index $.DidHandleMap $.Pull.OwnerDid }} 50 + {{ $owner := resolve $.Pull.OwnerDid }} 51 51 {{ $re := "re" }} 52 52 {{ if eq .RoundNumber 0 }} 53 53 {{ $re = "" }} 54 54 {{ end }} 55 55 <span class="hidden md:inline">{{$re}}submitted</span> 56 - by {{ template "user/fragments/picHandleLink" $owner }} 56 + by {{ template "user/fragments/picHandleLink" $.Pull.OwnerDid }} 57 57 <span class="select-none before:content-['\00B7']"></span> 58 58 <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ .RoundNumber }}">{{ template "repo/fragments/shortTime" .Created }}</a> 59 59 <span class="select-none before:content-['ยท']"></span> ··· 122 122 {{ end }} 123 123 </div> 124 124 <div class="flex items-center"> 125 - <span>{{ .Title }}</span> 125 + <span>{{ .Title | description }}</span> 126 126 {{ if gt (len .Body) 0 }} 127 127 <button 128 128 class="py-1/2 px-1 mx-2 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600" ··· 151 151 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 152 152 {{ end }} 153 153 <div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1"> 154 - {{ $owner := index $.DidHandleMap $c.OwnerDid }} 155 - {{ template "user/fragments/picHandleLink" $owner }} 154 + {{ template "user/fragments/picHandleLink" $c.OwnerDid }} 156 155 <span class="before:content-['ยท']"></span> 157 156 <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}">{{ template "repo/fragments/time" $c.Created }}</a> 158 157 </div>
+2 -3
appview/pages/templates/repo/pulls/pulls.html
··· 50 50 <div class="px-6 py-4 z-5"> 51 51 <div class="pb-2"> 52 52 <a href="/{{ $.RepoInfo.FullName }}/pulls/{{ .PullId }}" class="dark:text-white"> 53 - {{ .Title }} 53 + {{ .Title | description }} 54 54 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 55 55 </a> 56 56 </div> 57 57 <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 58 - {{ $owner := index $.DidHandleMap .OwnerDid }} 59 58 {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 60 59 {{ $icon := "ban" }} 61 60 ··· 76 75 </span> 77 76 78 77 <span class="ml-1"> 79 - {{ template "user/fragments/picHandleLink" $owner }} 78 + {{ template "user/fragments/picHandleLink" .OwnerDid }} 80 79 </span> 81 80 82 81 <span class="before:content-['ยท']">
+33 -23
appview/pages/templates/repo/settings/pipelines.html
··· 20 20 <div class="col-span-1 md:col-span-2"> 21 21 <h2 class="text-sm pb-2 uppercase font-bold">Spindle</h2> 22 22 <p class="text-gray-500 dark:text-gray-400"> 23 - Choose a spindle to execute your workflows on. Spindles can be 24 - selfhosted, 23 + Choose a spindle to execute your workflows on. Only repository owners 24 + can configure spindles. Spindles can be selfhosted, 25 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 26 click to learn more. 27 27 </a> 28 28 </p> 29 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 - {{ . }} 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 }} 43 48 </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> 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 }} 51 61 </div> 52 62 {{ end }} 53 63 ··· 77 87 {{ end }} 78 88 79 89 {{ define "addSecretButton" }} 80 - <button 90 + <button 81 91 class="btn flex items-center gap-2" 82 92 popovertarget="add-secret-modal" 83 93 popovertargetaction="toggle">
-168
appview/pages/templates/repo/settings.html
··· 1 - {{ define "title" }}settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 - 3 - {{ define "repoContent" }} 4 - {{ template "collaboratorSettings" . }} 5 - {{ template "branchSettings" . }} 6 - {{ template "dangerZone" . }} 7 - {{ template "spindleSelector" . }} 8 - {{ template "spindleSecrets" . }} 9 - {{ end }} 10 - 11 - {{ define "collaboratorSettings" }} 12 - <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 13 - Collaborators 14 - </header> 15 - 16 - <div id="collaborator-list" class="flex flex-col gap-2 mb-2"> 17 - {{ range .Collaborators }} 18 - <div id="collaborator" class="mb-2"> 19 - <a 20 - href="/{{ didOrHandle .Did .Handle }}" 21 - class="no-underline hover:underline text-black dark:text-white" 22 - > 23 - {{ didOrHandle .Did .Handle }} 24 - </a> 25 - <div> 26 - <span class="text-sm text-gray-500 dark:text-gray-400"> 27 - {{ .Role }} 28 - </span> 29 - </div> 30 - </div> 31 - {{ end }} 32 - </div> 33 - 34 - {{ if .RepoInfo.Roles.CollaboratorInviteAllowed }} 35 - <form 36 - hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator" 37 - class="group" 38 - > 39 - <label for="collaborator" class="dark:text-white"> 40 - add collaborator 41 - </label> 42 - <input 43 - type="text" 44 - id="collaborator" 45 - name="collaborator" 46 - required 47 - class="dark:bg-gray-700 dark:text-white" 48 - placeholder="enter did or handle"> 49 - <button class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700" type="text"> 50 - <span>add</span> 51 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 52 - </button> 53 - </form> 54 - {{ end }} 55 - {{ end }} 56 - 57 - {{ define "dangerZone" }} 58 - {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 59 - <form 60 - hx-confirm="Are you sure you want to delete this repository?" 61 - hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 62 - class="mt-6" 63 - hx-indicator="#delete-repo-spinner"> 64 - <label for="branch">delete repository</label> 65 - <button class="btn my-2 flex items-center" type="text"> 66 - <span>delete</span> 67 - <span id="delete-repo-spinner" class="group"> 68 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 69 - </span> 70 - </button> 71 - <span> 72 - Deleting a repository is irreversible and permanent. 73 - </span> 74 - </form> 75 - {{ end }} 76 - {{ end }} 77 - 78 - {{ define "branchSettings" }} 79 - <form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="mt-6 group"> 80 - <label for="branch">default branch</label> 81 - <div class="flex gap-2 items-center"> 82 - <select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 83 - <option value="" disabled selected > 84 - Choose a default branch 85 - </option> 86 - {{ range .Branches }} 87 - <option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} > 88 - {{ .Name }} 89 - </option> 90 - {{ end }} 91 - </select> 92 - <button class="btn my-2 flex gap-2 items-center" type="submit"> 93 - <span>save</span> 94 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 95 - </button> 96 - </div> 97 - </form> 98 - {{ end }} 99 - 100 - {{ define "spindleSelector" }} 101 - {{ if .RepoInfo.Roles.IsOwner }} 102 - <form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="mt-6 group" > 103 - <label for="spindle">spindle</label> 104 - <div class="flex gap-2 items-center"> 105 - <select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 106 - <option value="" selected > 107 - None 108 - </option> 109 - {{ range .Spindles }} 110 - <option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}> 111 - {{ . }} 112 - </option> 113 - {{ end }} 114 - </select> 115 - <button class="btn my-2 flex gap-2 items-center" type="submit"> 116 - <span>save</span> 117 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 118 - </button> 119 - </div> 120 - </form> 121 - {{ end }} 122 - {{ end }} 123 - 124 - {{ define "spindleSecrets" }} 125 - {{ if $.CurrentSpindle }} 126 - <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 127 - Secrets 128 - </header> 129 - 130 - <div id="secret-list" class="flex flex-col gap-2 mb-2"> 131 - {{ range $idx, $secret := .Secrets }} 132 - {{ with $secret }} 133 - <div id="secret-{{$idx}}" class="mb-2"> 134 - {{ .Key }} created on {{ .CreatedAt }} by {{ .CreatedBy }} 135 - </div> 136 - {{ end }} 137 - {{ end }} 138 - </div> 139 - <form 140 - hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets" 141 - class="mt-6" 142 - hx-indicator="#add-secret-spinner"> 143 - <label for="key">secret key</label> 144 - <input 145 - type="text" 146 - id="key" 147 - name="key" 148 - required 149 - class="dark:bg-gray-700 dark:text-white" 150 - placeholder="SECRET_KEY" /> 151 - <label for="value">secret value</label> 152 - <input 153 - type="text" 154 - id="value" 155 - name="value" 156 - required 157 - class="dark:bg-gray-700 dark:text-white" 158 - placeholder="SECRET VALUE" /> 159 - 160 - <button class="btn my-2 flex items-center" type="text"> 161 - <span>add</span> 162 - <span id="add-secret-spinner" class="group"> 163 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 164 - </span> 165 - </button> 166 - </form> 167 - {{ end }} 168 - {{ end }}
+8 -2
appview/pages/templates/repo/tags.html
··· 97 97 {{ $isPushAllowed := $root.RepoInfo.Roles.IsPushAllowed }} 98 98 {{ $artifacts := index $root.ArtifactMap $tag.Tag.Hash }} 99 99 100 - {{ if or (gt (len $artifacts) 0) $isPushAllowed }} 101 100 <h2 class="my-4 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">artifacts</h2> 102 101 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700"> 103 102 {{ range $artifact := $artifacts }} 104 103 {{ $args := dict "LoggedInUser" $root.LoggedInUser "RepoInfo" $root.RepoInfo "Artifact" $artifact }} 105 104 {{ template "repo/fragments/artifact" $args }} 106 105 {{ end }} 106 + <div id="artifact-git-source" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 107 + <div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]"> 108 + {{ i "archive" "w-4 h-4" }} 109 + <a href="/{{ $root.RepoInfo.FullName }}/archive/{{ pathEscape (print "refs/tags/" $tag.Name) }}" class="no-underline hover:no-underline"> 110 + Source code (.tar.gz) 111 + </a> 112 + </div> 113 + </div> 107 114 {{ if $isPushAllowed }} 108 115 {{ block "uploadArtifact" (list $root $tag) }} {{ end }} 109 116 {{ end }} 110 117 </div> 111 - {{ end }} 112 118 {{ end }} 113 119 114 120 {{ define "uploadArtifact" }}
+5 -4
appview/pages/templates/repo/tree.html
··· 54 54 55 55 {{ range .Files }} 56 56 <div class="grid grid-cols-12 gap-4 items-center py-1"> 57 - <div class="col-span-6 md:col-span-3"> 57 + <div class="col-span-8 md:col-span-4"> 58 58 {{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) $.TreePath .Name }} 59 59 {{ $icon := "folder" }} 60 60 {{ $iconStyle := "size-4 fill-current" }} ··· 65 65 {{ end }} 66 66 <a href="{{ $link }}" class="{{ $linkstyle }}"> 67 67 <div class="flex items-center gap-2"> 68 - {{ i $icon $iconStyle }}{{ .Name }} 68 + {{ i $icon $iconStyle "flex-shrink-0" }} 69 + <span class="truncate">{{ .Name }}</span> 69 70 </div> 70 71 </a> 71 72 </div> 72 73 73 - <div class="col-span-0 md:col-span-7 hidden md:block overflow-hidden"> 74 + <div class="col-span-0 md:col-span-6 hidden md:block overflow-hidden"> 74 75 {{ with .LastCommit }} 75 76 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400 block truncate">{{ .Message }}</a> 76 77 {{ end }} 77 78 </div> 78 79 79 - <div class="col-span-6 md:col-span-2 text-right"> 80 + <div class="col-span-4 md:col-span-2 text-sm text-right"> 80 81 {{ with .LastCommit }} 81 82 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a> 82 83 {{ end }}
+2 -4
appview/pages/templates/spindles/dashboard.html
··· 42 42 <div> 43 43 <div class="flex justify-between items-center"> 44 44 <div class="flex items-center gap-2"> 45 - {{ i "user" "size-4" }} 46 - {{ $user := index $.DidHandleMap . }} 47 - <a href="/{{ $user }}">{{ $user }}</a> 45 + {{ template "user/fragments/picHandleLink" . }} 48 46 </div> 49 47 {{ if ne $.LoggedInUser.Did . }} 50 48 {{ block "removeMemberButton" (list $ . ) }} {{ end }} ··· 109 107 hx-post="/spindles/{{ $root.Spindle.Instance }}/remove" 110 108 hx-swap="none" 111 109 hx-vals='{"member": "{{$member}}" }' 112 - hx-confirm="Are you sure you want to remove {{ index $root.DidHandleMap $member }} from this instance?" 110 + hx-confirm="Are you sure you want to remove {{ resolve $member }} from this instance?" 113 111 > 114 112 {{ i "user-minus" "w-4 h-4" }} 115 113 remove
+57
appview/pages/templates/strings/dashboard.html
··· 1 + {{ define "title" }}strings by {{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 + <meta property="og:type" content="profile" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" /> 7 + <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 + {{ end }} 9 + 10 + 11 + {{ define "content" }} 12 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 13 + <div class="md:col-span-3 order-1 md:order-1"> 14 + {{ template "user/fragments/profileCard" .Card }} 15 + </div> 16 + <div id="all-strings" class="md:col-span-8 order-2 md:order-2"> 17 + {{ block "allStrings" . }}{{ end }} 18 + </div> 19 + </div> 20 + {{ end }} 21 + 22 + {{ define "allStrings" }} 23 + <p class="text-sm font-bold p-2 dark:text-white">ALL STRINGS</p> 24 + <div id="strings" class="grid grid-cols-1 gap-4 mb-6"> 25 + {{ range .Strings }} 26 + {{ template "singleString" (list $ .) }} 27 + {{ else }} 28 + <p class="px-6 dark:text-white">This user does not have any strings yet.</p> 29 + {{ end }} 30 + </div> 31 + {{ end }} 32 + 33 + {{ define "singleString" }} 34 + {{ $root := index . 0 }} 35 + {{ $s := index . 1 }} 36 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 37 + <div class="font-medium dark:text-white flex gap-2 items-center"> 38 + <a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 39 + </div> 40 + {{ with $s.Description }} 41 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 42 + {{ . }} 43 + </div> 44 + {{ end }} 45 + 46 + {{ $stat := $s.Stats }} 47 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto"> 48 + <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 49 + <span class="select-none [&:before]:content-['ยท']"></span> 50 + {{ with $s.Edited }} 51 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 52 + {{ else }} 53 + {{ template "repo/fragments/shortTimeAgo" $s.Created }} 54 + {{ end }} 55 + </div> 56 + </div> 57 + {{ end }}
+90
appview/pages/templates/strings/fragments/form.html
··· 1 + {{ define "strings/fragments/form" }} 2 + <form 3 + {{ if eq .Action "new" }} 4 + hx-post="/strings/new" 5 + {{ else }} 6 + hx-post="/strings/{{.String.Did}}/{{.String.Rkey}}/edit" 7 + {{ end }} 8 + hx-indicator="#new-button" 9 + class="p-6 pb-4 dark:text-white flex flex-col gap-2 bg-white dark:bg-gray-800 drop-shadow-sm rounded" 10 + hx-swap="none"> 11 + <div class="flex flex-col md:flex-row md:items-center gap-2"> 12 + <input 13 + type="text" 14 + id="filename" 15 + name="filename" 16 + placeholder="Filename" 17 + required 18 + value="{{ .String.Filename }}" 19 + class="md:max-w-64 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" 20 + > 21 + <input 22 + type="text" 23 + id="description" 24 + name="description" 25 + value="{{ .String.Description }}" 26 + placeholder="Description ..." 27 + class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" 28 + > 29 + </div> 30 + <textarea 31 + name="content" 32 + id="content-textarea" 33 + wrap="off" 34 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 font-mono" 35 + rows="20" 36 + spellcheck="false" 37 + placeholder="Paste your string here!" 38 + required>{{ .String.Contents }}</textarea> 39 + <div class="flex justify-between items-center"> 40 + <div id="content-stats" class="text-sm text-gray-500 dark:text-gray-400"> 41 + <span id="line-count">0 lines</span> 42 + <span class="select-none px-1 [&:before]:content-['ยท']"></span> 43 + <span id="byte-count">0 bytes</span> 44 + </div> 45 + <div id="actions" class="flex gap-2 items-center"> 46 + {{ if eq .Action "edit" }} 47 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 " 48 + href="/strings/{{ .String.Did }}/{{ .String.Rkey }}"> 49 + {{ i "x" "size-4" }} 50 + <span class="hidden md:inline">cancel</span> 51 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 52 + </a> 53 + {{ end }} 54 + <button 55 + type="submit" 56 + id="new-button" 57 + class="w-fit btn-create rounded flex items-center py-0 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group" 58 + > 59 + <span class="inline-flex items-center gap-2"> 60 + {{ i "arrow-up" "w-4 h-4" }} 61 + publish 62 + </span> 63 + <span class="pl-2 hidden group-[.htmx-request]:inline"> 64 + {{ i "loader-circle" "w-4 h-4 animate-spin" }} 65 + </span> 66 + </button> 67 + </div> 68 + </div> 69 + <script> 70 + (function() { 71 + const textarea = document.getElementById('content-textarea'); 72 + const lineCount = document.getElementById('line-count'); 73 + const byteCount = document.getElementById('byte-count'); 74 + function updateStats() { 75 + const content = textarea.value; 76 + const lines = content === '' ? 0 : content.split('\n').length; 77 + const bytes = new TextEncoder().encode(content).length; 78 + lineCount.textContent = `${lines} line${lines !== 1 ? 's' : ''}`; 79 + byteCount.textContent = `${bytes} byte${bytes !== 1 ? 's' : ''}`; 80 + } 81 + textarea.addEventListener('input', updateStats); 82 + textarea.addEventListener('paste', () => { 83 + setTimeout(updateStats, 0); 84 + }); 85 + updateStats(); 86 + })(); 87 + </script> 88 + <div id="error" class="error dark:text-red-400"></div> 89 + </form> 90 + {{ end }}
+17
appview/pages/templates/strings/put.html
··· 1 + {{ define "title" }}publish a new string{{ end }} 2 + 3 + {{ define "topbar" }} 4 + {{ template "layouts/topbar" $ }} 5 + {{ end }} 6 + 7 + {{ define "content" }} 8 + <div class="px-6 py-2 mb-4"> 9 + {{ if eq .Action "new" }} 10 + <p class="text-xl font-bold dark:text-white">Create a new string</p> 11 + <p class="">Store and share code snippets with ease.</p> 12 + {{ else }} 13 + <p class="text-xl font-bold dark:text-white">Edit string</p> 14 + {{ end }} 15 + </div> 16 + {{ template "strings/fragments/form" . }} 17 + {{ end }}
+88
appview/pages/templates/strings/string.html
··· 1 + {{ define "title" }}{{ .String.Filename }} ยท by {{ didOrHandle .Owner.DID.String .Owner.Handle.String }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 5 + <meta property="og:title" content="{{ .String.Filename }} ยท by {{ $ownerId }}" /> 6 + <meta property="og:type" content="object" /> 7 + <meta property="og:url" content="https://tangled.sh/strings/{{ $ownerId }}/{{ .String.Rkey }}" /> 8 + <meta property="og:description" content="{{ .String.Description }}" /> 9 + {{ end }} 10 + 11 + {{ define "topbar" }} 12 + {{ template "layouts/topbar" $ }} 13 + {{ end }} 14 + 15 + {{ define "content" }} 16 + {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 17 + <section id="string-header" class="mb-4 py-2 px-6 dark:text-white"> 18 + <div class="text-lg flex items-center justify-between"> 19 + <div> 20 + <a href="/strings/{{ $ownerId }}">{{ $ownerId }}</a> 21 + <span class="select-none">/</span> 22 + <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 23 + </div> 24 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 25 + <div class="flex gap-2 text-base"> 26 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 27 + hx-boost="true" 28 + href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> 29 + {{ i "pencil" "size-4" }} 30 + <span class="hidden md:inline">edit</span> 31 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 32 + </a> 33 + <button 34 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group p-2" 35 + title="Delete string" 36 + hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/" 37 + hx-swap="none" 38 + hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?" 39 + > 40 + {{ i "trash-2" "size-4" }} 41 + <span class="hidden md:inline">delete</span> 42 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 43 + </button> 44 + </div> 45 + {{ end }} 46 + </div> 47 + <span> 48 + {{ with .String.Description }} 49 + {{ . }} 50 + {{ end }} 51 + </span> 52 + </section> 53 + <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white"> 54 + <div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 55 + <span> 56 + {{ .String.Filename }} 57 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 58 + <span> 59 + {{ with .String.Edited }} 60 + edited {{ template "repo/fragments/shortTimeAgo" . }} 61 + {{ else }} 62 + {{ template "repo/fragments/shortTimeAgo" .String.Created }} 63 + {{ end }} 64 + </span> 65 + </span> 66 + <div> 67 + <span>{{ .Stats.LineCount }} lines</span> 68 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 69 + <span>{{ byteFmt .Stats.ByteCount }}</span> 70 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 71 + <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}/raw">view raw</a> 72 + {{ if .RenderToggle }} 73 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 74 + <a href="?code={{ .ShowRendered }}" hx-boost="true"> 75 + view {{ if .ShowRendered }}code{{ else }}rendered{{ end }} 76 + </a> 77 + {{ end }} 78 + </div> 79 + </div> 80 + <div class="overflow-x-auto overflow-y-hidden relative"> 81 + {{ if .ShowRendered }} 82 + <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 83 + {{ else }} 84 + <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div> 85 + {{ end }} 86 + </div> 87 + </section> 88 + {{ end }}
+65
appview/pages/templates/strings/timeline.html
··· 1 + {{ define "title" }} all strings {{ end }} 2 + 3 + {{ define "topbar" }} 4 + {{ template "layouts/topbar" $ }} 5 + {{ end }} 6 + 7 + {{ define "content" }} 8 + {{ block "timeline" $ }}{{ end }} 9 + {{ end }} 10 + 11 + {{ define "timeline" }} 12 + <div> 13 + <div class="p-6"> 14 + <p class="text-xl font-bold dark:text-white">All strings</p> 15 + </div> 16 + 17 + <div class="flex flex-col gap-4"> 18 + {{ range $i, $s := .Strings }} 19 + <div class="relative"> 20 + {{ if ne $i 0 }} 21 + <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 22 + {{ end }} 23 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 24 + {{ template "stringCard" $s }} 25 + </div> 26 + </div> 27 + {{ end }} 28 + </div> 29 + </div> 30 + {{ end }} 31 + 32 + {{ define "stringCard" }} 33 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 34 + <div class="font-medium dark:text-white flex gap-2 items-center"> 35 + <a href="/strings/{{ resolve .Did.String }}/{{ .Rkey }}">{{ .Filename }}</a> 36 + </div> 37 + {{ with .Description }} 38 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 39 + {{ . }} 40 + </div> 41 + {{ end }} 42 + 43 + {{ template "stringCardInfo" . }} 44 + </div> 45 + {{ end }} 46 + 47 + {{ define "stringCardInfo" }} 48 + {{ $stat := .Stats }} 49 + {{ $resolved := resolve .Did.String }} 50 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto"> 51 + <a href="/strings/{{ $resolved }}" class="flex items-center"> 52 + {{ template "user/fragments/picHandle" $resolved }} 53 + </a> 54 + <span class="select-none [&:before]:content-['ยท']"></span> 55 + <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 56 + <span class="select-none [&:before]:content-['ยท']"></span> 57 + {{ with .Edited }} 58 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 59 + {{ else }} 60 + {{ template "repo/fragments/shortTimeAgo" .Created }} 61 + {{ end }} 62 + </div> 63 + {{ end }} 64 + 65 +
+183
appview/pages/templates/timeline/timeline.html
··· 1 + {{ define "title" }}timeline{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="timeline ยท tangled" /> 5 + <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.sh" /> 7 + <meta property="og:description" content="tightly-knit social coding" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + {{ if .LoggedInUser }} 12 + {{ else }} 13 + {{ block "hero" $ }}{{ end }} 14 + {{ end }} 15 + 16 + {{ block "trending" $ }}{{ end }} 17 + {{ block "timeline" $ }}{{ end }} 18 + {{ end }} 19 + 20 + {{ define "hero" }} 21 + <div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl"> 22 + <div class="font-bold text-4xl">tightly-knit<br>social coding.</div> 23 + 24 + <p class="text-lg"> 25 + tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>. 26 + </p> 27 + <p class="text-lg"> 28 + we envision a place where developers have complete ownership of their 29 + code, open source communities can freely self-govern and most 30 + importantly, coding can be social and fun again. 31 + </p> 32 + 33 + <div class="flex gap-6 items-center"> 34 + <a href="/signup" class="no-underline hover:no-underline "> 35 + <button class="btn-create flex gap-2 px-4 items-center"> 36 + join now {{ i "arrow-right" "size-4" }} 37 + </button> 38 + </a> 39 + </div> 40 + </div> 41 + {{ end }} 42 + 43 + {{ define "trending" }} 44 + <div class="w-full md:mx-0 py-4"> 45 + <div class="px-6 pb-4"> 46 + <h3 class="text-xl font-bold dark:text-white flex items-center gap-2"> 47 + Trending 48 + {{ i "trending-up" "size-4 flex-shrink-0" }} 49 + </h3> 50 + </div> 51 + <div class="flex gap-4 overflow-x-auto scrollbar-hide items-stretch"> 52 + {{ range $index, $repo := .Repos }} 53 + <div class="flex-none h-full border border-gray-200 dark:border-gray-700 rounded-sm w-96"> 54 + {{ template "user/fragments/repoCard" (list $ $repo true) }} 55 + </div> 56 + {{ else }} 57 + <div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm"> 58 + <div class="text-sm text-gray-500 dark:text-gray-400 text-center"> 59 + No trending repositories this week 60 + </div> 61 + </div> 62 + {{ end }} 63 + </div> 64 + </div> 65 + {{ end }} 66 + 67 + {{ define "timeline" }} 68 + <div class="py-4"> 69 + <div class="px-6 pb-4"> 70 + <p class="text-xl font-bold dark:text-white">Timeline</p> 71 + </div> 72 + 73 + <div class="flex flex-col gap-4"> 74 + {{ range $i, $e := .Timeline }} 75 + <div class="relative"> 76 + {{ if ne $i 0 }} 77 + <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 78 + {{ end }} 79 + {{ with $e }} 80 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 81 + {{ if .Repo }} 82 + {{ block "repoEvent" (list $ .Repo .Source) }} {{ end }} 83 + {{ else if .Star }} 84 + {{ block "starEvent" (list $ .Star) }} {{ end }} 85 + {{ else if .Follow }} 86 + {{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} {{ end }} 87 + {{ end }} 88 + </div> 89 + {{ end }} 90 + </div> 91 + {{ end }} 92 + </div> 93 + </div> 94 + {{ end }} 95 + 96 + {{ define "repoEvent" }} 97 + {{ $root := index . 0 }} 98 + {{ $repo := index . 1 }} 99 + {{ $source := index . 2 }} 100 + {{ $userHandle := resolve $repo.Did }} 101 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 102 + {{ template "user/fragments/picHandleLink" $repo.Did }} 103 + {{ with $source }} 104 + {{ $sourceDid := resolve .Did }} 105 + forked 106 + <a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline"> 107 + {{ $sourceDid }}/{{ .Name }} 108 + </a> 109 + to 110 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 111 + {{ else }} 112 + created 113 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline"> 114 + {{ $repo.Name }} 115 + </a> 116 + {{ end }} 117 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 118 + </div> 119 + {{ with $repo }} 120 + {{ template "user/fragments/repoCard" (list $root . true) }} 121 + {{ end }} 122 + {{ end }} 123 + 124 + {{ define "starEvent" }} 125 + {{ $root := index . 0 }} 126 + {{ $star := index . 1 }} 127 + {{ with $star }} 128 + {{ $starrerHandle := resolve .StarredByDid }} 129 + {{ $repoOwnerHandle := resolve .Repo.Did }} 130 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 131 + {{ template "user/fragments/picHandleLink" $starrerHandle }} 132 + starred 133 + <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 134 + {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 135 + </a> 136 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 137 + </div> 138 + {{ with .Repo }} 139 + {{ template "user/fragments/repoCard" (list $root . true) }} 140 + {{ end }} 141 + {{ end }} 142 + {{ end }} 143 + 144 + 145 + {{ define "followEvent" }} 146 + {{ $root := index . 0 }} 147 + {{ $follow := index . 1 }} 148 + {{ $profile := index . 2 }} 149 + {{ $stat := index . 3 }} 150 + 151 + {{ $userHandle := resolve $follow.UserDid }} 152 + {{ $subjectHandle := resolve $follow.SubjectDid }} 153 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 154 + {{ template "user/fragments/picHandleLink" $userHandle }} 155 + followed 156 + {{ template "user/fragments/picHandleLink" $subjectHandle }} 157 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 158 + </div> 159 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 160 + <div class="flex-shrink-0 max-h-full w-24 h-24"> 161 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 162 + </div> 163 + 164 + <div class="flex-1 min-h-0 justify-around flex flex-col"> 165 + <a href="/{{ $subjectHandle }}"> 166 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 167 + </a> 168 + {{ with $profile }} 169 + {{ with .Description }} 170 + <p class="text-sm pb-2 md:pb-2">{{.}}</p> 171 + {{ end }} 172 + {{ end }} 173 + {{ with $stat }} 174 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 175 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 176 + <span id="followers">{{ .Followers }} followers</span> 177 + <span class="select-none after:content-['ยท']"></span> 178 + <span id="following">{{ .Following }} following</span> 179 + </div> 180 + {{ end }} 181 + </div> 182 + </div> 183 + {{ end }}
-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 }}
+5 -5
appview/pages/templates/user/completeSignup.html
··· 38 38 tightly-knit social coding. 39 39 </h2> 40 40 <form 41 - class="mt-4 max-w-sm mx-auto" 41 + class="mt-4 max-w-sm mx-auto flex flex-col gap-4" 42 42 hx-post="/signup/complete" 43 43 hx-swap="none" 44 44 hx-disabled-elt="#complete-signup-button" ··· 58 58 </span> 59 59 </div> 60 60 61 - <div class="flex flex-col mt-4"> 62 - <label for="username">desired username</label> 61 + <div class="flex flex-col"> 62 + <label for="username">username</label> 63 63 <input 64 64 type="text" 65 65 id="username" ··· 73 73 </span> 74 74 </div> 75 75 76 - <div class="flex flex-col mt-4"> 76 + <div class="flex flex-col"> 77 77 <label for="password">password</label> 78 78 <input 79 79 type="password" ··· 88 88 </div> 89 89 90 90 <button 91 - class="btn-create w-full my-2 mt-6" 91 + class="btn-create w-full my-2 mt-6 text-base" 92 92 type="submit" 93 93 id="complete-signup-button" 94 94 tabindex="4"
+1 -1
appview/pages/templates/user/fragments/editPins.html
··· 27 27 <input type="checkbox" id="repo-{{$idx}}" name="pinnedRepo{{$idx}}" value="{{.RepoAt}}" {{if .IsPinned}}checked{{end}}> 28 28 <label for="repo-{{$idx}}" class="my-0 py-0 normal-case font-normal w-full"> 29 29 <div class="flex justify-between items-center w-full"> 30 - <span class="flex-shrink-0 overflow-hidden text-ellipsis ">{{ index $.DidHandleMap .Did }}/{{.Name}}</span> 30 + <span class="flex-shrink-0 overflow-hidden text-ellipsis ">{{ resolve .Did }}/{{.Name}}</span> 31 31 <div class="flex gap-1 items-center"> 32 32 {{ i "star" "size-4 fill-current" }} 33 33 <span>{{ .RepoStats.StarCount }}</span>
+3 -2
appview/pages/templates/user/fragments/picHandleLink.html
··· 1 1 {{ define "user/fragments/picHandleLink" }} 2 - <a href="/{{ . }}" class="flex items-center"> 3 - {{ template "user/fragments/picHandle" . }} 2 + {{ $resolved := resolve . }} 3 + <a href="/{{ $resolved }}" class="flex items-center"> 4 + {{ template "user/fragments/picHandle" $resolved }} 4 5 </a> 5 6 {{ end }}
+8 -7
appview/pages/templates/user/fragments/profileCard.html
··· 2 2 <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit"> 3 3 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 4 <div id="avatar" class="col-span-1 flex justify-center items-center"> 5 - {{ if .AvatarUri }} 6 5 <div class="w-3/4 aspect-square relative"> 7 - <img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ .AvatarUri }}" /> 6 + <img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ fullAvatar .UserDid }}" /> 8 7 </div> 9 - {{ end }} 10 8 </div> 11 9 <div class="col-span-2"> 12 - <p title="{{ didOrHandle .UserDid .UserHandle }}" 13 - class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 14 - {{ didOrHandle .UserDid .UserHandle }} 15 - </p> 10 + <div class="flex items-center flex-row flex-nowrap gap-2"> 11 + <p title="{{ didOrHandle .UserDid .UserHandle }}" 12 + class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 13 + {{ didOrHandle .UserDid .UserHandle }} 14 + </p> 15 + <a href="/{{ didOrHandle .UserDid .UserHandle }}/feed.atom">{{ i "rss" "size-4" }}</a> 16 + </div> 16 17 17 18 <div class="md:hidden"> 18 19 {{ block "followerFollowing" (list .Followers .Following) }} {{ end }}
+40 -34
appview/pages/templates/user/fragments/repoCard.html
··· 4 4 {{ $fullName := index . 2 }} 5 5 6 6 {{ with $repo }} 7 - <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 8 - <div class="font-medium dark:text-white flex gap-2 items-center"> 9 - {{- if $fullName -}} 10 - <a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}">{{ index $root.DidHandleMap .Did }}/{{ .Name }}</a> 11 - {{- else -}} 12 - <a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}">{{ .Name }}</a> 13 - {{- end -}} 7 + <div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32"> 8 + <div class="font-medium dark:text-white flex items-center"> 9 + {{ if .Source }} 10 + {{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }} 11 + {{ else }} 12 + {{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }} 13 + {{ end }} 14 + 15 + {{ $repoOwner := resolve .Did }} 16 + {{- if $fullName -}} 17 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a> 18 + {{- else -}} 19 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a> 20 + {{- end -}} 21 + </div> 22 + {{ with .Description }} 23 + <div class="text-gray-600 dark:text-gray-300 text-sm line-clamp-2"> 24 + {{ . | description }} 14 25 </div> 15 - {{ with .Description }} 16 - <div class="text-gray-600 dark:text-gray-300 text-sm"> 17 - {{ . }} 18 - </div> 19 - {{ end }} 26 + {{ end }} 20 27 21 - {{ if .RepoStats }} 22 - {{ block "repoStats" .RepoStats }} {{ end }} 23 - {{ end }} 28 + {{ if .RepoStats }} 29 + {{ block "repoStats" .RepoStats }}{{ end }} 30 + {{ end }} 24 31 </div> 25 32 {{ end }} 26 33 {{ end }} 27 34 28 35 {{ define "repoStats" }} 29 - <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-4 mt-auto"> 36 + <div class="text-gray-400 text-sm font-mono inline-flex gap-4 mt-auto"> 30 37 {{ with .Language }} 31 - <div class="flex gap-2 items-center text-sm"> 32 - <div class="size-2 rounded-full" style="background-color: {{ langColor . }};"></div> 33 - <span>{{ . }}</span> 34 - </div> 38 + <div class="flex gap-2 items-center text-sm"> 39 + <div class="size-2 rounded-full" 40 + style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ langColor . }} 70%, white), {{ langColor . }} 30%, color-mix(in srgb, {{ langColor . }} 85%, black));"></div> 41 + <span>{{ . }}</span> 42 + </div> 35 43 {{ end }} 36 44 {{ with .StarCount }} 37 - <div class="flex gap-1 items-center text-sm"> 38 - {{ i "star" "w-3 h-3 fill-current" }} 39 - <span>{{ . }}</span> 40 - </div> 45 + <div class="flex gap-1 items-center text-sm"> 46 + {{ i "star" "w-3 h-3 fill-current" }} 47 + <span>{{ . }}</span> 48 + </div> 41 49 {{ end }} 42 50 {{ with .IssueCount.Open }} 43 - <div class="flex gap-1 items-center text-sm"> 44 - {{ i "circle-dot" "w-3 h-3" }} 45 - <span>{{ . }}</span> 46 - </div> 51 + <div class="flex gap-1 items-center text-sm"> 52 + {{ i "circle-dot" "w-3 h-3" }} 53 + <span>{{ . }}</span> 54 + </div> 47 55 {{ end }} 48 56 {{ with .PullCount.Open }} 49 - <div class="flex gap-1 items-center text-sm"> 50 - {{ i "git-pull-request" "w-3 h-3" }} 51 - <span>{{ . }}</span> 52 - </div> 57 + <div class="flex gap-1 items-center text-sm"> 58 + {{ i "git-pull-request" "w-3 h-3" }} 59 + <span>{{ . }}</span> 60 + </div> 53 61 {{ end }} 54 62 </div> 55 63 {{ end }} 56 - 57 -
+12 -79
appview/pages/templates/user/login.html
··· 3 3 <html lang="en" class="dark:bg-gray-900"> 4 4 <head> 5 5 <meta charset="UTF-8" /> 6 - <meta 7 - name="viewport" 8 - content="width=device-width, initial-scale=1.0" 9 - /> 10 - <meta 11 - property="og:title" 12 - content="login ยท tangled" 13 - /> 14 - <meta 15 - property="og:url" 16 - content="https://tangled.sh/login" 17 - /> 18 - <meta 19 - property="og:description" 20 - content="login to or sign up for tangled" 21 - /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 + <meta property="og:title" content="login ยท tangled" /> 8 + <meta property="og:url" content="https://tangled.sh/login" /> 9 + <meta property="og:description" content="login to for tangled" /> 22 10 <script src="/static/htmx.min.js"></script> 23 - <link 24 - rel="stylesheet" 25 - href="/static/tw.css?{{ cssContentHash }}" 26 - type="text/css" 27 - /> 28 - <title>login or sign up &middot; tangled</title> 11 + <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 + <title>login &middot; tangled</title> 29 13 </head> 30 14 <body class="flex items-center justify-center min-h-screen"> 31 15 <main class="max-w-md px-6 -mt-4"> 32 - <h1 33 - class="text-center text-2xl font-semibold italic dark:text-white" 34 - > 16 + <h1 class="text-center text-2xl font-semibold italic dark:text-white" > 35 17 tangled 36 18 </h1> 37 19 <h2 class="text-center text-xl italic dark:text-white"> ··· 51 33 name="handle" 52 34 tabindex="1" 53 35 required 54 - placeholder="foo.tngl.sh" 36 + placeholder="akshay.tngl.sh" 55 37 /> 56 38 <span class="text-sm text-gray-500 mt-1"> 57 39 Use your <a href="https://atproto.com">ATProto</a> ··· 59 41 your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 60 42 </span> 61 43 </div> 44 + <input type="hidden" name="return_url" value="{{ .ReturnUrl }}"> 62 45 63 46 <button 64 - class="btn w-full my-2 mt-6" 47 + class="btn w-full my-2 mt-6 text-base " 65 48 type="submit" 66 49 id="login-button" 67 50 tabindex="3" ··· 69 52 <span>login</span> 70 53 </button> 71 54 </form> 72 - <hr class="my-4"> 73 - <p class="text-sm text-gray-500 mt-4"> 74 - Alternatively, you may create an account on Tangled below. You will 75 - get a <code>user.tngl.sh</code> handle. 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! 76 57 </p> 77 58 78 - <details class="group"> 79 - 80 - <summary 81 - class="btn cursor-pointer w-full mt-4 flex items-center justify-center gap-2" 82 - > 83 - create an account 84 - 85 - <div class="group-open:hidden flex">{{ i "arrow-right" "w-4 h-4" }}</div> 86 - <div class="hidden group-open:flex">{{ i "arrow-down" "w-4 h-4" }}</div> 87 - </summary> 88 - <form 89 - class="mt-4 max-w-sm mx-auto" 90 - hx-post="/signup" 91 - hx-swap="none" 92 - hx-disabled-elt="#signup-button" 93 - > 94 - <div class="flex flex-col mt-2"> 95 - <label for="email">email</label> 96 - <input 97 - type="email" 98 - id="email" 99 - name="email" 100 - tabindex="4" 101 - required 102 - placeholder="jason@bourne.co" 103 - /> 104 - </div> 105 - <span class="text-sm text-gray-500 mt-1"> 106 - You will receive an email with a code. Enter that, along with your 107 - desired username and password in the next page to complete your registration. 108 - </span> 109 - <button 110 - class="btn w-full my-2 mt-6" 111 - type="submit" 112 - id="signup-button" 113 - tabindex="7" 114 - > 115 - <span>sign up</span> 116 - </button> 117 - </form> 118 - </details> 119 - <p class="text-sm text-gray-500 mt-6"> 120 - Join our <a href="https://chat.tangled.sh">Discord</a> or 121 - IRC channel: 122 - <a href="https://web.libera.chat/#tangled" 123 - ><code>#tangled</code> on Libera Chat</a 124 - >. 125 - </p> 126 59 <p id="login-msg" class="error w-full"></p> 127 60 </main> 128 61 </body>
+13 -20
appview/pages/templates/user/profile.html
··· 50 50 </div> 51 51 {{ else }} 52 52 <div class="flex flex-col gap-1"> 53 - {{ block "repoEvents" (list .RepoEvents $.DidHandleMap) }} {{ end }} 54 - {{ block "issueEvents" (list .IssueEvents $.DidHandleMap) }} {{ end }} 55 - {{ block "pullEvents" (list .PullEvents $.DidHandleMap) }} {{ end }} 53 + {{ block "repoEvents" .RepoEvents }} {{ end }} 54 + {{ block "issueEvents" .IssueEvents }} {{ end }} 55 + {{ block "pullEvents" .PullEvents }} {{ end }} 56 56 </div> 57 57 {{ end }} 58 58 </div> ··· 66 66 {{ end }} 67 67 68 68 {{ define "repoEvents" }} 69 - {{ $items := index . 0 }} 70 - {{ $handleMap := index . 1 }} 71 - 72 - {{ if gt (len $items) 0 }} 69 + {{ if gt (len .) 0 }} 73 70 <details> 74 71 <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 75 72 <div class="flex flex-wrap items-center gap-2"> 76 73 {{ i "book-plus" "w-4 h-4" }} 77 - created {{ len $items }} {{if eq (len $items) 1 }}repository{{else}}repositories{{end}} 74 + created {{ len . }} {{if eq (len .) 1 }}repository{{else}}repositories{{end}} 78 75 </div> 79 76 </summary> 80 77 <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 81 - {{ range $items }} 78 + {{ range . }} 82 79 <div class="flex flex-wrap items-center gap-2"> 83 80 <span class="text-gray-500 dark:text-gray-400"> 84 81 {{ if .Source }} ··· 87 84 {{ i "book-plus" "w-4 h-4" }} 88 85 {{ end }} 89 86 </span> 90 - <a href="/{{ index $handleMap .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 87 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 91 88 {{- .Repo.Name -}} 92 89 </a> 93 90 </div> ··· 98 95 {{ end }} 99 96 100 97 {{ define "issueEvents" }} 101 - {{ $i := index . 0 }} 102 - {{ $items := $i.Items }} 103 - {{ $stats := $i.Stats }} 104 - {{ $handleMap := index . 1 }} 98 + {{ $items := .Items }} 99 + {{ $stats := .Stats }} 105 100 106 101 {{ if gt (len $items) 0 }} 107 102 <details> ··· 129 124 </summary> 130 125 <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 131 126 {{ range $items }} 132 - {{ $repoOwner := index $handleMap .Metadata.Repo.Did }} 127 + {{ $repoOwner := resolve .Metadata.Repo.Did }} 133 128 {{ $repoName := .Metadata.Repo.Name }} 134 129 {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 135 130 ··· 163 158 {{ end }} 164 159 165 160 {{ define "pullEvents" }} 166 - {{ $i := index . 0 }} 167 - {{ $items := $i.Items }} 168 - {{ $stats := $i.Stats }} 169 - {{ $handleMap := index . 1 }} 161 + {{ $items := .Items }} 162 + {{ $stats := .Stats }} 170 163 {{ if gt (len $items) 0 }} 171 164 <details> 172 165 <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> ··· 200 193 </summary> 201 194 <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 202 195 {{ range $items }} 203 - {{ $repoOwner := index $handleMap .Repo.Did }} 196 + {{ $repoOwner := resolve .Repo.Did }} 204 197 {{ $repoName := .Repo.Name }} 205 198 {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 206 199
+53
appview/pages/templates/user/signup.html
··· 1 + {{ define "user/signup" }} 2 + <!doctype html> 3 + <html lang="en" class="dark:bg-gray-900"> 4 + <head> 5 + <meta charset="UTF-8" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 + <meta property="og:title" content="signup ยท tangled" /> 8 + <meta property="og:url" content="https://tangled.sh/signup" /> 9 + <meta property="og:description" content="sign up for tangled" /> 10 + <script src="/static/htmx.min.js"></script> 11 + <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 + <title>sign up &middot; tangled</title> 13 + </head> 14 + <body class="flex items-center justify-center min-h-screen"> 15 + <main class="max-w-md px-6 -mt-4"> 16 + <h1 class="text-center text-2xl font-semibold italic dark:text-white" >tangled</h1> 17 + <h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2> 18 + <form 19 + class="mt-4 max-w-sm mx-auto" 20 + hx-post="/signup" 21 + hx-swap="none" 22 + hx-disabled-elt="#signup-button" 23 + > 24 + <div class="flex flex-col mt-2"> 25 + <label for="email">email</label> 26 + <input 27 + type="email" 28 + id="email" 29 + name="email" 30 + tabindex="4" 31 + required 32 + placeholder="jason@bourne.co" 33 + /> 34 + </div> 35 + <span class="text-sm text-gray-500 mt-1"> 36 + You will receive an email with an invite code. Enter your 37 + invite code, desired username, and password in the next 38 + page to complete your registration. 39 + </span> 40 + <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 41 + <span>join now</span> 42 + </button> 43 + </form> 44 + <p class="text-sm text-gray-500"> 45 + Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>. 46 + </p> 47 + 48 + <p id="signup-msg" class="error w-full"></p> 49 + </main> 50 + </body> 51 + </html> 52 + {{ end }} 53 +
+40 -90
appview/pulls/pulls.go
··· 19 19 "tangled.sh/tangled.sh/core/appview/notify" 20 20 "tangled.sh/tangled.sh/core/appview/oauth" 21 21 "tangled.sh/tangled.sh/core/appview/pages" 22 + "tangled.sh/tangled.sh/core/appview/pages/markup" 22 23 "tangled.sh/tangled.sh/core/appview/reporesolver" 23 24 "tangled.sh/tangled.sh/core/idresolver" 24 25 "tangled.sh/tangled.sh/core/knotclient" ··· 28 29 29 30 "github.com/bluekeyes/go-gitdiff/gitdiff" 30 31 comatproto "github.com/bluesky-social/indigo/api/atproto" 31 - "github.com/bluesky-social/indigo/atproto/syntax" 32 32 lexutil "github.com/bluesky-social/indigo/lex/util" 33 33 "github.com/go-chi/chi/v5" 34 34 "github.com/google/uuid" ··· 151 151 } 152 152 } 153 153 154 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 155 - didHandleMap := make(map[string]string) 156 - for _, identity := range resolvedIds { 157 - if !identity.Handle.IsInvalidHandle() { 158 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 159 - } else { 160 - didHandleMap[identity.DID.String()] = identity.DID.String() 161 - } 162 - } 163 - 164 154 mergeCheckResponse := s.mergeCheck(f, pull, stack) 165 155 resubmitResult := pages.Unknown 166 156 if user != nil && user.Did == pull.OwnerDid { ··· 212 202 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 213 203 LoggedInUser: user, 214 204 RepoInfo: repoInfo, 215 - DidHandleMap: didHandleMap, 216 205 Pull: pull, 217 206 Stack: stack, 218 207 AbandonedPulls: abandonedPulls, ··· 257 246 patch = mergeable.CombinedPatch() 258 247 } 259 248 260 - resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch) 249 + resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.Name, pull.TargetBranch) 261 250 if err != nil { 262 251 log.Println("failed to check for mergeability:", err) 263 252 return types.MergeCheckResponse{ ··· 318 307 // pulls within the same repo 319 308 knot = f.Knot 320 309 ownerDid = f.OwnerDid() 321 - repoName = f.RepoName 310 + repoName = f.Name 322 311 } 323 312 324 313 us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) ··· 377 366 return 378 367 } 379 368 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 369 patch := pull.Submissions[roundIdInt].Patch 392 370 diff := patchutil.AsNiceDiff(patch, pull.TargetBranch) 393 371 394 372 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 395 373 LoggedInUser: user, 396 - DidHandleMap: didHandleMap, 397 374 RepoInfo: f.RepoInfo(user), 398 375 Pull: pull, 399 376 Stack: stack, ··· 440 417 return 441 418 } 442 419 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 420 currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch) 455 421 if err != nil { 456 422 log.Println("failed to interdiff; current patch malformed") ··· 472 438 RepoInfo: f.RepoInfo(user), 473 439 Pull: pull, 474 440 Round: roundIdInt, 475 - DidHandleMap: didHandleMap, 476 441 Interdiff: interdiff, 477 442 DiffOpts: diffOpts, 478 443 }) ··· 494 459 return 495 460 } 496 461 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 462 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 509 463 w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 510 464 } ··· 529 483 530 484 pulls, err := db.GetPulls( 531 485 s.db, 532 - db.FilterEq("repo_at", f.RepoAt), 486 + db.FilterEq("repo_at", f.RepoAt()), 533 487 db.FilterEq("state", state), 534 488 ) 535 489 if err != nil { ··· 595 549 m[p.Sha] = p 596 550 } 597 551 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 552 s.pages.RepoPulls(w, pages.RepoPullsParams{ 613 553 LoggedInUser: s.oauth.GetUser(r), 614 554 RepoInfo: f.RepoInfo(user), 615 555 Pulls: pulls, 616 - DidHandleMap: didHandleMap, 617 556 FilteringBy: state, 618 557 Stacks: stacks, 619 558 Pipelines: m, ··· 671 610 createdAt := time.Now().Format(time.RFC3339) 672 611 ownerDid := user.Did 673 612 674 - pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId) 613 + pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId) 675 614 if err != nil { 676 615 log.Println("failed to get pull at", err) 677 616 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 678 617 return 679 618 } 680 619 681 - atUri := f.RepoAt.String() 620 + atUri := f.RepoAt().String() 682 621 client, err := s.oauth.AuthorizedClient(r) 683 622 if err != nil { 684 623 log.Println("failed to get authorized client", err) ··· 707 646 708 647 comment := &db.PullComment{ 709 648 OwnerDid: user.Did, 710 - RepoAt: f.RepoAt.String(), 649 + RepoAt: f.RepoAt().String(), 711 650 PullId: pull.PullId, 712 651 Body: body, 713 652 CommentAt: atResp.Uri, ··· 753 692 return 754 693 } 755 694 756 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 695 + result, err := us.Branches(f.OwnerDid(), f.Name) 757 696 if err != nil { 758 697 log.Println("failed to fetch branches", err) 759 698 return ··· 799 738 if isPatchBased && !patchutil.IsFormatPatch(patch) { 800 739 if title == "" { 801 740 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 741 + return 742 + } 743 + sanitizer := markup.NewSanitizer() 744 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 745 + s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 802 746 return 803 747 } 804 748 } ··· 877 821 return 878 822 } 879 823 880 - comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch) 824 + comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, targetBranch, sourceBranch) 881 825 if err != nil { 882 826 log.Println("failed to compare", err) 883 827 s.pages.Notice(w, "pull", err.Error()) ··· 979 923 return 980 924 } 981 925 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 - } 926 + forkAtUri := fork.RepoAt() 927 + forkAtUriStr := forkAtUri.String() 988 928 989 929 pullSource := &db.PullSource{ 990 930 Branch: sourceBranch, ··· 992 932 } 993 933 recordPullSource := &tangled.RepoPull_Source{ 994 934 Branch: sourceBranch, 995 - Repo: &fork.AtUri, 935 + Repo: &forkAtUriStr, 996 936 Sha: sourceRev, 997 937 } 998 938 ··· 1068 1008 Body: body, 1069 1009 TargetBranch: targetBranch, 1070 1010 OwnerDid: user.Did, 1071 - RepoAt: f.RepoAt, 1011 + RepoAt: f.RepoAt(), 1072 1012 Rkey: rkey, 1073 1013 Submissions: []*db.PullSubmission{ 1074 1014 &initialSubmission, ··· 1081 1021 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1082 1022 return 1083 1023 } 1084 - pullId, err := db.NextPullId(tx, f.RepoAt) 1024 + pullId, err := db.NextPullId(tx, f.RepoAt()) 1085 1025 if err != nil { 1086 1026 log.Println("failed to get pull id", err) 1087 1027 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 1096 1036 Val: &tangled.RepoPull{ 1097 1037 Title: title, 1098 1038 PullId: int64(pullId), 1099 - TargetRepo: string(f.RepoAt), 1039 + TargetRepo: string(f.RepoAt()), 1100 1040 TargetBranch: targetBranch, 1101 1041 Patch: patch, 1102 1042 Source: recordPullSource, ··· 1274 1214 return 1275 1215 } 1276 1216 1277 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1217 + result, err := us.Branches(f.OwnerDid(), f.Name) 1278 1218 if err != nil { 1279 1219 log.Println("failed to reach knotserver", err) 1280 1220 return ··· 1358 1298 return 1359 1299 } 1360 1300 1361 - targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName) 1301 + targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.Name) 1362 1302 if err != nil { 1363 1303 log.Println("failed to reach knotserver for target branches", err) 1364 1304 return ··· 1474 1414 return 1475 1415 } 1476 1416 1477 - comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch) 1417 + comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, pull.TargetBranch, pull.PullSource.Branch) 1478 1418 if err != nil { 1479 1419 log.Printf("compare request failed: %s", err) 1480 1420 s.pages.Notice(w, "resubmit-error", err.Error()) ··· 1658 1598 Val: &tangled.RepoPull{ 1659 1599 Title: pull.Title, 1660 1600 PullId: int64(pull.PullId), 1661 - TargetRepo: string(f.RepoAt), 1601 + TargetRepo: string(f.RepoAt()), 1662 1602 TargetBranch: pull.TargetBranch, 1663 1603 Patch: patch, // new patch 1664 1604 Source: recordPullSource, ··· 1774 1714 1775 1715 // deleted pulls are marked as deleted in the DB 1776 1716 for _, p := range deletions { 1717 + // do not do delete already merged PRs 1718 + if p.State == db.PullMerged { 1719 + continue 1720 + } 1721 + 1777 1722 err := db.DeletePull(tx, p.RepoAt, p.PullId) 1778 1723 if err != nil { 1779 1724 log.Println("failed to delete pull", err, p.PullId) ··· 1814 1759 op, _ := origById[id] 1815 1760 np, _ := newById[id] 1816 1761 1762 + // do not update already merged PRs 1763 + if op.State == db.PullMerged { 1764 + continue 1765 + } 1766 + 1817 1767 submission := np.Submissions[np.LastRoundNumber()] 1818 1768 1819 1769 // resubmit the old pull ··· 1985 1935 } 1986 1936 1987 1937 // 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) 1938 + resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.Name, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address) 1989 1939 if err != nil { 1990 1940 log.Printf("failed to merge pull request: %s", err) 1991 1941 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 2007 1957 defer tx.Rollback() 2008 1958 2009 1959 for _, p := range pullsToMerge { 2010 - err := db.MergePull(tx, f.RepoAt, p.PullId) 1960 + err := db.MergePull(tx, f.RepoAt(), p.PullId) 2011 1961 if err != nil { 2012 1962 log.Printf("failed to update pull request status in database: %s", err) 2013 1963 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 2023 1973 return 2024 1974 } 2025 1975 2026 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId)) 1976 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 2027 1977 } 2028 1978 2029 1979 func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { ··· 2075 2025 2076 2026 for _, p := range pullsToClose { 2077 2027 // Close the pull in the database 2078 - err = db.ClosePull(tx, f.RepoAt, p.PullId) 2028 + err = db.ClosePull(tx, f.RepoAt(), p.PullId) 2079 2029 if err != nil { 2080 2030 log.Println("failed to close pull", err) 2081 2031 s.pages.Notice(w, "pull-close", "Failed to close pull.") ··· 2143 2093 2144 2094 for _, p := range pullsToReopen { 2145 2095 // Close the pull in the database 2146 - err = db.ReopenPull(tx, f.RepoAt, p.PullId) 2096 + err = db.ReopenPull(tx, f.RepoAt(), p.PullId) 2147 2097 if err != nil { 2148 2098 log.Println("failed to close pull", err) 2149 2099 s.pages.Notice(w, "pull-close", "Failed to close pull.") ··· 2195 2145 Body: body, 2196 2146 TargetBranch: targetBranch, 2197 2147 OwnerDid: user.Did, 2198 - RepoAt: f.RepoAt, 2148 + RepoAt: f.RepoAt(), 2199 2149 Rkey: rkey, 2200 2150 Submissions: []*db.PullSubmission{ 2201 2151 &initialSubmission,
+6 -6
appview/repo/artifact.go
··· 76 76 Artifact: uploadBlobResp.Blob, 77 77 CreatedAt: createdAt.Format(time.RFC3339), 78 78 Name: handler.Filename, 79 - Repo: f.RepoAt.String(), 79 + Repo: f.RepoAt().String(), 80 80 Tag: tag.Tag.Hash[:], 81 81 }, 82 82 }, ··· 100 100 artifact := db.Artifact{ 101 101 Did: user.Did, 102 102 Rkey: rkey, 103 - RepoAt: f.RepoAt, 103 + RepoAt: f.RepoAt(), 104 104 Tag: tag.Tag.Hash, 105 105 CreatedAt: createdAt, 106 106 BlobCid: cid.Cid(uploadBlobResp.Blob.Ref), ··· 155 155 156 156 artifacts, err := db.GetArtifact( 157 157 rp.db, 158 - db.FilterEq("repo_at", f.RepoAt), 158 + db.FilterEq("repo_at", f.RepoAt()), 159 159 db.FilterEq("tag", tag.Tag.Hash[:]), 160 160 db.FilterEq("name", filename), 161 161 ) ··· 197 197 198 198 artifacts, err := db.GetArtifact( 199 199 rp.db, 200 - db.FilterEq("repo_at", f.RepoAt), 200 + db.FilterEq("repo_at", f.RepoAt()), 201 201 db.FilterEq("tag", tag[:]), 202 202 db.FilterEq("name", filename), 203 203 ) ··· 239 239 defer tx.Rollback() 240 240 241 241 err = db.DeleteArtifact(tx, 242 - db.FilterEq("repo_at", f.RepoAt), 242 + db.FilterEq("repo_at", f.RepoAt()), 243 243 db.FilterEq("tag", artifact.Tag[:]), 244 244 db.FilterEq("name", filename), 245 245 ) ··· 270 270 return nil, err 271 271 } 272 272 273 - result, err := us.Tags(f.OwnerDid(), f.RepoName) 273 + result, err := us.Tags(f.OwnerDid(), f.Name) 274 274 if err != nil { 275 275 log.Println("failed to reach knotserver", err) 276 276 return nil, err
+165
appview/repo/feed.go
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log" 7 + "net/http" 8 + "slices" 9 + "time" 10 + 11 + "tangled.sh/tangled.sh/core/appview/db" 12 + "tangled.sh/tangled.sh/core/appview/reporesolver" 13 + 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + "github.com/gorilla/feeds" 16 + ) 17 + 18 + func (rp *Repo) getRepoFeed(ctx context.Context, f *reporesolver.ResolvedRepo) (*feeds.Feed, error) { 19 + const feedLimitPerType = 100 20 + 21 + pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt())) 22 + if err != nil { 23 + return nil, err 24 + } 25 + 26 + issues, err := db.GetIssuesWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt())) 27 + if err != nil { 28 + return nil, err 29 + } 30 + 31 + feed := &feeds.Feed{ 32 + Title: fmt.Sprintf("activity feed for %s", f.OwnerSlashRepo()), 33 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.AppviewHost, f.OwnerSlashRepo()), Type: "text/html", Rel: "alternate"}, 34 + Items: make([]*feeds.Item, 0), 35 + Updated: time.UnixMilli(0), 36 + } 37 + 38 + for _, pull := range pulls { 39 + items, err := rp.createPullItems(ctx, pull, f) 40 + if err != nil { 41 + return nil, err 42 + } 43 + feed.Items = append(feed.Items, items...) 44 + } 45 + 46 + for _, issue := range issues { 47 + item, err := rp.createIssueItem(ctx, issue, f) 48 + if err != nil { 49 + return nil, err 50 + } 51 + feed.Items = append(feed.Items, item) 52 + } 53 + 54 + slices.SortFunc(feed.Items, func(a, b *feeds.Item) int { 55 + if a.Created.After(b.Created) { 56 + return -1 57 + } 58 + return 1 59 + }) 60 + 61 + if len(feed.Items) > 0 { 62 + feed.Updated = feed.Items[0].Created 63 + } 64 + 65 + return feed, nil 66 + } 67 + 68 + func (rp *Repo) createPullItems(ctx context.Context, pull *db.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) { 69 + owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid) 70 + if err != nil { 71 + return nil, err 72 + } 73 + 74 + var items []*feeds.Item 75 + 76 + state := rp.getPullState(pull) 77 + description := rp.buildPullDescription(owner.Handle, state, pull, f.OwnerSlashRepo()) 78 + 79 + mainItem := &feeds.Item{ 80 + Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title), 81 + Description: description, 82 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId)}, 83 + Created: pull.Created, 84 + Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 85 + } 86 + items = append(items, mainItem) 87 + 88 + for _, round := range pull.Submissions { 89 + if round == nil || round.RoundNumber == 0 { 90 + continue 91 + } 92 + 93 + roundItem := &feeds.Item{ 94 + Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.RoundNumber), 95 + Description: fmt.Sprintf("@%s submitted changes (at round #%d) on PR #%d in %s", owner.Handle, round.RoundNumber, pull.PullId, f.OwnerSlashRepo()), 96 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId, round.RoundNumber)}, 97 + Created: round.Created, 98 + Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 99 + } 100 + items = append(items, roundItem) 101 + } 102 + 103 + return items, nil 104 + } 105 + 106 + func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { 107 + owner, err := rp.idResolver.ResolveIdent(ctx, issue.OwnerDid) 108 + if err != nil { 109 + return nil, err 110 + } 111 + 112 + state := "closed" 113 + if issue.Open { 114 + state = "opened" 115 + } 116 + 117 + return &feeds.Item{ 118 + Title: fmt.Sprintf("[Issue #%d] %s", issue.IssueId, issue.Title), 119 + Description: fmt.Sprintf("@%s %s issue #%d in %s", owner.Handle, state, issue.IssueId, f.OwnerSlashRepo()), 120 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), issue.IssueId)}, 121 + Created: issue.Created, 122 + Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 123 + }, nil 124 + } 125 + 126 + func (rp *Repo) getPullState(pull *db.Pull) string { 127 + if pull.State == db.PullOpen { 128 + return "opened" 129 + } 130 + return pull.State.String() 131 + } 132 + 133 + func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *db.Pull, repoName string) string { 134 + base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId) 135 + 136 + if pull.State == db.PullMerged { 137 + return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName) 138 + } 139 + 140 + return fmt.Sprintf("%s in %s", base, repoName) 141 + } 142 + 143 + func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) { 144 + f, err := rp.repoResolver.Resolve(r) 145 + if err != nil { 146 + log.Println("failed to fully resolve repo:", err) 147 + return 148 + } 149 + 150 + feed, err := rp.getRepoFeed(r.Context(), f) 151 + if err != nil { 152 + log.Println("failed to get repo feed:", err) 153 + rp.pages.Error500(w) 154 + return 155 + } 156 + 157 + atom, err := feed.ToAtom() 158 + if err != nil { 159 + rp.pages.Error500(w) 160 + return 161 + } 162 + 163 + w.Header().Set("content-type", "application/atom+xml") 164 + w.Write([]byte(atom)) 165 + }
+15 -12
appview/repo/index.go
··· 24 24 25 25 func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 26 26 ref := chi.URLParam(r, "ref") 27 + 27 28 f, err := rp.repoResolver.Resolve(r) 28 29 if err != nil { 29 30 log.Println("failed to fully resolve repo", err) ··· 37 38 return 38 39 } 39 40 40 - result, err := us.Index(f.OwnerDid(), f.RepoName, ref) 41 + result, err := us.Index(f.OwnerDid(), f.Name, ref) 41 42 if err != nil { 42 43 rp.pages.Error503(w) 43 44 log.Println("failed to reach knotserver", err) ··· 118 119 119 120 var forkInfo *types.ForkInfo 120 121 if user != nil && (repoInfo.Roles.IsOwner() || repoInfo.Roles.IsCollaborator()) { 121 - forkInfo, err = getForkInfo(repoInfo, rp, f, user, signedClient) 122 + forkInfo, err = getForkInfo(repoInfo, rp, f, result.Ref, user, signedClient) 122 123 if err != nil { 123 124 log.Printf("Failed to fetch fork information: %v", err) 124 125 return ··· 126 127 } 127 128 128 129 // TODO: a bit dirty 129 - languageInfo, err := rp.getLanguageInfo(f, signedClient, chi.URLParam(r, "ref") == "") 130 + languageInfo, err := rp.getLanguageInfo(f, signedClient, result.Ref, ref == "") 130 131 if err != nil { 131 132 log.Printf("failed to compute language percentages: %s", err) 132 133 // non-fatal ··· 161 162 func (rp *Repo) getLanguageInfo( 162 163 f *reporesolver.ResolvedRepo, 163 164 signedClient *knotclient.SignedClient, 165 + currentRef string, 164 166 isDefaultRef bool, 165 167 ) ([]types.RepoLanguageDetails, error) { 166 168 // first attempt to fetch from db 167 169 langs, err := db.GetRepoLanguages( 168 170 rp.db, 169 - db.FilterEq("repo_at", f.RepoAt), 170 - db.FilterEq("ref", f.Ref), 171 + db.FilterEq("repo_at", f.RepoAt()), 172 + db.FilterEq("ref", currentRef), 171 173 ) 172 174 173 175 if err != nil || langs == nil { 174 176 // non-fatal, fetch langs from ks 175 - ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, f.Ref) 177 + ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.Name, currentRef) 176 178 if err != nil { 177 179 return nil, err 178 180 } ··· 182 184 183 185 for l, s := range ls.Languages { 184 186 langs = append(langs, db.RepoLanguage{ 185 - RepoAt: f.RepoAt, 186 - Ref: f.Ref, 187 + RepoAt: f.RepoAt(), 188 + Ref: currentRef, 187 189 IsDefaultRef: isDefaultRef, 188 190 Language: l, 189 191 Bytes: s, ··· 234 236 repoInfo repoinfo.RepoInfo, 235 237 rp *Repo, 236 238 f *reporesolver.ResolvedRepo, 239 + currentRef string, 237 240 user *oauth.User, 238 241 signedClient *knotclient.SignedClient, 239 242 ) (*types.ForkInfo, error) { ··· 264 267 } 265 268 266 269 if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool { 267 - return branch.Name == f.Ref 270 + return branch.Name == currentRef 268 271 }) { 269 272 forkInfo.Status = types.MissingBranch 270 273 return &forkInfo, nil 271 274 } 272 275 273 - newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, f.Ref, f.Ref) 276 + newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, currentRef, currentRef) 274 277 if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent { 275 278 log.Printf("failed to update tracking branch: %s", err) 276 279 return nil, err 277 280 } 278 281 279 - hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref) 282 + hiddenRef := fmt.Sprintf("hidden/%s/%s", currentRef, currentRef) 280 283 281 284 var status types.AncestorCheckResponse 282 - forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt), repoInfo.Name, f.Ref, hiddenRef) 285 + forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt()), repoInfo.Name, currentRef, hiddenRef) 283 286 if err != nil { 284 287 log.Printf("failed to check if fork is ahead/behind: %s", err) 285 288 return nil, err
+120 -66
appview/repo/repo.go
··· 81 81 } 82 82 } 83 83 84 + func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 85 + refParam := chi.URLParam(r, "ref") 86 + f, err := rp.repoResolver.Resolve(r) 87 + if err != nil { 88 + log.Println("failed to get repo and knot", err) 89 + return 90 + } 91 + 92 + var uri string 93 + if rp.config.Core.Dev { 94 + uri = "http" 95 + } else { 96 + uri = "https" 97 + } 98 + url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.Name, url.PathEscape(refParam)) 99 + 100 + http.Redirect(w, r, url, http.StatusFound) 101 + } 102 + 84 103 func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { 85 104 f, err := rp.repoResolver.Resolve(r) 86 105 if err != nil { ··· 104 123 return 105 124 } 106 125 107 - repolog, err := us.Log(f.OwnerDid(), f.RepoName, ref, page) 126 + repolog, err := us.Log(f.OwnerDid(), f.Name, ref, page) 108 127 if err != nil { 109 128 log.Println("failed to reach knotserver", err) 110 129 return 111 130 } 112 131 113 - tagResult, err := us.Tags(f.OwnerDid(), f.RepoName) 132 + tagResult, err := us.Tags(f.OwnerDid(), f.Name) 114 133 if err != nil { 115 134 log.Println("failed to reach knotserver", err) 116 135 return ··· 125 144 tagMap[hash] = append(tagMap[hash], tag.Name) 126 145 } 127 146 128 - branchResult, err := us.Branches(f.OwnerDid(), f.RepoName) 147 + branchResult, err := us.Branches(f.OwnerDid(), f.Name) 129 148 if err != nil { 130 149 log.Println("failed to reach knotserver", err) 131 150 return ··· 193 212 return 194 213 } 195 214 196 - repoAt := f.RepoAt 215 + repoAt := f.RepoAt() 197 216 rkey := repoAt.RecordKey().String() 198 217 if rkey == "" { 199 218 log.Println("invalid aturi for repo", err) ··· 243 262 Record: &lexutil.LexiconTypeDecoder{ 244 263 Val: &tangled.Repo{ 245 264 Knot: f.Knot, 246 - Name: f.RepoName, 265 + Name: f.Name, 247 266 Owner: user.Did, 248 - CreatedAt: f.CreatedAt, 267 + CreatedAt: f.Created.Format(time.RFC3339), 249 268 Description: &newDescription, 250 269 Spindle: &f.Spindle, 251 270 }, ··· 291 310 return 292 311 } 293 312 294 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)) 313 + resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref)) 295 314 if err != nil { 296 315 log.Println("failed to reach knotserver", err) 297 316 return ··· 356 375 if !rp.config.Core.Dev { 357 376 protocol = "https" 358 377 } 359 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath)) 378 + resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, treePath)) 360 379 if err != nil { 361 380 log.Println("failed to reach knotserver", err) 362 381 return ··· 386 405 user := rp.oauth.GetUser(r) 387 406 388 407 var breadcrumbs [][]string 389 - breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 408 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 390 409 if treePath != "" { 391 410 for idx, elem := range strings.Split(treePath, "/") { 392 411 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) ··· 417 436 return 418 437 } 419 438 420 - result, err := us.Tags(f.OwnerDid(), f.RepoName) 439 + result, err := us.Tags(f.OwnerDid(), f.Name) 421 440 if err != nil { 422 441 log.Println("failed to reach knotserver", err) 423 442 return 424 443 } 425 444 426 - artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt)) 445 + artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 427 446 if err != nil { 428 447 log.Println("failed grab artifacts", err) 429 448 return ··· 474 493 return 475 494 } 476 495 477 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 496 + result, err := us.Branches(f.OwnerDid(), f.Name) 478 497 if err != nil { 479 498 log.Println("failed to reach knotserver", err) 480 499 return ··· 503 522 if !rp.config.Core.Dev { 504 523 protocol = "https" 505 524 } 506 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 525 + resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath)) 507 526 if err != nil { 508 527 log.Println("failed to reach knotserver", err) 509 528 return ··· 523 542 } 524 543 525 544 var breadcrumbs [][]string 526 - breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 545 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 527 546 if filePath != "" { 528 547 for idx, elem := range strings.Split(filePath, "/") { 529 548 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) ··· 556 575 557 576 // fetch the actual binary content like in RepoBlobRaw 558 577 559 - blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath) 578 + blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Name, ref, filePath) 560 579 contentSrc = blobURL 561 580 if !rp.config.Core.Dev { 562 581 contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) ··· 593 612 if !rp.config.Core.Dev { 594 613 protocol = "https" 595 614 } 596 - blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath) 597 - resp, err := http.Get(blobURL) 615 + 616 + blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath) 617 + 618 + req, err := http.NewRequest("GET", blobURL, nil) 619 + if err != nil { 620 + log.Println("failed to create request", err) 621 + return 622 + } 623 + 624 + // forward the If-None-Match header 625 + if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 626 + req.Header.Set("If-None-Match", clientETag) 627 + } 628 + 629 + client := &http.Client{} 630 + resp, err := client.Do(req) 598 631 if err != nil { 599 - log.Println("failed to reach knotserver:", err) 632 + log.Println("failed to reach knotserver", err) 600 633 rp.pages.Error503(w) 601 634 return 602 635 } 603 636 defer resp.Body.Close() 604 637 638 + // forward 304 not modified 639 + if resp.StatusCode == http.StatusNotModified { 640 + w.WriteHeader(http.StatusNotModified) 641 + return 642 + } 643 + 605 644 if resp.StatusCode != http.StatusOK { 606 645 log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) 607 646 w.WriteHeader(resp.StatusCode) ··· 649 688 return 650 689 } 651 690 652 - repoAt := f.RepoAt 691 + repoAt := f.RepoAt() 653 692 rkey := repoAt.RecordKey().String() 654 693 if rkey == "" { 655 694 fail("Failed to resolve repo. Try again later", err) ··· 657 696 } 658 697 659 698 newSpindle := r.FormValue("spindle") 699 + removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value 660 700 client, err := rp.oauth.AuthorizedClient(r) 661 701 if err != nil { 662 702 fail("Failed to authorize. Try again later.", err) 663 703 return 664 704 } 665 705 666 - // ensure that this is a valid spindle for this user 667 - validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 668 - if err != nil { 669 - fail("Failed to find spindles. Try again later.", err) 670 - return 706 + if !removingSpindle { 707 + // ensure that this is a valid spindle for this user 708 + validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 709 + if err != nil { 710 + fail("Failed to find spindles. Try again later.", err) 711 + return 712 + } 713 + 714 + if !slices.Contains(validSpindles, newSpindle) { 715 + fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles)) 716 + return 717 + } 671 718 } 672 719 673 - if !slices.Contains(validSpindles, newSpindle) { 674 - fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles)) 675 - return 720 + spindlePtr := &newSpindle 721 + if removingSpindle { 722 + spindlePtr = nil 676 723 } 677 724 678 725 // optimistic update 679 - err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle) 726 + err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr) 680 727 if err != nil { 681 728 fail("Failed to update spindle. Try again later.", err) 682 729 return ··· 695 742 Record: &lexutil.LexiconTypeDecoder{ 696 743 Val: &tangled.Repo{ 697 744 Knot: f.Knot, 698 - Name: f.RepoName, 745 + Name: f.Name, 699 746 Owner: user.Did, 700 - CreatedAt: f.CreatedAt, 747 + CreatedAt: f.Created.Format(time.RFC3339), 701 748 Description: &f.Description, 702 - Spindle: &newSpindle, 749 + Spindle: spindlePtr, 703 750 }, 704 751 }, 705 752 }) ··· 709 756 return 710 757 } 711 758 712 - // add this spindle to spindle stream 713 - rp.spindlestream.AddSource( 714 - context.Background(), 715 - eventconsumer.NewSpindleSource(newSpindle), 716 - ) 759 + if !removingSpindle { 760 + // add this spindle to spindle stream 761 + rp.spindlestream.AddSource( 762 + context.Background(), 763 + eventconsumer.NewSpindleSource(newSpindle), 764 + ) 765 + } 717 766 718 767 rp.pages.HxRefresh(w) 719 768 } ··· 742 791 return 743 792 } 744 793 794 + // remove a single leading `@`, to make @handle work with ResolveIdent 795 + collaborator = strings.TrimPrefix(collaborator, "@") 796 + 745 797 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator) 746 798 if err != nil { 747 799 fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err) ··· 773 825 Record: &lexutil.LexiconTypeDecoder{ 774 826 Val: &tangled.RepoCollaborator{ 775 827 Subject: collaboratorIdent.DID.String(), 776 - Repo: string(f.RepoAt), 828 + Repo: string(f.RepoAt()), 777 829 CreatedAt: createdAt.Format(time.RFC3339), 778 830 }}, 779 831 }) ··· 798 850 return 799 851 } 800 852 801 - ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 853 + ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.Name, collaboratorIdent.DID.String()) 802 854 if err != nil { 803 855 fail("Knot was unreachable.", err) 804 856 return ··· 832 884 Did: syntax.DID(currentUser.Did), 833 885 Rkey: rkey, 834 886 SubjectDid: collaboratorIdent.DID, 835 - RepoAt: f.RepoAt, 887 + RepoAt: f.RepoAt(), 836 888 Created: createdAt, 837 889 }) 838 890 if err != nil { ··· 870 922 log.Println("failed to get authorized client", err) 871 923 return 872 924 } 873 - repoRkey := f.RepoAt.RecordKey().String() 874 925 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 875 926 Collection: tangled.RepoNSID, 876 927 Repo: user.Did, 877 - Rkey: repoRkey, 928 + Rkey: f.Rkey, 878 929 }) 879 930 if err != nil { 880 931 log.Printf("failed to delete record: %s", err) 881 932 rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 882 933 return 883 934 } 884 - log.Println("removed repo record ", f.RepoAt.String()) 935 + log.Println("removed repo record ", f.RepoAt().String()) 885 936 886 937 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 887 938 if err != nil { ··· 895 946 return 896 947 } 897 948 898 - ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName) 949 + ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.Name) 899 950 if err != nil { 900 951 log.Printf("failed to make request to %s: %s", f.Knot, err) 901 952 return ··· 941 992 } 942 993 943 994 // remove repo from db 944 - err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName) 995 + err = db.RemoveRepo(tx, f.OwnerDid(), f.Name) 945 996 if err != nil { 946 997 rp.pages.Notice(w, "settings-delete", "Failed to update appview") 947 998 return ··· 990 1041 return 991 1042 } 992 1043 993 - ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch) 1044 + ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.Name, branch) 994 1045 if err != nil { 995 1046 log.Printf("failed to make request to %s: %s", f.Knot, err) 996 1047 return ··· 1030 1081 r, 1031 1082 oauth.WithService(f.Spindle), 1032 1083 oauth.WithLxm(lxm), 1084 + oauth.WithExp(60), 1033 1085 oauth.WithDev(rp.config.Core.Dev), 1034 1086 ) 1035 1087 if err != nil { ··· 1057 1109 r.Context(), 1058 1110 spindleClient, 1059 1111 &tangled.RepoAddSecret_Input{ 1060 - Repo: f.RepoAt.String(), 1112 + Repo: f.RepoAt().String(), 1061 1113 Key: key, 1062 1114 Value: value, 1063 1115 }, ··· 1075 1127 r.Context(), 1076 1128 spindleClient, 1077 1129 &tangled.RepoRemoveSecret_Input{ 1078 - Repo: f.RepoAt.String(), 1130 + Repo: f.RepoAt().String(), 1079 1131 Key: key, 1080 1132 }, 1081 1133 ) ··· 1137 1189 // return 1138 1190 // } 1139 1191 1140 - // result, err := us.Branches(f.OwnerDid(), f.RepoName) 1192 + // result, err := us.Branches(f.OwnerDid(), f.Name) 1141 1193 // if err != nil { 1142 1194 // log.Println("failed to reach knotserver", err) 1143 1195 // return ··· 1159 1211 // oauth.WithDev(rp.config.Core.Dev), 1160 1212 // ); err != nil { 1161 1213 // log.Println("failed to create spindle client", err) 1162 - // } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil { 1214 + // } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 1163 1215 // log.Println("failed to fetch secrets", err) 1164 1216 // } else { 1165 1217 // secrets = resp.Secrets ··· 1188 1240 return 1189 1241 } 1190 1242 1191 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1243 + result, err := us.Branches(f.OwnerDid(), f.Name) 1192 1244 if err != nil { 1193 1245 log.Println("failed to reach knotserver", err) 1194 1246 return ··· 1225 1277 f, err := rp.repoResolver.Resolve(r) 1226 1278 user := rp.oauth.GetUser(r) 1227 1279 1228 - // all spindles that this user is a member of 1229 - spindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 1280 + // all spindles that the repo owner is a member of 1281 + spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 1230 1282 if err != nil { 1231 1283 log.Println("failed to fetch spindles", err) 1232 1284 return ··· 1238 1290 r, 1239 1291 oauth.WithService(f.Spindle), 1240 1292 oauth.WithLxm(tangled.RepoListSecretsNSID), 1293 + oauth.WithExp(60), 1241 1294 oauth.WithDev(rp.config.Core.Dev), 1242 1295 ); err != nil { 1243 1296 log.Println("failed to create spindle client", err) 1244 - } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil { 1297 + } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 1245 1298 log.Println("failed to fetch secrets", err) 1246 1299 } else { 1247 1300 secrets = resp.Secrets ··· 1282 1335 } 1283 1336 1284 1337 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1338 + ref := chi.URLParam(r, "ref") 1339 + 1285 1340 user := rp.oauth.GetUser(r) 1286 1341 f, err := rp.repoResolver.Resolve(r) 1287 1342 if err != nil { ··· 1309 1364 } else { 1310 1365 uri = "https" 1311 1366 } 1312 - forkName := fmt.Sprintf("%s", f.RepoName) 1313 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1367 + forkName := fmt.Sprintf("%s", f.Name) 1368 + forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1314 1369 1315 - _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref) 1370 + _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, ref) 1316 1371 if err != nil { 1317 1372 rp.pages.Notice(w, "repo", "Failed to sync repository fork.") 1318 1373 return ··· 1360 1415 return 1361 1416 } 1362 1417 1363 - forkName := fmt.Sprintf("%s", f.RepoName) 1418 + forkName := fmt.Sprintf("%s", f.Name) 1364 1419 1365 1420 // this check is *only* to see if the forked repo name already exists 1366 1421 // in the user's account. 1367 - existingRepo, err := db.GetRepo(rp.db, user.Did, f.RepoName) 1422 + existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name) 1368 1423 if err != nil { 1369 1424 if errors.Is(err, sql.ErrNoRows) { 1370 1425 // no existing repo with this name found, we can use the name as is ··· 1395 1450 } else { 1396 1451 uri = "https" 1397 1452 } 1398 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1399 - sourceAt := f.RepoAt.String() 1453 + forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1454 + sourceAt := f.RepoAt().String() 1400 1455 1401 1456 rkey := tid.TID() 1402 1457 repo := &db.Repo{ ··· 1465 1520 } 1466 1521 log.Println("created repo record: ", atresp.Uri) 1467 1522 1468 - repo.AtUri = atresp.Uri 1469 1523 err = db.AddRepo(tx, repo) 1470 1524 if err != nil { 1471 1525 log.Println(err) ··· 1516 1570 return 1517 1571 } 1518 1572 1519 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1573 + result, err := us.Branches(f.OwnerDid(), f.Name) 1520 1574 if err != nil { 1521 1575 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1522 1576 log.Println("failed to reach knotserver", err) ··· 1546 1600 head = queryHead 1547 1601 } 1548 1602 1549 - tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1603 + tags, err := us.Tags(f.OwnerDid(), f.Name) 1550 1604 if err != nil { 1551 1605 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1552 1606 log.Println("failed to reach knotserver", err) ··· 1608 1662 return 1609 1663 } 1610 1664 1611 - branches, err := us.Branches(f.OwnerDid(), f.RepoName) 1665 + branches, err := us.Branches(f.OwnerDid(), f.Name) 1612 1666 if err != nil { 1613 1667 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1614 1668 log.Println("failed to reach knotserver", err) 1615 1669 return 1616 1670 } 1617 1671 1618 - tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1672 + tags, err := us.Tags(f.OwnerDid(), f.Name) 1619 1673 if err != nil { 1620 1674 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1621 1675 log.Println("failed to reach knotserver", err) 1622 1676 return 1623 1677 } 1624 1678 1625 - formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head) 1679 + formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head) 1626 1680 if err != nil { 1627 1681 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1628 1682 log.Println("failed to compare", err)
+5
appview/repo/router.go
··· 10 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 11 r := chi.NewRouter() 12 12 r.Get("/", rp.RepoIndex) 13 + r.Get("/feed.atom", rp.RepoAtomFeed) 13 14 r.Get("/commits/{ref}", rp.RepoLog) 14 15 r.Route("/tree/{ref}", func(r chi.Router) { 15 16 r.Get("/", rp.RepoIndex) ··· 37 38 }) 38 39 r.Get("/blob/{ref}/*", rp.RepoBlob) 39 40 r.Get("/raw/{ref}/*", rp.RepoBlobRaw) 41 + 42 + // intentionally doesn't use /* as this isn't 43 + // a file path 44 + r.Get("/archive/{ref}", rp.DownloadArchive) 40 45 41 46 r.Route("/fork", func(r chi.Router) { 42 47 r.Use(middleware.AuthMiddleware(rp.oauth))
+37 -104
appview/reporesolver/resolver.go
··· 7 7 "fmt" 8 8 "log" 9 9 "net/http" 10 - "net/url" 11 10 "path" 11 + "regexp" 12 12 "strings" 13 13 14 14 "github.com/bluesky-social/indigo/atproto/identity" 15 - "github.com/bluesky-social/indigo/atproto/syntax" 16 15 securejoin "github.com/cyphar/filepath-securejoin" 17 16 "github.com/go-chi/chi/v5" 18 17 "tangled.sh/tangled.sh/core/appview/config" ··· 21 20 "tangled.sh/tangled.sh/core/appview/pages" 22 21 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 23 22 "tangled.sh/tangled.sh/core/idresolver" 24 - "tangled.sh/tangled.sh/core/knotclient" 25 23 "tangled.sh/tangled.sh/core/rbac" 26 24 ) 27 25 28 26 type ResolvedRepo struct { 29 - Knot string 30 - OwnerId identity.Identity 31 - RepoName string 32 - RepoAt syntax.ATURI 33 - Description string 34 - Spindle string 35 - CreatedAt string 36 - Ref string 37 - CurrentDir string 27 + db.Repo 28 + OwnerId identity.Identity 29 + CurrentDir string 30 + Ref string 38 31 39 32 rr *RepoResolver 40 33 } ··· 51 44 } 52 45 53 46 func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) { 54 - repoName := chi.URLParam(r, "repo") 55 - knot, ok := r.Context().Value("knot").(string) 47 + repo, ok := r.Context().Value("repo").(*db.Repo) 56 48 if !ok { 57 - log.Println("malformed middleware") 49 + log.Println("malformed middleware: `repo` not exist in context") 58 50 return nil, fmt.Errorf("malformed middleware") 59 51 } 60 52 id, ok := r.Context().Value("resolvedId").(identity.Identity) ··· 63 55 return nil, fmt.Errorf("malformed middleware") 64 56 } 65 57 66 - repoAt, ok := r.Context().Value("repoAt").(string) 67 - if !ok { 68 - log.Println("malformed middleware") 69 - return nil, fmt.Errorf("malformed middleware") 70 - } 71 - 72 - parsedRepoAt, err := syntax.ParseATURI(repoAt) 73 - if err != nil { 74 - log.Println("malformed repo at-uri") 75 - return nil, fmt.Errorf("malformed middleware") 76 - } 77 - 58 + currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath())) 78 59 ref := chi.URLParam(r, "ref") 79 60 80 - if ref == "" { 81 - us, err := knotclient.NewUnsignedClient(knot, rr.config.Core.Dev) 82 - if err != nil { 83 - return nil, err 84 - } 85 - 86 - defaultBranch, err := us.DefaultBranch(id.DID.String(), repoName) 87 - if err != nil { 88 - return nil, err 89 - } 90 - 91 - ref = defaultBranch.Branch 92 - } 93 - 94 - currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath(), ref)) 95 - 96 - // pass through values from the middleware 97 - description, ok := r.Context().Value("repoDescription").(string) 98 - addedAt, ok := r.Context().Value("repoAddedAt").(string) 99 - spindle, ok := r.Context().Value("repoSpindle").(string) 100 - 101 61 return &ResolvedRepo{ 102 - Knot: knot, 103 - OwnerId: id, 104 - RepoName: repoName, 105 - RepoAt: parsedRepoAt, 106 - Description: description, 107 - CreatedAt: addedAt, 108 - Ref: ref, 109 - CurrentDir: currentDir, 110 - Spindle: spindle, 62 + Repo: *repo, 63 + OwnerId: id, 64 + CurrentDir: currentDir, 65 + Ref: ref, 111 66 112 67 rr: rr, 113 68 }, nil ··· 126 81 127 82 var p string 128 83 if handle != "" && !handle.IsInvalidHandle() { 129 - p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName) 84 + p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.Name) 130 85 } else { 131 - p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 86 + p, _ = securejoin.SecureJoin(f.OwnerDid(), f.Name) 132 87 } 133 88 134 - return p 135 - } 136 - 137 - func (f *ResolvedRepo) DidSlashRepo() string { 138 - p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 139 89 return p 140 90 } 141 91 ··· 187 137 // this function is a bit weird since it now returns RepoInfo from an entirely different 188 138 // package. we should refactor this or get rid of RepoInfo entirely. 189 139 func (f *ResolvedRepo) RepoInfo(user *oauth.User) repoinfo.RepoInfo { 140 + repoAt := f.RepoAt() 190 141 isStarred := false 191 142 if user != nil { 192 - isStarred = db.GetStarStatus(f.rr.execer, user.Did, syntax.ATURI(f.RepoAt)) 143 + isStarred = db.GetStarStatus(f.rr.execer, user.Did, repoAt) 193 144 } 194 145 195 - starCount, err := db.GetStarCount(f.rr.execer, f.RepoAt) 146 + starCount, err := db.GetStarCount(f.rr.execer, repoAt) 196 147 if err != nil { 197 - log.Println("failed to get star count for ", f.RepoAt) 148 + log.Println("failed to get star count for ", repoAt) 198 149 } 199 - issueCount, err := db.GetIssueCount(f.rr.execer, f.RepoAt) 150 + issueCount, err := db.GetIssueCount(f.rr.execer, repoAt) 200 151 if err != nil { 201 - log.Println("failed to get issue count for ", f.RepoAt) 152 + log.Println("failed to get issue count for ", repoAt) 202 153 } 203 - pullCount, err := db.GetPullCount(f.rr.execer, f.RepoAt) 154 + pullCount, err := db.GetPullCount(f.rr.execer, repoAt) 204 155 if err != nil { 205 - log.Println("failed to get issue count for ", f.RepoAt) 156 + log.Println("failed to get issue count for ", repoAt) 206 157 } 207 - source, err := db.GetRepoSource(f.rr.execer, f.RepoAt) 158 + source, err := db.GetRepoSource(f.rr.execer, repoAt) 208 159 if errors.Is(err, sql.ErrNoRows) { 209 160 source = "" 210 161 } else if err != nil { 211 - log.Println("failed to get repo source for ", f.RepoAt, err) 162 + log.Println("failed to get repo source for ", repoAt, err) 212 163 } 213 164 214 165 var sourceRepo *db.Repo ··· 228 179 } 229 180 230 181 knot := f.Knot 231 - var disableFork bool 232 - us, err := knotclient.NewUnsignedClient(knot, f.rr.config.Core.Dev) 233 - if err != nil { 234 - log.Printf("failed to create unsigned client for %s: %v", knot, err) 235 - } else { 236 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 237 - if err != nil { 238 - log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err) 239 - } 240 - 241 - if len(result.Branches) == 0 { 242 - disableFork = true 243 - } 244 - } 245 182 246 183 repoInfo := repoinfo.RepoInfo{ 247 184 OwnerDid: f.OwnerDid(), 248 185 OwnerHandle: f.OwnerHandle(), 249 - Name: f.RepoName, 250 - RepoAt: f.RepoAt, 186 + Name: f.Name, 187 + RepoAt: repoAt, 251 188 Description: f.Description, 252 - Ref: f.Ref, 253 189 IsStarred: isStarred, 254 190 Knot: knot, 255 191 Spindle: f.Spindle, ··· 259 195 IssueCount: issueCount, 260 196 PullCount: pullCount, 261 197 }, 262 - DisableFork: disableFork, 263 - CurrentDir: f.CurrentDir, 198 + CurrentDir: f.CurrentDir, 199 + Ref: f.Ref, 264 200 } 265 201 266 202 if sourceRepo != nil { ··· 284 220 // after the ref. for example: 285 221 // 286 222 // /@icyphox.sh/foorepo/blob/main/abc/xyz/ => abc/xyz/ 287 - func extractPathAfterRef(fullPath, ref string) string { 223 + func extractPathAfterRef(fullPath string) string { 288 224 fullPath = strings.TrimPrefix(fullPath, "/") 289 225 290 - ref = url.PathEscape(ref) 226 + // match blob/, tree/, or raw/ followed by any ref and then a slash 227 + // 228 + // captures everything after the final slash 229 + pattern := `(?:blob|tree|raw)/[^/]+/(.*)$` 291 230 292 - prefixes := []string{ 293 - fmt.Sprintf("blob/%s/", ref), 294 - fmt.Sprintf("tree/%s/", ref), 295 - fmt.Sprintf("raw/%s/", ref), 296 - } 231 + re := regexp.MustCompile(pattern) 232 + matches := re.FindStringSubmatch(fullPath) 297 233 298 - for _, prefix := range prefixes { 299 - idx := strings.Index(fullPath, prefix) 300 - if idx != -1 { 301 - return fullPath[idx+len(prefix):] 302 - } 234 + if len(matches) > 1 { 235 + return matches[1] 303 236 } 304 237 305 238 return ""
+57 -50
appview/signup/signup.go
··· 104 104 105 105 func (s *Signup) Router() http.Handler { 106 106 r := chi.NewRouter() 107 + r.Get("/", s.signup) 107 108 r.Post("/", s.signup) 108 109 r.Get("/complete", s.complete) 109 110 r.Post("/complete", s.complete) ··· 112 113 } 113 114 114 115 func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 115 - if s.cf == nil { 116 - http.Error(w, "signup is disabled", http.StatusFailedDependency) 117 - } 118 - emailId := r.FormValue("email") 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") 119 124 120 - if !email.IsValidEmail(emailId) { 121 - s.pages.Notice(w, "login-msg", "Invalid email address.") 122 - return 123 - } 125 + noticeId := "signup-msg" 126 + if !email.IsValidEmail(emailId) { 127 + s.pages.Notice(w, noticeId, "Invalid email address.") 128 + return 129 + } 124 130 125 - exists, err := db.CheckEmailExistsAtAll(s.db, emailId) 126 - if err != nil { 127 - s.l.Error("failed to check email existence", "error", err) 128 - s.pages.Notice(w, "login-msg", "Failed to complete signup. Try again later.") 129 - return 130 - } 131 - if exists { 132 - s.pages.Notice(w, "login-msg", "Email already exists.") 133 - return 134 - } 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 + } 135 141 136 - code, err := s.inviteCodeRequest() 137 - if err != nil { 138 - s.l.Error("failed to create invite code", "error", err) 139 - s.pages.Notice(w, "login-msg", "Failed to create invite code.") 140 - return 141 - } 142 + 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 + } 142 148 143 - em := email.Email{ 144 - APIKey: s.config.Resend.ApiKey, 145 - From: s.config.Resend.SentFrom, 146 - To: emailId, 147 - Subject: "Verify your Tangled account", 148 - Text: `Copy and paste this code below to verify your account on Tangled. 149 + 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. 149 155 ` + code, 150 - Html: `<p>Copy and paste this code below to verify your account on Tangled.</p> 156 + Html: `<p>Copy and paste this code below to verify your account on Tangled.</p> 151 157 <p><code>` + code + `</code></p>`, 152 - } 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 + } 153 175 154 - err = email.SendEmail(em) 155 - if err != nil { 156 - s.l.Error("failed to send email", "error", err) 157 - s.pages.Notice(w, "login-msg", "Failed to send email.") 158 - return 176 + s.pages.HxRedirect(w, "/signup/complete") 159 177 } 160 - err = db.AddInflightSignup(s.db, db.InflightSignup{ 161 - Email: emailId, 162 - InviteCode: code, 163 - }) 164 - if err != nil { 165 - s.l.Error("failed to add inflight signup", "error", err) 166 - s.pages.Notice(w, "login-msg", "Failed to complete sign up. Try again later.") 167 - return 168 - } 169 - 170 - s.pages.HxRedirect(w, "/signup/complete") 171 178 } 172 179 173 180 func (s *Signup) complete(w http.ResponseWriter, r *http.Request) { 174 181 switch r.Method { 175 182 case http.MethodGet: 176 - s.pages.CompleteSignup(w, pages.SignupParams{}) 183 + s.pages.CompleteSignup(w) 177 184 case http.MethodPost: 178 185 username := r.FormValue("username") 179 186 password := r.FormValue("password") ··· 212 219 err = s.cf.CreateDNSRecord(r.Context(), dns.Record{ 213 220 Type: "TXT", 214 221 Name: "_atproto." + username, 215 - Content: "did=" + did, 222 + Content: fmt.Sprintf(`"did=%s"`, did), 216 223 TTL: 6400, 217 224 Proxied: false, 218 225 })
+4 -17
appview/spindles/spindles.go
··· 113 113 return 114 114 } 115 115 116 - identsToResolve := make([]string, len(members)) 117 - copy(identsToResolve, members) 118 - resolvedIds := s.IdResolver.ResolveIdents(r.Context(), identsToResolve) 119 - didHandleMap := make(map[string]string) 120 - for _, identity := range resolvedIds { 121 - if !identity.Handle.IsInvalidHandle() { 122 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 123 - } else { 124 - didHandleMap[identity.DID.String()] = identity.DID.String() 125 - } 126 - } 127 - 128 116 // organize repos by did 129 117 repoMap := make(map[string][]db.Repo) 130 118 for _, r := range repos { ··· 136 124 Spindle: spindle, 137 125 Members: members, 138 126 Repos: repoMap, 139 - DidHandleMap: didHandleMap, 140 127 }) 141 128 } 142 129 ··· 619 606 620 607 if string(spindles[0].Owner) != user.Did { 621 608 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 622 - s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.") 609 + s.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.") 623 610 return 624 611 } 625 612 626 613 member := r.FormValue("member") 627 614 if member == "" { 628 615 l.Error("empty member") 629 - s.Pages.Notice(w, noticeId, "Failed to add member, empty form.") 616 + s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") 630 617 return 631 618 } 632 619 l = l.With("member", member) ··· 634 621 memberId, err := s.IdResolver.ResolveIdent(r.Context(), member) 635 622 if err != nil { 636 623 l.Error("failed to resolve member identity to handle", "err", err) 637 - s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 624 + s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 638 625 return 639 626 } 640 627 if memberId.Handle.IsInvalidHandle() { 641 628 l.Error("failed to resolve member identity to handle") 642 - s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 629 + s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 643 630 return 644 631 } 645 632
+9 -12
appview/state/git_http.go
··· 3 3 import ( 4 4 "fmt" 5 5 "io" 6 + "maps" 6 7 "net/http" 7 8 8 9 "github.com/bluesky-social/indigo/atproto/identity" 9 10 "github.com/go-chi/chi/v5" 11 + "tangled.sh/tangled.sh/core/appview/db" 10 12 ) 11 13 12 14 func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) { 13 15 user := r.Context().Value("resolvedId").(identity.Identity) 14 - knot := r.Context().Value("knot").(string) 15 - repo := chi.URLParam(r, "repo") 16 + repo := r.Context().Value("repo").(*db.Repo) 16 17 17 18 scheme := "https" 18 19 if s.config.Core.Dev { 19 20 scheme = "http" 20 21 } 21 22 22 - targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, knot, user.DID, repo, r.URL.RawQuery) 23 + targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 23 24 s.proxyRequest(w, r, targetURL) 24 25 25 26 } ··· 30 31 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 31 32 return 32 33 } 33 - knot := r.Context().Value("knot").(string) 34 - repo := chi.URLParam(r, "repo") 34 + repo := r.Context().Value("repo").(*db.Repo) 35 35 36 36 scheme := "https" 37 37 if s.config.Core.Dev { 38 38 scheme = "http" 39 39 } 40 40 41 - targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery) 41 + targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 42 42 s.proxyRequest(w, r, targetURL) 43 43 } 44 44 ··· 48 48 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 49 49 return 50 50 } 51 - knot := r.Context().Value("knot").(string) 52 - repo := chi.URLParam(r, "repo") 51 + repo := r.Context().Value("repo").(*db.Repo) 53 52 54 53 scheme := "https" 55 54 if s.config.Core.Dev { 56 55 scheme = "http" 57 56 } 58 57 59 - targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery) 58 + targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 60 59 s.proxyRequest(w, r, targetURL) 61 60 } 62 61 ··· 85 84 defer resp.Body.Close() 86 85 87 86 // Copy response headers 88 - for k, v := range resp.Header { 89 - w.Header()[k] = v 90 - } 87 + maps.Copy(w.Header(), resp.Header) 91 88 92 89 // Set response status code 93 90 w.WriteHeader(resp.StatusCode)
+144 -62
appview/state/profile.go
··· 1 1 package state 2 2 3 3 import ( 4 - "crypto/hmac" 5 - "crypto/sha256" 6 - "encoding/hex" 4 + "context" 7 5 "fmt" 8 6 "log" 9 7 "net/http" ··· 16 14 "github.com/bluesky-social/indigo/atproto/syntax" 17 15 lexutil "github.com/bluesky-social/indigo/lex/util" 18 16 "github.com/go-chi/chi/v5" 17 + "github.com/gorilla/feeds" 19 18 "tangled.sh/tangled.sh/core/api/tangled" 20 19 "tangled.sh/tangled.sh/core/appview/db" 21 20 "tangled.sh/tangled.sh/core/appview/pages" ··· 90 89 log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err) 91 90 } 92 91 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()) 92 + followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String()) 123 93 if err != nil { 124 94 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 125 95 } ··· 142 112 log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err) 143 113 } 144 114 145 - profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 146 115 s.pages.ProfilePage(w, pages.ProfilePageParams{ 147 116 LoggedInUser: loggedInUser, 148 117 Repos: pinnedRepos, 149 118 CollaboratingRepos: pinnedCollaboratingRepos, 150 - DidHandleMap: didHandleMap, 151 119 Card: pages.ProfileCard{ 152 120 UserDid: ident.DID.String(), 153 121 UserHandle: ident.Handle.String(), 154 - AvatarUri: profileAvatarUri, 155 122 Profile: profile, 156 123 FollowStatus: followStatus, 157 124 Followers: followers, ··· 189 156 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 190 157 } 191 158 192 - followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String()) 159 + followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String()) 193 160 if err != nil { 194 161 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 195 162 } 196 - 197 - profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 198 163 199 164 s.pages.ReposPage(w, pages.ReposPageParams{ 200 165 LoggedInUser: loggedInUser, 201 166 Repos: repos, 202 - DidHandleMap: map[string]string{ident.DID.String(): ident.Handle.String()}, 203 167 Card: pages.ProfileCard{ 204 168 UserDid: ident.DID.String(), 205 169 UserHandle: ident.Handle.String(), 206 - AvatarUri: profileAvatarUri, 207 170 Profile: profile, 208 171 FollowStatus: followStatus, 209 172 Followers: followers, ··· 212 175 }) 213 176 } 214 177 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) 178 + func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) { 179 + ident, ok := r.Context().Value("resolvedId").(identity.Identity) 180 + if !ok { 181 + s.pages.Error404(w) 182 + return 183 + } 184 + 185 + feed, err := s.getProfileFeed(r.Context(), &ident) 186 + if err != nil { 187 + s.pages.Error500(w) 188 + return 189 + } 190 + 191 + if feed == nil { 192 + return 193 + } 194 + 195 + atom, err := feed.ToAtom() 196 + if err != nil { 197 + s.pages.Error500(w) 198 + return 199 + } 200 + 201 + w.Header().Set("content-type", "application/atom+xml") 202 + w.Write([]byte(atom)) 203 + } 204 + 205 + func (s *State) getProfileFeed(ctx context.Context, id *identity.Identity) (*feeds.Feed, error) { 206 + timeline, err := db.MakeProfileTimeline(s.db, id.DID.String()) 207 + if err != nil { 208 + return nil, err 209 + } 210 + 211 + author := &feeds.Author{ 212 + Name: fmt.Sprintf("@%s", id.Handle), 213 + } 214 + 215 + feed := feeds.Feed{ 216 + Title: fmt.Sprintf("%s's timeline", author.Name), 217 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, id.Handle), Type: "text/html", Rel: "alternate"}, 218 + Items: make([]*feeds.Item, 0), 219 + Updated: time.UnixMilli(0), 220 + Author: author, 221 + } 222 + 223 + for _, byMonth := range timeline.ByMonth { 224 + if err := s.addPullRequestItems(ctx, &feed, byMonth.PullEvents.Items, author); err != nil { 225 + return nil, err 226 + } 227 + if err := s.addIssueItems(ctx, &feed, byMonth.IssueEvents.Items, author); err != nil { 228 + return nil, err 229 + } 230 + if err := s.addRepoItems(ctx, &feed, byMonth.RepoEvents, author); err != nil { 231 + return nil, err 232 + } 233 + } 234 + 235 + slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int { 236 + return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli()) 237 + }) 238 + 239 + if len(feed.Items) > 0 { 240 + feed.Updated = feed.Items[0].Created 241 + } 242 + 243 + return &feed, nil 244 + } 245 + 246 + func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error { 247 + for _, pull := range pulls { 248 + owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 249 + if err != nil { 250 + return err 251 + } 252 + 253 + // Add pull request creation item 254 + feed.Items = append(feed.Items, s.createPullRequestItem(pull, owner, author)) 255 + } 256 + return nil 257 + } 258 + 259 + func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error { 260 + for _, issue := range issues { 261 + owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did) 262 + if err != nil { 263 + return err 264 + } 265 + 266 + feed.Items = append(feed.Items, s.createIssueItem(issue, owner, author)) 267 + } 268 + return nil 269 + } 270 + 271 + func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error { 272 + for _, repo := range repos { 273 + item, err := s.createRepoItem(ctx, repo, author) 274 + if err != nil { 275 + return err 276 + } 277 + feed.Items = append(feed.Items, item) 278 + } 279 + return nil 280 + } 281 + 282 + func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item { 283 + return &feeds.Item{ 284 + Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), 285 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"}, 286 + Created: pull.Created, 287 + Author: author, 288 + } 289 + } 290 + 291 + func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 292 + return &feeds.Item{ 293 + Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name), 294 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Metadata.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"}, 295 + Created: issue.Created, 296 + Author: author, 297 + } 298 + } 299 + 300 + func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) { 301 + var title string 302 + if repo.Source != nil { 303 + sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did) 304 + if err != nil { 305 + return nil, err 306 + } 307 + title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, sourceOwner.Handle, repo.Source.Name, repo.Repo.Name) 308 + } else { 309 + title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name) 310 + } 311 + 312 + return &feeds.Item{ 313 + Title: title, 314 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.AppviewHost, author.Name[1:], repo.Repo.Name), Type: "text/html", Rel: "alternate"}, // Remove @ prefix 315 + Created: repo.Repo.Created, 316 + Author: author, 317 + }, nil 221 318 } 222 319 223 320 func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { ··· 422 519 }) 423 520 } 424 521 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 522 s.pages.EditPinsFragment(w, pages.EditPinsParams{ 440 523 LoggedInUser: user, 441 524 Profile: profile, 442 525 AllRepos: allRepos, 443 - DidHandleMap: didHandleMap, 444 526 }) 445 527 }
+34 -6
appview/state/router.go
··· 17 17 "tangled.sh/tangled.sh/core/appview/signup" 18 18 "tangled.sh/tangled.sh/core/appview/spindles" 19 19 "tangled.sh/tangled.sh/core/appview/state/userutil" 20 + avstrings "tangled.sh/tangled.sh/core/appview/strings" 20 21 "tangled.sh/tangled.sh/core/log" 21 22 ) 22 23 ··· 31 32 s.pages, 32 33 ) 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 + 34 41 router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { 35 42 pat := chi.URLParam(r, "*") 36 43 if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") { 37 - s.UserRouter(&middleware).ServeHTTP(w, r) 44 + userRouter.ServeHTTP(w, r) 38 45 } else { 39 46 // Check if the first path element is a valid handle without '@' or a flattened DID 40 47 pathParts := strings.SplitN(pat, "/", 2) ··· 57 64 return 58 65 } 59 66 } 60 - s.StandardRouter(&middleware).ServeHTTP(w, r) 67 + standardRouter.ServeHTTP(w, r) 61 68 } 62 69 }) 63 70 ··· 67 74 func (s *State) UserRouter(mw *middleware.Middleware) http.Handler { 68 75 r := chi.NewRouter() 69 76 70 - // strip @ from user 71 - r.Use(middleware.StripLeadingAt) 72 - 73 77 r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) { 74 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 + }) 75 86 76 87 r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { 77 88 r.Use(mw.GoImport()) 78 - 79 89 r.Mount("/", s.RepoRouter(mw)) 80 90 r.Mount("/issues", s.IssuesRouter(mw)) 81 91 r.Mount("/pulls", s.PullsRouter(mw)) ··· 136 146 }) 137 147 138 148 r.Mount("/settings", s.SettingsRouter()) 149 + r.Mount("/strings", s.StringsRouter(mw)) 139 150 r.Mount("/knots", s.KnotsRouter(mw)) 140 151 r.Mount("/spindles", s.SpindlesRouter()) 141 152 r.Mount("/signup", s.SignupRouter()) ··· 199 210 } 200 211 201 212 return knots.Router(mw) 213 + } 214 + 215 + func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler { 216 + logger := log.New("strings") 217 + 218 + strs := &avstrings.Strings{ 219 + Db: s.db, 220 + OAuth: s.oauth, 221 + Pages: s.pages, 222 + Config: s.config, 223 + Enforcer: s.enforcer, 224 + IdResolver: s.idResolver, 225 + Knotstream: s.knotstream, 226 + Logger: logger, 227 + } 228 + 229 + return strs.Router(mw) 202 230 } 203 231 204 232 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
+30 -30
appview/state/state.go
··· 61 61 return nil, fmt.Errorf("failed to create enforcer: %w", err) 62 62 } 63 63 64 - pgs := pages.NewPages(config) 65 - 66 64 res, err := idresolver.RedisResolver(config.Redis.ToURL()) 67 65 if err != nil { 68 66 log.Printf("failed to create redis resolver: %v", err) 69 67 res = idresolver.DefaultResolver() 70 68 } 69 + 70 + pgs := pages.NewPages(config, res) 71 71 72 72 cache := cache.New(config.Redis.Addr) 73 73 sess := session.New(cache) ··· 93 93 tangled.ActorProfileNSID, 94 94 tangled.SpindleMemberNSID, 95 95 tangled.SpindleNSID, 96 + tangled.StringNSID, 97 + tangled.RepoIssueNSID, 98 + tangled.RepoIssueCommentNSID, 96 99 }, 97 100 nil, 98 101 slog.Default(), ··· 156 159 return state, nil 157 160 } 158 161 162 + func (s *State) Favicon(w http.ResponseWriter, r *http.Request) { 163 + w.Header().Set("Content-Type", "image/svg+xml") 164 + w.Header().Set("Cache-Control", "public, max-age=31536000") // one year 165 + w.Header().Set("ETag", `"favicon-svg-v1"`) 166 + 167 + if match := r.Header.Get("If-None-Match"); match == `"favicon-svg-v1"` { 168 + w.WriteHeader(http.StatusNotModified) 169 + return 170 + } 171 + 172 + s.pages.Favicon(w) 173 + } 174 + 159 175 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 160 176 user := s.oauth.GetUser(r) 161 177 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 179 195 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 180 196 } 181 197 182 - var didsToResolve []string 183 - for _, ev := range timeline { 184 - if ev.Repo != nil { 185 - didsToResolve = append(didsToResolve, ev.Repo.Did) 186 - if ev.Source != nil { 187 - didsToResolve = append(didsToResolve, ev.Source.Did) 188 - } 189 - } 190 - if ev.Follow != nil { 191 - didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid) 192 - } 193 - if ev.Star != nil { 194 - didsToResolve = append(didsToResolve, ev.Star.StarredByDid, ev.Star.Repo.Did) 195 - } 196 - } 197 - 198 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 199 - didHandleMap := make(map[string]string) 200 - for _, identity := range resolvedIds { 201 - if !identity.Handle.IsInvalidHandle() { 202 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 203 - } else { 204 - didHandleMap[identity.DID.String()] = identity.DID.String() 205 - } 198 + repos, err := db.GetTopStarredReposLastWeek(s.db) 199 + if err != nil { 200 + log.Println(err) 201 + s.pages.Notice(w, "topstarredrepos", "Unable to load.") 202 + return 206 203 } 207 204 208 205 s.pages.Timeline(w, pages.TimelineParams{ 209 206 LoggedInUser: user, 210 207 Timeline: timeline, 211 - DidHandleMap: didHandleMap, 208 + Repos: repos, 212 209 }) 213 - 214 - return 215 210 } 216 211 217 212 func (s *State) Keys(w http.ResponseWriter, r *http.Request) { ··· 278 273 return nil 279 274 } 280 275 276 + func stripGitExt(name string) string { 277 + return strings.TrimSuffix(name, ".git") 278 + } 279 + 281 280 func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { 282 281 switch r.Method { 283 282 case http.MethodGet: ··· 312 311 s.pages.Notice(w, "repo", err.Error()) 313 312 return 314 313 } 314 + 315 + repoName = stripGitExt(repoName) 315 316 316 317 defaultBranch := r.FormValue("branch") 317 318 if defaultBranch == "" { ··· 409 410 // continue 410 411 } 411 412 412 - repo.AtUri = atresp.Uri 413 413 err = db.AddRepo(tx, repo) 414 414 if err != nil { 415 415 log.Println(err)
+465
appview/strings/strings.go
··· 1 + package strings 2 + 3 + import ( 4 + "fmt" 5 + "log/slog" 6 + "net/http" 7 + "path" 8 + "slices" 9 + "strconv" 10 + "time" 11 + 12 + "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.sh/tangled.sh/core/appview/config" 14 + "tangled.sh/tangled.sh/core/appview/db" 15 + "tangled.sh/tangled.sh/core/appview/middleware" 16 + "tangled.sh/tangled.sh/core/appview/oauth" 17 + "tangled.sh/tangled.sh/core/appview/pages" 18 + "tangled.sh/tangled.sh/core/appview/pages/markup" 19 + "tangled.sh/tangled.sh/core/eventconsumer" 20 + "tangled.sh/tangled.sh/core/idresolver" 21 + "tangled.sh/tangled.sh/core/rbac" 22 + "tangled.sh/tangled.sh/core/tid" 23 + 24 + "github.com/bluesky-social/indigo/api/atproto" 25 + "github.com/bluesky-social/indigo/atproto/identity" 26 + "github.com/bluesky-social/indigo/atproto/syntax" 27 + lexutil "github.com/bluesky-social/indigo/lex/util" 28 + "github.com/go-chi/chi/v5" 29 + ) 30 + 31 + type Strings struct { 32 + Db *db.DB 33 + OAuth *oauth.OAuth 34 + Pages *pages.Pages 35 + Config *config.Config 36 + Enforcer *rbac.Enforcer 37 + IdResolver *idresolver.Resolver 38 + Logger *slog.Logger 39 + Knotstream *eventconsumer.Consumer 40 + } 41 + 42 + func (s *Strings) Router(mw *middleware.Middleware) http.Handler { 43 + r := chi.NewRouter() 44 + 45 + r. 46 + Get("/", s.timeline) 47 + 48 + r. 49 + With(mw.ResolveIdent()). 50 + Route("/{user}", func(r chi.Router) { 51 + r.Get("/", s.dashboard) 52 + 53 + r.Route("/{rkey}", func(r chi.Router) { 54 + r.Get("/", s.contents) 55 + r.Delete("/", s.delete) 56 + r.Get("/raw", s.contents) 57 + r.Get("/edit", s.edit) 58 + r.Post("/edit", s.edit) 59 + r. 60 + With(middleware.AuthMiddleware(s.OAuth)). 61 + Post("/comment", s.comment) 62 + }) 63 + }) 64 + 65 + r. 66 + With(middleware.AuthMiddleware(s.OAuth)). 67 + Route("/new", func(r chi.Router) { 68 + r.Get("/", s.create) 69 + r.Post("/", s.create) 70 + }) 71 + 72 + return r 73 + } 74 + 75 + func (s *Strings) timeline(w http.ResponseWriter, r *http.Request) { 76 + l := s.Logger.With("handler", "timeline") 77 + 78 + strings, err := db.GetStrings(s.Db, 50) 79 + if err != nil { 80 + l.Error("failed to fetch string", "err", err) 81 + w.WriteHeader(http.StatusInternalServerError) 82 + return 83 + } 84 + 85 + s.Pages.StringsTimeline(w, pages.StringTimelineParams{ 86 + LoggedInUser: s.OAuth.GetUser(r), 87 + Strings: strings, 88 + }) 89 + } 90 + 91 + func (s *Strings) contents(w http.ResponseWriter, r *http.Request) { 92 + l := s.Logger.With("handler", "contents") 93 + 94 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 95 + if !ok { 96 + l.Error("malformed middleware") 97 + w.WriteHeader(http.StatusInternalServerError) 98 + return 99 + } 100 + l = l.With("did", id.DID, "handle", id.Handle) 101 + 102 + rkey := chi.URLParam(r, "rkey") 103 + if rkey == "" { 104 + l.Error("malformed url, empty rkey") 105 + w.WriteHeader(http.StatusBadRequest) 106 + return 107 + } 108 + l = l.With("rkey", rkey) 109 + 110 + strings, err := db.GetStrings( 111 + s.Db, 112 + 0, 113 + db.FilterEq("did", id.DID), 114 + db.FilterEq("rkey", rkey), 115 + ) 116 + if err != nil { 117 + l.Error("failed to fetch string", "err", err) 118 + w.WriteHeader(http.StatusInternalServerError) 119 + return 120 + } 121 + if len(strings) < 1 { 122 + l.Error("string not found") 123 + s.Pages.Error404(w) 124 + return 125 + } 126 + if len(strings) != 1 { 127 + l.Error("incorrect number of records returned", "len(strings)", len(strings)) 128 + w.WriteHeader(http.StatusInternalServerError) 129 + return 130 + } 131 + string := strings[0] 132 + 133 + if path.Base(r.URL.Path) == "raw" { 134 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 135 + if string.Filename != "" { 136 + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", string.Filename)) 137 + } 138 + w.Header().Set("Content-Length", strconv.Itoa(len(string.Contents))) 139 + 140 + _, err = w.Write([]byte(string.Contents)) 141 + if err != nil { 142 + l.Error("failed to write raw response", "err", err) 143 + } 144 + return 145 + } 146 + 147 + var showRendered, renderToggle bool 148 + if markup.GetFormat(string.Filename) == markup.FormatMarkdown { 149 + renderToggle = true 150 + showRendered = r.URL.Query().Get("code") != "true" 151 + } 152 + 153 + s.Pages.SingleString(w, pages.SingleStringParams{ 154 + LoggedInUser: s.OAuth.GetUser(r), 155 + RenderToggle: renderToggle, 156 + ShowRendered: showRendered, 157 + String: string, 158 + Stats: string.Stats(), 159 + Owner: id, 160 + }) 161 + } 162 + 163 + func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { 164 + l := s.Logger.With("handler", "dashboard") 165 + 166 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 167 + if !ok { 168 + l.Error("malformed middleware") 169 + w.WriteHeader(http.StatusInternalServerError) 170 + return 171 + } 172 + l = l.With("did", id.DID, "handle", id.Handle) 173 + 174 + all, err := db.GetStrings( 175 + s.Db, 176 + 0, 177 + db.FilterEq("did", id.DID), 178 + ) 179 + if err != nil { 180 + l.Error("failed to fetch strings", "err", err) 181 + w.WriteHeader(http.StatusInternalServerError) 182 + return 183 + } 184 + 185 + slices.SortFunc(all, func(a, b db.String) int { 186 + if a.Created.After(b.Created) { 187 + return -1 188 + } else { 189 + return 1 190 + } 191 + }) 192 + 193 + profile, err := db.GetProfile(s.Db, id.DID.String()) 194 + if err != nil { 195 + l.Error("failed to fetch user profile", "err", err) 196 + w.WriteHeader(http.StatusInternalServerError) 197 + return 198 + } 199 + loggedInUser := s.OAuth.GetUser(r) 200 + followStatus := db.IsNotFollowing 201 + if loggedInUser != nil { 202 + followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String()) 203 + } 204 + 205 + followers, following, err := db.GetFollowerFollowingCount(s.Db, id.DID.String()) 206 + if err != nil { 207 + l.Error("failed to get follow stats", "err", err) 208 + } 209 + 210 + s.Pages.StringsDashboard(w, pages.StringsDashboardParams{ 211 + LoggedInUser: s.OAuth.GetUser(r), 212 + Card: pages.ProfileCard{ 213 + UserDid: id.DID.String(), 214 + UserHandle: id.Handle.String(), 215 + Profile: profile, 216 + FollowStatus: followStatus, 217 + Followers: followers, 218 + Following: following, 219 + }, 220 + Strings: all, 221 + }) 222 + } 223 + 224 + func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { 225 + l := s.Logger.With("handler", "edit") 226 + 227 + user := s.OAuth.GetUser(r) 228 + 229 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 230 + if !ok { 231 + l.Error("malformed middleware") 232 + w.WriteHeader(http.StatusInternalServerError) 233 + return 234 + } 235 + l = l.With("did", id.DID, "handle", id.Handle) 236 + 237 + rkey := chi.URLParam(r, "rkey") 238 + if rkey == "" { 239 + l.Error("malformed url, empty rkey") 240 + w.WriteHeader(http.StatusBadRequest) 241 + return 242 + } 243 + l = l.With("rkey", rkey) 244 + 245 + // get the string currently being edited 246 + all, err := db.GetStrings( 247 + s.Db, 248 + 0, 249 + db.FilterEq("did", id.DID), 250 + db.FilterEq("rkey", rkey), 251 + ) 252 + if err != nil { 253 + l.Error("failed to fetch string", "err", err) 254 + w.WriteHeader(http.StatusInternalServerError) 255 + return 256 + } 257 + if len(all) != 1 { 258 + l.Error("incorrect number of records returned", "len(strings)", len(all)) 259 + w.WriteHeader(http.StatusInternalServerError) 260 + return 261 + } 262 + first := all[0] 263 + 264 + // verify that the logged in user owns this string 265 + if user.Did != id.DID.String() { 266 + l.Error("unauthorized request", "expected", id.DID, "got", user.Did) 267 + w.WriteHeader(http.StatusUnauthorized) 268 + return 269 + } 270 + 271 + switch r.Method { 272 + case http.MethodGet: 273 + // return the form with prefilled fields 274 + s.Pages.PutString(w, pages.PutStringParams{ 275 + LoggedInUser: s.OAuth.GetUser(r), 276 + Action: "edit", 277 + String: first, 278 + }) 279 + case http.MethodPost: 280 + fail := func(msg string, err error) { 281 + l.Error(msg, "err", err) 282 + s.Pages.Notice(w, "error", msg) 283 + } 284 + 285 + filename := r.FormValue("filename") 286 + if filename == "" { 287 + fail("Empty filename.", nil) 288 + return 289 + } 290 + 291 + content := r.FormValue("content") 292 + if content == "" { 293 + fail("Empty contents.", nil) 294 + return 295 + } 296 + 297 + description := r.FormValue("description") 298 + 299 + // construct new string from form values 300 + entry := db.String{ 301 + Did: first.Did, 302 + Rkey: first.Rkey, 303 + Filename: filename, 304 + Description: description, 305 + Contents: content, 306 + Created: first.Created, 307 + } 308 + 309 + record := entry.AsRecord() 310 + 311 + client, err := s.OAuth.AuthorizedClient(r) 312 + if err != nil { 313 + fail("Failed to create record.", err) 314 + return 315 + } 316 + 317 + // first replace the existing record in the PDS 318 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 319 + if err != nil { 320 + fail("Failed to updated existing record.", err) 321 + return 322 + } 323 + resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 324 + Collection: tangled.StringNSID, 325 + Repo: entry.Did.String(), 326 + Rkey: entry.Rkey, 327 + SwapRecord: ex.Cid, 328 + Record: &lexutil.LexiconTypeDecoder{ 329 + Val: &record, 330 + }, 331 + }) 332 + if err != nil { 333 + fail("Failed to updated existing record.", err) 334 + return 335 + } 336 + l := l.With("aturi", resp.Uri) 337 + l.Info("edited string") 338 + 339 + // if that went okay, updated the db 340 + if err = db.AddString(s.Db, entry); err != nil { 341 + fail("Failed to update string.", err) 342 + return 343 + } 344 + 345 + // if that went okay, redir to the string 346 + s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 347 + } 348 + 349 + } 350 + 351 + func (s *Strings) create(w http.ResponseWriter, r *http.Request) { 352 + l := s.Logger.With("handler", "create") 353 + user := s.OAuth.GetUser(r) 354 + 355 + switch r.Method { 356 + case http.MethodGet: 357 + s.Pages.PutString(w, pages.PutStringParams{ 358 + LoggedInUser: s.OAuth.GetUser(r), 359 + Action: "new", 360 + }) 361 + case http.MethodPost: 362 + fail := func(msg string, err error) { 363 + l.Error(msg, "err", err) 364 + s.Pages.Notice(w, "error", msg) 365 + } 366 + 367 + filename := r.FormValue("filename") 368 + if filename == "" { 369 + fail("Empty filename.", nil) 370 + return 371 + } 372 + 373 + content := r.FormValue("content") 374 + if content == "" { 375 + fail("Empty contents.", nil) 376 + return 377 + } 378 + 379 + description := r.FormValue("description") 380 + 381 + string := db.String{ 382 + Did: syntax.DID(user.Did), 383 + Rkey: tid.TID(), 384 + Filename: filename, 385 + Description: description, 386 + Contents: content, 387 + Created: time.Now(), 388 + } 389 + 390 + record := string.AsRecord() 391 + 392 + client, err := s.OAuth.AuthorizedClient(r) 393 + if err != nil { 394 + fail("Failed to create record.", err) 395 + return 396 + } 397 + 398 + resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 399 + Collection: tangled.StringNSID, 400 + Repo: user.Did, 401 + Rkey: string.Rkey, 402 + Record: &lexutil.LexiconTypeDecoder{ 403 + Val: &record, 404 + }, 405 + }) 406 + if err != nil { 407 + fail("Failed to create record.", err) 408 + return 409 + } 410 + l := l.With("aturi", resp.Uri) 411 + l.Info("created record") 412 + 413 + // insert into DB 414 + if err = db.AddString(s.Db, string); err != nil { 415 + fail("Failed to create string.", err) 416 + return 417 + } 418 + 419 + // successful 420 + s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 421 + } 422 + } 423 + 424 + func (s *Strings) delete(w http.ResponseWriter, r *http.Request) { 425 + l := s.Logger.With("handler", "create") 426 + user := s.OAuth.GetUser(r) 427 + fail := func(msg string, err error) { 428 + l.Error(msg, "err", err) 429 + s.Pages.Notice(w, "error", msg) 430 + } 431 + 432 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 433 + if !ok { 434 + l.Error("malformed middleware") 435 + w.WriteHeader(http.StatusInternalServerError) 436 + return 437 + } 438 + l = l.With("did", id.DID, "handle", id.Handle) 439 + 440 + rkey := chi.URLParam(r, "rkey") 441 + if rkey == "" { 442 + l.Error("malformed url, empty rkey") 443 + w.WriteHeader(http.StatusBadRequest) 444 + return 445 + } 446 + 447 + if user.Did != id.DID.String() { 448 + fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) 449 + return 450 + } 451 + 452 + if err := db.DeleteString( 453 + s.Db, 454 + db.FilterEq("did", user.Did), 455 + db.FilterEq("rkey", rkey), 456 + ); err != nil { 457 + fail("Failed to delete string.", err) 458 + return 459 + } 460 + 461 + s.Pages.HxRedirect(w, "/strings/"+user.Handle) 462 + } 463 + 464 + func (s *Strings) comment(w http.ResponseWriter, r *http.Request) { 465 + }
+1 -2
cmd/gen.go
··· 27 27 tangled.KnotMember{}, 28 28 tangled.Pipeline{}, 29 29 tangled.Pipeline_CloneOpts{}, 30 - tangled.Pipeline_Dependency{}, 31 30 tangled.Pipeline_ManualTriggerData{}, 32 31 tangled.Pipeline_Pair{}, 33 32 tangled.Pipeline_PullRequestTriggerData{}, 34 33 tangled.Pipeline_PushTriggerData{}, 35 34 tangled.PipelineStatus{}, 36 - tangled.Pipeline_Step{}, 37 35 tangled.Pipeline_TriggerMetadata{}, 38 36 tangled.Pipeline_TriggerRepo{}, 39 37 tangled.Pipeline_Workflow{}, ··· 50 48 tangled.RepoPullStatus{}, 51 49 tangled.Spindle{}, 52 50 tangled.SpindleMember{}, 51 + tangled.String{}, 53 52 ); err != nil { 54 53 panic(err) 55 54 }
+4
cmd/genjwks/main.go
··· 30 30 panic(err) 31 31 } 32 32 33 + if err := key.Set("use", "sig"); err != nil { 34 + panic(err) 35 + } 36 + 33 37 b, err := json.Marshal(key) 34 38 if err != nil { 35 39 panic(err)
+1 -1
cmd/punchcardPopulate/main.go
··· 11 11 ) 12 12 13 13 func main() { 14 - db, err := sql.Open("sqlite3", "./appview.db") 14 + db, err := sql.Open("sqlite3", "./appview.db?_foreign_keys=1") 15 15 if err != nil { 16 16 log.Fatal("Failed to open database:", err) 17 17 }
+14 -15
docs/contributing.md
··· 55 55 - Avoid noisy commit messages like "wip" or "final fix"โ€”rewrite history 56 56 before submitting if necessary. 57 57 58 + ## code formatting 59 + 60 + We use a variety of tools to format our code, and multiplex them with 61 + [`treefmt`](https://treefmt.com): all you need to do to format your changes 62 + is run `nix run .#fmt` (or just `treefmt` if you're in the devshell). 63 + 58 64 ## proposals for bigger changes 59 65 60 66 Small fixes like typos, minor bugs, or trivial refactors can be ··· 115 121 If you're submitting a PR with multiple commits, make sure each one is 116 122 signed. 117 123 118 - For [jj](https://jj-vcs.github.io/jj/latest/) users, you can add this to 119 - your jj config: 124 + For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command 125 + to make it sign off commits in the tangled repo: 120 126 121 - ``` 122 - ui.should-sign-off = true 123 - ``` 124 - 125 - and to your `templates.draft_commit_description`, add the following `if` 126 - block: 127 - 128 - ``` 129 - if( 130 - config("ui.should-sign-off").as_boolean() && !description.contains("Signed-off-by: " ++ author.name()), 131 - "\nSigned-off-by: " ++ author.name() ++ " <" ++ author.email() ++ ">", 132 - ), 127 + ```shell 128 + # Safety check, should say "No matching config key..." 129 + jj config list templates.commit_trailers 130 + # The command below may need to be adjusted if the command above returned something. 131 + jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)" 133 132 ``` 134 133 135 134 Refer to the [jj 136 - documentation](https://jj-vcs.github.io/jj/latest/config/#default-description) 135 + documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers) 137 136 for more information.
+20 -15
docs/hacking.md
··· 56 56 `nixosConfiguration` to do so. 57 57 58 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. 59 + and create a knot with hostname `localhost:6000`. This will 60 + generate a knot secret. Set `$TANGLED_VM_KNOT_SECRET` to it, 61 + ideally in a `.envrc` with [direnv](https://direnv.net) so you 62 + don't lose it. 62 63 63 - You can now start a lightweight NixOS VM using 64 - `nixos-shell` like so: 64 + You will also need to set the `$TANGLED_VM_SPINDLE_OWNER` 65 + variable to some value. If you don't want to [set up a 66 + spindle](#running-a-spindle), you can use any placeholder 67 + value. 68 + 69 + You can now start a lightweight NixOS VM like so: 65 70 66 71 ```bash 67 - nix run .#vm 68 - # or nixos-shell --flake .#vm 72 + nix run --impure .#vm 69 73 70 - # hit Ctrl-a + c + q to exit the VM 74 + # type `poweroff` at the shell to exit the VM 71 75 ``` 72 76 73 77 This starts a knot on port 6000, a spindle on port 6555 ··· 91 95 92 96 ## running a spindle 93 97 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. 98 + You will need to find out your DID by entering your login handle into 99 + <https://pdsls.dev/>. Set `$TANGLED_VM_SPINDLE_OWNER` to your DID. 100 + 101 + The above VM should already be running a spindle on `localhost:6555`. 102 + You can head to the spindle dashboard on `http://localhost:3000/spindles`, 103 + and register a spindle with hostname `localhost:6555`. It should instantly 104 + be verified. You can then configure each repository to use this spindle 105 + and run CI jobs. 101 106 102 107 Of interest when debugging spindles: 103 108
+11 -5
docs/knot-hosting.md
··· 2 2 3 3 So you want to run your own knot server? Great! Here are a few prerequisites: 4 4 5 - 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux of some kind. 5 + 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind. 6 6 2. A (sub)domain name. People generally use `knot.example.com`. 7 7 3. A valid SSL certificate for your domain. 8 8 ··· 57 57 AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys 58 58 AuthorizedKeysCommandUser nobody 59 59 EOF 60 + ``` 61 + 62 + Then, reload `sshd`: 63 + 64 + ``` 65 + sudo systemctl reload ssh 60 66 ``` 61 67 62 68 Next, create the `git` user. We'll use the `git` user's home directory ··· 67 73 ``` 68 74 69 75 Create `/home/git/.knot.env` with the following, updating the values as 70 - necessary. The `KNOT_SERVER_SECRET` can be obtaind from the 71 - [/knots](/knots) page on Tangled. 76 + necessary. The `KNOT_SERVER_SECRET` can be obtained from the 77 + [/knots](https://tangled.sh/knots) page on Tangled. 72 78 73 79 ``` 74 80 KNOT_REPO_SCAN_PATH=/home/git ··· 89 95 systemctl start knotserver 90 96 ``` 91 97 92 - The last step is to configure a reverse proxy like Nginx or Caddy to front yourself 98 + The last step is to configure a reverse proxy like Nginx or Caddy to front your 93 99 knot. Here's an example configuration for Nginx: 94 100 95 101 ``` ··· 123 129 knot domain. 124 130 125 131 You should now have a running knot server! You can finalize your registration by hitting the 126 - `initialize` button on the [/knots](/knots) page. 132 + `initialize` button on the [/knots](https://tangled.sh/knots) page. 127 133 128 134 ### custom paths 129 135
+1 -1
docs/spindle/openbao.md
··· 114 114 ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id) 115 115 116 116 # Generate secret ID 117 - SECRET_ID=$(bao write -field=secret_id auth/approle/role/spindle/secret-id) 117 + SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id) 118 118 119 119 echo "Role ID: $ROLE_ID" 120 120 echo "Secret ID: $SECRET_ID"
+26 -3
docs/spindle/pipeline.md
··· 4 4 repo. Generally: 5 5 6 6 * Pipelines are defined in YAML. 7 - * Dependencies can be specified from 8 - [Nixpkgs](https://search.nixos.org) or custom registries. 9 - * Environment variables can be set globally or per-step. 7 + * Workflows can run using different *engines*. 8 + 9 + The most barebones workflow looks like this: 10 + 11 + ```yaml 12 + when: 13 + - event: ["push"] 14 + branch: ["main"] 15 + 16 + engine: "nixery" 17 + 18 + # optional 19 + clone: 20 + skip: false 21 + depth: 50 22 + submodules: true 23 + ``` 24 + 25 + The `when` and `engine` fields are required, while every other aspect 26 + of how the definition is parsed is up to the engine. Currently, a spindle 27 + provides at least one of these built-in engines: 28 + 29 + ## `nixery` 30 + 31 + The Nixery engine uses an instance of [Nixery](https://nixery.dev) to run 32 + steps that use dependencies from [Nixpkgs](https://github.com/NixOS/nixpkgs). 10 33 11 34 Here's an example that uses all fields: 12 35
+1 -1
eventconsumer/cursor/sqlite.go
··· 21 21 } 22 22 23 23 func NewSQLiteStore(dbPath string, opts ...SqliteStoreOpt) (*SqliteStore, error) { 24 - db, err := sql.Open("sqlite3", dbPath) 24 + db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1") 25 25 if err != nil { 26 26 return nil, fmt.Errorf("failed to open sqlite database: %w", err) 27 27 }
+10 -31
flake.lock
··· 1 1 { 2 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 3 "flake-utils": { 24 4 "inputs": { 25 5 "systems": "systems" ··· 46 26 ] 47 27 }, 48 28 "locked": { 49 - "lastModified": 1751702058, 50 - "narHash": "sha256-/GTdqFzFw/Y9DSNAfzvzyCMlKjUyRKMPO+apIuaTU4A=", 29 + "lastModified": 1754078208, 30 + "narHash": "sha256-YVoIFDCDpYuU3riaDEJ3xiGdPOtsx4sR5eTzHTytPV8=", 51 31 "owner": "nix-community", 52 32 "repo": "gomod2nix", 53 - "rev": "664ad7a2df4623037e315e4094346bff5c44e9ee", 33 + "rev": "7f963246a71626c7fc70b431a315c4388a0c95cf", 54 34 "type": "github" 55 35 }, 56 36 "original": { ··· 99 79 "indigo": { 100 80 "flake": false, 101 81 "locked": { 102 - "lastModified": 1745333930, 103 - "narHash": "sha256-83fIHqDE+dfnZ88HaNuwfKFO+R0RKAM1WxMfNh/Matk=", 82 + "lastModified": 1753693716, 83 + "narHash": "sha256-DMIKnCJRODQXEHUxA+7mLzRALmnZhkkbHlFT2rCQYrE=", 104 84 "owner": "oppiliappan", 105 85 "repo": "indigo", 106 - "rev": "e4e59280737b8676611fc077a228d47b3e8e9491", 86 + "rev": "5f170569da9360f57add450a278d73538092d8ca", 107 87 "type": "github" 108 88 }, 109 89 "original": { ··· 128 108 "lucide-src": { 129 109 "flake": false, 130 110 "locked": { 131 - "lastModified": 1742302029, 132 - "narHash": "sha256-OyPVtpnC4/AAmPq84Wt1r1Gcs48d9KG+UBCtZK87e9k=", 111 + "lastModified": 1754044466, 112 + "narHash": "sha256-+exBR2OToB1iv7ZQI2S4B0lXA/QRvC9n6U99UxGpJGs=", 133 113 "type": "tarball", 134 - "url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip" 114 + "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 135 115 }, 136 116 "original": { 137 117 "type": "tarball", 138 - "url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip" 118 + "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 139 119 } 140 120 }, 141 121 "nixpkgs": { ··· 156 136 }, 157 137 "root": { 158 138 "inputs": { 159 - "gitignore": "gitignore", 160 139 "gomod2nix": "gomod2nix", 161 140 "htmx-src": "htmx-src", 162 141 "htmx-ws-src": "htmx-ws-src",
+103 -29
flake.nix
··· 22 22 flake = false; 23 23 }; 24 24 lucide-src = { 25 - url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"; 25 + url = "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"; 26 26 flake = false; 27 27 }; 28 28 inter-fonts-src = { ··· 37 37 url = "https://sqlite.org/2024/sqlite-amalgamation-3450100.zip"; 38 38 flake = false; 39 39 }; 40 - gitignore = { 41 - url = "github:hercules-ci/gitignore.nix"; 42 - inputs.nixpkgs.follows = "nixpkgs"; 43 - }; 44 40 }; 45 41 46 42 outputs = { ··· 51 47 htmx-src, 52 48 htmx-ws-src, 53 49 lucide-src, 54 - gitignore, 55 50 inter-fonts-src, 56 51 sqlite-lib-src, 57 52 ibm-plex-mono-src, ··· 62 57 63 58 mkPackageSet = pkgs: 64 59 pkgs.lib.makeScope pkgs.newScope (self: { 65 - inherit (gitignore.lib) gitignoreSource; 60 + src = let 61 + fs = pkgs.lib.fileset; 62 + in 63 + fs.toSource { 64 + root = ./.; 65 + fileset = fs.difference (fs.intersection (fs.gitTracked ./.) (fs.fileFilter (file: !(file.hasExt "nix")) ./.)) (fs.maybeMissing ./.jj); 66 + }; 66 67 buildGoApplication = 67 68 (self.callPackage "${gomod2nix}/builder" { 68 69 gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix; ··· 74 75 }; 75 76 genjwks = self.callPackage ./nix/pkgs/genjwks.nix {}; 76 77 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 77 - appview = self.callPackage ./nix/pkgs/appview.nix { 78 + appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix { 78 79 inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src; 79 80 }; 81 + appview = self.callPackage ./nix/pkgs/appview.nix {}; 80 82 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 81 83 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 82 84 knot = self.callPackage ./nix/pkgs/knot.nix {}; ··· 92 94 staticPackages = mkPackageSet pkgs.pkgsStatic; 93 95 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 94 96 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; 97 + inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib; 102 98 103 99 pkgsStatic-appview = staticPackages.appview; 104 100 pkgsStatic-knot = staticPackages.knot; ··· 110 106 pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot; 111 107 pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped; 112 108 pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle; 109 + 110 + treefmt-wrapper = pkgs.treefmt.withConfig { 111 + settings.formatter = { 112 + alejandra = { 113 + command = pkgs.lib.getExe pkgs.alejandra; 114 + includes = ["*.nix"]; 115 + }; 116 + 117 + gofmt = { 118 + command = pkgs.lib.getExe' pkgs.go "gofmt"; 119 + options = ["-w"]; 120 + includes = ["*.go"]; 121 + }; 122 + 123 + # prettier = let 124 + # wrapper = pkgs.runCommandLocal "prettier-wrapper" {nativeBuildInputs = [pkgs.makeWrapper];} '' 125 + # makeWrapper ${pkgs.prettier}/bin/prettier "$out" --add-flags "--plugin=${pkgs.prettier-plugin-go-template}/lib/node_modules/prettier-plugin-go-template/lib/index.js" 126 + # ''; 127 + # in { 128 + # command = wrapper; 129 + # options = ["-w"]; 130 + # includes = ["*.html"]; 131 + # # causes Go template plugin errors: https://github.com/NiklasPor/prettier-plugin-go-template/issues/120 132 + # excludes = ["appview/pages/templates/layouts/repobase.html" "appview/pages/templates/repo/tags.html"]; 133 + # }; 134 + }; 135 + }; 113 136 }); 114 137 defaultPackage = forAllSystems (system: self.packages.${system}.appview); 115 - formatter = forAllSystems (system: nixpkgsFor.${system}.alejandra); 116 138 devShells = forAllSystems (system: let 117 139 pkgs = nixpkgsFor.${system}; 118 140 packages' = self.packages.${system}; ··· 131 153 pkgs.tailwindcss 132 154 pkgs.nixos-shell 133 155 pkgs.redis 156 + pkgs.coreutils # for those of us who are on systems that use busybox (alpine) 134 157 packages'.lexgen 158 + packages'.treefmt-wrapper 135 159 ]; 136 160 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/ 161 + mkdir -p appview/pages/static 162 + # no preserve is needed because watch-tailwind will want to be able to overwrite 163 + cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 144 164 export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)" 145 165 ''; 146 166 env.CGO_ENABLED = 1; ··· 148 168 }); 149 169 apps = forAllSystems (system: let 150 170 pkgs = nixpkgsFor."${system}"; 171 + packages' = self.packages.${system}; 151 172 air-watcher = name: arg: 152 173 pkgs.writeShellScriptBin "run" 153 174 '' ··· 164 185 ${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css 165 186 ''; 166 187 in { 188 + fmt = { 189 + type = "app"; 190 + program = pkgs.lib.getExe packages'.treefmt-wrapper; 191 + }; 167 192 watch-appview = { 168 193 type = "app"; 169 - program = ''${air-watcher "appview" ""}/bin/run''; 194 + program = toString (pkgs.writeShellScript "watch-appview" '' 195 + echo "copying static files to appview/pages/static..." 196 + ${pkgs.coreutils}/bin/cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 197 + ${air-watcher "appview" ""}/bin/run 198 + ''); 170 199 }; 171 200 watch-knot = { 172 201 type = "app"; ··· 176 205 type = "app"; 177 206 program = ''${tailwind-watcher}/bin/run''; 178 207 }; 179 - vm = { 208 + vm = let 209 + guestSystem = 210 + if pkgs.stdenv.hostPlatform.isAarch64 211 + then "aarch64-linux" 212 + else "x86_64-linux"; 213 + in { 180 214 type = "app"; 181 - program = toString (pkgs.writeShellScript "vm" '' 182 - ${pkgs.nixos-shell}/bin/nixos-shell --flake .#vm 183 - ''); 215 + program = 216 + (pkgs.writeShellApplication { 217 + name = "launch-vm"; 218 + text = '' 219 + rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 220 + cd "$rootDir" 221 + 222 + mkdir -p nix/vm-data/{knot,repos,spindle,spindle-logs} 223 + 224 + export TANGLED_VM_DATA_DIR="$rootDir/nix/vm-data" 225 + exec ${pkgs.lib.getExe 226 + (import ./nix/vm.nix { 227 + inherit nixpkgs self; 228 + system = guestSystem; 229 + hostSystem = system; 230 + }).config.system.build.vm} 231 + ''; 232 + }) 233 + + /bin/launch-vm; 184 234 }; 185 235 gomod2nix = { 186 236 type = "app"; ··· 188 238 ${gomod2nix.legacyPackages.${system}.gomod2nix}/bin/gomod2nix generate --outdir ./nix 189 239 ''); 190 240 }; 241 + lexgen = { 242 + type = "app"; 243 + program = 244 + (pkgs.writeShellApplication { 245 + name = "lexgen"; 246 + text = '' 247 + if ! command -v lexgen > /dev/null; then 248 + echo "error: must be executed from devshell" 249 + exit 1 250 + fi 251 + 252 + rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 253 + cd "$rootDir" 254 + 255 + rm api/tangled/* 256 + lexgen --build-file lexicon-build-config.json lexicons 257 + sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/* 258 + ${pkgs.gotools}/bin/goimports -w api/tangled/* 259 + go run cmd/gen.go 260 + lexgen --build-file lexicon-build-config.json lexicons 261 + rm api/tangled/*.bak 262 + ''; 263 + }) 264 + + /bin/lexgen; 265 + }; 191 266 }); 192 267 193 268 nixosModules.appview = { ··· 217 292 218 293 services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 219 294 }; 220 - nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;}; 221 295 }; 222 296 }
+3 -1
go.mod
··· 22 22 github.com/go-enry/go-enry/v2 v2.9.2 23 23 github.com/go-git/go-git/v5 v5.14.0 24 24 github.com/google/uuid v1.6.0 25 + github.com/gorilla/feeds v1.2.0 25 26 github.com/gorilla/sessions v1.4.0 26 27 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 27 28 github.com/hiddeco/sshsig v0.2.0 ··· 38 39 github.com/stretchr/testify v1.10.0 39 40 github.com/urfave/cli/v3 v3.3.3 40 41 github.com/whyrusleeping/cbor-gen v0.3.1 41 - github.com/yuin/goldmark v1.4.13 42 + github.com/yuin/goldmark v1.4.15 43 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 42 44 golang.org/x/crypto v0.40.0 43 45 golang.org/x/net v0.42.0 44 46 golang.org/x/sync v0.16.0
+7 -1
go.sum
··· 79 79 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 80 80 github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= 81 81 github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= 82 + github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 82 83 github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= 83 84 github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 84 85 github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw= ··· 173 174 github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 174 175 github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 175 176 github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 177 + github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc= 178 + github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y= 176 179 github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 177 180 github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 178 181 github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= ··· 427 430 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 428 431 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 429 432 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 430 - github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= 431 433 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 434 + github.com/yuin/goldmark v1.4.15 h1:CFa84T0goNn/UIXYS+dmjjVxMyTAvpOmzld40N/nfK0= 435 + github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 436 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 437 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 432 438 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 433 439 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 434 440 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
+83 -6
input.css
··· 13 13 @font-face { 14 14 font-family: "InterVariable"; 15 15 src: url("/static/fonts/InterVariable-Italic.woff2") format("woff2"); 16 - font-weight: 400; 16 + font-weight: normal; 17 17 font-style: italic; 18 18 font-display: swap; 19 19 } 20 20 21 21 @font-face { 22 22 font-family: "InterVariable"; 23 - src: url("/static/fonts/InterVariable.woff2") format("woff2"); 24 - font-weight: 600; 23 + src: url("/static/fonts/InterDisplay-Bold.woff2") format("woff2"); 24 + font-weight: bold; 25 25 font-style: normal; 26 26 font-display: swap; 27 27 } 28 28 29 29 @font-face { 30 + font-family: "InterVariable"; 31 + src: url("/static/fonts/InterDisplay-BoldItalic.woff2") format("woff2"); 32 + font-weight: bold; 33 + font-style: italic; 34 + font-display: swap; 35 + } 36 + 37 + @font-face { 30 38 font-family: "IBMPlexMono"; 31 39 src: url("/static/fonts/IBMPlexMono-Regular.woff2") format("woff2"); 40 + font-weight: normal; 41 + font-style: normal; 42 + font-display: swap; 43 + } 44 + 45 + @font-face { 46 + font-family: "IBMPlexMono"; 47 + src: url("/static/fonts/IBMPlexMono-Italic.woff2") format("woff2"); 32 48 font-weight: normal; 33 49 font-style: italic; 34 50 font-display: swap; 35 51 } 36 52 53 + @font-face { 54 + font-family: "IBMPlexMono"; 55 + src: url("/static/fonts/IBMPlexMono-Bold.woff2") format("woff2"); 56 + font-weight: bold; 57 + font-style: normal; 58 + font-display: swap; 59 + } 60 + 61 + @font-face { 62 + font-family: "IBMPlexMono"; 63 + src: url("/static/fonts/IBMPlexMono-BoldItalic.woff2") format("woff2"); 64 + font-weight: bold; 65 + font-style: italic; 66 + font-display: swap; 67 + } 68 + 37 69 ::selection { 38 70 @apply bg-yellow-400 text-black bg-opacity-30 dark:bg-yellow-600 dark:bg-opacity-50 dark:text-white; 39 71 } ··· 46 78 @supports (font-variation-settings: normal) { 47 79 html { 48 80 font-feature-settings: 49 - "ss01" 1, 50 81 "kern" 1, 51 82 "liga" 1, 52 83 "cv05" 1, ··· 70 101 details summary::-webkit-details-marker { 71 102 display: none; 72 103 } 104 + 105 + code { 106 + @apply font-mono rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white; 107 + } 73 108 } 74 109 75 110 @layer components { ··· 98 133 disabled:before:bg-green-400 dark:disabled:before:bg-green-600; 99 134 } 100 135 136 + .prose hr { 137 + @apply my-2; 138 + } 139 + 140 + .prose li:has(input) { 141 + @apply list-none; 142 + } 143 + 144 + .prose ul:has(input) { 145 + @apply pl-2; 146 + } 147 + 148 + .prose .heading .anchor { 149 + @apply no-underline mx-2 opacity-0; 150 + } 151 + 152 + .prose .heading:hover .anchor { 153 + @apply opacity-70; 154 + } 155 + 156 + .prose .heading .anchor:hover { 157 + @apply opacity-70; 158 + } 159 + 160 + .prose a.footnote-backref { 161 + @apply no-underline; 162 + } 163 + 164 + .prose li { 165 + @apply my-0 py-0; 166 + } 167 + 168 + .prose ul, .prose ol { 169 + @apply my-1 py-0; 170 + } 171 + 101 172 .prose img { 102 173 display: inline; 103 174 margin: 0; 104 175 vertical-align: middle; 176 + } 177 + 178 + .prose input { 179 + @apply inline-block my-0 mb-1 mx-1; 180 + } 181 + 182 + .prose input[type="checkbox"] { 183 + @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500; 105 184 } 106 185 } 107 186 @layer utilities { ··· 122 201 /* PreWrapper */ 123 202 .chroma { 124 203 color: #4c4f69; 125 - background-color: #eff1f5; 126 204 } 127 205 /* Error */ 128 206 .chroma .err { ··· 459 537 /* PreWrapper */ 460 538 .chroma { 461 539 color: #cad3f5; 462 - background-color: #24273a; 463 540 } 464 541 /* Error */ 465 542 .chroma .err {
+6 -4
jetstream/jetstream.go
··· 68 68 type processor func(context.Context, *models.Event) error 69 69 70 70 func (j *JetstreamClient) withDidFilter(processFunc processor) processor { 71 - // empty filter => all dids allowed 72 - if len(j.wantedDids) == 0 { 73 - return processFunc 74 - } 75 71 // since this closure references j.WantedDids; it should auto-update 76 72 // existing instances of the closure when j.WantedDids is mutated 77 73 return func(ctx context.Context, evt *models.Event) error { 74 + 75 + // empty filter => all dids allowed 76 + if len(j.wantedDids) == 0 { 77 + return processFunc(ctx, evt) 78 + } 79 + 78 80 if _, ok := j.wantedDids[evt.Did]; ok { 79 81 return processFunc(ctx, evt) 80 82 } else {
+14 -10
knotserver/db/init.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "strings" 5 6 6 7 _ "github.com/mattn/go-sqlite3" 7 8 ) ··· 11 12 } 12 13 13 14 func Setup(dbPath string) (*DB, error) { 14 - db, err := sql.Open("sqlite3", dbPath) 15 + // https://github.com/mattn/go-sqlite3#connection-string 16 + opts := []string{ 17 + "_foreign_keys=1", 18 + "_journal_mode=WAL", 19 + "_synchronous=NORMAL", 20 + "_auto_vacuum=incremental", 21 + } 22 + 23 + db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 15 24 if err != nil { 16 25 return nil, err 17 26 } 18 27 19 - _, err = db.Exec(` 20 - pragma journal_mode = WAL; 21 - pragma synchronous = normal; 22 - pragma foreign_keys = on; 23 - pragma temp_store = memory; 24 - pragma mmap_size = 30000000000; 25 - pragma page_size = 32768; 26 - pragma auto_vacuum = incremental; 27 - pragma busy_timeout = 5000; 28 + // NOTE: If any other migration is added here, you MUST 29 + // copy the pattern in appview: use a single sql.Conn 30 + // for every migration. 28 31 32 + _, err = db.Exec(` 29 33 create table if not exists known_dids ( 30 34 did text primary key 31 35 );
-8
knotserver/file.go
··· 10 10 "tangled.sh/tangled.sh/core/types" 11 11 ) 12 12 13 - func (h *Handle) listFiles(files []types.NiceTree, data map[string]any, w http.ResponseWriter) { 14 - data["files"] = files 15 - 16 - writeJSON(w, data) 17 - return 18 - } 19 - 20 13 func countLines(r io.Reader) (int, error) { 21 14 buf := make([]byte, 32*1024) 22 15 bufLen := 0 ··· 52 45 53 46 resp.Lines = lc 54 47 writeJSON(w, resp) 55 - return 56 48 }
+8 -10
knotserver/git/fork.go
··· 10 10 ) 11 11 12 12 func Fork(repoPath, source string) error { 13 - _, err := git.PlainClone(repoPath, true, &git.CloneOptions{ 14 - URL: source, 15 - SingleBranch: false, 16 - }) 17 - 18 - if err != nil { 13 + cloneCmd := exec.Command("git", "clone", "--bare", source, repoPath) 14 + if err := cloneCmd.Run(); err != nil { 19 15 return fmt.Errorf("failed to bare clone repository: %w", err) 20 16 } 21 17 22 - err = exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden").Run() 23 - if err != nil { 18 + configureCmd := exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden") 19 + if err := configureCmd.Run(); err != nil { 24 20 return fmt.Errorf("failed to configure hidden refs: %w", err) 25 21 } 26 22 27 23 return nil 28 24 } 29 25 30 - func (g *GitRepo) Sync(branch string) error { 26 + func (g *GitRepo) Sync() error { 27 + branch := g.h.String() 28 + 31 29 fetchOpts := &git.FetchOptions{ 32 30 RefSpecs: []config.RefSpec{ 33 - config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/heads/%s", branch, branch)), 31 + config.RefSpec("+" + branch + ":" + branch), // +refs/heads/master:refs/heads/master 34 32 }, 35 33 } 36 34
+19 -12
knotserver/git/post_receive.go
··· 3 3 import ( 4 4 "bufio" 5 5 "context" 6 + "errors" 6 7 "fmt" 7 8 "io" 8 9 "strings" ··· 57 58 ByEmail map[string]int 58 59 } 59 60 60 - func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) RefUpdateMeta { 61 + func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) (RefUpdateMeta, error) { 62 + var errs error 63 + 61 64 commitCount, err := g.newCommitCount(line) 62 - if err != nil { 63 - // TODO: log this 64 - } 65 + errors.Join(errs, err) 65 66 66 67 isDefaultRef, err := g.isDefaultBranch(line) 67 - if err != nil { 68 - // TODO: log this 69 - } 68 + errors.Join(errs, err) 70 69 71 70 ctx, cancel := context.WithTimeout(context.Background(), time.Second*2) 72 71 defer cancel() 73 72 breakdown, err := g.AnalyzeLanguages(ctx) 74 - if err != nil { 75 - // TODO: log this 76 - } 73 + errors.Join(errs, err) 77 74 78 75 return RefUpdateMeta{ 79 76 CommitCount: commitCount, 80 77 IsDefaultRef: isDefaultRef, 81 78 LangBreakdown: breakdown, 82 - } 79 + }, errs 83 80 } 84 81 85 82 func (g *GitRepo) newCommitCount(line PostReceiveLine) (CommitCount, error) { ··· 95 92 args := []string{fmt.Sprintf("--max-count=%d", 100)} 96 93 97 94 if line.OldSha.IsZero() { 98 - // just git rev-list <newsha> 95 + // git rev-list <newsha> ^other-branches --not ^this-branch 99 96 args = append(args, line.NewSha.String()) 97 + 98 + branches, _ := g.Branches() 99 + for _, b := range branches { 100 + if !strings.Contains(line.Ref, b.Name) { 101 + args = append(args, fmt.Sprintf("^%s", b.Name)) 102 + } 103 + } 104 + 105 + args = append(args, "--not") 106 + args = append(args, fmt.Sprintf("^%s", line.Ref)) 100 107 } else { 101 108 // git rev-list <oldsha>..<newsha> 102 109 args = append(args, fmt.Sprintf("%s..%s", line.OldSha.String(), line.NewSha.String()))
+5
knotserver/git.go
··· 129 129 // If the appview gave us the repository owner's handle we can attempt to 130 130 // construct the correct ssh url. 131 131 ownerHandle := r.Header.Get("x-tangled-repo-owner-handle") 132 + ownerHandle = strings.TrimPrefix(ownerHandle, "@") 132 133 if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") { 133 134 hostname := d.c.Server.Hostname 134 135 if strings.Contains(hostname, ":") { 135 136 hostname = strings.Split(hostname, ":")[0] 137 + } 138 + 139 + if hostname == "knot1.tangled.sh" { 140 + hostname = "tangled.sh" 136 141 } 137 142 138 143 fmt.Fprintf(w, " Try:\ngit remote set-url --push origin git@%s:%s/%s\n\n... and push again.", hostname, ownerHandle, unqualifiedRepoName)
+7 -7
knotserver/handler.go
··· 52 52 return nil, fmt.Errorf("failed to setup enforcer: %w", err) 53 53 } 54 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 55 // Check if the knot knows about any Dids; 61 56 // if it does, it is already initialized and we can repopulate the 62 57 // Jetstream subscriptions. ··· 71 66 for _, d := range dids { 72 67 h.jc.AddDid(d) 73 68 } 69 + } 70 + 71 + err = h.jc.StartJetstream(ctx, h.processMessages) 72 + if err != nil { 73 + return nil, fmt.Errorf("failed to start jetstream: %w", err) 74 74 } 75 75 76 76 r.Get("/", h.Index) ··· 142 142 r.Delete("/", h.RemoveRepo) 143 143 r.Route("/fork", func(r chi.Router) { 144 144 r.Post("/", h.RepoFork) 145 - r.Post("/sync/{branch}", h.RepoForkSync) 146 - r.Get("/sync/{branch}", h.RepoForkAheadBehind) 145 + r.Post("/sync/*", h.RepoForkSync) 146 + r.Get("/sync/*", h.RepoForkAheadBehind) 147 147 }) 148 148 }) 149 149
+52 -53
knotserver/ingester.go
··· 25 25 "tangled.sh/tangled.sh/core/workflow" 26 26 ) 27 27 28 - func (h *Handle) processPublicKey(ctx context.Context, did string, record tangled.PublicKey) error { 28 + func (h *Handle) processPublicKey(ctx context.Context, event *models.Event) error { 29 29 l := log.FromContext(ctx) 30 + raw := json.RawMessage(event.Commit.Record) 31 + did := event.Did 32 + 33 + var record tangled.PublicKey 34 + if err := json.Unmarshal(raw, &record); err != nil { 35 + return fmt.Errorf("failed to unmarshal record: %w", err) 36 + } 37 + 30 38 pk := db.PublicKey{ 31 39 Did: did, 32 40 PublicKey: record, ··· 39 47 return nil 40 48 } 41 49 42 - func (h *Handle) processKnotMember(ctx context.Context, did string, record tangled.KnotMember) error { 50 + func (h *Handle) processKnotMember(ctx context.Context, event *models.Event) error { 43 51 l := log.FromContext(ctx) 52 + raw := json.RawMessage(event.Commit.Record) 53 + did := event.Did 54 + 55 + var record tangled.KnotMember 56 + if err := json.Unmarshal(raw, &record); err != nil { 57 + return fmt.Errorf("failed to unmarshal record: %w", err) 58 + } 44 59 45 60 if record.Domain != h.c.Server.Hostname { 46 61 l.Error("domain mismatch", "domain", record.Domain, "expected", h.c.Server.Hostname) ··· 72 87 return nil 73 88 } 74 89 75 - func (h *Handle) processPull(ctx context.Context, did string, record tangled.RepoPull) error { 90 + func (h *Handle) processPull(ctx context.Context, event *models.Event) error { 91 + raw := json.RawMessage(event.Commit.Record) 92 + did := event.Did 93 + 94 + var record tangled.RepoPull 95 + if err := json.Unmarshal(raw, &record); err != nil { 96 + return fmt.Errorf("failed to unmarshal record: %w", err) 97 + } 98 + 76 99 l := log.FromContext(ctx) 77 100 l = l.With("handler", "processPull") 78 101 l = l.With("did", did) ··· 152 175 return err 153 176 } 154 177 155 - var pipeline workflow.Pipeline 178 + var pipeline workflow.RawPipeline 156 179 for _, e := range workflowDir { 157 180 if !e.IsFile { 158 181 continue ··· 164 187 continue 165 188 } 166 189 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) 190 + pipeline = append(pipeline, workflow.RawWorkflow{ 191 + Name: e.Name, 192 + Contents: contents, 193 + }) 175 194 } 176 195 177 196 trigger := tangled.Pipeline_PullRequestTriggerData{ ··· 193 212 }, 194 213 } 195 214 196 - cp := compiler.Compile(pipeline) 215 + cp := compiler.Compile(compiler.Parse(pipeline)) 197 216 eventJson, err := json.Marshal(cp) 198 217 if err != nil { 199 218 return err ··· 204 223 return nil 205 224 } 206 225 207 - event := db.Event{ 226 + ev := db.Event{ 208 227 Rkey: TID(), 209 228 Nsid: tangled.PipelineNSID, 210 229 EventJson: string(eventJson), 211 230 } 212 231 213 - return h.db.InsertEvent(event, h.n) 232 + return h.db.InsertEvent(ev, h.n) 214 233 } 215 234 216 235 // duplicated from add collaborator 217 - func (h *Handle) processCollaborator(ctx context.Context, did string, record tangled.RepoCollaborator) error { 236 + func (h *Handle) processCollaborator(ctx context.Context, event *models.Event) error { 237 + raw := json.RawMessage(event.Commit.Record) 238 + did := event.Did 239 + 240 + var record tangled.RepoCollaborator 241 + if err := json.Unmarshal(raw, &record); err != nil { 242 + return fmt.Errorf("failed to unmarshal record: %w", err) 243 + } 244 + 218 245 repoAt, err := syntax.ParseATURI(record.Repo) 219 246 if err != nil { 220 247 return err ··· 247 274 didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 248 275 249 276 // check perms for this user 250 - if ok, err := h.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil { 277 + if ok, err := h.e.IsCollaboratorInviteAllowed(did, rbac.ThisServer, didSlashRepo); !ok || err != nil { 251 278 return fmt.Errorf("insufficient permissions: %w", err) 252 279 } 253 280 ··· 307 334 } 308 335 309 336 func (h *Handle) processMessages(ctx context.Context, event *models.Event) error { 310 - did := event.Did 311 337 if event.Kind != models.EventKindCommit { 312 338 return nil 313 339 } ··· 316 342 defer func() { 317 343 eventTime := event.TimeUS 318 344 lastTimeUs := eventTime + 1 319 - fmt.Println("lastTimeUs", lastTimeUs) 320 345 if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil { 321 346 err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 322 347 } 323 348 }() 324 349 325 - raw := json.RawMessage(event.Commit.Record) 326 - 327 350 switch event.Commit.Collection { 328 351 case tangled.PublicKeyNSID: 329 - var record tangled.PublicKey 330 - if err := json.Unmarshal(raw, &record); err != nil { 331 - return fmt.Errorf("failed to unmarshal record: %w", err) 332 - } 333 - if err := h.processPublicKey(ctx, did, record); err != nil { 334 - return fmt.Errorf("failed to process public key: %w", err) 335 - } 336 - 352 + err = h.processPublicKey(ctx, event) 337 353 case tangled.KnotMemberNSID: 338 - var record tangled.KnotMember 339 - if err := json.Unmarshal(raw, &record); err != nil { 340 - return fmt.Errorf("failed to unmarshal record: %w", err) 341 - } 342 - if err := h.processKnotMember(ctx, did, record); err != nil { 343 - return fmt.Errorf("failed to process knot member: %w", err) 344 - } 345 - 354 + err = h.processKnotMember(ctx, event) 346 355 case tangled.RepoPullNSID: 347 - var record tangled.RepoPull 348 - if err := json.Unmarshal(raw, &record); err != nil { 349 - return fmt.Errorf("failed to unmarshal record: %w", err) 350 - } 351 - if err := h.processPull(ctx, did, record); err != nil { 352 - return fmt.Errorf("failed to process knot member: %w", err) 353 - } 354 - 356 + err = h.processPull(ctx, event) 355 357 case tangled.RepoCollaboratorNSID: 356 - var record tangled.RepoCollaborator 357 - if err := json.Unmarshal(raw, &record); err != nil { 358 - return fmt.Errorf("failed to unmarshal record: %w", err) 359 - } 360 - if err := h.processCollaborator(ctx, did, record); err != nil { 361 - return fmt.Errorf("failed to process knot member: %w", err) 362 - } 358 + err = h.processCollaborator(ctx, event) 359 + } 363 360 361 + if err != nil { 362 + h.l.Debug("failed to process event", "nsid", event.Commit.Collection, "err", err) 364 363 } 365 364 366 - return err 365 + return nil 367 366 }
+20 -37
knotserver/internal.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 7 8 "log/slog" 8 9 "net/http" ··· 145 146 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) 146 147 } 147 148 148 - meta := gr.RefUpdateMeta(line) 149 + var errs error 150 + meta, err := gr.RefUpdateMeta(line) 151 + errors.Join(errs, err) 149 152 150 153 metaRecord := meta.AsRecord() 151 154 ··· 169 172 EventJson: string(eventJson), 170 173 } 171 174 172 - return h.db.InsertEvent(event, h.n) 175 + return errors.Join(errs, h.db.InsertEvent(event, h.n)) 173 176 } 174 177 175 178 func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error { ··· 197 200 return err 198 201 } 199 202 200 - pipelineParseErrors := []string{} 201 - 202 - var pipeline workflow.Pipeline 203 + var pipeline workflow.RawPipeline 203 204 for _, e := range workflowDir { 204 205 if !e.IsFile { 205 206 continue ··· 211 212 continue 212 213 } 213 214 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) 215 + pipeline = append(pipeline, workflow.RawWorkflow{ 216 + Name: e.Name, 217 + Contents: contents, 218 + }) 222 219 } 223 220 224 221 trigger := tangled.Pipeline_PushTriggerData{ ··· 239 236 }, 240 237 } 241 238 242 - cp := compiler.Compile(pipeline) 239 + cp := compiler.Compile(compiler.Parse(pipeline)) 243 240 eventJson, err := json.Marshal(cp) 244 241 if err != nil { 245 242 return err 243 + } 244 + 245 + for _, e := range compiler.Diagnostics.Errors { 246 + *clientMsgs = append(*clientMsgs, e.String()) 246 247 } 247 248 248 249 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 - } 250 + if compiler.Diagnostics.IsEmpty() { 251 + *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 270 252 } 271 - if !hasDiagnostics { 272 - *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 253 + 254 + for _, w := range compiler.Diagnostics.Warnings { 255 + *clientMsgs = append(*clientMsgs, w.String()) 273 256 } 274 257 } 275 258
+27 -14
knotserver/routes.go
··· 286 286 mimeType = "image/svg+xml" 287 287 } 288 288 289 + contentHash := sha256.Sum256(contents) 290 + eTag := fmt.Sprintf("\"%x\"", contentHash) 291 + 289 292 // allow image, video, and text/plain files to be served directly 290 293 switch { 291 - case strings.HasPrefix(mimeType, "image/"): 292 - // allowed 293 - case strings.HasPrefix(mimeType, "video/"): 294 - // allowed 294 + case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"): 295 + if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag { 296 + w.WriteHeader(http.StatusNotModified) 297 + return 298 + } 299 + w.Header().Set("ETag", eTag) 300 + 295 301 case strings.HasPrefix(mimeType, "text/plain"): 296 - // allowed 302 + w.Header().Set("Cache-Control", "public, no-cache") 303 + 297 304 default: 298 305 l.Error("attempted to serve disallowed file type", "mimetype", mimeType) 299 306 writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden) 300 307 return 301 308 } 302 309 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 310 w.Header().Set("Content-Type", mimeType) 306 311 w.Write(contents) 307 312 } ··· 361 366 362 367 ref := strings.TrimSuffix(file, ".tar.gz") 363 368 369 + unescapedRef, err := url.PathUnescape(ref) 370 + if err != nil { 371 + notFound(w) 372 + return 373 + } 374 + 375 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-") 376 + 364 377 // This allows the browser to use a proper name for the file when 365 378 // downloading 366 - filename := fmt.Sprintf("%s-%s.tar.gz", name, ref) 379 + filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename) 367 380 setContentDisposition(w, filename) 368 381 setGZipMIME(w) 369 382 370 383 path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 371 - gr, err := git.Open(path, ref) 384 + gr, err := git.Open(path, unescapedRef) 372 385 if err != nil { 373 386 notFound(w) 374 387 return ··· 377 390 gw := gzip.NewWriter(w) 378 391 defer gw.Close() 379 392 380 - prefix := fmt.Sprintf("%s-%s", name, ref) 393 + prefix := fmt.Sprintf("%s-%s", name, safeRefFilename) 381 394 err = gr.WriteTar(gw, prefix) 382 395 if err != nil { 383 396 // once we start writing to the body we can't report error anymore ··· 702 715 } 703 716 704 717 func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) { 705 - l := h.l.With("handler", "RepoForkSync") 718 + l := h.l.With("handler", "RepoForkAheadBehind") 706 719 707 720 data := struct { 708 721 Did string `json:"did"` ··· 837 850 name = filepath.Base(source) 838 851 } 839 852 840 - branch := chi.URLParam(r, "branch") 853 + branch := chi.URLParam(r, "*") 841 854 branch, _ = url.PathUnescape(branch) 842 855 843 856 relativeRepoPath := filepath.Join(did, name) 844 857 repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 845 858 846 - gr, err := git.PlainOpen(repoPath) 859 + gr, err := git.Open(repoPath, branch) 847 860 if err != nil { 848 861 log.Println(err) 849 862 notFound(w) 850 863 return 851 864 } 852 865 853 - err = gr.Sync(branch) 866 + err = gr.Sync() 854 867 if err != nil { 855 868 l.Error("error syncing repo fork", "error", err.Error()) 856 869 writeError(w, err.Error(), http.StatusInternalServerError)
+1 -8
lexicons/issue/comment.json
··· 9 9 "key": "tid", 10 10 "record": { 11 11 "type": "object", 12 - "required": [ 13 - "issue", 14 - "body", 15 - "createdAt" 16 - ], 12 + "required": ["issue", "body", "createdAt"], 17 13 "properties": { 18 14 "issue": { 19 15 "type": "string", ··· 22 18 "repo": { 23 19 "type": "string", 24 20 "format": "at-uri" 25 - }, 26 - "commentId": { 27 - "type": "integer" 28 21 }, 29 22 "owner": { 30 23 "type": "string",
+1 -10
lexicons/issue/issue.json
··· 9 9 "key": "tid", 10 10 "record": { 11 11 "type": "object", 12 - "required": [ 13 - "repo", 14 - "issueId", 15 - "owner", 16 - "title", 17 - "createdAt" 18 - ], 12 + "required": ["repo", "owner", "title", "createdAt"], 19 13 "properties": { 20 14 "repo": { 21 15 "type": "string", 22 16 "format": "at-uri" 23 - }, 24 - "issueId": { 25 - "type": "integer" 26 17 }, 27 18 "owner": { 28 19 "type": "string",
+7 -63
lexicons/pipeline/pipeline.json
··· 149 149 "type": "object", 150 150 "required": [ 151 151 "name", 152 - "dependencies", 153 - "steps", 154 - "environment", 155 - "clone" 152 + "engine", 153 + "clone", 154 + "raw" 156 155 ], 157 156 "properties": { 158 157 "name": { 159 158 "type": "string" 160 159 }, 161 - "dependencies": { 162 - "type": "array", 163 - "items": { 164 - "type": "ref", 165 - "ref": "#dependency" 166 - } 167 - }, 168 - "steps": { 169 - "type": "array", 170 - "items": { 171 - "type": "ref", 172 - "ref": "#step" 173 - } 174 - }, 175 - "environment": { 176 - "type": "array", 177 - "items": { 178 - "type": "ref", 179 - "ref": "#pair" 180 - } 160 + "engine": { 161 + "type": "string" 181 162 }, 182 163 "clone": { 183 164 "type": "ref", 184 165 "ref": "#cloneOpts" 185 - } 186 - } 187 - }, 188 - "dependency": { 189 - "type": "object", 190 - "required": [ 191 - "registry", 192 - "packages" 193 - ], 194 - "properties": { 195 - "registry": { 166 + }, 167 + "raw": { 196 168 "type": "string" 197 - }, 198 - "packages": { 199 - "type": "array", 200 - "items": { 201 - "type": "string" 202 - } 203 169 } 204 170 } 205 171 }, ··· 219 185 }, 220 186 "submodules": { 221 187 "type": "boolean" 222 - } 223 - } 224 - }, 225 - "step": { 226 - "type": "object", 227 - "required": [ 228 - "name", 229 - "command" 230 - ], 231 - "properties": { 232 - "name": { 233 - "type": "string" 234 - }, 235 - "command": { 236 - "type": "string" 237 - }, 238 - "environment": { 239 - "type": "array", 240 - "items": { 241 - "type": "ref", 242 - "ref": "#pair" 243 - } 244 188 } 245 189 } 246 190 },
+40
lexicons/string/string.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.string", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "filename", 14 + "description", 15 + "createdAt", 16 + "contents" 17 + ], 18 + "properties": { 19 + "filename": { 20 + "type": "string", 21 + "maxGraphemes": 140, 22 + "minGraphemes": 1 23 + }, 24 + "description": { 25 + "type": "string", 26 + "maxGraphemes": 280 27 + }, 28 + "createdAt": { 29 + "type": "string", 30 + "format": "datetime" 31 + }, 32 + "contents": { 33 + "type": "string", 34 + "minGraphemes": 1 35 + } 36 + } 37 + } 38 + } 39 + } 40 + }
+3 -1
log/log.go
··· 9 9 // NewHandler sets up a new slog.Handler with the service name 10 10 // as an attribute 11 11 func NewHandler(name string) slog.Handler { 12 - handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{}) 12 + handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ 13 + Level: slog.LevelDebug, 14 + }) 13 15 14 16 var attrs []slog.Attr 15 17 attrs = append(attrs, slog.Attr{Key: "service", Value: slog.StringValue(name)})
+14 -2
nix/gomod2nix.toml
··· 66 66 [mod."github.com/cloudflare/circl"] 67 67 version = "v1.6.2-0.20250618153321-aa837fd1539d" 68 68 hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y=" 69 + [mod."github.com/cloudflare/cloudflare-go"] 70 + version = "v0.115.0" 71 + hash = "sha256-jezmDs6IsHA4rag7DzcHDfDgde0vU4iKgCN9+0XDViw=" 69 72 [mod."github.com/containerd/errdefs"] 70 73 version = "v1.0.0" 71 74 hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI=" ··· 169 172 [mod."github.com/golang/mock"] 170 173 version = "v1.6.0" 171 174 hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno=" 175 + [mod."github.com/google/go-querystring"] 176 + version = "v1.1.0" 177 + hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY=" 172 178 [mod."github.com/google/uuid"] 173 179 version = "v1.6.0" 174 180 hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw=" 175 181 [mod."github.com/gorilla/css"] 176 182 version = "v1.0.1" 177 183 hash = "sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A=" 184 + [mod."github.com/gorilla/feeds"] 185 + version = "v1.2.0" 186 + hash = "sha256-ptczizo27t6Bsq6rHJ4WiHmBRP54UC5yNfHghAqOBQk=" 178 187 [mod."github.com/gorilla/securecookie"] 179 188 version = "v1.1.2" 180 189 hash = "sha256-KeMHNM9emxX+N0WYiZsTii7n8sNsmjWwbnQ9SaJfTKE=" ··· 417 426 version = "v0.3.1" 418 427 hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc=" 419 428 [mod."github.com/yuin/goldmark"] 420 - version = "v1.4.13" 421 - hash = "sha256-GVwFKZY6moIS6I0ZGuio/WtDif+lkZRfqWS6b4AAJyI=" 429 + version = "v1.4.15" 430 + hash = "sha256-MvSOT6dwf5hVYkIg4MnqMpsy5ZtWZ7amAE7Zo9HkEa0=" 431 + [mod."github.com/yuin/goldmark-highlighting/v2"] 432 + version = "v2.0.0-20230729083705-37449abec8cc" 433 + hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg=" 422 434 [mod."gitlab.com/yawning/secp256k1-voi"] 423 435 version = "v0.0.0-20230925100816-f2616030848b" 424 436 hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA="
+14
nix/modules/appview.nix
··· 27 27 default = "00000000000000000000000000000000"; 28 28 description = "Cookie secret"; 29 29 }; 30 + environmentFile = mkOption { 31 + type = with types; nullOr path; 32 + default = null; 33 + example = "/etc/tangled-appview.env"; 34 + description = '' 35 + Additional environment file as defined in {manpage}`systemd.exec(5)`. 36 + 37 + Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET` may be 38 + passed to the service without makeing them world readable in the 39 + nix store. 40 + 41 + ''; 42 + }; 30 43 }; 31 44 }; 32 45 ··· 39 52 ListenStream = "0.0.0.0:${toString cfg.port}"; 40 53 ExecStart = "${cfg.package}/bin/appview"; 41 54 Restart = "always"; 55 + EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile; 42 56 }; 43 57 44 58 environment = {
+27 -24
nix/modules/knot.nix
··· 126 126 cfg.package 127 127 ]; 128 128 129 - system.activationScripts.gitConfig = let 130 - setMotd = 131 - if cfg.motdFile != null && cfg.motd != null 132 - then throw "motdFile and motd cannot be both set" 133 - else '' 134 - ${optionalString (cfg.motdFile != null) "cat ${cfg.motdFile} > ${cfg.stateDir}/motd"} 135 - ${optionalString (cfg.motd != null) ''printf "${cfg.motd}" > ${cfg.stateDir}/motd''} 136 - ''; 137 - in '' 138 - mkdir -p "${cfg.repo.scanPath}" 139 - chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}" 140 - 141 - mkdir -p "${cfg.stateDir}/.config/git" 142 - cat > "${cfg.stateDir}/.config/git/config" << EOF 143 - [user] 144 - name = Git User 145 - email = git@example.com 146 - [receive] 147 - advertisePushOptions = true 148 - EOF 149 - ${setMotd} 150 - chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}" 151 - ''; 152 - 153 129 users.users.${cfg.gitUser} = { 154 130 isSystemUser = true; 155 131 useDefaultShell = true; ··· 185 161 description = "knot service"; 186 162 after = ["network.target" "sshd.service"]; 187 163 wantedBy = ["multi-user.target"]; 164 + enableStrictShellChecks = true; 165 + 166 + preStart = let 167 + setMotd = 168 + if cfg.motdFile != null && cfg.motd != null 169 + then throw "motdFile and motd cannot be both set" 170 + else '' 171 + ${optionalString (cfg.motdFile != null) "cat ${cfg.motdFile} > ${cfg.stateDir}/motd"} 172 + ${optionalString (cfg.motd != null) ''printf "${cfg.motd}" > ${cfg.stateDir}/motd''} 173 + ''; 174 + in '' 175 + mkdir -p "${cfg.repo.scanPath}" 176 + chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}" 177 + 178 + mkdir -p "${cfg.stateDir}/.config/git" 179 + cat > "${cfg.stateDir}/.config/git/config" << EOF 180 + [user] 181 + name = Git User 182 + email = git@example.com 183 + [receive] 184 + advertisePushOptions = true 185 + EOF 186 + ${setMotd} 187 + chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}" 188 + ''; 189 + 188 190 serviceConfig = { 189 191 User = cfg.gitUser; 192 + PermissionsStartOnly = true; 190 193 WorkingDirectory = cfg.stateDir; 191 194 Environment = [ 192 195 "KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}"
+24 -2
nix/modules/spindle.nix
··· 54 54 example = "did:plc:qfpnj4og54vl56wngdriaxug"; 55 55 description = "DID of owner (required)"; 56 56 }; 57 + 58 + secrets = { 59 + provider = mkOption { 60 + type = types.str; 61 + default = "sqlite"; 62 + description = "Backend to use for secret management, valid options are 'sqlite', and 'openbao'."; 63 + }; 64 + 65 + openbao = { 66 + proxyAddr = mkOption { 67 + type = types.str; 68 + default = "http://127.0.0.1:8200"; 69 + }; 70 + mount = mkOption { 71 + type = types.str; 72 + default = "spindle"; 73 + }; 74 + }; 75 + }; 57 76 }; 58 77 59 78 pipelines = { ··· 89 108 "SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}" 90 109 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 91 110 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 92 - "SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 93 - "SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 111 + "SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}" 112 + "SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}" 113 + "SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}" 114 + "SPINDLE_NIXERY_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 115 + "SPINDLE_NIXERY_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 94 116 ]; 95 117 ExecStart = "${cfg.package}/bin/spindle"; 96 118 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 1 { 2 2 buildGoApplication, 3 3 modules, 4 - htmx-src, 5 - htmx-ws-src, 6 - lucide-src, 7 - inter-fonts-src, 8 - ibm-plex-mono-src, 9 - tailwindcss, 4 + appview-static-files, 10 5 sqlite-lib, 11 - gitignoreSource, 6 + src, 12 7 }: 13 8 buildGoApplication { 14 9 pname = "appview"; 15 10 version = "0.1.0"; 16 - src = gitignoreSource ../..; 17 - inherit modules; 11 + inherit src modules; 18 12 19 13 postUnpack = '' 20 14 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 15 + mkdir -p appview/pages/static 16 + cp -frv ${appview-static-files}/* appview/pages/static 29 17 popd 30 18 ''; 31 19
+7 -3
nix/pkgs/genjwks.nix
··· 1 1 { 2 - gitignoreSource, 3 2 buildGoApplication, 4 3 modules, 5 4 }: 6 5 buildGoApplication { 7 6 pname = "genjwks"; 8 7 version = "0.1.0"; 9 - src = gitignoreSource ../..; 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 + ''; 10 15 inherit modules; 11 - subPackages = ["cmd/genjwks"]; 12 16 doCheck = false; 13 17 CGO_ENABLED = 0; 14 18 }
+2 -3
nix/pkgs/knot-unwrapped.nix
··· 2 2 buildGoApplication, 3 3 modules, 4 4 sqlite-lib, 5 - gitignoreSource, 5 + src, 6 6 }: 7 7 buildGoApplication { 8 8 pname = "knot"; 9 9 version = "0.1.0"; 10 - src = gitignoreSource ../..; 11 - inherit modules; 10 + inherit src modules; 12 11 13 12 doCheck = false; 14 13
+1 -1
nix/pkgs/lexgen.nix
··· 7 7 version = "0.1.0"; 8 8 src = indigo; 9 9 subPackages = ["cmd/lexgen"]; 10 - vendorHash = "sha256-pGc29fgJFq8LP7n/pY1cv6ExZl88PAeFqIbFEhB3xXs="; 10 + vendorHash = "sha256-VbDrcN4r5b7utRFQzVsKgDsVgdQLSXl7oZ5kdPA/huw="; 11 11 doCheck = false; 12 12 }
+2 -3
nix/pkgs/spindle.nix
··· 2 2 buildGoApplication, 3 3 modules, 4 4 sqlite-lib, 5 - gitignoreSource, 5 + src, 6 6 }: 7 7 buildGoApplication { 8 8 pname = "spindle"; 9 9 version = "0.1.0"; 10 - src = gitignoreSource ../..; 11 - inherit modules; 10 + inherit src modules; 12 11 13 12 doCheck = false; 14 13
+120 -64
nix/vm.nix
··· 1 1 { 2 2 nixpkgs, 3 + system, 4 + hostSystem, 3 5 self, 4 - }: 5 - nixpkgs.lib.nixosSystem { 6 - system = "x86_64-linux"; 7 - modules = [ 8 - self.nixosModules.knot 9 - self.nixosModules.spindle 10 - ({ 11 - config, 12 - pkgs, 13 - ... 14 - }: { 15 - virtualisation = { 16 - memorySize = 2048; 17 - diskSize = 10 * 1024; 18 - cores = 2; 19 - forwardPorts = [ 20 - # ssh 21 - { 22 - from = "host"; 23 - host.port = 2222; 24 - guest.port = 22; 25 - } 26 - # knot 27 - { 28 - from = "host"; 29 - host.port = 6000; 30 - guest.port = 6000; 31 - } 32 - # spindle 33 - { 34 - from = "host"; 35 - host.port = 6555; 36 - guest.port = 6555; 37 - } 38 - ]; 39 - }; 40 - services.getty.autologinUser = "root"; 41 - environment.systemPackages = with pkgs; [curl vim git]; 42 - systemd.tmpfiles.rules = let 43 - u = config.services.tangled-knot.gitUser; 44 - g = config.services.tangled-knot.gitUser; 45 - in [ 46 - "d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first 47 - "f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=168c426fa6d9829fcbe85c96bdf144e800fb9737d6ca87f21acc543b1aa3e440" 48 - ]; 49 - services.tangled-knot = { 50 - enable = true; 51 - motd = "Welcome to the development knot!\n"; 52 - server = { 53 - secretFile = "/var/lib/knot/secret"; 54 - hostname = "localhost:6000"; 55 - listenAddr = "0.0.0.0:6000"; 6 + }: let 7 + envVar = name: let 8 + var = builtins.getEnv name; 9 + in 10 + if var == "" 11 + then throw "\$${name} must be defined, see docs/hacking.md for more details" 12 + else var; 13 + in 14 + nixpkgs.lib.nixosSystem { 15 + inherit system; 16 + modules = [ 17 + self.nixosModules.knot 18 + self.nixosModules.spindle 19 + ({ 20 + lib, 21 + config, 22 + pkgs, 23 + ... 24 + }: { 25 + virtualisation.vmVariant.virtualisation = { 26 + host.pkgs = import nixpkgs {system = hostSystem;}; 27 + 28 + graphics = false; 29 + memorySize = 2048; 30 + diskSize = 10 * 1024; 31 + cores = 2; 32 + forwardPorts = [ 33 + # ssh 34 + { 35 + from = "host"; 36 + host.port = 2222; 37 + guest.port = 22; 38 + } 39 + # knot 40 + { 41 + from = "host"; 42 + host.port = 6000; 43 + guest.port = 6000; 44 + } 45 + # spindle 46 + { 47 + from = "host"; 48 + host.port = 6555; 49 + guest.port = 6555; 50 + } 51 + ]; 52 + sharedDirectories = { 53 + # We can't use the 9p mounts directly for most of these 54 + # as SQLite is incompatible with them. So instead we 55 + # mount the shared directories to a different location 56 + # and copy the contents around on service start/stop. 57 + knotData = { 58 + source = "$TANGLED_VM_DATA_DIR/knot"; 59 + target = "/mnt/knot-data"; 60 + }; 61 + spindleData = { 62 + source = "$TANGLED_VM_DATA_DIR/spindle"; 63 + target = "/mnt/spindle-data"; 64 + }; 65 + spindleLogs = { 66 + source = "$TANGLED_VM_DATA_DIR/spindle-logs"; 67 + target = "/var/log/spindle"; 68 + }; 69 + }; 56 70 }; 57 - }; 58 - services.tangled-spindle = { 59 - enable = true; 60 - server = { 61 - owner = "did:plc:qfpnj4og54vl56wngdriaxug"; 62 - hostname = "localhost:6555"; 63 - listenAddr = "0.0.0.0:6555"; 64 - dev = true; 71 + # This is fine because any and all ports that are forwarded to host are explicitly marked above, we don't need a separate guest firewall 72 + networking.firewall.enable = false; 73 + services.getty.autologinUser = "root"; 74 + environment.systemPackages = with pkgs; [curl vim git sqlite litecli]; 75 + services.tangled-knot = { 76 + enable = true; 77 + motd = "Welcome to the development knot!\n"; 78 + server = { 79 + secretFile = builtins.toFile "knot-secret" ("KNOT_SERVER_SECRET=" + (envVar "TANGLED_VM_KNOT_SECRET")); 80 + hostname = "localhost:6000"; 81 + listenAddr = "0.0.0.0:6000"; 82 + }; 65 83 }; 66 - }; 67 - }) 68 - ]; 69 - } 84 + services.tangled-spindle = { 85 + enable = true; 86 + server = { 87 + owner = envVar "TANGLED_VM_SPINDLE_OWNER"; 88 + hostname = "localhost:6555"; 89 + listenAddr = "0.0.0.0:6555"; 90 + dev = true; 91 + secrets = { 92 + provider = "sqlite"; 93 + }; 94 + }; 95 + }; 96 + users = { 97 + # So we don't have to deal with permission clashing between 98 + # blank disk VMs and existing state 99 + users.${config.services.tangled-knot.gitUser}.uid = 666; 100 + groups.${config.services.tangled-knot.gitUser}.gid = 666; 101 + 102 + # TODO: separate spindle user 103 + }; 104 + systemd.services = let 105 + mkDataSyncScripts = source: target: { 106 + enableStrictShellChecks = true; 107 + 108 + preStart = lib.mkBefore '' 109 + mkdir -p ${target} 110 + ${lib.getExe pkgs.rsync} -a ${source}/ ${target} 111 + ''; 112 + 113 + postStop = lib.mkAfter '' 114 + ${lib.getExe pkgs.rsync} -a ${target}/ ${source} 115 + ''; 116 + 117 + serviceConfig.PermissionsStartOnly = true; 118 + }; 119 + in { 120 + knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled-knot.stateDir; 121 + spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled-spindle.server.dbPath); 122 + }; 123 + }) 124 + ]; 125 + }
+1 -1
rbac/rbac.go
··· 43 43 return nil, err 44 44 } 45 45 46 - db, err := sql.Open("sqlite3", path) 46 + db, err := sql.Open("sqlite3", path+"?_foreign_keys=1") 47 47 if err != nil { 48 48 return nil, err 49 49 }
+1 -1
rbac/rbac_test.go
··· 14 14 ) 15 15 16 16 func setup(t *testing.T) *rbac.Enforcer { 17 - db, err := sql.Open("sqlite3", ":memory:") 17 + db, err := sql.Open("sqlite3", ":memory:?_foreign_keys=1") 18 18 assert.NoError(t, err) 19 19 20 20 a, err := adapter.NewAdapter(db, "sqlite3", "acl")
+4 -4
spindle/config/config.go
··· 16 16 Dev bool `env:"DEV, default=false"` 17 17 Owner string `env:"OWNER, required"` 18 18 Secrets Secrets `env:",prefix=SECRETS_"` 19 + LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 19 20 } 20 21 21 22 func (s Server) Did() syntax.DID { ··· 32 33 Mount string `env:"MOUNT, default=spindle"` 33 34 } 34 35 35 - type Pipelines struct { 36 + type NixeryPipelines struct { 36 37 Nixery string `env:"NIXERY, default=nixery.tangled.sh"` 37 38 WorkflowTimeout string `env:"WORKFLOW_TIMEOUT, default=5m"` 38 - LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 39 39 } 40 40 41 41 type Config struct { 42 - Server Server `env:",prefix=SPINDLE_SERVER_"` 43 - Pipelines Pipelines `env:",prefix=SPINDLE_PIPELINES_"` 42 + Server Server `env:",prefix=SPINDLE_SERVER_"` 43 + NixeryPipelines NixeryPipelines `env:",prefix=SPINDLE_NIXERY_PIPELINES_"` 44 44 } 45 45 46 46 func Load(ctx context.Context) (*Config, error) {
+29 -10
spindle/db/db.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "strings" 5 6 6 7 _ "github.com/mattn/go-sqlite3" 7 8 ) ··· 11 12 } 12 13 13 14 func Make(dbPath string) (*DB, error) { 14 - db, err := sql.Open("sqlite3", dbPath) 15 + // https://github.com/mattn/go-sqlite3#connection-string 16 + opts := []string{ 17 + "_foreign_keys=1", 18 + "_journal_mode=WAL", 19 + "_synchronous=NORMAL", 20 + "_auto_vacuum=incremental", 21 + } 22 + 23 + db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 15 24 if err != nil { 16 25 return nil, err 17 26 } 27 + 28 + // NOTE: If any other migration is added here, you MUST 29 + // copy the pattern in appview: use a single sql.Conn 30 + // for every migration. 18 31 19 32 _, err = db.Exec(` 20 - pragma journal_mode = WAL; 21 - pragma synchronous = normal; 22 - pragma foreign_keys = on; 23 - pragma temp_store = memory; 24 - pragma mmap_size = 30000000000; 25 - pragma page_size = 32768; 26 - pragma auto_vacuum = incremental; 27 - pragma busy_timeout = 5000; 28 - 29 33 create table if not exists _jetstream ( 30 34 id integer primary key autoincrement, 31 35 last_time_us integer not null ··· 43 47 addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 44 48 45 49 unique(owner, name) 50 + ); 51 + 52 + create table if not exists spindle_members ( 53 + -- identifiers for the record 54 + id integer primary key autoincrement, 55 + did text not null, 56 + rkey text not null, 57 + 58 + -- data 59 + instance text not null, 60 + subject text not null, 61 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 62 + 63 + -- constraints 64 + unique (did, instance, subject) 46 65 ); 47 66 48 67 -- status event for a single workflow
+59
spindle/db/member.go
··· 1 + package db 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type SpindleMember struct { 10 + Id int 11 + Did syntax.DID // owner of the record 12 + Rkey string // rkey of the record 13 + Instance string 14 + Subject syntax.DID // the member being added 15 + Created time.Time 16 + } 17 + 18 + func AddSpindleMember(db *DB, member SpindleMember) error { 19 + _, err := db.Exec( 20 + `insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`, 21 + member.Did, 22 + member.Rkey, 23 + member.Instance, 24 + member.Subject, 25 + ) 26 + return err 27 + } 28 + 29 + func RemoveSpindleMember(db *DB, owner_did, rkey string) error { 30 + _, err := db.Exec( 31 + "delete from spindle_members where did = ? and rkey = ?", 32 + owner_did, 33 + rkey, 34 + ) 35 + return err 36 + } 37 + 38 + func GetSpindleMember(db *DB, did, rkey string) (*SpindleMember, error) { 39 + query := 40 + `select id, did, rkey, instance, subject, created 41 + from spindle_members 42 + where did = ? and rkey = ?` 43 + 44 + var member SpindleMember 45 + var createdAt string 46 + err := db.QueryRow(query, did, rkey).Scan( 47 + &member.Id, 48 + &member.Did, 49 + &member.Rkey, 50 + &member.Instance, 51 + &member.Subject, 52 + &createdAt, 53 + ) 54 + if err != nil { 55 + return nil, err 56 + } 57 + 58 + return &member, nil 59 + }
-21
spindle/engine/ansi_stripper.go
··· 1 - package engine 2 - 3 - import ( 4 - "io" 5 - 6 - "regexp" 7 - ) 8 - 9 - // regex to match ANSI escape codes (e.g., color codes, cursor moves) 10 - const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" 11 - 12 - var re = regexp.MustCompile(ansi) 13 - 14 - type ansiStrippingWriter struct { 15 - underlying io.Writer 16 - } 17 - 18 - func (w *ansiStrippingWriter) Write(p []byte) (int, error) { 19 - clean := re.ReplaceAll(p, []byte{}) 20 - return w.underlying.Write(clean) 21 - }
+68 -415
spindle/engine/engine.go
··· 4 4 "context" 5 5 "errors" 6 6 "fmt" 7 - "io" 8 7 "log/slog" 9 - "os" 10 - "strings" 11 - "sync" 12 - "time" 13 8 14 9 securejoin "github.com/cyphar/filepath-securejoin" 15 - "github.com/docker/docker/api/types/container" 16 - "github.com/docker/docker/api/types/image" 17 - "github.com/docker/docker/api/types/mount" 18 - "github.com/docker/docker/api/types/network" 19 - "github.com/docker/docker/api/types/volume" 20 - "github.com/docker/docker/client" 21 - "github.com/docker/docker/pkg/stdcopy" 22 10 "golang.org/x/sync/errgroup" 23 - "tangled.sh/tangled.sh/core/log" 24 11 "tangled.sh/tangled.sh/core/notifier" 25 12 "tangled.sh/tangled.sh/core/spindle/config" 26 13 "tangled.sh/tangled.sh/core/spindle/db" ··· 28 15 "tangled.sh/tangled.sh/core/spindle/secrets" 29 16 ) 30 17 31 - const ( 32 - workspaceDir = "/tangled/workspace" 18 + var ( 19 + ErrTimedOut = errors.New("timed out") 20 + ErrWorkflowFailed = errors.New("workflow failed") 33 21 ) 34 22 35 - type cleanupFunc func(context.Context) error 36 - 37 - type Engine struct { 38 - docker client.APIClient 39 - l *slog.Logger 40 - db *db.DB 41 - n *notifier.Notifier 42 - cfg *config.Config 43 - vault secrets.Manager 44 - 45 - cleanupMu sync.Mutex 46 - cleanup map[string][]cleanupFunc 47 - } 48 - 49 - func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier, vault secrets.Manager) (*Engine, error) { 50 - dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 51 - if err != nil { 52 - return nil, err 53 - } 54 - 55 - l := log.FromContext(ctx).With("component", "spindle") 56 - 57 - e := &Engine{ 58 - docker: dcli, 59 - l: l, 60 - db: db, 61 - n: n, 62 - cfg: cfg, 63 - vault: vault, 64 - } 65 - 66 - e.cleanup = make(map[string][]cleanupFunc) 67 - 68 - return e, nil 69 - } 70 - 71 - func (e *Engine) StartWorkflows(ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) { 72 - e.l.Info("starting all workflows in parallel", "pipeline", pipelineId) 23 + func StartWorkflows(l *slog.Logger, vault secrets.Manager, cfg *config.Config, db *db.DB, n *notifier.Notifier, ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) { 24 + l.Info("starting all workflows in parallel", "pipeline", pipelineId) 73 25 74 26 // extract secrets 75 27 var allSecrets []secrets.UnlockedSecret 76 28 if didSlashRepo, err := securejoin.SecureJoin(pipeline.RepoOwner, pipeline.RepoName); err == nil { 77 - if res, err := e.vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)); err == nil { 29 + if res, err := vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)); err == nil { 78 30 allSecrets = res 79 31 } 80 32 } 81 33 82 - workflowTimeoutStr := e.cfg.Pipelines.WorkflowTimeout 83 - workflowTimeout, err := time.ParseDuration(workflowTimeoutStr) 84 - if err != nil { 85 - e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr) 86 - workflowTimeout = 5 * time.Minute 87 - } 88 - e.l.Info("using workflow timeout", "timeout", workflowTimeout) 89 - 90 34 eg, ctx := errgroup.WithContext(ctx) 91 - for _, w := range pipeline.Workflows { 92 - eg.Go(func() error { 93 - wid := models.WorkflowId{ 94 - PipelineId: pipelineId, 95 - Name: w.Name, 96 - } 97 - 98 - err := e.db.StatusRunning(wid, e.n) 99 - if err != nil { 100 - return err 101 - } 35 + for eng, wfs := range pipeline.Workflows { 36 + workflowTimeout := eng.WorkflowTimeout() 37 + l.Info("using workflow timeout", "timeout", workflowTimeout) 102 38 103 - err = e.SetupWorkflow(ctx, wid) 104 - if err != nil { 105 - e.l.Error("setting up worklow", "wid", wid, "err", err) 106 - return err 107 - } 108 - defer e.DestroyWorkflow(ctx, wid) 109 - 110 - reader, err := e.docker.ImagePull(ctx, w.Image, image.PullOptions{}) 111 - if err != nil { 112 - e.l.Error("pipeline image pull failed!", "image", w.Image, "workflowId", wid, "error", err.Error()) 39 + for _, w := range wfs { 40 + eg.Go(func() error { 41 + wid := models.WorkflowId{ 42 + PipelineId: pipelineId, 43 + Name: w.Name, 44 + } 113 45 114 - err := e.db.StatusFailed(wid, err.Error(), -1, e.n) 46 + err := db.StatusRunning(wid, n) 115 47 if err != nil { 116 48 return err 117 49 } 118 50 119 - return fmt.Errorf("pulling image: %w", err) 120 - } 121 - defer reader.Close() 122 - io.Copy(os.Stdout, reader) 123 - 124 - ctx, cancel := context.WithTimeout(ctx, workflowTimeout) 125 - defer cancel() 51 + err = eng.SetupWorkflow(ctx, wid, &w) 52 + if err != nil { 53 + // TODO(winter): Should this always set StatusFailed? 54 + // In the original, we only do in a subset of cases. 55 + l.Error("setting up worklow", "wid", wid, "err", err) 126 56 127 - err = e.StartSteps(ctx, wid, w, allSecrets) 128 - if err != nil { 129 - if errors.Is(err, ErrTimedOut) { 130 - dbErr := e.db.StatusTimeout(wid, e.n) 131 - if dbErr != nil { 132 - return dbErr 57 + destroyErr := eng.DestroyWorkflow(ctx, wid) 58 + if destroyErr != nil { 59 + l.Error("failed to destroy workflow after setup failure", "error", destroyErr) 133 60 } 134 - } else { 135 - dbErr := e.db.StatusFailed(wid, err.Error(), -1, e.n) 61 + 62 + dbErr := db.StatusFailed(wid, err.Error(), -1, n) 136 63 if dbErr != nil { 137 64 return dbErr 138 65 } 66 + return err 139 67 } 68 + defer eng.DestroyWorkflow(ctx, wid) 140 69 141 - return fmt.Errorf("starting steps image: %w", err) 142 - } 70 + wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid) 71 + if err != nil { 72 + l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 73 + wfLogger = nil 74 + } else { 75 + defer wfLogger.Close() 76 + } 143 77 144 - err = e.db.StatusSuccess(wid, e.n) 145 - if err != nil { 146 - return err 147 - } 78 + ctx, cancel := context.WithTimeout(ctx, workflowTimeout) 79 + defer cancel() 148 80 149 - return nil 150 - }) 151 - } 81 + for stepIdx, step := range w.Steps { 82 + if wfLogger != nil { 83 + ctl := wfLogger.ControlWriter(stepIdx, step) 84 + ctl.Write([]byte(step.Name())) 85 + } 152 86 153 - if err = eg.Wait(); err != nil { 154 - e.l.Error("failed to run one or more workflows", "err", err) 155 - } else { 156 - e.l.Error("successfully ran full pipeline") 157 - } 158 - } 87 + err = eng.RunStep(ctx, wid, &w, stepIdx, allSecrets, wfLogger) 88 + if err != nil { 89 + if errors.Is(err, ErrTimedOut) { 90 + dbErr := db.StatusTimeout(wid, n) 91 + if dbErr != nil { 92 + return dbErr 93 + } 94 + } else { 95 + dbErr := db.StatusFailed(wid, err.Error(), -1, n) 96 + if dbErr != nil { 97 + return dbErr 98 + } 99 + } 159 100 160 - // SetupWorkflow sets up a new network for the workflow and volumes for 161 - // the workspace and Nix store. These are persisted across steps and are 162 - // destroyed at the end of the workflow. 163 - func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId) error { 164 - e.l.Info("setting up workflow", "workflow", wid) 101 + return fmt.Errorf("starting steps image: %w", err) 102 + } 103 + } 165 104 166 - _, err := e.docker.VolumeCreate(ctx, volume.CreateOptions{ 167 - Name: workspaceVolume(wid), 168 - Driver: "local", 169 - }) 170 - if err != nil { 171 - return err 172 - } 173 - e.registerCleanup(wid, func(ctx context.Context) error { 174 - return e.docker.VolumeRemove(ctx, workspaceVolume(wid), true) 175 - }) 176 - 177 - _, err = e.docker.VolumeCreate(ctx, volume.CreateOptions{ 178 - Name: nixVolume(wid), 179 - Driver: "local", 180 - }) 181 - if err != nil { 182 - return err 183 - } 184 - e.registerCleanup(wid, func(ctx context.Context) error { 185 - return e.docker.VolumeRemove(ctx, nixVolume(wid), true) 186 - }) 187 - 188 - _, err = e.docker.NetworkCreate(ctx, networkName(wid), network.CreateOptions{ 189 - Driver: "bridge", 190 - }) 191 - if err != nil { 192 - return err 193 - } 194 - e.registerCleanup(wid, func(ctx context.Context) error { 195 - return e.docker.NetworkRemove(ctx, networkName(wid)) 196 - }) 105 + err = db.StatusSuccess(wid, n) 106 + if err != nil { 107 + return err 108 + } 197 109 198 - return nil 199 - } 200 - 201 - // StartSteps starts all steps sequentially with the same base image. 202 - // ONLY marks pipeline as failed if container's exit code is non-zero. 203 - // All other errors are bubbled up. 204 - // Fixed version of the step execution logic 205 - func (e *Engine) StartSteps(ctx context.Context, wid models.WorkflowId, w models.Workflow, secrets []secrets.UnlockedSecret) error { 206 - workflowEnvs := ConstructEnvs(w.Environment) 207 - for _, s := range secrets { 208 - workflowEnvs.AddEnv(s.Key, s.Value) 209 - } 210 - 211 - for stepIdx, step := range w.Steps { 212 - select { 213 - case <-ctx.Done(): 214 - return ctx.Err() 215 - default: 216 - } 217 - 218 - envs := append(EnvVars(nil), workflowEnvs...) 219 - for k, v := range step.Environment { 220 - envs.AddEnv(k, v) 221 - } 222 - envs.AddEnv("HOME", workspaceDir) 223 - e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice()) 224 - 225 - hostConfig := hostConfig(wid) 226 - resp, err := e.docker.ContainerCreate(ctx, &container.Config{ 227 - Image: w.Image, 228 - Cmd: []string{"bash", "-c", step.Command}, 229 - WorkingDir: workspaceDir, 230 - Tty: false, 231 - Hostname: "spindle", 232 - Env: envs.Slice(), 233 - }, hostConfig, nil, nil, "") 234 - defer e.DestroyStep(ctx, resp.ID) 235 - if err != nil { 236 - return fmt.Errorf("creating container: %w", err) 237 - } 238 - 239 - err = e.docker.NetworkConnect(ctx, networkName(wid), resp.ID, nil) 240 - if err != nil { 241 - return fmt.Errorf("connecting network: %w", err) 242 - } 243 - 244 - err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}) 245 - if err != nil { 246 - return err 247 - } 248 - e.l.Info("started container", "name", resp.ID, "step", step.Name) 249 - 250 - // start tailing logs in background 251 - tailDone := make(chan error, 1) 252 - go func() { 253 - tailDone <- e.TailStep(ctx, resp.ID, wid, stepIdx, step) 254 - }() 255 - 256 - // wait for container completion or timeout 257 - waitDone := make(chan struct{}) 258 - var state *container.State 259 - var waitErr error 260 - 261 - go func() { 262 - defer close(waitDone) 263 - state, waitErr = e.WaitStep(ctx, resp.ID) 264 - }() 265 - 266 - select { 267 - case <-waitDone: 268 - 269 - // wait for tailing to complete 270 - <-tailDone 271 - 272 - case <-ctx.Done(): 273 - e.l.Warn("step timed out; killing container", "container", resp.ID, "step", step.Name) 274 - err = e.DestroyStep(context.Background(), resp.ID) 275 - if err != nil { 276 - e.l.Error("failed to destroy step", "container", resp.ID, "error", err) 277 - } 278 - 279 - // wait for both goroutines to finish 280 - <-waitDone 281 - <-tailDone 282 - 283 - return ErrTimedOut 284 - } 285 - 286 - select { 287 - case <-ctx.Done(): 288 - return ctx.Err() 289 - default: 290 - } 291 - 292 - if waitErr != nil { 293 - return waitErr 294 - } 295 - 296 - err = e.DestroyStep(ctx, resp.ID) 297 - if err != nil { 298 - return err 299 - } 300 - 301 - if state.ExitCode != 0 { 302 - e.l.Error("workflow failed!", "workflow_id", wid.String(), "error", state.Error, "exit_code", state.ExitCode, "oom_killed", state.OOMKilled) 303 - if state.OOMKilled { 304 - return ErrOOMKilled 305 - } 306 - return ErrWorkflowFailed 110 + return nil 111 + }) 307 112 } 308 113 } 309 114 310 - return nil 311 - } 312 - 313 - func (e *Engine) WaitStep(ctx context.Context, containerID string) (*container.State, error) { 314 - wait, errCh := e.docker.ContainerWait(ctx, containerID, container.WaitConditionNotRunning) 315 - select { 316 - case err := <-errCh: 317 - if err != nil { 318 - return nil, err 319 - } 320 - case <-wait: 321 - } 322 - 323 - e.l.Info("waited for container", "name", containerID) 324 - 325 - info, err := e.docker.ContainerInspect(ctx, containerID) 326 - if err != nil { 327 - return nil, err 328 - } 329 - 330 - return info.State, nil 331 - } 332 - 333 - func (e *Engine) TailStep(ctx context.Context, containerID string, wid models.WorkflowId, stepIdx int, step models.Step) error { 334 - wfLogger, err := NewWorkflowLogger(e.cfg.Pipelines.LogDir, wid) 335 - if err != nil { 336 - e.l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 337 - return err 115 + if err := eg.Wait(); err != nil { 116 + l.Error("failed to run one or more workflows", "err", err) 117 + } else { 118 + l.Error("successfully ran full pipeline") 338 119 } 339 - defer wfLogger.Close() 340 - 341 - ctl := wfLogger.ControlWriter(stepIdx, step) 342 - ctl.Write([]byte(step.Name)) 343 - 344 - logs, err := e.docker.ContainerLogs(ctx, containerID, container.LogsOptions{ 345 - Follow: true, 346 - ShowStdout: true, 347 - ShowStderr: true, 348 - Details: false, 349 - Timestamps: false, 350 - }) 351 - if err != nil { 352 - return err 353 - } 354 - 355 - _, err = stdcopy.StdCopy( 356 - wfLogger.DataWriter("stdout"), 357 - wfLogger.DataWriter("stderr"), 358 - logs, 359 - ) 360 - if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) { 361 - return fmt.Errorf("failed to copy logs: %w", err) 362 - } 363 - 364 - return nil 365 - } 366 - 367 - func (e *Engine) DestroyStep(ctx context.Context, containerID string) error { 368 - err := e.docker.ContainerKill(ctx, containerID, "9") // SIGKILL 369 - if err != nil && !isErrContainerNotFoundOrNotRunning(err) { 370 - return err 371 - } 372 - 373 - if err := e.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{ 374 - RemoveVolumes: true, 375 - RemoveLinks: false, 376 - Force: false, 377 - }); err != nil && !isErrContainerNotFoundOrNotRunning(err) { 378 - return err 379 - } 380 - 381 - return nil 382 - } 383 - 384 - func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error { 385 - e.cleanupMu.Lock() 386 - key := wid.String() 387 - 388 - fns := e.cleanup[key] 389 - delete(e.cleanup, key) 390 - e.cleanupMu.Unlock() 391 - 392 - for _, fn := range fns { 393 - if err := fn(ctx); err != nil { 394 - e.l.Error("failed to cleanup workflow resource", "workflowId", wid, "error", err) 395 - } 396 - } 397 - return nil 398 - } 399 - 400 - func (e *Engine) registerCleanup(wid models.WorkflowId, fn cleanupFunc) { 401 - e.cleanupMu.Lock() 402 - defer e.cleanupMu.Unlock() 403 - 404 - key := wid.String() 405 - e.cleanup[key] = append(e.cleanup[key], fn) 406 - } 407 - 408 - func workspaceVolume(wid models.WorkflowId) string { 409 - return fmt.Sprintf("workspace-%s", wid) 410 - } 411 - 412 - func nixVolume(wid models.WorkflowId) string { 413 - return fmt.Sprintf("nix-%s", wid) 414 - } 415 - 416 - func networkName(wid models.WorkflowId) string { 417 - return fmt.Sprintf("workflow-network-%s", wid) 418 - } 419 - 420 - func hostConfig(wid models.WorkflowId) *container.HostConfig { 421 - hostConfig := &container.HostConfig{ 422 - Mounts: []mount.Mount{ 423 - { 424 - Type: mount.TypeVolume, 425 - Source: workspaceVolume(wid), 426 - Target: workspaceDir, 427 - }, 428 - { 429 - Type: mount.TypeVolume, 430 - Source: nixVolume(wid), 431 - Target: "/nix", 432 - }, 433 - { 434 - Type: mount.TypeTmpfs, 435 - Target: "/tmp", 436 - ReadOnly: false, 437 - TmpfsOptions: &mount.TmpfsOptions{ 438 - Mode: 0o1777, // world-writeable sticky bit 439 - Options: [][]string{ 440 - {"exec"}, 441 - }, 442 - }, 443 - }, 444 - { 445 - Type: mount.TypeVolume, 446 - Source: "etc-nix-" + wid.String(), 447 - Target: "/etc/nix", 448 - }, 449 - }, 450 - ReadonlyRootfs: false, 451 - CapDrop: []string{"ALL"}, 452 - CapAdd: []string{"CAP_DAC_OVERRIDE"}, 453 - SecurityOpt: []string{"no-new-privileges"}, 454 - ExtraHosts: []string{"host.docker.internal:host-gateway"}, 455 - } 456 - 457 - return hostConfig 458 - } 459 - 460 - // thanks woodpecker 461 - func isErrContainerNotFoundOrNotRunning(err error) bool { 462 - // Error response from daemon: Cannot kill container: ...: No such container: ... 463 - // Error response from daemon: Cannot kill container: ...: Container ... is not running" 464 - // Error response from podman daemon: can only kill running containers. ... is in state exited 465 - // Error: No such container: ... 466 - return err != nil && (strings.Contains(err.Error(), "No such container") || strings.Contains(err.Error(), "is not running") || strings.Contains(err.Error(), "can only kill running containers")) 467 120 }
-28
spindle/engine/envs.go
··· 1 - package engine 2 - 3 - import ( 4 - "fmt" 5 - ) 6 - 7 - type EnvVars []string 8 - 9 - // ConstructEnvs converts a tangled.Pipeline_Step_Environment_Elem.{Key,Value} 10 - // representation into a docker-friendly []string{"KEY=value", ...} slice. 11 - func ConstructEnvs(envs map[string]string) EnvVars { 12 - var dockerEnvs EnvVars 13 - for k, v := range envs { 14 - ev := fmt.Sprintf("%s=%s", k, v) 15 - dockerEnvs = append(dockerEnvs, ev) 16 - } 17 - return dockerEnvs 18 - } 19 - 20 - // Slice returns the EnvVar as a []string slice. 21 - func (ev EnvVars) Slice() []string { 22 - return ev 23 - } 24 - 25 - // AddEnv adds a key=value string to the EnvVar. 26 - func (ev *EnvVars) AddEnv(key, value string) { 27 - *ev = append(*ev, fmt.Sprintf("%s=%s", key, value)) 28 - }
-48
spindle/engine/envs_test.go
··· 1 - package engine 2 - 3 - import ( 4 - "testing" 5 - 6 - "github.com/stretchr/testify/assert" 7 - ) 8 - 9 - func TestConstructEnvs(t *testing.T) { 10 - tests := []struct { 11 - name string 12 - in map[string]string 13 - want EnvVars 14 - }{ 15 - { 16 - name: "empty input", 17 - in: make(map[string]string), 18 - want: EnvVars{}, 19 - }, 20 - { 21 - name: "single env var", 22 - in: map[string]string{"FOO": "bar"}, 23 - want: EnvVars{"FOO=bar"}, 24 - }, 25 - { 26 - name: "multiple env vars", 27 - in: map[string]string{"FOO": "bar", "BAZ": "qux"}, 28 - want: EnvVars{"FOO=bar", "BAZ=qux"}, 29 - }, 30 - } 31 - for _, tt := range tests { 32 - t.Run(tt.name, func(t *testing.T) { 33 - got := ConstructEnvs(tt.in) 34 - if got == nil { 35 - got = EnvVars{} 36 - } 37 - assert.ElementsMatch(t, tt.want, got) 38 - }) 39 - } 40 - } 41 - 42 - func TestAddEnv(t *testing.T) { 43 - ev := EnvVars{} 44 - ev.AddEnv("FOO", "bar") 45 - ev.AddEnv("BAZ", "qux") 46 - want := EnvVars{"FOO=bar", "BAZ=qux"} 47 - assert.ElementsMatch(t, want, ev) 48 - }
-9
spindle/engine/errors.go
··· 1 - package engine 2 - 3 - import "errors" 4 - 5 - var ( 6 - ErrOOMKilled = errors.New("oom killed") 7 - ErrTimedOut = errors.New("timed out") 8 - ErrWorkflowFailed = errors.New("workflow failed") 9 - )
-84
spindle/engine/logger.go
··· 1 - package engine 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - "io" 7 - "os" 8 - "path/filepath" 9 - "strings" 10 - 11 - "tangled.sh/tangled.sh/core/spindle/models" 12 - ) 13 - 14 - type WorkflowLogger struct { 15 - file *os.File 16 - encoder *json.Encoder 17 - } 18 - 19 - func NewWorkflowLogger(baseDir string, wid models.WorkflowId) (*WorkflowLogger, error) { 20 - path := LogFilePath(baseDir, wid) 21 - 22 - file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) 23 - if err != nil { 24 - return nil, fmt.Errorf("creating log file: %w", err) 25 - } 26 - 27 - return &WorkflowLogger{ 28 - file: file, 29 - encoder: json.NewEncoder(file), 30 - }, nil 31 - } 32 - 33 - func LogFilePath(baseDir string, workflowID models.WorkflowId) string { 34 - logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String())) 35 - return logFilePath 36 - } 37 - 38 - func (l *WorkflowLogger) Close() error { 39 - return l.file.Close() 40 - } 41 - 42 - func (l *WorkflowLogger) DataWriter(stream string) io.Writer { 43 - // TODO: emit stream 44 - return &dataWriter{ 45 - logger: l, 46 - stream: stream, 47 - } 48 - } 49 - 50 - func (l *WorkflowLogger) ControlWriter(idx int, step models.Step) io.Writer { 51 - return &controlWriter{ 52 - logger: l, 53 - idx: idx, 54 - step: step, 55 - } 56 - } 57 - 58 - type dataWriter struct { 59 - logger *WorkflowLogger 60 - stream string 61 - } 62 - 63 - func (w *dataWriter) Write(p []byte) (int, error) { 64 - line := strings.TrimRight(string(p), "\r\n") 65 - entry := models.NewDataLogLine(line, w.stream) 66 - if err := w.logger.encoder.Encode(entry); err != nil { 67 - return 0, err 68 - } 69 - return len(p), nil 70 - } 71 - 72 - type controlWriter struct { 73 - logger *WorkflowLogger 74 - idx int 75 - step models.Step 76 - } 77 - 78 - func (w *controlWriter) Write(_ []byte) (int, error) { 79 - entry := models.NewControlLogLine(w.idx, w.step) 80 - if err := w.logger.encoder.Encode(entry); err != nil { 81 - return 0, err 82 - } 83 - return len(w.step.Name), nil 84 - }
+21
spindle/engines/nixery/ansi_stripper.go
··· 1 + package nixery 2 + 3 + import ( 4 + "io" 5 + 6 + "regexp" 7 + ) 8 + 9 + // regex to match ANSI escape codes (e.g., color codes, cursor moves) 10 + const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" 11 + 12 + var re = regexp.MustCompile(ansi) 13 + 14 + type ansiStrippingWriter struct { 15 + underlying io.Writer 16 + } 17 + 18 + func (w *ansiStrippingWriter) Write(p []byte) (int, error) { 19 + clean := re.ReplaceAll(p, []byte{}) 20 + return w.underlying.Write(clean) 21 + }
+418
spindle/engines/nixery/engine.go
··· 1 + package nixery 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "log/slog" 9 + "os" 10 + "path" 11 + "runtime" 12 + "sync" 13 + "time" 14 + 15 + "github.com/docker/docker/api/types/container" 16 + "github.com/docker/docker/api/types/image" 17 + "github.com/docker/docker/api/types/mount" 18 + "github.com/docker/docker/api/types/network" 19 + "github.com/docker/docker/client" 20 + "github.com/docker/docker/pkg/stdcopy" 21 + "gopkg.in/yaml.v3" 22 + "tangled.sh/tangled.sh/core/api/tangled" 23 + "tangled.sh/tangled.sh/core/log" 24 + "tangled.sh/tangled.sh/core/spindle/config" 25 + "tangled.sh/tangled.sh/core/spindle/engine" 26 + "tangled.sh/tangled.sh/core/spindle/models" 27 + "tangled.sh/tangled.sh/core/spindle/secrets" 28 + ) 29 + 30 + const ( 31 + workspaceDir = "/tangled/workspace" 32 + homeDir = "/tangled/home" 33 + ) 34 + 35 + type cleanupFunc func(context.Context) error 36 + 37 + type Engine struct { 38 + docker client.APIClient 39 + l *slog.Logger 40 + cfg *config.Config 41 + 42 + cleanupMu sync.Mutex 43 + cleanup map[string][]cleanupFunc 44 + } 45 + 46 + type Step struct { 47 + name string 48 + kind models.StepKind 49 + command string 50 + environment map[string]string 51 + } 52 + 53 + func (s Step) Name() string { 54 + return s.name 55 + } 56 + 57 + func (s Step) Command() string { 58 + return s.command 59 + } 60 + 61 + func (s Step) Kind() models.StepKind { 62 + return s.kind 63 + } 64 + 65 + // setupSteps get added to start of Steps 66 + type setupSteps []models.Step 67 + 68 + // addStep adds a step to the beginning of the workflow's steps. 69 + func (ss *setupSteps) addStep(step models.Step) { 70 + *ss = append(*ss, step) 71 + } 72 + 73 + type addlFields struct { 74 + image string 75 + container string 76 + env map[string]string 77 + } 78 + 79 + func (e *Engine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) { 80 + swf := &models.Workflow{} 81 + addl := addlFields{} 82 + 83 + dwf := &struct { 84 + Steps []struct { 85 + Command string `yaml:"command"` 86 + Name string `yaml:"name"` 87 + Environment map[string]string `yaml:"environment"` 88 + } `yaml:"steps"` 89 + Dependencies map[string][]string `yaml:"dependencies"` 90 + Environment map[string]string `yaml:"environment"` 91 + }{} 92 + err := yaml.Unmarshal([]byte(twf.Raw), &dwf) 93 + if err != nil { 94 + return nil, err 95 + } 96 + 97 + for _, dstep := range dwf.Steps { 98 + sstep := Step{} 99 + sstep.environment = dstep.Environment 100 + sstep.command = dstep.Command 101 + sstep.name = dstep.Name 102 + sstep.kind = models.StepKindUser 103 + swf.Steps = append(swf.Steps, sstep) 104 + } 105 + swf.Name = twf.Name 106 + addl.env = dwf.Environment 107 + addl.image = workflowImage(dwf.Dependencies, e.cfg.NixeryPipelines.Nixery) 108 + 109 + setup := &setupSteps{} 110 + 111 + setup.addStep(nixConfStep()) 112 + setup.addStep(cloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev)) 113 + // this step could be empty 114 + if s := dependencyStep(dwf.Dependencies); s != nil { 115 + setup.addStep(*s) 116 + } 117 + 118 + // append setup steps in order to the start of workflow steps 119 + swf.Steps = append(*setup, swf.Steps...) 120 + swf.Data = addl 121 + 122 + return swf, nil 123 + } 124 + 125 + func (e *Engine) WorkflowTimeout() time.Duration { 126 + workflowTimeoutStr := e.cfg.NixeryPipelines.WorkflowTimeout 127 + workflowTimeout, err := time.ParseDuration(workflowTimeoutStr) 128 + if err != nil { 129 + e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr) 130 + workflowTimeout = 5 * time.Minute 131 + } 132 + 133 + return workflowTimeout 134 + } 135 + 136 + func workflowImage(deps map[string][]string, nixery string) string { 137 + var dependencies string 138 + for reg, ds := range deps { 139 + if reg == "nixpkgs" { 140 + dependencies = path.Join(ds...) 141 + } 142 + } 143 + 144 + // load defaults from somewhere else 145 + dependencies = path.Join(dependencies, "bash", "git", "coreutils", "nix") 146 + 147 + if runtime.GOARCH == "arm64" { 148 + dependencies = path.Join("arm64", dependencies) 149 + } 150 + 151 + return path.Join(nixery, dependencies) 152 + } 153 + 154 + func New(ctx context.Context, cfg *config.Config) (*Engine, error) { 155 + dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 156 + if err != nil { 157 + return nil, err 158 + } 159 + 160 + l := log.FromContext(ctx).With("component", "spindle") 161 + 162 + e := &Engine{ 163 + docker: dcli, 164 + l: l, 165 + cfg: cfg, 166 + } 167 + 168 + e.cleanup = make(map[string][]cleanupFunc) 169 + 170 + return e, nil 171 + } 172 + 173 + func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId, wf *models.Workflow) error { 174 + e.l.Info("setting up workflow", "workflow", wid) 175 + 176 + _, err := e.docker.NetworkCreate(ctx, networkName(wid), network.CreateOptions{ 177 + Driver: "bridge", 178 + }) 179 + if err != nil { 180 + return err 181 + } 182 + e.registerCleanup(wid, func(ctx context.Context) error { 183 + return e.docker.NetworkRemove(ctx, networkName(wid)) 184 + }) 185 + 186 + addl := wf.Data.(addlFields) 187 + 188 + reader, err := e.docker.ImagePull(ctx, addl.image, image.PullOptions{}) 189 + if err != nil { 190 + e.l.Error("pipeline image pull failed!", "image", addl.image, "workflowId", wid, "error", err.Error()) 191 + 192 + return fmt.Errorf("pulling image: %w", err) 193 + } 194 + defer reader.Close() 195 + io.Copy(os.Stdout, reader) 196 + 197 + resp, err := e.docker.ContainerCreate(ctx, &container.Config{ 198 + Image: addl.image, 199 + Cmd: []string{"cat"}, 200 + OpenStdin: true, // so cat stays alive :3 201 + Tty: false, 202 + Hostname: "spindle", 203 + WorkingDir: workspaceDir, 204 + // TODO(winter): investigate whether environment variables passed here 205 + // get propagated to ContainerExec processes 206 + }, &container.HostConfig{ 207 + Mounts: []mount.Mount{ 208 + { 209 + Type: mount.TypeTmpfs, 210 + Target: "/tmp", 211 + ReadOnly: false, 212 + TmpfsOptions: &mount.TmpfsOptions{ 213 + Mode: 0o1777, // world-writeable sticky bit 214 + Options: [][]string{ 215 + {"exec"}, 216 + }, 217 + }, 218 + }, 219 + }, 220 + ReadonlyRootfs: false, 221 + CapDrop: []string{"ALL"}, 222 + CapAdd: []string{"CAP_DAC_OVERRIDE"}, 223 + SecurityOpt: []string{"no-new-privileges"}, 224 + ExtraHosts: []string{"host.docker.internal:host-gateway"}, 225 + }, nil, nil, "") 226 + if err != nil { 227 + return fmt.Errorf("creating container: %w", err) 228 + } 229 + e.registerCleanup(wid, func(ctx context.Context) error { 230 + err = e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{}) 231 + if err != nil { 232 + return err 233 + } 234 + 235 + return e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{ 236 + RemoveVolumes: true, 237 + RemoveLinks: false, 238 + Force: false, 239 + }) 240 + }) 241 + 242 + err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}) 243 + if err != nil { 244 + return fmt.Errorf("starting container: %w", err) 245 + } 246 + 247 + mkExecResp, err := e.docker.ContainerExecCreate(ctx, resp.ID, container.ExecOptions{ 248 + Cmd: []string{"mkdir", "-p", workspaceDir, homeDir}, 249 + AttachStdout: true, // NOTE(winter): pretty sure this will make it so that when stdout read is done below, mkdir is done. maybe?? 250 + AttachStderr: true, // for good measure, backed up by docker/cli ("If -d is not set, attach to everything by default") 251 + }) 252 + if err != nil { 253 + return err 254 + } 255 + 256 + // This actually *starts* the command. Thanks, Docker! 257 + execResp, err := e.docker.ContainerExecAttach(ctx, mkExecResp.ID, container.ExecAttachOptions{}) 258 + if err != nil { 259 + return err 260 + } 261 + defer execResp.Close() 262 + 263 + // This is apparently best way to wait for the command to complete. 264 + _, err = io.ReadAll(execResp.Reader) 265 + if err != nil { 266 + return err 267 + } 268 + 269 + execInspectResp, err := e.docker.ContainerExecInspect(ctx, mkExecResp.ID) 270 + if err != nil { 271 + return err 272 + } 273 + 274 + if execInspectResp.ExitCode != 0 { 275 + return fmt.Errorf("mkdir exited with exit code %d", execInspectResp.ExitCode) 276 + } else if execInspectResp.Running { 277 + return errors.New("mkdir is somehow still running??") 278 + } 279 + 280 + addl.container = resp.ID 281 + wf.Data = addl 282 + 283 + return nil 284 + } 285 + 286 + func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *models.WorkflowLogger) error { 287 + addl := w.Data.(addlFields) 288 + workflowEnvs := ConstructEnvs(addl.env) 289 + // TODO(winter): should SetupWorkflow also have secret access? 290 + // IMO yes, but probably worth thinking on. 291 + for _, s := range secrets { 292 + workflowEnvs.AddEnv(s.Key, s.Value) 293 + } 294 + 295 + step := w.Steps[idx].(Step) 296 + 297 + select { 298 + case <-ctx.Done(): 299 + return ctx.Err() 300 + default: 301 + } 302 + 303 + envs := append(EnvVars(nil), workflowEnvs...) 304 + for k, v := range step.environment { 305 + envs.AddEnv(k, v) 306 + } 307 + envs.AddEnv("HOME", homeDir) 308 + 309 + mkExecResp, err := e.docker.ContainerExecCreate(ctx, addl.container, container.ExecOptions{ 310 + Cmd: []string{"bash", "-c", step.command}, 311 + AttachStdout: true, 312 + AttachStderr: true, 313 + Env: envs, 314 + }) 315 + if err != nil { 316 + return fmt.Errorf("creating exec: %w", err) 317 + } 318 + 319 + // start tailing logs in background 320 + tailDone := make(chan error, 1) 321 + go func() { 322 + tailDone <- e.tailStep(ctx, wfLogger, mkExecResp.ID, wid, idx, step) 323 + }() 324 + 325 + select { 326 + case <-tailDone: 327 + 328 + case <-ctx.Done(): 329 + // cleanup will be handled by DestroyWorkflow, since 330 + // Docker doesn't provide an API to kill an exec run 331 + // (sure, we could grab the PID and kill it ourselves, 332 + // but that's wasted effort) 333 + e.l.Warn("step timed out", "step", step.Name) 334 + 335 + <-tailDone 336 + 337 + return engine.ErrTimedOut 338 + } 339 + 340 + select { 341 + case <-ctx.Done(): 342 + return ctx.Err() 343 + default: 344 + } 345 + 346 + execInspectResp, err := e.docker.ContainerExecInspect(ctx, mkExecResp.ID) 347 + if err != nil { 348 + return err 349 + } 350 + 351 + if execInspectResp.ExitCode != 0 { 352 + inspectResp, err := e.docker.ContainerInspect(ctx, addl.container) 353 + if err != nil { 354 + return err 355 + } 356 + 357 + e.l.Error("workflow failed!", "workflow_id", wid.String(), "exit_code", execInspectResp.ExitCode, "oom_killed", inspectResp.State.OOMKilled) 358 + 359 + if inspectResp.State.OOMKilled { 360 + return ErrOOMKilled 361 + } 362 + return engine.ErrWorkflowFailed 363 + } 364 + 365 + return nil 366 + } 367 + 368 + func (e *Engine) tailStep(ctx context.Context, wfLogger *models.WorkflowLogger, execID string, wid models.WorkflowId, stepIdx int, step models.Step) error { 369 + if wfLogger == nil { 370 + return nil 371 + } 372 + 373 + // This actually *starts* the command. Thanks, Docker! 374 + logs, err := e.docker.ContainerExecAttach(ctx, execID, container.ExecAttachOptions{}) 375 + if err != nil { 376 + return err 377 + } 378 + defer logs.Close() 379 + 380 + _, err = stdcopy.StdCopy( 381 + wfLogger.DataWriter("stdout"), 382 + wfLogger.DataWriter("stderr"), 383 + logs.Reader, 384 + ) 385 + if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) { 386 + return fmt.Errorf("failed to copy logs: %w", err) 387 + } 388 + 389 + return nil 390 + } 391 + 392 + func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error { 393 + e.cleanupMu.Lock() 394 + key := wid.String() 395 + 396 + fns := e.cleanup[key] 397 + delete(e.cleanup, key) 398 + e.cleanupMu.Unlock() 399 + 400 + for _, fn := range fns { 401 + if err := fn(ctx); err != nil { 402 + e.l.Error("failed to cleanup workflow resource", "workflowId", wid, "error", err) 403 + } 404 + } 405 + return nil 406 + } 407 + 408 + func (e *Engine) registerCleanup(wid models.WorkflowId, fn cleanupFunc) { 409 + e.cleanupMu.Lock() 410 + defer e.cleanupMu.Unlock() 411 + 412 + key := wid.String() 413 + e.cleanup[key] = append(e.cleanup[key], fn) 414 + } 415 + 416 + func networkName(wid models.WorkflowId) string { 417 + return fmt.Sprintf("workflow-network-%s", wid) 418 + }
+28
spindle/engines/nixery/envs.go
··· 1 + package nixery 2 + 3 + import ( 4 + "fmt" 5 + ) 6 + 7 + type EnvVars []string 8 + 9 + // ConstructEnvs converts a tangled.Pipeline_Step_Environment_Elem.{Key,Value} 10 + // representation into a docker-friendly []string{"KEY=value", ...} slice. 11 + func ConstructEnvs(envs map[string]string) EnvVars { 12 + var dockerEnvs EnvVars 13 + for k, v := range envs { 14 + ev := fmt.Sprintf("%s=%s", k, v) 15 + dockerEnvs = append(dockerEnvs, ev) 16 + } 17 + return dockerEnvs 18 + } 19 + 20 + // Slice returns the EnvVar as a []string slice. 21 + func (ev EnvVars) Slice() []string { 22 + return ev 23 + } 24 + 25 + // AddEnv adds a key=value string to the EnvVar. 26 + func (ev *EnvVars) AddEnv(key, value string) { 27 + *ev = append(*ev, fmt.Sprintf("%s=%s", key, value)) 28 + }
+48
spindle/engines/nixery/envs_test.go
··· 1 + package nixery 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + ) 8 + 9 + func TestConstructEnvs(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + in map[string]string 13 + want EnvVars 14 + }{ 15 + { 16 + name: "empty input", 17 + in: make(map[string]string), 18 + want: EnvVars{}, 19 + }, 20 + { 21 + name: "single env var", 22 + in: map[string]string{"FOO": "bar"}, 23 + want: EnvVars{"FOO=bar"}, 24 + }, 25 + { 26 + name: "multiple env vars", 27 + in: map[string]string{"FOO": "bar", "BAZ": "qux"}, 28 + want: EnvVars{"FOO=bar", "BAZ=qux"}, 29 + }, 30 + } 31 + for _, tt := range tests { 32 + t.Run(tt.name, func(t *testing.T) { 33 + got := ConstructEnvs(tt.in) 34 + if got == nil { 35 + got = EnvVars{} 36 + } 37 + assert.ElementsMatch(t, tt.want, got) 38 + }) 39 + } 40 + } 41 + 42 + func TestAddEnv(t *testing.T) { 43 + ev := EnvVars{} 44 + ev.AddEnv("FOO", "bar") 45 + ev.AddEnv("BAZ", "qux") 46 + want := EnvVars{"FOO=bar", "BAZ=qux"} 47 + assert.ElementsMatch(t, want, ev) 48 + }
+7
spindle/engines/nixery/errors.go
··· 1 + package nixery 2 + 3 + import "errors" 4 + 5 + var ( 6 + ErrOOMKilled = errors.New("oom killed") 7 + )
+126
spindle/engines/nixery/setup_steps.go
··· 1 + package nixery 2 + 3 + import ( 4 + "fmt" 5 + "path" 6 + "strings" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + "tangled.sh/tangled.sh/core/workflow" 10 + ) 11 + 12 + func nixConfStep() Step { 13 + setupCmd := `mkdir -p /etc/nix 14 + echo 'extra-experimental-features = nix-command flakes' >> /etc/nix/nix.conf 15 + echo 'build-users-group = ' >> /etc/nix/nix.conf` 16 + return Step{ 17 + command: setupCmd, 18 + name: "Configure Nix", 19 + } 20 + } 21 + 22 + // cloneOptsAsSteps processes clone options and adds corresponding steps 23 + // to the beginning of the workflow's step list if cloning is not skipped. 24 + // 25 + // the steps to do here are: 26 + // - git init 27 + // - git remote add origin <url> 28 + // - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha> 29 + // - git checkout FETCH_HEAD 30 + func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step { 31 + if twf.Clone.Skip { 32 + return Step{} 33 + } 34 + 35 + var commands []string 36 + 37 + // initialize git repo in workspace 38 + commands = append(commands, "git init") 39 + 40 + // add repo as git remote 41 + scheme := "https://" 42 + if dev { 43 + scheme = "http://" 44 + tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal") 45 + } 46 + url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo) 47 + commands = append(commands, fmt.Sprintf("git remote add origin %s", url)) 48 + 49 + // run git fetch 50 + { 51 + var fetchArgs []string 52 + 53 + // default clone depth is 1 54 + depth := 1 55 + if twf.Clone.Depth > 1 { 56 + depth = int(twf.Clone.Depth) 57 + } 58 + fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth)) 59 + 60 + // optionally recurse submodules 61 + if twf.Clone.Submodules { 62 + fetchArgs = append(fetchArgs, "--recurse-submodules=yes") 63 + } 64 + 65 + // set remote to fetch from 66 + fetchArgs = append(fetchArgs, "origin") 67 + 68 + // set revision to checkout 69 + switch workflow.TriggerKind(tr.Kind) { 70 + case workflow.TriggerKindManual: 71 + // TODO: unimplemented 72 + case workflow.TriggerKindPush: 73 + fetchArgs = append(fetchArgs, tr.Push.NewSha) 74 + case workflow.TriggerKindPullRequest: 75 + fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha) 76 + } 77 + 78 + commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " "))) 79 + } 80 + 81 + // run git checkout 82 + commands = append(commands, "git checkout FETCH_HEAD") 83 + 84 + cloneStep := Step{ 85 + command: strings.Join(commands, "\n"), 86 + name: "Clone repository into workspace", 87 + } 88 + return cloneStep 89 + } 90 + 91 + // dependencyStep processes dependencies defined in the workflow. 92 + // For dependencies using a custom registry (i.e. not nixpkgs), it collects 93 + // all packages and adds a single 'nix profile install' step to the 94 + // beginning of the workflow's step list. 95 + func dependencyStep(deps map[string][]string) *Step { 96 + var customPackages []string 97 + 98 + for registry, packages := range deps { 99 + if registry == "nixpkgs" { 100 + continue 101 + } 102 + 103 + if len(packages) == 0 { 104 + customPackages = append(customPackages, registry) 105 + } 106 + // collect packages from custom registries 107 + for _, pkg := range packages { 108 + customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg)) 109 + } 110 + } 111 + 112 + if len(customPackages) > 0 { 113 + installCmd := "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install" 114 + cmd := fmt.Sprintf("%s %s", installCmd, strings.Join(customPackages, " ")) 115 + installStep := Step{ 116 + command: cmd, 117 + name: "Install custom dependencies", 118 + environment: map[string]string{ 119 + "NIX_NO_COLOR": "1", 120 + "NIX_SHOW_DOWNLOAD_PROGRESS": "0", 121 + }, 122 + } 123 + return &installStep 124 + } 125 + return nil 126 + }
+66 -11
spindle/ingester.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 7 - "path/filepath" 8 + "time" 8 9 9 10 "tangled.sh/tangled.sh/core/api/tangled" 10 11 "tangled.sh/tangled.sh/core/eventconsumer" 11 12 "tangled.sh/tangled.sh/core/idresolver" 12 13 "tangled.sh/tangled.sh/core/rbac" 14 + "tangled.sh/tangled.sh/core/spindle/db" 13 15 14 16 comatproto "github.com/bluesky-social/indigo/api/atproto" 15 17 "github.com/bluesky-social/indigo/atproto/identity" ··· 38 40 39 41 switch e.Commit.Collection { 40 42 case tangled.SpindleMemberNSID: 41 - s.ingestMember(ctx, e) 43 + err = s.ingestMember(ctx, e) 42 44 case tangled.RepoNSID: 43 - s.ingestRepo(ctx, e) 45 + err = s.ingestRepo(ctx, e) 44 46 case tangled.RepoCollaboratorNSID: 45 - s.ingestCollaborator(ctx, e) 47 + err = s.ingestCollaborator(ctx, e) 46 48 } 47 49 48 - return err 50 + if err != nil { 51 + s.l.Debug("failed to process message", "nsid", e.Commit.Collection, "err", err) 52 + } 53 + 54 + return nil 49 55 } 50 56 } 51 57 52 58 func (s *Spindle) ingestMember(_ context.Context, e *models.Event) error { 59 + var err error 53 60 did := e.Did 54 - var err error 61 + rkey := e.Commit.RKey 55 62 56 63 l := s.l.With("component", "ingester", "record", tangled.SpindleMemberNSID) 57 64 ··· 66 73 } 67 74 68 75 domain := s.cfg.Server.Hostname 69 - if s.cfg.Server.Dev { 70 - domain = s.cfg.Server.ListenAddr 71 - } 72 76 recordInstance := record.Instance 73 77 74 78 if recordInstance != domain { ··· 82 86 return fmt.Errorf("failed to enforce permissions: %w", err) 83 87 } 84 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 + 85 100 if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil { 86 101 l.Error("failed to add member", "error", err) 87 102 return fmt.Errorf("failed to add member: %w", err) ··· 96 111 97 112 return nil 98 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 + 99 138 } 100 139 return nil 101 140 } 102 141 103 - func (s *Spindle) ingestRepo(_ context.Context, e *models.Event) error { 142 + func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 104 143 var err error 144 + did := e.Did 145 + resolver := idresolver.DefaultResolver() 105 146 106 147 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 107 148 ··· 137 178 return fmt.Errorf("failed to add repo: %w", err) 138 179 } 139 180 181 + didSlashRepo, err := securejoin.SecureJoin(record.Owner, record.Name) 182 + if err != nil { 183 + return err 184 + } 185 + 140 186 // add repo to rbac 141 - if err := s.e.AddRepo(record.Owner, rbac.ThisServer, filepath.Join(record.Owner, record.Name)); err != nil { 187 + if err := s.e.AddRepo(record.Owner, rbac.ThisServer, didSlashRepo); err != nil { 142 188 l.Error("failed to add repo to enforcer", "error", err) 143 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 144 199 } 145 200 146 201 // add this knot to the event consumer
+17
spindle/models/engine.go
··· 1 + package models 2 + 3 + import ( 4 + "context" 5 + "time" 6 + 7 + "tangled.sh/tangled.sh/core/api/tangled" 8 + "tangled.sh/tangled.sh/core/spindle/secrets" 9 + ) 10 + 11 + type Engine interface { 12 + InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*Workflow, error) 13 + SetupWorkflow(ctx context.Context, wid WorkflowId, wf *Workflow) error 14 + WorkflowTimeout() time.Duration 15 + DestroyWorkflow(ctx context.Context, wid WorkflowId) error 16 + RunStep(ctx context.Context, wid WorkflowId, w *Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *WorkflowLogger) error 17 + }
+82
spindle/models/logger.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "io" 7 + "os" 8 + "path/filepath" 9 + "strings" 10 + ) 11 + 12 + type WorkflowLogger struct { 13 + file *os.File 14 + encoder *json.Encoder 15 + } 16 + 17 + func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) { 18 + path := LogFilePath(baseDir, wid) 19 + 20 + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) 21 + if err != nil { 22 + return nil, fmt.Errorf("creating log file: %w", err) 23 + } 24 + 25 + return &WorkflowLogger{ 26 + file: file, 27 + encoder: json.NewEncoder(file), 28 + }, nil 29 + } 30 + 31 + func LogFilePath(baseDir string, workflowID WorkflowId) string { 32 + logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String())) 33 + return logFilePath 34 + } 35 + 36 + func (l *WorkflowLogger) Close() error { 37 + return l.file.Close() 38 + } 39 + 40 + func (l *WorkflowLogger) DataWriter(stream string) io.Writer { 41 + // TODO: emit stream 42 + return &dataWriter{ 43 + logger: l, 44 + stream: stream, 45 + } 46 + } 47 + 48 + func (l *WorkflowLogger) ControlWriter(idx int, step Step) io.Writer { 49 + return &controlWriter{ 50 + logger: l, 51 + idx: idx, 52 + step: step, 53 + } 54 + } 55 + 56 + type dataWriter struct { 57 + logger *WorkflowLogger 58 + stream string 59 + } 60 + 61 + func (w *dataWriter) Write(p []byte) (int, error) { 62 + line := strings.TrimRight(string(p), "\r\n") 63 + entry := NewDataLogLine(line, w.stream) 64 + if err := w.logger.encoder.Encode(entry); err != nil { 65 + return 0, err 66 + } 67 + return len(p), nil 68 + } 69 + 70 + type controlWriter struct { 71 + logger *WorkflowLogger 72 + idx int 73 + step Step 74 + } 75 + 76 + func (w *controlWriter) Write(_ []byte) (int, error) { 77 + entry := NewControlLogLine(w.idx, w.step) 78 + if err := w.logger.encoder.Encode(entry); err != nil { 79 + return 0, err 80 + } 81 + return len(w.step.Name()), nil 82 + }
+3 -3
spindle/models/models.go
··· 104 104 func NewControlLogLine(idx int, step Step) LogLine { 105 105 return LogLine{ 106 106 Kind: LogKindControl, 107 - Content: step.Name, 107 + Content: step.Name(), 108 108 StepId: idx, 109 - StepKind: step.Kind, 110 - StepCommand: step.Command, 109 + StepKind: step.Kind(), 110 + StepCommand: step.Command(), 111 111 } 112 112 }
+8 -103
spindle/models/pipeline.go
··· 1 1 package models 2 2 3 - import ( 4 - "path" 5 - 6 - "tangled.sh/tangled.sh/core/api/tangled" 7 - "tangled.sh/tangled.sh/core/spindle/config" 8 - ) 9 - 10 3 type Pipeline struct { 11 4 RepoOwner string 12 5 RepoName string 13 - Workflows []Workflow 6 + Workflows map[Engine][]Workflow 14 7 } 15 8 16 - type Step struct { 17 - Command string 18 - Name string 19 - Environment map[string]string 20 - Kind StepKind 9 + type Step interface { 10 + Name() string 11 + Command() string 12 + Kind() StepKind 21 13 } 22 14 23 15 type StepKind int ··· 30 22 ) 31 23 32 24 type Workflow struct { 33 - Steps []Step 34 - Environment map[string]string 35 - Name string 36 - Image string 37 - } 38 - 39 - // setupSteps get added to start of Steps 40 - type setupSteps []Step 41 - 42 - // addStep adds a step to the beginning of the workflow's steps. 43 - func (ss *setupSteps) addStep(step Step) { 44 - *ss = append(*ss, step) 45 - } 46 - 47 - // ToPipeline converts a tangled.Pipeline into a model.Pipeline. 48 - // In the process, dependencies are resolved: nixpkgs deps 49 - // are constructed atop nixery and set as the Workflow.Image, 50 - // and ones from custom registries 51 - func ToPipeline(pl tangled.Pipeline, cfg config.Config) *Pipeline { 52 - workflows := []Workflow{} 53 - 54 - for _, twf := range pl.Workflows { 55 - swf := &Workflow{} 56 - for _, tstep := range twf.Steps { 57 - sstep := Step{} 58 - sstep.Environment = stepEnvToMap(tstep.Environment) 59 - sstep.Command = tstep.Command 60 - sstep.Name = tstep.Name 61 - sstep.Kind = StepKindUser 62 - swf.Steps = append(swf.Steps, sstep) 63 - } 64 - swf.Name = twf.Name 65 - swf.Environment = workflowEnvToMap(twf.Environment) 66 - swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery) 67 - 68 - setup := &setupSteps{} 69 - 70 - setup.addStep(nixConfStep()) 71 - setup.addStep(cloneStep(*twf, *pl.TriggerMetadata, cfg.Server.Dev)) 72 - // this step could be empty 73 - if s := dependencyStep(*twf); s != nil { 74 - setup.addStep(*s) 75 - } 76 - 77 - // append setup steps in order to the start of workflow steps 78 - swf.Steps = append(*setup, swf.Steps...) 79 - 80 - workflows = append(workflows, *swf) 81 - } 82 - repoOwner := pl.TriggerMetadata.Repo.Did 83 - repoName := pl.TriggerMetadata.Repo.Repo 84 - return &Pipeline{ 85 - RepoOwner: repoOwner, 86 - RepoName: repoName, 87 - Workflows: workflows, 88 - } 89 - } 90 - 91 - func workflowEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string { 92 - envMap := map[string]string{} 93 - for _, env := range envs { 94 - if env != nil { 95 - envMap[env.Key] = env.Value 96 - } 97 - } 98 - return envMap 99 - } 100 - 101 - func stepEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string { 102 - envMap := map[string]string{} 103 - for _, env := range envs { 104 - if env != nil { 105 - envMap[env.Key] = env.Value 106 - } 107 - } 108 - return envMap 109 - } 110 - 111 - func workflowImage(deps []*tangled.Pipeline_Dependency, nixery string) string { 112 - var dependencies string 113 - for _, d := range deps { 114 - if d.Registry == "nixpkgs" { 115 - dependencies = path.Join(d.Packages...) 116 - } 117 - } 118 - 119 - // load defaults from somewhere else 120 - dependencies = path.Join(dependencies, "bash", "git", "coreutils", "nix") 121 - 122 - return path.Join(nixery, dependencies) 25 + Steps []Step 26 + Name string 27 + Data any 123 28 }
-128
spindle/models/setup_steps.go
··· 1 - package models 2 - 3 - import ( 4 - "fmt" 5 - "path" 6 - "strings" 7 - 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 - "tangled.sh/tangled.sh/core/workflow" 10 - ) 11 - 12 - func nixConfStep() Step { 13 - setupCmd := `echo 'extra-experimental-features = nix-command flakes' >> /etc/nix/nix.conf 14 - echo 'build-users-group = ' >> /etc/nix/nix.conf` 15 - return Step{ 16 - Command: setupCmd, 17 - Name: "Configure Nix", 18 - } 19 - } 20 - 21 - // cloneOptsAsSteps processes clone options and adds corresponding steps 22 - // to the beginning of the workflow's step list if cloning is not skipped. 23 - // 24 - // the steps to do here are: 25 - // - git init 26 - // - git remote add origin <url> 27 - // - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha> 28 - // - git checkout FETCH_HEAD 29 - func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step { 30 - if twf.Clone.Skip { 31 - return Step{} 32 - } 33 - 34 - var commands []string 35 - 36 - // initialize git repo in workspace 37 - commands = append(commands, "git init") 38 - 39 - // add repo as git remote 40 - scheme := "https://" 41 - if dev { 42 - scheme = "http://" 43 - tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal") 44 - } 45 - url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo) 46 - commands = append(commands, fmt.Sprintf("git remote add origin %s", url)) 47 - 48 - // run git fetch 49 - { 50 - var fetchArgs []string 51 - 52 - // default clone depth is 1 53 - depth := 1 54 - if twf.Clone.Depth > 1 { 55 - depth = int(twf.Clone.Depth) 56 - } 57 - fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth)) 58 - 59 - // optionally recurse submodules 60 - if twf.Clone.Submodules { 61 - fetchArgs = append(fetchArgs, "--recurse-submodules=yes") 62 - } 63 - 64 - // set remote to fetch from 65 - fetchArgs = append(fetchArgs, "origin") 66 - 67 - // set revision to checkout 68 - switch workflow.TriggerKind(tr.Kind) { 69 - case workflow.TriggerKindManual: 70 - // TODO: unimplemented 71 - case workflow.TriggerKindPush: 72 - fetchArgs = append(fetchArgs, tr.Push.NewSha) 73 - case workflow.TriggerKindPullRequest: 74 - fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha) 75 - } 76 - 77 - commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " "))) 78 - } 79 - 80 - // run git checkout 81 - commands = append(commands, "git checkout FETCH_HEAD") 82 - 83 - cloneStep := Step{ 84 - Command: strings.Join(commands, "\n"), 85 - Name: "Clone repository into workspace", 86 - } 87 - return cloneStep 88 - } 89 - 90 - // dependencyStep processes dependencies defined in the workflow. 91 - // For dependencies using a custom registry (i.e. not nixpkgs), it collects 92 - // all packages and adds a single 'nix profile install' step to the 93 - // beginning of the workflow's step list. 94 - func dependencyStep(twf tangled.Pipeline_Workflow) *Step { 95 - var customPackages []string 96 - 97 - for _, d := range twf.Dependencies { 98 - registry := d.Registry 99 - packages := d.Packages 100 - 101 - if registry == "nixpkgs" { 102 - continue 103 - } 104 - 105 - if len(packages) == 0 { 106 - customPackages = append(customPackages, registry) 107 - } 108 - // collect packages from custom registries 109 - for _, pkg := range packages { 110 - customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg)) 111 - } 112 - } 113 - 114 - if len(customPackages) > 0 { 115 - installCmd := "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install" 116 - cmd := fmt.Sprintf("%s %s", installCmd, strings.Join(customPackages, " ")) 117 - installStep := Step{ 118 - Command: cmd, 119 - Name: "Install custom dependencies", 120 - Environment: map[string]string{ 121 - "NIX_NO_COLOR": "1", 122 - "NIX_SHOW_DOWNLOAD_PROGRESS": "0", 123 - }, 124 - } 125 - return &installStep 126 - } 127 - return nil 128 - }
+1 -1
spindle/secrets/openbao.go
··· 132 132 return ErrKeyNotFound 133 133 } 134 134 135 - err = v.client.KVv2(v.mountPath).Delete(ctx, secretPath) 135 + err = v.client.KVv2(v.mountPath).DeleteMetadata(ctx, secretPath) 136 136 if err != nil { 137 137 return fmt.Errorf("failed to delete secret from openbao: %w", err) 138 138 }
+1 -1
spindle/secrets/sqlite.go
··· 24 24 } 25 25 26 26 func NewSQLiteManager(dbPath string, opts ...SqliteManagerOpt) (*SqliteManager, error) { 27 - db, err := sql.Open("sqlite3", dbPath) 27 + db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1") 28 28 if err != nil { 29 29 return nil, fmt.Errorf("failed to open sqlite database: %w", err) 30 30 }
+52 -9
spindle/server.go
··· 20 20 "tangled.sh/tangled.sh/core/spindle/config" 21 21 "tangled.sh/tangled.sh/core/spindle/db" 22 22 "tangled.sh/tangled.sh/core/spindle/engine" 23 + "tangled.sh/tangled.sh/core/spindle/engines/nixery" 23 24 "tangled.sh/tangled.sh/core/spindle/models" 24 25 "tangled.sh/tangled.sh/core/spindle/queue" 25 26 "tangled.sh/tangled.sh/core/spindle/secrets" ··· 39 40 e *rbac.Enforcer 40 41 l *slog.Logger 41 42 n *notifier.Notifier 42 - eng *engine.Engine 43 + engs map[string]models.Engine 43 44 jq *queue.Queue 44 45 cfg *config.Config 45 46 ks *eventconsumer.Consumer ··· 93 94 return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 94 95 } 95 96 96 - eng, err := engine.New(ctx, cfg, d, &n, vault) 97 + nixeryEng, err := nixery.New(ctx, cfg) 97 98 if err != nil { 98 99 return err 99 100 } 100 101 101 - jq := queue.NewQueue(100, 2) 102 + jq := queue.NewQueue(100, 5) 102 103 103 104 collections := []string{ 104 105 tangled.SpindleMemberNSID, ··· 110 111 return fmt.Errorf("failed to setup jetstream client: %w", err) 111 112 } 112 113 jc.AddDid(cfg.Server.Owner) 114 + 115 + // Check if the spindle knows about any Dids; 116 + dids, err := d.GetAllDids() 117 + if err != nil { 118 + return fmt.Errorf("failed to get all dids: %w", err) 119 + } 120 + for _, d := range dids { 121 + jc.AddDid(d) 122 + } 113 123 114 124 resolver := idresolver.DefaultResolver() 115 125 ··· 119 129 db: d, 120 130 l: logger, 121 131 n: &n, 122 - eng: eng, 132 + engs: map[string]models.Engine{"nixery": nixeryEng}, 123 133 jq: jq, 124 134 cfg: cfg, 125 135 res: resolver, ··· 207 217 Logger: logger, 208 218 Db: s.db, 209 219 Enforcer: s.e, 210 - Engine: s.eng, 220 + Engines: s.engs, 211 221 Config: s.cfg, 212 222 Resolver: s.res, 213 223 Vault: s.vault, ··· 233 243 return fmt.Errorf("no repo data found") 234 244 } 235 245 246 + if src.Key() != tpl.TriggerMetadata.Repo.Knot { 247 + return fmt.Errorf("repo knot does not match event source: %s != %s", src.Key(), tpl.TriggerMetadata.Repo.Knot) 248 + } 249 + 236 250 // filter by repos 237 251 _, err = s.db.GetRepo( 238 252 tpl.TriggerMetadata.Repo.Knot, ··· 248 262 Rkey: msg.Rkey, 249 263 } 250 264 265 + workflows := make(map[models.Engine][]models.Workflow) 266 + 251 267 for _, w := range tpl.Workflows { 252 268 if w != nil { 253 - err := s.db.StatusPending(models.WorkflowId{ 269 + if _, ok := s.engs[w.Engine]; !ok { 270 + err = s.db.StatusFailed(models.WorkflowId{ 271 + PipelineId: pipelineId, 272 + Name: w.Name, 273 + }, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n) 274 + if err != nil { 275 + return err 276 + } 277 + 278 + continue 279 + } 280 + 281 + eng := s.engs[w.Engine] 282 + 283 + if _, ok := workflows[eng]; !ok { 284 + workflows[eng] = []models.Workflow{} 285 + } 286 + 287 + ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl) 288 + if err != nil { 289 + return err 290 + } 291 + 292 + workflows[eng] = append(workflows[eng], *ewf) 293 + 294 + err = s.db.StatusPending(models.WorkflowId{ 254 295 PipelineId: pipelineId, 255 296 Name: w.Name, 256 297 }, s.n) ··· 260 301 } 261 302 } 262 303 263 - spl := models.ToPipeline(tpl, *s.cfg) 264 - 265 304 ok := s.jq.Enqueue(queue.Job{ 266 305 Run: func() error { 267 - s.eng.StartWorkflows(ctx, spl, pipelineId) 306 + engine.StartWorkflows(s.l, s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 307 + RepoOwner: tpl.TriggerMetadata.Repo.Did, 308 + RepoName: tpl.TriggerMetadata.Repo.Repo, 309 + Workflows: workflows, 310 + }, pipelineId) 268 311 return nil 269 312 }, 270 313 OnFail: func(jobError error) {
+32 -2
spindle/stream.go
··· 6 6 "fmt" 7 7 "io" 8 8 "net/http" 9 + "os" 9 10 "strconv" 10 11 "time" 11 12 12 - "tangled.sh/tangled.sh/core/spindle/engine" 13 13 "tangled.sh/tangled.sh/core/spindle/models" 14 14 15 15 "github.com/go-chi/chi/v5" ··· 143 143 } 144 144 isFinished := models.StatusKind(status.Status).IsFinish() 145 145 146 - filePath := engine.LogFilePath(s.cfg.Pipelines.LogDir, wid) 146 + filePath := models.LogFilePath(s.cfg.Server.LogDir, wid) 147 + 148 + if status.Status == models.StatusKindFailed.String() && status.Error != nil { 149 + if _, err := os.Stat(filePath); os.IsNotExist(err) { 150 + msgs := []models.LogLine{ 151 + { 152 + Kind: models.LogKindControl, 153 + Content: "", 154 + StepId: 0, 155 + StepKind: models.StepKindUser, 156 + }, 157 + { 158 + Kind: models.LogKindData, 159 + Content: *status.Error, 160 + }, 161 + } 162 + 163 + for _, msg := range msgs { 164 + b, err := json.Marshal(msg) 165 + if err != nil { 166 + return err 167 + } 168 + 169 + if err := conn.WriteMessage(websocket.TextMessage, b); err != nil { 170 + return fmt.Errorf("failed to write to websocket: %w", err) 171 + } 172 + } 173 + 174 + return nil 175 + } 176 + } 147 177 148 178 config := tail.Config{ 149 179 Follow: !isFinished,
+2 -2
spindle/xrpc/xrpc.go
··· 17 17 "tangled.sh/tangled.sh/core/rbac" 18 18 "tangled.sh/tangled.sh/core/spindle/config" 19 19 "tangled.sh/tangled.sh/core/spindle/db" 20 - "tangled.sh/tangled.sh/core/spindle/engine" 20 + "tangled.sh/tangled.sh/core/spindle/models" 21 21 "tangled.sh/tangled.sh/core/spindle/secrets" 22 22 ) 23 23 ··· 27 27 Logger *slog.Logger 28 28 Db *db.DB 29 29 Enforcer *rbac.Enforcer 30 - Engine *engine.Engine 30 + Engines map[string]models.Engine 31 31 Config *config.Config 32 32 Resolver *idresolver.Resolver 33 33 Vault secrets.Manager
+1 -3
tailwind.config.js
··· 36 36 css: { 37 37 maxWidth: "none", 38 38 pre: { 39 - backgroundColor: colors.gray[100], 40 - color: colors.black, 41 - "@apply font-normal text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 dark:border": {}, 39 + "@apply font-normal text-black bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 border": {}, 42 40 }, 43 41 code: { 44 42 "@apply font-normal font-mono p-1 rounded text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700": {},
+62 -41
workflow/compile.go
··· 1 1 package workflow 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 6 6 7 "tangled.sh/tangled.sh/core/api/tangled" 7 8 ) 8 9 10 + type RawWorkflow struct { 11 + Name string 12 + Contents []byte 13 + } 14 + 15 + type RawPipeline = []RawWorkflow 16 + 9 17 type Compiler struct { 10 18 Trigger tangled.Pipeline_TriggerMetadata 11 19 Diagnostics Diagnostics 12 20 } 13 21 14 22 type Diagnostics struct { 15 - Errors []error 23 + Errors []Error 16 24 Warnings []Warning 17 25 } 18 26 27 + func (d *Diagnostics) IsEmpty() bool { 28 + return len(d.Errors) == 0 && len(d.Warnings) == 0 29 + } 30 + 19 31 func (d *Diagnostics) Combine(o Diagnostics) { 20 32 d.Errors = append(d.Errors, o.Errors...) 21 33 d.Warnings = append(d.Warnings, o.Warnings...) ··· 25 37 d.Warnings = append(d.Warnings, Warning{path, kind, reason}) 26 38 } 27 39 28 - func (d *Diagnostics) AddError(err error) { 29 - d.Errors = append(d.Errors, err) 40 + func (d *Diagnostics) AddError(path string, err error) { 41 + d.Errors = append(d.Errors, Error{path, err}) 30 42 } 31 43 32 44 func (d Diagnostics) IsErr() bool { 33 45 return len(d.Errors) != 0 34 46 } 35 47 48 + type Error struct { 49 + Path string 50 + Error error 51 + } 52 + 53 + func (e Error) String() string { 54 + return fmt.Sprintf("error: %s: %s", e.Path, e.Error.Error()) 55 + } 56 + 36 57 type Warning struct { 37 58 Path string 38 59 Type WarningKind 39 60 Reason string 40 61 } 41 62 63 + func (w Warning) String() string { 64 + return fmt.Sprintf("warning: %s: %s: %s", w.Path, w.Type, w.Reason) 65 + } 66 + 67 + var ( 68 + MissingEngine error = errors.New("missing engine") 69 + ) 70 + 42 71 type WarningKind string 43 72 44 73 var ( ··· 46 75 InvalidConfiguration WarningKind = "invalid configuration" 47 76 ) 48 77 78 + func (compiler *Compiler) Parse(p RawPipeline) Pipeline { 79 + var pp Pipeline 80 + 81 + for _, w := range p { 82 + wf, err := FromFile(w.Name, w.Contents) 83 + if err != nil { 84 + compiler.Diagnostics.AddError(w.Name, err) 85 + continue 86 + } 87 + 88 + pp = append(pp, wf) 89 + } 90 + 91 + return pp 92 + } 93 + 49 94 // convert a repositories' workflow files into a fully compiled pipeline that runners accept 50 95 func (compiler *Compiler) Compile(p Pipeline) tangled.Pipeline { 51 96 cp := tangled.Pipeline{ 52 97 TriggerMetadata: &compiler.Trigger, 53 98 } 54 99 55 - for _, w := range p { 56 - cw := compiler.compileWorkflow(w) 100 + for _, wf := range p { 101 + cw := compiler.compileWorkflow(wf) 57 102 58 - // empty workflows are not added to the pipeline 59 - if len(cw.Steps) == 0 { 103 + if cw == nil { 60 104 continue 61 105 } 62 106 63 - cp.Workflows = append(cp.Workflows, &cw) 107 + cp.Workflows = append(cp.Workflows, cw) 64 108 } 65 109 66 110 return cp 67 111 } 68 112 69 - func (compiler *Compiler) compileWorkflow(w Workflow) tangled.Pipeline_Workflow { 70 - cw := tangled.Pipeline_Workflow{} 113 + func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow { 114 + cw := &tangled.Pipeline_Workflow{} 71 115 72 116 if !w.Match(compiler.Trigger) { 73 117 compiler.Diagnostics.AddWarning( ··· 75 119 WorkflowSkipped, 76 120 fmt.Sprintf("did not match trigger %s", compiler.Trigger.Kind), 77 121 ) 78 - return cw 79 - } 80 - 81 - if len(w.Steps) == 0 { 82 - compiler.Diagnostics.AddWarning( 83 - w.Name, 84 - WorkflowSkipped, 85 - "empty workflow", 86 - ) 87 - return cw 122 + return nil 88 123 } 89 124 90 125 // validate clone options 91 126 compiler.analyzeCloneOptions(w) 92 127 93 128 cw.Name = w.Name 94 - cw.Dependencies = w.Dependencies.AsRecord() 95 - for _, s := range w.Steps { 96 - step := tangled.Pipeline_Step{ 97 - Command: s.Command, 98 - Name: s.Name, 99 - } 100 - for k, v := range s.Environment { 101 - e := &tangled.Pipeline_Pair{ 102 - Key: k, 103 - Value: v, 104 - } 105 - step.Environment = append(step.Environment, e) 106 - } 107 - cw.Steps = append(cw.Steps, &step) 129 + 130 + if w.Engine == "" { 131 + compiler.Diagnostics.AddError(w.Name, MissingEngine) 132 + return nil 108 133 } 109 - for k, v := range w.Environment { 110 - e := &tangled.Pipeline_Pair{ 111 - Key: k, 112 - Value: v, 113 - } 114 - cw.Environment = append(cw.Environment, e) 115 - } 134 + 135 + cw.Engine = w.Engine 136 + cw.Raw = w.Raw 116 137 117 138 o := w.CloneOpts.AsRecord() 118 139 cw.Clone = &o
+23 -29
workflow/compile_test.go
··· 26 26 27 27 func TestCompileWorkflow_MatchingWorkflowWithSteps(t *testing.T) { 28 28 wf := Workflow{ 29 - Name: ".tangled/workflows/test.yml", 30 - When: when, 31 - Steps: []Step{ 32 - {Name: "Test", Command: "go test ./..."}, 33 - }, 29 + Name: ".tangled/workflows/test.yml", 30 + Engine: "nixery", 31 + When: when, 34 32 CloneOpts: CloneOpts{}, // default true 35 33 } 36 34 ··· 43 41 assert.False(t, c.Diagnostics.IsErr()) 44 42 } 45 43 46 - func TestCompileWorkflow_EmptySteps(t *testing.T) { 47 - wf := Workflow{ 48 - Name: ".tangled/workflows/empty.yml", 49 - When: when, 50 - Steps: []Step{}, // no steps 51 - } 52 - 53 - c := Compiler{Trigger: trigger} 54 - cp := c.Compile([]Workflow{wf}) 55 - 56 - assert.Len(t, cp.Workflows, 0) 57 - assert.Len(t, c.Diagnostics.Warnings, 1) 58 - assert.Equal(t, WorkflowSkipped, c.Diagnostics.Warnings[0].Type) 59 - } 60 - 61 44 func TestCompileWorkflow_TriggerMismatch(t *testing.T) { 62 45 wf := Workflow{ 63 - Name: ".tangled/workflows/mismatch.yml", 46 + Name: ".tangled/workflows/mismatch.yml", 47 + Engine: "nixery", 64 48 When: []Constraint{ 65 49 { 66 50 Event: []string{"push"}, 67 51 Branch: []string{"master"}, // different branch 68 52 }, 69 53 }, 70 - Steps: []Step{ 71 - {Name: "Lint", Command: "golint ./..."}, 72 - }, 73 54 } 74 55 75 56 c := Compiler{Trigger: trigger} ··· 82 63 83 64 func TestCompileWorkflow_CloneFalseWithShallowTrue(t *testing.T) { 84 65 wf := Workflow{ 85 - Name: ".tangled/workflows/clone_skip.yml", 86 - When: when, 87 - Steps: []Step{ 88 - {Name: "Skip", Command: "echo skip"}, 89 - }, 66 + Name: ".tangled/workflows/clone_skip.yml", 67 + Engine: "nixery", 68 + When: when, 90 69 CloneOpts: CloneOpts{ 91 70 Skip: true, 92 71 Depth: 1, ··· 101 80 assert.Len(t, c.Diagnostics.Warnings, 1) 102 81 assert.Equal(t, InvalidConfiguration, c.Diagnostics.Warnings[0].Type) 103 82 } 83 + 84 + func TestCompileWorkflow_MissingEngine(t *testing.T) { 85 + wf := Workflow{ 86 + Name: ".tangled/workflows/missing_engine.yml", 87 + When: when, 88 + Engine: "", 89 + } 90 + 91 + c := Compiler{Trigger: trigger} 92 + cp := c.Compile([]Workflow{wf}) 93 + 94 + assert.Len(t, cp.Workflows, 0) 95 + assert.Len(t, c.Diagnostics.Errors, 1) 96 + assert.Equal(t, MissingEngine, c.Diagnostics.Errors[0].Error) 97 + }
+6 -33
workflow/def.go
··· 24 24 25 25 // this is simply a structural representation of the workflow file 26 26 Workflow struct { 27 - Name string `yaml:"-"` // name of the workflow file 28 - When []Constraint `yaml:"when"` 29 - Dependencies Dependencies `yaml:"dependencies"` 30 - Steps []Step `yaml:"steps"` 31 - Environment map[string]string `yaml:"environment"` 32 - CloneOpts CloneOpts `yaml:"clone"` 27 + Name string `yaml:"-"` // name of the workflow file 28 + Engine string `yaml:"engine"` 29 + When []Constraint `yaml:"when"` 30 + CloneOpts CloneOpts `yaml:"clone"` 31 + Raw string `yaml:"-"` 33 32 } 34 33 35 34 Constraint struct { 36 35 Event StringList `yaml:"event"` 37 36 Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events 38 37 } 39 - 40 - Dependencies map[string][]string 41 38 42 39 CloneOpts struct { 43 40 Skip bool `yaml:"skip"` 44 41 Depth int `yaml:"depth"` 45 42 IncludeSubmodules bool `yaml:"submodules"` 46 - } 47 - 48 - Step struct { 49 - Name string `yaml:"name"` 50 - Command string `yaml:"command"` 51 - Environment map[string]string `yaml:"environment"` 52 43 } 53 44 54 45 StringList []string ··· 77 68 } 78 69 79 70 wf.Name = name 71 + wf.Raw = string(contents) 80 72 81 73 return wf, nil 82 74 } ··· 173 165 } 174 166 175 167 return errors.New("failed to unmarshal StringOrSlice") 176 - } 177 - 178 - // conversion utilities to atproto records 179 - func (d Dependencies) AsRecord() []*tangled.Pipeline_Dependency { 180 - var deps []*tangled.Pipeline_Dependency 181 - for registry, packages := range d { 182 - deps = append(deps, &tangled.Pipeline_Dependency{ 183 - Registry: registry, 184 - Packages: packages, 185 - }) 186 - } 187 - return deps 188 - } 189 - 190 - func (s Step) AsRecord() tangled.Pipeline_Step { 191 - return tangled.Pipeline_Step{ 192 - Command: s.Command, 193 - Name: s.Name, 194 - } 195 168 } 196 169 197 170 func (c CloneOpts) AsRecord() tangled.Pipeline_CloneOpts {
+1 -86
workflow/def_test.go
··· 10 10 yamlData := ` 11 11 when: 12 12 - event: ["push", "pull_request"] 13 - branch: ["main", "develop"] 14 - 15 - dependencies: 16 - nixpkgs: 17 - - go 18 - - git 19 - - curl 20 - 21 - steps: 22 - - name: "Test" 23 - command: | 24 - go test ./...` 13 + branch: ["main", "develop"]` 25 14 26 15 wf, err := FromFile("test.yml", []byte(yamlData)) 27 16 assert.NoError(t, err, "YAML should unmarshal without error") ··· 30 19 assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch) 31 20 assert.ElementsMatch(t, []string{"push", "pull_request"}, wf.When[0].Event) 32 21 33 - assert.Len(t, wf.Steps, 1) 34 - assert.Equal(t, "Test", wf.Steps[0].Name) 35 - assert.Equal(t, "go test ./...", wf.Steps[0].Command) 36 - 37 - pkgs, ok := wf.Dependencies["nixpkgs"] 38 - assert.True(t, ok, "`nixpkgs` should be present in dependencies") 39 - assert.ElementsMatch(t, []string{"go", "git", "curl"}, pkgs) 40 - 41 22 assert.False(t, wf.CloneOpts.Skip, "Skip should default to false") 42 23 } 43 24 44 - func TestUnmarshalCustomRegistry(t *testing.T) { 45 - yamlData := ` 46 - when: 47 - - event: push 48 - branch: main 49 - 50 - dependencies: 51 - git+https://tangled.sh/@oppi.li/tbsp: 52 - - tbsp 53 - git+https://git.peppe.rs/languages/statix: 54 - - statix 55 - 56 - steps: 57 - - name: "Check" 58 - command: | 59 - statix check` 60 - 61 - wf, err := FromFile("test.yml", []byte(yamlData)) 62 - assert.NoError(t, err, "YAML should unmarshal without error") 63 - 64 - assert.ElementsMatch(t, []string{"push"}, wf.When[0].Event) 65 - assert.ElementsMatch(t, []string{"main"}, wf.When[0].Branch) 66 - 67 - assert.ElementsMatch(t, []string{"tbsp"}, wf.Dependencies["git+https://tangled.sh/@oppi.li/tbsp"]) 68 - assert.ElementsMatch(t, []string{"statix"}, wf.Dependencies["git+https://git.peppe.rs/languages/statix"]) 69 - } 70 - 71 25 func TestUnmarshalCloneFalse(t *testing.T) { 72 26 yamlData := ` 73 27 when: ··· 75 29 76 30 clone: 77 31 skip: true 78 - 79 - dependencies: 80 - nixpkgs: 81 - - python3 82 - 83 - steps: 84 - - name: Notify 85 - command: | 86 - python3 ./notify.py 87 32 ` 88 33 89 34 wf, err := FromFile("test.yml", []byte(yamlData)) ··· 93 38 94 39 assert.True(t, wf.CloneOpts.Skip, "Skip should be false") 95 40 } 96 - 97 - func TestUnmarshalEnv(t *testing.T) { 98 - yamlData := ` 99 - when: 100 - - event: ["pull_request_close"] 101 - 102 - clone: 103 - skip: false 104 - 105 - environment: 106 - HOME: /home/foo bar/baz 107 - CGO_ENABLED: 1 108 - 109 - steps: 110 - - name: Something 111 - command: echo "hello" 112 - environment: 113 - FOO: bar 114 - BAZ: qux 115 - ` 116 - 117 - wf, err := FromFile("test.yml", []byte(yamlData)) 118 - assert.NoError(t, err) 119 - 120 - assert.Len(t, wf.Environment, 2) 121 - assert.Equal(t, "/home/foo bar/baz", wf.Environment["HOME"]) 122 - assert.Equal(t, "1", wf.Environment["CGO_ENABLED"]) 123 - assert.Equal(t, "bar", wf.Steps[0].Environment["FOO"]) 124 - assert.Equal(t, "qux", wf.Steps[0].Environment["BAZ"]) 125 - }