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

Compare changes

Choose any two refs to compare.

Changed files
+9313 -7133
.air
.tangled
.zed
api
appview
cache
session
config
db
issues
knots
middleware
oauth
pages
pulls
repo
reporesolver
serververify
settings
spindles
spindleverify
state
strings
xrpcclient
cmd
genjwks
punchcardPopulate
docs
eventconsumer
cursor
jetstream
knotclient
knotserver
lexicons
log
nix
rbac
spindle
workflow
xrpc
errors
serviceauth
+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"]
+3
.gitignore
··· 15 15 .env 16 16 *.rdb 17 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 - }
+183 -600
api/tangled/cbor_gen.go
··· 2141 2141 2142 2142 return nil 2143 2143 } 2144 + func (t *Knot) MarshalCBOR(w io.Writer) error { 2145 + if t == nil { 2146 + _, err := w.Write(cbg.CborNull) 2147 + return err 2148 + } 2149 + 2150 + cw := cbg.NewCborWriter(w) 2151 + 2152 + if _, err := cw.Write([]byte{162}); err != nil { 2153 + return err 2154 + } 2155 + 2156 + // t.LexiconTypeID (string) (string) 2157 + if len("$type") > 1000000 { 2158 + return xerrors.Errorf("Value in field \"$type\" was too long") 2159 + } 2160 + 2161 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 2162 + return err 2163 + } 2164 + if _, err := cw.WriteString(string("$type")); err != nil { 2165 + return err 2166 + } 2167 + 2168 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.knot"))); err != nil { 2169 + return err 2170 + } 2171 + if _, err := cw.WriteString(string("sh.tangled.knot")); err != nil { 2172 + return err 2173 + } 2174 + 2175 + // t.CreatedAt (string) (string) 2176 + if len("createdAt") > 1000000 { 2177 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 2178 + } 2179 + 2180 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 2181 + return err 2182 + } 2183 + if _, err := cw.WriteString(string("createdAt")); err != nil { 2184 + return err 2185 + } 2186 + 2187 + if len(t.CreatedAt) > 1000000 { 2188 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 2189 + } 2190 + 2191 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 2192 + return err 2193 + } 2194 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 2195 + return err 2196 + } 2197 + return nil 2198 + } 2199 + 2200 + func (t *Knot) UnmarshalCBOR(r io.Reader) (err error) { 2201 + *t = Knot{} 2202 + 2203 + cr := cbg.NewCborReader(r) 2204 + 2205 + maj, extra, err := cr.ReadHeader() 2206 + if err != nil { 2207 + return err 2208 + } 2209 + defer func() { 2210 + if err == io.EOF { 2211 + err = io.ErrUnexpectedEOF 2212 + } 2213 + }() 2214 + 2215 + if maj != cbg.MajMap { 2216 + return fmt.Errorf("cbor input should be of type map") 2217 + } 2218 + 2219 + if extra > cbg.MaxLength { 2220 + return fmt.Errorf("Knot: map struct too large (%d)", extra) 2221 + } 2222 + 2223 + n := extra 2224 + 2225 + nameBuf := make([]byte, 9) 2226 + for i := uint64(0); i < n; i++ { 2227 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 2228 + if err != nil { 2229 + return err 2230 + } 2231 + 2232 + if !ok { 2233 + // Field doesn't exist on this type, so ignore it 2234 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 2235 + return err 2236 + } 2237 + continue 2238 + } 2239 + 2240 + switch string(nameBuf[:nameLen]) { 2241 + // t.LexiconTypeID (string) (string) 2242 + case "$type": 2243 + 2244 + { 2245 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 2246 + if err != nil { 2247 + return err 2248 + } 2249 + 2250 + t.LexiconTypeID = string(sval) 2251 + } 2252 + // t.CreatedAt (string) (string) 2253 + case "createdAt": 2254 + 2255 + { 2256 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 2257 + if err != nil { 2258 + return err 2259 + } 2260 + 2261 + t.CreatedAt = string(sval) 2262 + } 2263 + 2264 + default: 2265 + // Field doesn't exist on this type, so ignore it 2266 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 2267 + return err 2268 + } 2269 + } 2270 + } 2271 + 2272 + return nil 2273 + } 2144 2274 func (t *KnotMember) MarshalCBOR(w io.Writer) error { 2145 2275 if t == nil { 2146 2276 _, err := w.Write(cbg.CborNull) ··· 2716 2846 t.Submodules = true 2717 2847 default: 2718 2848 return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra) 2719 - } 2720 - 2721 - default: 2722 - // Field doesn't exist on this type, so ignore it 2723 - if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 2724 - return err 2725 - } 2726 - } 2727 - } 2728 - 2729 - return nil 2730 - } 2731 - func (t *Pipeline_Dependency) MarshalCBOR(w io.Writer) error { 2732 - if t == nil { 2733 - _, err := w.Write(cbg.CborNull) 2734 - return err 2735 - } 2736 - 2737 - cw := cbg.NewCborWriter(w) 2738 - 2739 - if _, err := cw.Write([]byte{162}); err != nil { 2740 - return err 2741 - } 2742 - 2743 - // t.Packages ([]string) (slice) 2744 - if len("packages") > 1000000 { 2745 - return xerrors.Errorf("Value in field \"packages\" was too long") 2746 - } 2747 - 2748 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("packages"))); err != nil { 2749 - return err 2750 - } 2751 - if _, err := cw.WriteString(string("packages")); err != nil { 2752 - return err 2753 - } 2754 - 2755 - if len(t.Packages) > 8192 { 2756 - return xerrors.Errorf("Slice value in field t.Packages was too long") 2757 - } 2758 - 2759 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Packages))); err != nil { 2760 - return err 2761 - } 2762 - for _, v := range t.Packages { 2763 - if len(v) > 1000000 { 2764 - return xerrors.Errorf("Value in field v was too long") 2765 - } 2766 - 2767 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 2768 - return err 2769 - } 2770 - if _, err := cw.WriteString(string(v)); err != nil { 2771 - return err 2772 - } 2773 - 2774 - } 2775 - 2776 - // t.Registry (string) (string) 2777 - if len("registry") > 1000000 { 2778 - return xerrors.Errorf("Value in field \"registry\" was too long") 2779 - } 2780 - 2781 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("registry"))); err != nil { 2782 - return err 2783 - } 2784 - if _, err := cw.WriteString(string("registry")); err != nil { 2785 - return err 2786 - } 2787 - 2788 - if len(t.Registry) > 1000000 { 2789 - return xerrors.Errorf("Value in field t.Registry was too long") 2790 - } 2791 - 2792 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Registry))); err != nil { 2793 - return err 2794 - } 2795 - if _, err := cw.WriteString(string(t.Registry)); err != nil { 2796 - return err 2797 - } 2798 - return nil 2799 - } 2800 - 2801 - func (t *Pipeline_Dependency) UnmarshalCBOR(r io.Reader) (err error) { 2802 - *t = Pipeline_Dependency{} 2803 - 2804 - cr := cbg.NewCborReader(r) 2805 - 2806 - maj, extra, err := cr.ReadHeader() 2807 - if err != nil { 2808 - return err 2809 - } 2810 - defer func() { 2811 - if err == io.EOF { 2812 - err = io.ErrUnexpectedEOF 2813 - } 2814 - }() 2815 - 2816 - if maj != cbg.MajMap { 2817 - return fmt.Errorf("cbor input should be of type map") 2818 - } 2819 - 2820 - if extra > cbg.MaxLength { 2821 - return fmt.Errorf("Pipeline_Dependency: map struct too large (%d)", extra) 2822 - } 2823 - 2824 - n := extra 2825 - 2826 - nameBuf := make([]byte, 8) 2827 - for i := uint64(0); i < n; i++ { 2828 - nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 2829 - if err != nil { 2830 - return err 2831 - } 2832 - 2833 - if !ok { 2834 - // Field doesn't exist on this type, so ignore it 2835 - if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 2836 - return err 2837 - } 2838 - continue 2839 - } 2840 - 2841 - switch string(nameBuf[:nameLen]) { 2842 - // t.Packages ([]string) (slice) 2843 - case "packages": 2844 - 2845 - maj, extra, err = cr.ReadHeader() 2846 - if err != nil { 2847 - return err 2848 - } 2849 - 2850 - if extra > 8192 { 2851 - return fmt.Errorf("t.Packages: array too large (%d)", extra) 2852 - } 2853 - 2854 - if maj != cbg.MajArray { 2855 - return fmt.Errorf("expected cbor array") 2856 - } 2857 - 2858 - if extra > 0 { 2859 - t.Packages = make([]string, extra) 2860 - } 2861 - 2862 - for i := 0; i < int(extra); i++ { 2863 - { 2864 - var maj byte 2865 - var extra uint64 2866 - var err error 2867 - _ = maj 2868 - _ = extra 2869 - _ = err 2870 - 2871 - { 2872 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 2873 - if err != nil { 2874 - return err 2875 - } 2876 - 2877 - t.Packages[i] = string(sval) 2878 - } 2879 - 2880 - } 2881 - } 2882 - // t.Registry (string) (string) 2883 - case "registry": 2884 - 2885 - { 2886 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 2887 - if err != nil { 2888 - return err 2889 - } 2890 - 2891 - t.Registry = string(sval) 2892 2849 } 2893 2850 2894 2851 default: ··· 3916 3873 3917 3874 return nil 3918 3875 } 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 3876 func (t *Pipeline_TriggerMetadata) MarshalCBOR(w io.Writer) error { 4137 3877 if t == nil { 4138 3878 _, err := w.Write(cbg.CborNull) ··· 4609 4349 4610 4350 cw := cbg.NewCborWriter(w) 4611 4351 4612 - if _, err := cw.Write([]byte{165}); err != nil { 4352 + if _, err := cw.Write([]byte{164}); err != nil { 4353 + return err 4354 + } 4355 + 4356 + // t.Raw (string) (string) 4357 + if len("raw") > 1000000 { 4358 + return xerrors.Errorf("Value in field \"raw\" was too long") 4359 + } 4360 + 4361 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("raw"))); err != nil { 4362 + return err 4363 + } 4364 + if _, err := cw.WriteString(string("raw")); err != nil { 4365 + return err 4366 + } 4367 + 4368 + if len(t.Raw) > 1000000 { 4369 + return xerrors.Errorf("Value in field t.Raw was too long") 4370 + } 4371 + 4372 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Raw))); err != nil { 4373 + return err 4374 + } 4375 + if _, err := cw.WriteString(string(t.Raw)); err != nil { 4613 4376 return err 4614 4377 } 4615 4378 ··· 4652 4415 return err 4653 4416 } 4654 4417 4655 - // t.Steps ([]*tangled.Pipeline_Step) (slice) 4656 - if len("steps") > 1000000 { 4657 - return xerrors.Errorf("Value in field \"steps\" was too long") 4418 + // t.Engine (string) (string) 4419 + if len("engine") > 1000000 { 4420 + return xerrors.Errorf("Value in field \"engine\" was too long") 4658 4421 } 4659 4422 4660 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("steps"))); err != nil { 4661 - return err 4662 - } 4663 - if _, err := cw.WriteString(string("steps")); err != nil { 4423 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("engine"))); err != nil { 4664 4424 return err 4665 4425 } 4666 - 4667 - if len(t.Steps) > 8192 { 4668 - return xerrors.Errorf("Slice value in field t.Steps was too long") 4669 - } 4670 - 4671 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Steps))); err != nil { 4672 - return err 4673 - } 4674 - for _, v := range t.Steps { 4675 - if err := v.MarshalCBOR(cw); err != nil { 4676 - return err 4677 - } 4678 - 4679 - } 4680 - 4681 - // t.Environment ([]*tangled.Pipeline_Pair) (slice) 4682 - if len("environment") > 1000000 { 4683 - return xerrors.Errorf("Value in field \"environment\" was too long") 4684 - } 4685 - 4686 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("environment"))); err != nil { 4687 - return err 4688 - } 4689 - if _, err := cw.WriteString(string("environment")); err != nil { 4690 - return err 4691 - } 4692 - 4693 - if len(t.Environment) > 8192 { 4694 - return xerrors.Errorf("Slice value in field t.Environment was too long") 4695 - } 4696 - 4697 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Environment))); err != nil { 4426 + if _, err := cw.WriteString(string("engine")); err != nil { 4698 4427 return err 4699 4428 } 4700 - for _, v := range t.Environment { 4701 - if err := v.MarshalCBOR(cw); err != nil { 4702 - return err 4703 - } 4704 4429 4430 + if len(t.Engine) > 1000000 { 4431 + return xerrors.Errorf("Value in field t.Engine was too long") 4705 4432 } 4706 4433 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 { 4434 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Engine))); err != nil { 4713 4435 return err 4714 4436 } 4715 - if _, err := cw.WriteString(string("dependencies")); err != nil { 4437 + if _, err := cw.WriteString(string(t.Engine)); err != nil { 4716 4438 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 4439 } 4732 4440 return nil 4733 4441 } ··· 4757 4465 4758 4466 n := extra 4759 4467 4760 - nameBuf := make([]byte, 12) 4468 + nameBuf := make([]byte, 6) 4761 4469 for i := uint64(0); i < n; i++ { 4762 4470 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 4763 4471 if err != nil { ··· 4773 4481 } 4774 4482 4775 4483 switch string(nameBuf[:nameLen]) { 4776 - // t.Name (string) (string) 4484 + // t.Raw (string) (string) 4485 + case "raw": 4486 + 4487 + { 4488 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4489 + if err != nil { 4490 + return err 4491 + } 4492 + 4493 + t.Raw = string(sval) 4494 + } 4495 + // t.Name (string) (string) 4777 4496 case "name": 4778 4497 4779 4498 { ··· 4804 4523 } 4805 4524 4806 4525 } 4807 - // t.Steps ([]*tangled.Pipeline_Step) (slice) 4808 - case "steps": 4526 + // t.Engine (string) (string) 4527 + case "engine": 4809 4528 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 - 4529 + { 4530 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4531 + if err != nil { 4532 + return err 4854 4533 } 4855 - } 4856 - // t.Environment ([]*tangled.Pipeline_Pair) (slice) 4857 - case "environment": 4858 4534 4859 - maj, extra, err = cr.ReadHeader() 4860 - if err != nil { 4861 - return err 4862 - } 4863 - 4864 - if extra > 8192 { 4865 - return fmt.Errorf("t.Environment: array too large (%d)", extra) 4866 - } 4867 - 4868 - if maj != cbg.MajArray { 4869 - return fmt.Errorf("expected cbor array") 4870 - } 4871 - 4872 - if extra > 0 { 4873 - t.Environment = make([]*Pipeline_Pair, extra) 4874 - } 4875 - 4876 - for i := 0; i < int(extra); i++ { 4877 - { 4878 - var maj byte 4879 - var extra uint64 4880 - var err error 4881 - _ = maj 4882 - _ = extra 4883 - _ = err 4884 - 4885 - { 4886 - 4887 - b, err := cr.ReadByte() 4888 - if err != nil { 4889 - return err 4890 - } 4891 - if b != cbg.CborNull[0] { 4892 - if err := cr.UnreadByte(); err != nil { 4893 - return err 4894 - } 4895 - t.Environment[i] = new(Pipeline_Pair) 4896 - if err := t.Environment[i].UnmarshalCBOR(cr); err != nil { 4897 - return xerrors.Errorf("unmarshaling t.Environment[i] pointer: %w", err) 4898 - } 4899 - } 4900 - 4901 - } 4902 - 4903 - } 4904 - } 4905 - // t.Dependencies ([]*tangled.Pipeline_Dependency) (slice) 4906 - case "dependencies": 4907 - 4908 - maj, extra, err = cr.ReadHeader() 4909 - if err != nil { 4910 - return err 4911 - } 4912 - 4913 - if extra > 8192 { 4914 - return fmt.Errorf("t.Dependencies: array too large (%d)", extra) 4915 - } 4916 - 4917 - if maj != cbg.MajArray { 4918 - return fmt.Errorf("expected cbor array") 4919 - } 4920 - 4921 - if extra > 0 { 4922 - t.Dependencies = make([]*Pipeline_Dependency, extra) 4923 - } 4924 - 4925 - for i := 0; i < int(extra); i++ { 4926 - { 4927 - var maj byte 4928 - var extra uint64 4929 - var err error 4930 - _ = maj 4931 - _ = extra 4932 - _ = err 4933 - 4934 - { 4935 - 4936 - b, err := cr.ReadByte() 4937 - if err != nil { 4938 - return err 4939 - } 4940 - if b != cbg.CborNull[0] { 4941 - if err := cr.UnreadByte(); err != nil { 4942 - return err 4943 - } 4944 - t.Dependencies[i] = new(Pipeline_Dependency) 4945 - if err := t.Dependencies[i].UnmarshalCBOR(cr); err != nil { 4946 - return xerrors.Errorf("unmarshaling t.Dependencies[i] pointer: %w", err) 4947 - } 4948 - } 4949 - 4950 - } 4951 - 4952 - } 4535 + t.Engine = string(sval) 4953 4536 } 4954 4537 4955 4538 default:
+34
api/tangled/repocreate.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.create 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoCreateNSID = "sh.tangled.repo.create" 15 + ) 16 + 17 + // RepoCreate_Input is the input argument to a sh.tangled.repo.create call. 18 + type RepoCreate_Input struct { 19 + // defaultBranch: Default branch to push to 20 + DefaultBranch *string `json:"defaultBranch,omitempty" cborgen:"defaultBranch,omitempty"` 21 + // rkey: Rkey of the repository record 22 + Rkey string `json:"rkey" cborgen:"rkey"` 23 + // source: A source URL to clone from, populate this when forking or importing a repository. 24 + Source *string `json:"source,omitempty" cborgen:"source,omitempty"` 25 + } 26 + 27 + // RepoCreate calls the XRPC method "sh.tangled.repo.create". 28 + func RepoCreate(ctx context.Context, c util.LexClient, input *RepoCreate_Input) error { 29 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.create", nil, input, nil); err != nil { 30 + return err 31 + } 32 + 33 + return nil 34 + }
+34
api/tangled/repodelete.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.delete 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoDeleteNSID = "sh.tangled.repo.delete" 15 + ) 16 + 17 + // RepoDelete_Input is the input argument to a sh.tangled.repo.delete call. 18 + type RepoDelete_Input struct { 19 + // did: DID of the repository owner 20 + Did string `json:"did" cborgen:"did"` 21 + // name: Name of the repository to delete 22 + Name string `json:"name" cborgen:"name"` 23 + // rkey: Rkey of the repository record 24 + Rkey string `json:"rkey" cborgen:"rkey"` 25 + } 26 + 27 + // RepoDelete calls the XRPC method "sh.tangled.repo.delete". 28 + func RepoDelete(ctx context.Context, c util.LexClient, input *RepoDelete_Input) error { 29 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.delete", nil, input, nil); err != nil { 30 + return err 31 + } 32 + 33 + return nil 34 + }
+45
api/tangled/repoforkStatus.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.forkStatus 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoForkStatusNSID = "sh.tangled.repo.forkStatus" 15 + ) 16 + 17 + // RepoForkStatus_Input is the input argument to a sh.tangled.repo.forkStatus call. 18 + type RepoForkStatus_Input struct { 19 + // branch: Branch to check status for 20 + Branch string `json:"branch" cborgen:"branch"` 21 + // did: DID of the fork owner 22 + Did string `json:"did" cborgen:"did"` 23 + // hiddenRef: Hidden ref to use for comparison 24 + HiddenRef string `json:"hiddenRef" cborgen:"hiddenRef"` 25 + // name: Name of the forked repository 26 + Name string `json:"name" cborgen:"name"` 27 + // source: Source repository URL 28 + Source string `json:"source" cborgen:"source"` 29 + } 30 + 31 + // RepoForkStatus_Output is the output of a sh.tangled.repo.forkStatus call. 32 + type RepoForkStatus_Output struct { 33 + // status: Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch 34 + Status int64 `json:"status" cborgen:"status"` 35 + } 36 + 37 + // RepoForkStatus calls the XRPC method "sh.tangled.repo.forkStatus". 38 + func RepoForkStatus(ctx context.Context, c util.LexClient, input *RepoForkStatus_Input) (*RepoForkStatus_Output, error) { 39 + var out RepoForkStatus_Output 40 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.forkStatus", nil, input, &out); err != nil { 41 + return nil, err 42 + } 43 + 44 + return &out, nil 45 + }
+36
api/tangled/repoforkSync.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.forkSync 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoForkSyncNSID = "sh.tangled.repo.forkSync" 15 + ) 16 + 17 + // RepoForkSync_Input is the input argument to a sh.tangled.repo.forkSync call. 18 + type RepoForkSync_Input struct { 19 + // branch: Branch to sync 20 + Branch string `json:"branch" cborgen:"branch"` 21 + // did: DID of the fork owner 22 + Did string `json:"did" cborgen:"did"` 23 + // name: Name of the forked repository 24 + Name string `json:"name" cborgen:"name"` 25 + // source: AT-URI of the source repository 26 + Source string `json:"source" cborgen:"source"` 27 + } 28 + 29 + // RepoForkSync calls the XRPC method "sh.tangled.repo.forkSync". 30 + func RepoForkSync(ctx context.Context, c util.LexClient, input *RepoForkSync_Input) error { 31 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.forkSync", nil, input, nil); err != nil { 32 + return err 33 + } 34 + 35 + return nil 36 + }
+45
api/tangled/repohiddenRef.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.hiddenRef 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoHiddenRefNSID = "sh.tangled.repo.hiddenRef" 15 + ) 16 + 17 + // RepoHiddenRef_Input is the input argument to a sh.tangled.repo.hiddenRef call. 18 + type RepoHiddenRef_Input struct { 19 + // forkRef: Fork reference name 20 + ForkRef string `json:"forkRef" cborgen:"forkRef"` 21 + // remoteRef: Remote reference name 22 + RemoteRef string `json:"remoteRef" cborgen:"remoteRef"` 23 + // repo: AT-URI of the repository 24 + Repo string `json:"repo" cborgen:"repo"` 25 + } 26 + 27 + // RepoHiddenRef_Output is the output of a sh.tangled.repo.hiddenRef call. 28 + type RepoHiddenRef_Output struct { 29 + // error: Error message if creation failed 30 + Error *string `json:"error,omitempty" cborgen:"error,omitempty"` 31 + // ref: The created hidden ref name 32 + Ref *string `json:"ref,omitempty" cborgen:"ref,omitempty"` 33 + // success: Whether the hidden ref was created successfully 34 + Success bool `json:"success" cborgen:"success"` 35 + } 36 + 37 + // RepoHiddenRef calls the XRPC method "sh.tangled.repo.hiddenRef". 38 + func RepoHiddenRef(ctx context.Context, c util.LexClient, input *RepoHiddenRef_Input) (*RepoHiddenRef_Output, error) { 39 + var out RepoHiddenRef_Output 40 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.hiddenRef", nil, input, &out); err != nil { 41 + return nil, err 42 + } 43 + 44 + return &out, nil 45 + }
+44
api/tangled/repomerge.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.merge 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoMergeNSID = "sh.tangled.repo.merge" 15 + ) 16 + 17 + // RepoMerge_Input is the input argument to a sh.tangled.repo.merge call. 18 + type RepoMerge_Input struct { 19 + // authorEmail: Author email for the merge commit 20 + AuthorEmail *string `json:"authorEmail,omitempty" cborgen:"authorEmail,omitempty"` 21 + // authorName: Author name for the merge commit 22 + AuthorName *string `json:"authorName,omitempty" cborgen:"authorName,omitempty"` 23 + // branch: Target branch to merge into 24 + Branch string `json:"branch" cborgen:"branch"` 25 + // commitBody: Additional commit message body 26 + CommitBody *string `json:"commitBody,omitempty" cborgen:"commitBody,omitempty"` 27 + // commitMessage: Merge commit message 28 + CommitMessage *string `json:"commitMessage,omitempty" cborgen:"commitMessage,omitempty"` 29 + // did: DID of the repository owner 30 + Did string `json:"did" cborgen:"did"` 31 + // name: Name of the repository 32 + Name string `json:"name" cborgen:"name"` 33 + // patch: Patch content to merge 34 + Patch string `json:"patch" cborgen:"patch"` 35 + } 36 + 37 + // RepoMerge calls the XRPC method "sh.tangled.repo.merge". 38 + func RepoMerge(ctx context.Context, c util.LexClient, input *RepoMerge_Input) error { 39 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.merge", nil, input, nil); err != nil { 40 + return err 41 + } 42 + 43 + return nil 44 + }
+57
api/tangled/repomergeCheck.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.mergeCheck 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoMergeCheckNSID = "sh.tangled.repo.mergeCheck" 15 + ) 16 + 17 + // RepoMergeCheck_ConflictInfo is a "conflictInfo" in the sh.tangled.repo.mergeCheck schema. 18 + type RepoMergeCheck_ConflictInfo struct { 19 + // filename: Name of the conflicted file 20 + Filename string `json:"filename" cborgen:"filename"` 21 + // reason: Reason for the conflict 22 + Reason string `json:"reason" cborgen:"reason"` 23 + } 24 + 25 + // RepoMergeCheck_Input is the input argument to a sh.tangled.repo.mergeCheck call. 26 + type RepoMergeCheck_Input struct { 27 + // branch: Target branch to merge into 28 + Branch string `json:"branch" cborgen:"branch"` 29 + // did: DID of the repository owner 30 + Did string `json:"did" cborgen:"did"` 31 + // name: Name of the repository 32 + Name string `json:"name" cborgen:"name"` 33 + // patch: Patch or pull request to check for merge conflicts 34 + Patch string `json:"patch" cborgen:"patch"` 35 + } 36 + 37 + // RepoMergeCheck_Output is the output of a sh.tangled.repo.mergeCheck call. 38 + type RepoMergeCheck_Output struct { 39 + // conflicts: List of files with merge conflicts 40 + Conflicts []*RepoMergeCheck_ConflictInfo `json:"conflicts,omitempty" cborgen:"conflicts,omitempty"` 41 + // error: Error message if check failed 42 + Error *string `json:"error,omitempty" cborgen:"error,omitempty"` 43 + // is_conflicted: Whether the merge has conflicts 44 + Is_conflicted bool `json:"is_conflicted" cborgen:"is_conflicted"` 45 + // message: Additional message about the merge check 46 + Message *string `json:"message,omitempty" cborgen:"message,omitempty"` 47 + } 48 + 49 + // RepoMergeCheck calls the XRPC method "sh.tangled.repo.mergeCheck". 50 + func RepoMergeCheck(ctx context.Context, c util.LexClient, input *RepoMergeCheck_Input) (*RepoMergeCheck_Output, error) { 51 + var out RepoMergeCheck_Output 52 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.mergeCheck", nil, input, &out); err != nil { 53 + return nil, err 54 + } 55 + 56 + return &out, nil 57 + }
+22
api/tangled/tangledknot.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.knot 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + KnotNSID = "sh.tangled.knot" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.knot", &Knot{}) 17 + } // 18 + // RECORDTYPE: Knot 19 + type Knot struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.knot" cborgen:"$type,const=sh.tangled.knot"` 21 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 22 + }
+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 }
+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 {
+1 -1
appview/config/config.go
··· 17 17 Dev bool `env:"DEV, default=false"` 18 18 DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` 19 19 20 - // temporarily, to add users to default spindle 20 + // temporarily, to add users to default knot and spindle 21 21 AppPassword string `env:"APP_PASSWORD"` 22 22 } 23 23
+71 -23
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, ··· 462 470 id integer primary key autoincrement, 463 471 name text unique 464 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); 465 477 `) 466 478 if err != nil { 467 479 return nil, err 468 480 } 469 481 470 482 // run migrations 471 - runMigration(db, "add-description-to-repos", func(tx *sql.Tx) error { 483 + runMigration(conn, "add-description-to-repos", func(tx *sql.Tx) error { 472 484 tx.Exec(` 473 485 alter table repos add column description text check (length(description) <= 200); 474 486 `) 475 487 return nil 476 488 }) 477 489 478 - runMigration(db, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 490 + runMigration(conn, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 479 491 // add unconstrained column 480 492 _, err := tx.Exec(` 481 493 alter table public_keys ··· 498 510 return nil 499 511 }) 500 512 501 - runMigration(db, "add-rkey-to-comments", func(tx *sql.Tx) error { 513 + runMigration(conn, "add-rkey-to-comments", func(tx *sql.Tx) error { 502 514 _, err := tx.Exec(` 503 515 alter table comments drop column comment_at; 504 516 alter table comments add column rkey text; ··· 506 518 return err 507 519 }) 508 520 509 - 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 { 510 522 _, err := tx.Exec(` 511 523 alter table comments add column deleted text; -- timestamp 512 524 alter table comments add column edited text; -- timestamp ··· 514 526 return err 515 527 }) 516 528 517 - 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 { 518 530 _, err := tx.Exec(` 519 531 alter table pulls add column source_branch text; 520 532 alter table pulls add column source_repo_at text; ··· 523 535 return err 524 536 }) 525 537 526 - runMigration(db, "add-source-to-repos", func(tx *sql.Tx) error { 538 + runMigration(conn, "add-source-to-repos", func(tx *sql.Tx) error { 527 539 _, err := tx.Exec(` 528 540 alter table repos add column source text; 529 541 `) ··· 534 546 // NOTE: this cannot be done in a transaction, so it is run outside [0] 535 547 // 536 548 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys 537 - db.Exec("pragma foreign_keys = off;") 538 - 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 { 539 551 _, err := tx.Exec(` 540 552 create table pulls_new ( 541 553 -- identifiers ··· 590 602 `) 591 603 return err 592 604 }) 593 - db.Exec("pragma foreign_keys = on;") 605 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 594 606 595 607 // run migrations 596 - runMigration(db, "add-spindle-to-repos", func(tx *sql.Tx) error { 608 + runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error { 597 609 tx.Exec(` 598 610 alter table repos add column spindle text; 599 611 `) 600 612 return nil 601 613 }) 602 614 615 + // drop all knot secrets, add unique constraint to knots 616 + // 617 + // knots will henceforth use service auth for signed requests 618 + runMigration(conn, "no-more-secrets", func(tx *sql.Tx) error { 619 + _, err := tx.Exec(` 620 + create table registrations_new ( 621 + id integer primary key autoincrement, 622 + domain text not null, 623 + did text not null, 624 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 625 + registered text, 626 + read_only integer not null default 0, 627 + unique(domain, did) 628 + ); 629 + 630 + insert into registrations_new (id, domain, did, created, registered, read_only) 631 + select id, domain, did, created, registered, 1 from registrations 632 + where registered is not null; 633 + 634 + drop table registrations; 635 + alter table registrations_new rename to registrations; 636 + `) 637 + return err 638 + }) 639 + 603 640 // recreate and add rkey + created columns with default constraint 604 - runMigration(db, "rework-collaborators-table", func(tx *sql.Tx) error { 641 + runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error { 605 642 // create new table 606 643 // - repo_at instead of repo integer 607 644 // - rkey field ··· 655 692 return err 656 693 }) 657 694 695 + runMigration(conn, "add-rkey-to-issues", func(tx *sql.Tx) error { 696 + _, err := tx.Exec(` 697 + alter table issues add column rkey text not null default ''; 698 + 699 + -- get last url section from issue_at and save to rkey column 700 + update issues 701 + set rkey = replace(issue_at, rtrim(issue_at, replace(issue_at, '/', '')), ''); 702 + `) 703 + return err 704 + }) 705 + 658 706 return &DB{db}, nil 659 707 } 660 708 661 709 type migrationFn = func(*sql.Tx) error 662 710 663 - func runMigration(d *sql.DB, name string, migrationFn migrationFn) error { 664 - tx, err := d.Begin() 711 + func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error { 712 + tx, err := c.BeginTx(context.Background(), nil) 665 713 if err != nil { 666 714 return err 667 715 }
+144 -41
appview/db/follow.go
··· 1 1 package db 2 2 3 3 import ( 4 + "fmt" 4 5 "log" 6 + "strings" 5 7 "time" 6 8 ) 7 9 ··· 53 55 return err 54 56 } 55 57 56 - func GetFollowerFollowing(e Execer, did string) (int, int, error) { 58 + type FollowStats struct { 59 + Followers int 60 + Following int 61 + } 62 + 63 + func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) { 57 64 followers, following := 0, 0 58 65 err := e.QueryRow( 59 - `SELECT 66 + `SELECT 60 67 COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers, 61 68 COUNT(CASE WHEN user_did = ? THEN 1 END) AS following 62 69 FROM follows;`, did, did).Scan(&followers, &following) 63 70 if err != nil { 64 - return 0, 0, err 71 + return FollowStats{}, err 65 72 } 66 - return followers, following, nil 73 + return FollowStats{ 74 + Followers: followers, 75 + Following: following, 76 + }, nil 67 77 } 68 78 69 - type FollowStatus int 79 + func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]FollowStats, error) { 80 + if len(dids) == 0 { 81 + return nil, nil 82 + } 83 + 84 + placeholders := make([]string, len(dids)) 85 + for i := range placeholders { 86 + placeholders[i] = "?" 87 + } 88 + placeholderStr := strings.Join(placeholders, ",") 89 + 90 + args := make([]any, len(dids)*2) 91 + for i, did := range dids { 92 + args[i] = did 93 + args[i+len(dids)] = did 94 + } 95 + 96 + query := fmt.Sprintf(` 97 + select 98 + coalesce(f.did, g.did) as did, 99 + coalesce(f.followers, 0) as followers, 100 + coalesce(g.following, 0) as following 101 + from ( 102 + select subject_did as did, count(*) as followers 103 + from follows 104 + where subject_did in (%s) 105 + group by subject_did 106 + ) f 107 + full outer join ( 108 + select user_did as did, count(*) as following 109 + from follows 110 + where user_did in (%s) 111 + group by user_did 112 + ) g on f.did = g.did`, 113 + placeholderStr, placeholderStr) 70 114 71 - const ( 72 - IsNotFollowing FollowStatus = iota 73 - IsFollowing 74 - IsSelf 75 - ) 115 + result := make(map[string]FollowStats) 76 116 77 - func (s FollowStatus) String() string { 78 - switch s { 79 - case IsNotFollowing: 80 - return "IsNotFollowing" 81 - case IsFollowing: 82 - return "IsFollowing" 83 - case IsSelf: 84 - return "IsSelf" 85 - default: 86 - return "IsNotFollowing" 117 + rows, err := e.Query(query, args...) 118 + if err != nil { 119 + return nil, err 87 120 } 88 - } 121 + defer rows.Close() 122 + 123 + for rows.Next() { 124 + var did string 125 + var followers, following int 126 + if err := rows.Scan(&did, &followers, &following); err != nil { 127 + return nil, err 128 + } 129 + result[did] = FollowStats{ 130 + Followers: followers, 131 + Following: following, 132 + } 133 + } 89 134 90 - func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus { 91 - if userDid == subjectDid { 92 - return IsSelf 93 - } else if _, err := GetFollow(e, userDid, subjectDid); err != nil { 94 - return IsNotFollowing 95 - } else { 96 - return IsFollowing 135 + for _, did := range dids { 136 + if _, exists := result[did]; !exists { 137 + result[did] = FollowStats{ 138 + Followers: 0, 139 + Following: 0, 140 + } 141 + } 97 142 } 143 + 144 + return result, nil 98 145 } 99 146 100 - func GetAllFollows(e Execer, limit int) ([]Follow, error) { 147 + func GetFollows(e Execer, limit int, filters ...filter) ([]Follow, error) { 101 148 var follows []Follow 102 149 103 - rows, err := e.Query(` 104 - select user_did, subject_did, followed_at, rkey 150 + var conditions []string 151 + var args []any 152 + for _, filter := range filters { 153 + conditions = append(conditions, filter.Condition()) 154 + args = append(args, filter.Arg()...) 155 + } 156 + 157 + whereClause := "" 158 + if conditions != nil { 159 + whereClause = " where " + strings.Join(conditions, " and ") 160 + } 161 + limitClause := "" 162 + if limit > 0 { 163 + limitClause = " limit ?" 164 + args = append(args, limit) 165 + } 166 + 167 + query := fmt.Sprintf( 168 + `select user_did, subject_did, followed_at, rkey 105 169 from follows 170 + %s 106 171 order by followed_at desc 107 - limit ?`, limit, 108 - ) 172 + %s 173 + `, whereClause, limitClause) 174 + 175 + rows, err := e.Query(query, args...) 109 176 if err != nil { 110 177 return nil, err 111 178 } 112 - defer rows.Close() 113 - 114 179 for rows.Next() { 115 180 var follow Follow 116 181 var followedAt string 117 - if err := rows.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey); err != nil { 182 + err := rows.Scan( 183 + &follow.UserDid, 184 + &follow.SubjectDid, 185 + &followedAt, 186 + &follow.Rkey, 187 + ) 188 + if err != nil { 118 189 return nil, err 119 190 } 120 - 121 191 followedAtTime, err := time.Parse(time.RFC3339, followedAt) 122 192 if err != nil { 123 193 log.Println("unable to determine followed at time") ··· 125 195 } else { 126 196 follow.FollowedAt = followedAtTime 127 197 } 128 - 129 198 follows = append(follows, follow) 130 199 } 200 + return follows, nil 201 + } 202 + 203 + func GetFollowers(e Execer, did string) ([]Follow, error) { 204 + return GetFollows(e, 0, FilterEq("subject_did", did)) 205 + } 131 206 132 - if err := rows.Err(); err != nil { 133 - return nil, err 207 + func GetFollowing(e Execer, did string) ([]Follow, error) { 208 + return GetFollows(e, 0, FilterEq("user_did", did)) 209 + } 210 + 211 + type FollowStatus int 212 + 213 + const ( 214 + IsNotFollowing FollowStatus = iota 215 + IsFollowing 216 + IsSelf 217 + ) 218 + 219 + func (s FollowStatus) String() string { 220 + switch s { 221 + case IsNotFollowing: 222 + return "IsNotFollowing" 223 + case IsFollowing: 224 + return "IsFollowing" 225 + case IsSelf: 226 + return "IsSelf" 227 + default: 228 + return "IsNotFollowing" 134 229 } 230 + } 135 231 136 - return follows, nil 232 + func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus { 233 + if userDid == subjectDid { 234 + return IsSelf 235 + } else if _, err := GetFollow(e, userDid, subjectDid); err != nil { 236 + return IsNotFollowing 237 + } else { 238 + return IsFollowing 239 + } 137 240 }
+103 -17
appview/db/issues.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "fmt" 6 + "strings" 5 7 "time" 6 8 7 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.sh/tangled.sh/core/api/tangled" 8 11 "tangled.sh/tangled.sh/core/appview/pagination" 9 12 ) 10 13 ··· 13 16 RepoAt syntax.ATURI 14 17 OwnerDid string 15 18 IssueId int 16 - IssueAt string 19 + Rkey string 17 20 Created time.Time 18 21 Title string 19 22 Body string ··· 42 45 Edited *time.Time 43 46 } 44 47 48 + func (i *Issue) AtUri() syntax.ATURI { 49 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.OwnerDid, tangled.RepoIssueNSID, i.Rkey)) 50 + } 51 + 45 52 func NewIssue(tx *sql.Tx, issue *Issue) error { 46 53 defer tx.Rollback() 47 54 ··· 67 74 issue.IssueId = nextId 68 75 69 76 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) 77 + insert into issues (repo_at, owner_did, rkey, issue_at, issue_id, title, body) 78 + values (?, ?, ?, ?, ?, ?, ?) 79 + `, issue.RepoAt, issue.OwnerDid, issue.Rkey, issue.AtUri(), issue.IssueId, issue.Title, issue.Body) 73 80 if err != nil { 74 81 return err 75 82 } ··· 87 94 return nil 88 95 } 89 96 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 97 func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 96 98 var issueAt string 97 99 err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt) ··· 104 106 return ownerDid, err 105 107 } 106 108 107 - func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { 109 + func GetIssuesPaginated(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { 108 110 var issues []Issue 109 111 openValue := 0 110 112 if isOpen { ··· 117 119 select 118 120 i.id, 119 121 i.owner_did, 122 + i.rkey, 120 123 i.issue_id, 121 124 i.created, 122 125 i.title, ··· 136 139 select 137 140 id, 138 141 owner_did, 142 + rkey, 139 143 issue_id, 140 144 created, 141 145 title, 142 146 body, 143 147 open, 144 148 comment_count 145 - from 149 + from 146 150 numbered_issue 147 - where 151 + where 148 152 row_num between ? and ?`, 149 153 repoAt, openValue, page.Offset+1, page.Offset+page.Limit) 150 154 if err != nil { ··· 156 160 var issue Issue 157 161 var createdAt string 158 162 var metadata IssueMetadata 159 - err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 163 + err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 160 164 if err != nil { 161 165 return nil, err 162 166 } ··· 178 182 return issues, nil 179 183 } 180 184 185 + func GetIssuesWithLimit(e Execer, limit int, filters ...filter) ([]Issue, error) { 186 + issues := make([]Issue, 0, limit) 187 + 188 + var conditions []string 189 + var args []any 190 + for _, filter := range filters { 191 + conditions = append(conditions, filter.Condition()) 192 + args = append(args, filter.Arg()...) 193 + } 194 + 195 + whereClause := "" 196 + if conditions != nil { 197 + whereClause = " where " + strings.Join(conditions, " and ") 198 + } 199 + limitClause := "" 200 + if limit != 0 { 201 + limitClause = fmt.Sprintf(" limit %d ", limit) 202 + } 203 + 204 + query := fmt.Sprintf( 205 + `select 206 + i.id, 207 + i.owner_did, 208 + i.repo_at, 209 + i.issue_id, 210 + i.created, 211 + i.title, 212 + i.body, 213 + i.open 214 + from 215 + issues i 216 + %s 217 + order by 218 + i.created desc 219 + %s`, 220 + whereClause, limitClause) 221 + 222 + rows, err := e.Query(query, args...) 223 + if err != nil { 224 + return nil, err 225 + } 226 + defer rows.Close() 227 + 228 + for rows.Next() { 229 + var issue Issue 230 + var issueCreatedAt string 231 + err := rows.Scan( 232 + &issue.ID, 233 + &issue.OwnerDid, 234 + &issue.RepoAt, 235 + &issue.IssueId, 236 + &issueCreatedAt, 237 + &issue.Title, 238 + &issue.Body, 239 + &issue.Open, 240 + ) 241 + if err != nil { 242 + return nil, err 243 + } 244 + 245 + issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt) 246 + if err != nil { 247 + return nil, err 248 + } 249 + issue.Created = issueCreatedTime 250 + 251 + issues = append(issues, issue) 252 + } 253 + 254 + if err := rows.Err(); err != nil { 255 + return nil, err 256 + } 257 + 258 + return issues, nil 259 + } 260 + 261 + func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 262 + return GetIssuesWithLimit(e, 0, filters...) 263 + } 264 + 181 265 // timeframe here is directly passed into the sql query filter, and any 182 266 // timeframe in the past should be negative; e.g.: "-3 months" 183 267 func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) { ··· 187 271 `select 188 272 i.id, 189 273 i.owner_did, 274 + i.rkey, 190 275 i.repo_at, 191 276 i.issue_id, 192 277 i.created, ··· 219 304 err := rows.Scan( 220 305 &issue.ID, 221 306 &issue.OwnerDid, 307 + &issue.Rkey, 222 308 &issue.RepoAt, 223 309 &issue.IssueId, 224 310 &issueCreatedAt, ··· 262 348 } 263 349 264 350 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 = ?` 351 + query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 266 352 row := e.QueryRow(query, repoAt, issueId) 267 353 268 354 var issue Issue 269 355 var createdAt string 270 - err := row.Scan(&issue.ID, &issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open) 356 + err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 271 357 if err != nil { 272 358 return nil, err 273 359 } ··· 282 368 } 283 369 284 370 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 = ?` 371 + query := `select id, owner_did, rkey, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?` 286 372 row := e.QueryRow(query, repoAt, issueId) 287 373 288 374 var issue Issue 289 375 var createdAt string 290 - err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &issue.IssueAt) 376 + err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open) 291 377 if err != nil { 292 378 return nil, nil, err 293 379 }
-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;
+2 -7
appview/db/profile.go
··· 348 348 return tx.Commit() 349 349 } 350 350 351 - func GetProfiles(e Execer, filters ...filter) ([]Profile, error) { 351 + func GetProfiles(e Execer, filters ...filter) (map[string]*Profile, error) { 352 352 var conditions []string 353 353 var args []any 354 354 for _, filter := range filters { ··· 448 448 idxs[did] = idx + 1 449 449 } 450 450 451 - var profiles []Profile 452 - for _, p := range profileMap { 453 - profiles = append(profiles, *p) 454 - } 455 - 456 - return profiles, nil 451 + return profileMap, nil 457 452 } 458 453 459 454 func GetProfile(e Execer, did string) (*Profile, error) {
+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 {
+89 -125
appview/db/registration.go
··· 1 1 package db 2 2 3 3 import ( 4 - "crypto/rand" 5 4 "database/sql" 6 - "encoding/hex" 7 5 "fmt" 8 - "log" 6 + "strings" 9 7 "time" 10 8 ) 11 9 10 + // Registration represents a knot registration. Knot would've been a better 11 + // name but we're stuck with this for historical reasons. 12 12 type Registration struct { 13 13 Id int64 14 14 Domain string 15 15 ByDid string 16 16 Created *time.Time 17 17 Registered *time.Time 18 + ReadOnly bool 18 19 } 19 20 20 21 func (r *Registration) Status() Status { 21 - if r.Registered != nil { 22 + if r.ReadOnly { 23 + return ReadOnly 24 + } else if r.Registered != nil { 22 25 return Registered 23 26 } else { 24 27 return Pending 25 28 } 26 29 } 27 30 31 + func (r *Registration) IsRegistered() bool { 32 + return r.Status() == Registered 33 + } 34 + 35 + func (r *Registration) IsReadOnly() bool { 36 + return r.Status() == ReadOnly 37 + } 38 + 39 + func (r *Registration) IsPending() bool { 40 + return r.Status() == Pending 41 + } 42 + 28 43 type Status uint32 29 44 30 45 const ( 31 46 Registered Status = iota 32 47 Pending 48 + ReadOnly 33 49 ) 34 50 35 - // returns registered status, did of owner, error 36 - func RegistrationsByDid(e Execer, did string) ([]Registration, error) { 51 + func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) { 37 52 var registrations []Registration 38 53 39 - rows, err := e.Query(` 40 - select id, domain, did, created, registered from registrations 41 - where did = ? 42 - `, did) 54 + var conditions []string 55 + var args []any 56 + for _, filter := range filters { 57 + conditions = append(conditions, filter.Condition()) 58 + args = append(args, filter.Arg()...) 59 + } 60 + 61 + whereClause := "" 62 + if conditions != nil { 63 + whereClause = " where " + strings.Join(conditions, " and ") 64 + } 65 + 66 + query := fmt.Sprintf(` 67 + select id, domain, did, created, registered, read_only 68 + from registrations 69 + %s 70 + order by created 71 + `, 72 + whereClause, 73 + ) 74 + 75 + rows, err := e.Query(query, args...) 43 76 if err != nil { 44 77 return nil, err 45 78 } 46 79 47 80 for rows.Next() { 48 - var createdAt *string 49 - var registeredAt *string 50 - var registration Registration 51 - err = rows.Scan(&registration.Id, &registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 81 + var createdAt string 82 + var registeredAt sql.Null[string] 83 + var readOnly int 84 + var reg Registration 52 85 86 + err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &readOnly) 53 87 if err != nil { 54 - log.Println(err) 55 - } else { 56 - createdAtTime, _ := time.Parse(time.RFC3339, *createdAt) 57 - var registeredAtTime *time.Time 58 - if registeredAt != nil { 59 - x, _ := time.Parse(time.RFC3339, *registeredAt) 60 - registeredAtTime = &x 61 - } 88 + return nil, err 89 + } 62 90 63 - registration.Created = &createdAtTime 64 - registration.Registered = registeredAtTime 65 - registrations = append(registrations, registration) 91 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 92 + reg.Created = &t 66 93 } 67 - } 68 94 69 - return registrations, nil 70 - } 71 - 72 - // returns registered status, did of owner, error 73 - func RegistrationByDomain(e Execer, domain string) (*Registration, error) { 74 - var createdAt *string 75 - var registeredAt *string 76 - var registration Registration 77 - 78 - err := e.QueryRow(` 79 - select id, domain, did, created, registered from registrations 80 - where domain = ? 81 - `, domain).Scan(&registration.Id, &registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 95 + if registeredAt.Valid { 96 + if t, err := time.Parse(time.RFC3339, registeredAt.V); err == nil { 97 + reg.Registered = &t 98 + } 99 + } 82 100 83 - if err != nil { 84 - if err == sql.ErrNoRows { 85 - return nil, nil 86 - } else { 87 - return nil, err 101 + if readOnly != 0 { 102 + reg.ReadOnly = true 88 103 } 89 - } 90 104 91 - createdAtTime, _ := time.Parse(time.RFC3339, *createdAt) 92 - var registeredAtTime *time.Time 93 - if registeredAt != nil { 94 - x, _ := time.Parse(time.RFC3339, *registeredAt) 95 - registeredAtTime = &x 105 + registrations = append(registrations, reg) 96 106 } 97 107 98 - registration.Created = &createdAtTime 99 - registration.Registered = registeredAtTime 100 - 101 - return &registration, nil 102 - } 103 - 104 - func genSecret() string { 105 - key := make([]byte, 32) 106 - rand.Read(key) 107 - return hex.EncodeToString(key) 108 + return registrations, nil 108 109 } 109 110 110 - func GenerateRegistrationKey(e Execer, domain, did string) (string, error) { 111 - // sanity check: does this domain already have a registration? 112 - reg, err := RegistrationByDomain(e, domain) 113 - if err != nil { 114 - return "", err 115 - } 116 - 117 - // registration is open 118 - if reg != nil { 119 - switch reg.Status() { 120 - case Registered: 121 - // already registered by `owner` 122 - return "", fmt.Errorf("%s already registered by %s", domain, reg.ByDid) 123 - case Pending: 124 - // TODO: be loud about this 125 - log.Printf("%s registered by %s, status pending", domain, reg.ByDid) 126 - } 111 + func MarkRegistered(e Execer, filters ...filter) error { 112 + var conditions []string 113 + var args []any 114 + for _, filter := range filters { 115 + conditions = append(conditions, filter.Condition()) 116 + args = append(args, filter.Arg()...) 127 117 } 128 118 129 - secret := genSecret() 130 - 131 - _, err = e.Exec(` 132 - insert into registrations (domain, did, secret) 133 - values (?, ?, ?) 134 - on conflict(domain) do update set did = excluded.did, secret = excluded.secret, created = excluded.created 135 - `, domain, did, secret) 136 - 137 - if err != nil { 138 - return "", err 119 + query := "update registrations set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), read_only = 0" 120 + if len(conditions) > 0 { 121 + query += " where " + strings.Join(conditions, " and ") 139 122 } 140 123 141 - return secret, nil 124 + _, err := e.Exec(query, args...) 125 + return err 142 126 } 143 127 144 - func GetRegistrationKey(e Execer, domain string) (string, error) { 145 - res := e.QueryRow(`select secret from registrations where domain = ?`, domain) 146 - 147 - var secret string 148 - err := res.Scan(&secret) 149 - if err != nil || secret == "" { 150 - return "", err 151 - } 152 - 153 - return secret, nil 128 + func AddKnot(e Execer, domain, did string) error { 129 + _, err := e.Exec(` 130 + insert into registrations (domain, did) 131 + values (?, ?) 132 + `, domain, did) 133 + return err 154 134 } 155 135 156 - func GetCompletedRegistrations(e Execer) ([]string, error) { 157 - rows, err := e.Query(`select domain from registrations where registered not null`) 158 - if err != nil { 159 - return nil, err 136 + func DeleteKnot(e Execer, filters ...filter) error { 137 + var conditions []string 138 + var args []any 139 + for _, filter := range filters { 140 + conditions = append(conditions, filter.Condition()) 141 + args = append(args, filter.Arg()...) 160 142 } 161 143 162 - var domains []string 163 - for rows.Next() { 164 - var domain string 165 - err = rows.Scan(&domain) 166 - 167 - if err != nil { 168 - log.Println(err) 169 - } else { 170 - domains = append(domains, domain) 171 - } 172 - } 173 - 174 - if err = rows.Err(); err != nil { 175 - return nil, err 144 + whereClause := "" 145 + if conditions != nil { 146 + whereClause = " where " + strings.Join(conditions, " and ") 176 147 } 177 148 178 - return domains, nil 179 - } 149 + query := fmt.Sprintf(`delete from registrations %s`, whereClause) 180 150 181 - func Register(e Execer, domain string) error { 182 - _, err := e.Exec(` 183 - update registrations 184 - set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 185 - where domain = ?; 186 - `, domain) 187 - 151 + _, err := e.Exec(query, args...) 188 152 return err 189 153 }
+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 + }
+12 -11
appview/db/strings.go
··· 50 50 func (s String) Validate() error { 51 51 var err error 52 52 53 - if !strings.Contains(s.Filename, ".") { 54 - err = errors.Join(err, fmt.Errorf("missing filename extension")) 55 - } 56 - 57 - if strings.HasSuffix(s.Filename, ".") { 58 - err = errors.Join(err, fmt.Errorf("filename ends with `.`")) 59 - } 60 - 61 53 if utf8.RuneCountInString(s.Filename) > 140 { 62 54 err = errors.Join(err, fmt.Errorf("filename too long")) 63 55 } ··· 113 105 filename = excluded.filename, 114 106 description = excluded.description, 115 107 content = excluded.content, 116 - edited = case 108 + edited = case 117 109 when 118 110 strings.content != excluded.content 119 111 or strings.filename != excluded.filename ··· 131 123 return err 132 124 } 133 125 134 - func GetStrings(e Execer, filters ...filter) ([]String, error) { 126 + func GetStrings(e Execer, limit int, filters ...filter) ([]String, error) { 135 127 var all []String 136 128 137 129 var conditions []string ··· 146 138 whereClause = " where " + strings.Join(conditions, " and ") 147 139 } 148 140 141 + limitClause := "" 142 + if limit != 0 { 143 + limitClause = fmt.Sprintf(" limit %d ", limit) 144 + } 145 + 149 146 query := fmt.Sprintf(`select 150 147 did, 151 148 rkey, ··· 154 151 content, 155 152 created, 156 153 edited 157 - from strings %s`, 154 + from strings 155 + %s 156 + order by created desc 157 + %s`, 158 158 whereClause, 159 + limitClause, 159 160 ) 160 161 161 162 rows, err := e.Query(query, args...)
+6 -22
appview/db/timeline.go
··· 20 20 *FollowStats 21 21 } 22 22 23 - type FollowStats struct { 24 - Followers int 25 - Following int 26 - } 27 - 28 23 const Limit = 50 29 24 30 25 // TODO: this gathers heterogenous events from different sources and aggregates ··· 137 132 } 138 133 139 134 func getTimelineFollows(e Execer) ([]TimelineEvent, error) { 140 - follows, err := GetAllFollows(e, Limit) 135 + follows, err := GetFollows(e, Limit) 141 136 if err != nil { 142 137 return nil, err 143 138 } ··· 151 146 return nil, nil 152 147 } 153 148 154 - profileMap := make(map[string]Profile) 155 149 profiles, err := GetProfiles(e, FilterIn("did", subjects)) 156 150 if err != nil { 157 151 return nil, err 158 152 } 159 - for _, p := range profiles { 160 - profileMap[p.Did] = p 161 - } 162 153 163 - followStatMap := make(map[string]FollowStats) 164 - for _, s := range subjects { 165 - followers, following, err := GetFollowerFollowing(e, s) 166 - if err != nil { 167 - return nil, err 168 - } 169 - followStatMap[s] = FollowStats{ 170 - Followers: followers, 171 - Following: following, 172 - } 154 + followStatMap, err := GetFollowerFollowingCounts(e, subjects) 155 + if err != nil { 156 + return nil, err 173 157 } 174 158 175 159 var events []TimelineEvent 176 160 for _, f := range follows { 177 - profile, _ := profileMap[f.SubjectDid] 161 + profile, _ := profiles[f.SubjectDid] 178 162 followStatMap, _ := followStatMap[f.SubjectDid] 179 163 180 164 events = append(events, TimelineEvent{ 181 165 Follow: &f, 182 - Profile: &profile, 166 + Profile: profile, 183 167 FollowStats: &followStatMap, 184 168 EventAt: f.FollowedAt, 185 169 })
+165 -5
appview/ingester.go
··· 14 14 "tangled.sh/tangled.sh/core/api/tangled" 15 15 "tangled.sh/tangled.sh/core/appview/config" 16 16 "tangled.sh/tangled.sh/core/appview/db" 17 - "tangled.sh/tangled.sh/core/appview/spindleverify" 17 + "tangled.sh/tangled.sh/core/appview/serververify" 18 18 "tangled.sh/tangled.sh/core/idresolver" 19 19 "tangled.sh/tangled.sh/core/rbac" 20 20 ) ··· 64 64 err = i.ingestSpindleMember(e) 65 65 case tangled.SpindleNSID: 66 66 err = i.ingestSpindle(e) 67 + case tangled.KnotMemberNSID: 68 + err = i.ingestKnotMember(e) 69 + case tangled.KnotNSID: 70 + err = i.ingestKnot(e) 67 71 case tangled.StringNSID: 68 72 err = i.ingestString(e) 69 73 } ··· 71 75 } 72 76 73 77 if err != nil { 74 - l.Error("error ingesting record", "err", err) 78 + l.Debug("error ingesting record", "err", err) 75 79 } 76 80 77 - return err 81 + return nil 78 82 } 79 83 } 80 84 ··· 475 479 return err 476 480 } 477 481 478 - err = spindleverify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev) 482 + err = serververify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev) 479 483 if err != nil { 480 484 l.Error("failed to add spindle to db", "err", err, "instance", instance) 481 485 return err 482 486 } 483 487 484 - _, err = spindleverify.MarkVerified(ddb, i.Enforcer, instance, did) 488 + _, err = serververify.MarkSpindleVerified(ddb, i.Enforcer, instance, did) 485 489 if err != nil { 486 490 return fmt.Errorf("failed to mark verified: %w", err) 487 491 } ··· 609 613 610 614 return nil 611 615 } 616 + 617 + func (i *Ingester) ingestKnotMember(e *models.Event) error { 618 + did := e.Did 619 + var err error 620 + 621 + l := i.Logger.With("handler", "ingestKnotMember") 622 + l = l.With("nsid", e.Commit.Collection) 623 + 624 + switch e.Commit.Operation { 625 + case models.CommitOperationCreate: 626 + raw := json.RawMessage(e.Commit.Record) 627 + record := tangled.KnotMember{} 628 + err = json.Unmarshal(raw, &record) 629 + if err != nil { 630 + l.Error("invalid record", "err", err) 631 + return err 632 + } 633 + 634 + // only knot owner can invite to knots 635 + ok, err := i.Enforcer.IsKnotInviteAllowed(did, record.Domain) 636 + if err != nil || !ok { 637 + return fmt.Errorf("failed to enforce permissions: %w", err) 638 + } 639 + 640 + memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject) 641 + if err != nil { 642 + return err 643 + } 644 + 645 + if memberId.Handle.IsInvalidHandle() { 646 + return err 647 + } 648 + 649 + err = i.Enforcer.AddKnotMember(record.Domain, memberId.DID.String()) 650 + if err != nil { 651 + return fmt.Errorf("failed to update ACLs: %w", err) 652 + } 653 + 654 + l.Info("added knot member") 655 + case models.CommitOperationDelete: 656 + // we don't store knot members in a table (like we do for spindle) 657 + // and we can't remove this just yet. possibly fixed if we switch 658 + // to either: 659 + // 1. a knot_members table like with spindle and store the rkey 660 + // 2. use the knot host as the rkey 661 + // 662 + // TODO: implement member deletion 663 + l.Info("skipping knot member delete", "did", did, "rkey", e.Commit.RKey) 664 + } 665 + 666 + return nil 667 + } 668 + 669 + func (i *Ingester) ingestKnot(e *models.Event) error { 670 + did := e.Did 671 + var err error 672 + 673 + l := i.Logger.With("handler", "ingestKnot") 674 + l = l.With("nsid", e.Commit.Collection) 675 + 676 + switch e.Commit.Operation { 677 + case models.CommitOperationCreate: 678 + raw := json.RawMessage(e.Commit.Record) 679 + record := tangled.Knot{} 680 + err = json.Unmarshal(raw, &record) 681 + if err != nil { 682 + l.Error("invalid record", "err", err) 683 + return err 684 + } 685 + 686 + domain := e.Commit.RKey 687 + 688 + ddb, ok := i.Db.Execer.(*db.DB) 689 + if !ok { 690 + return fmt.Errorf("failed to index profile record, invalid db cast") 691 + } 692 + 693 + err := db.AddKnot(ddb, domain, did) 694 + if err != nil { 695 + l.Error("failed to add knot to db", "err", err, "domain", domain) 696 + return err 697 + } 698 + 699 + err = serververify.RunVerification(context.Background(), domain, did, i.Config.Core.Dev) 700 + if err != nil { 701 + l.Error("failed to verify knot", "err", err, "domain", domain) 702 + return err 703 + } 704 + 705 + err = serververify.MarkKnotVerified(ddb, i.Enforcer, domain, did) 706 + if err != nil { 707 + return fmt.Errorf("failed to mark verified: %w", err) 708 + } 709 + 710 + return nil 711 + 712 + case models.CommitOperationDelete: 713 + domain := e.Commit.RKey 714 + 715 + ddb, ok := i.Db.Execer.(*db.DB) 716 + if !ok { 717 + return fmt.Errorf("failed to index knot record, invalid db cast") 718 + } 719 + 720 + // get record from db first 721 + registrations, err := db.GetRegistrations( 722 + ddb, 723 + db.FilterEq("domain", domain), 724 + db.FilterEq("did", did), 725 + ) 726 + if err != nil { 727 + return fmt.Errorf("failed to get registration: %w", err) 728 + } 729 + if len(registrations) != 1 { 730 + return fmt.Errorf("got incorret number of registrations: %d, expected 1", len(registrations)) 731 + } 732 + registration := registrations[0] 733 + 734 + tx, err := ddb.Begin() 735 + if err != nil { 736 + return err 737 + } 738 + defer func() { 739 + tx.Rollback() 740 + i.Enforcer.E.LoadPolicy() 741 + }() 742 + 743 + err = db.DeleteKnot( 744 + tx, 745 + db.FilterEq("did", did), 746 + db.FilterEq("domain", domain), 747 + ) 748 + if err != nil { 749 + return err 750 + } 751 + 752 + if registration.Registered != nil { 753 + err = i.Enforcer.RemoveKnot(domain) 754 + if err != nil { 755 + return err 756 + } 757 + } 758 + 759 + err = tx.Commit() 760 + if err != nil { 761 + return err 762 + } 763 + 764 + err = i.Enforcer.E.SavePolicy() 765 + if err != nil { 766 + return err 767 + } 768 + } 769 + 770 + return nil 771 + }
+37 -86
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, ··· 294 280 createdAt := time.Now().Format(time.RFC3339) 295 281 commentIdInt64 := int64(commentId) 296 282 ownerDid := user.Did 297 - issueAt, err := db.GetIssueAt(rp.db, f.RepoAt, issueIdInt) 283 + issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt) 298 284 if err != nil { 299 285 log.Println("failed to get issue at", err) 300 286 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 301 287 return 302 288 } 303 289 304 - atUri := f.RepoAt.String() 290 + atUri := f.RepoAt().String() 305 291 client, err := rp.oauth.AuthorizedClient(r) 306 292 if err != nil { 307 293 log.Println("failed to get authorized client", err) ··· 358 344 return 359 345 } 360 346 361 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 347 + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 362 348 if err != nil { 363 349 log.Println("failed to get issue", err) 364 350 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 365 351 return 366 352 } 367 353 368 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 354 + comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 369 355 if err != nil { 370 356 http.Error(w, "bad comment id", http.StatusBadRequest) 371 357 return 372 358 } 373 359 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 360 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 388 361 LoggedInUser: user, 389 362 RepoInfo: f.RepoInfo(user), 390 - DidHandleMap: didHandleMap, 391 363 Issue: issue, 392 364 Comment: comment, 393 365 }) ··· 417 389 return 418 390 } 419 391 420 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 392 + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 421 393 if err != nil { 422 394 log.Println("failed to get issue", err) 423 395 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 424 396 return 425 397 } 426 398 427 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 399 + comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 428 400 if err != nil { 429 401 http.Error(w, "bad comment id", http.StatusBadRequest) 430 402 return ··· 503 475 } 504 476 505 477 // optimistic update for htmx 506 - didHandleMap := map[string]string{ 507 - user.Did: user.Handle, 508 - } 509 478 comment.Body = newBody 510 479 comment.Edited = &edited 511 480 ··· 513 482 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 514 483 LoggedInUser: user, 515 484 RepoInfo: f.RepoInfo(user), 516 - DidHandleMap: didHandleMap, 517 485 Issue: issue, 518 486 Comment: comment, 519 487 }) ··· 539 507 return 540 508 } 541 509 542 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 510 + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 543 511 if err != nil { 544 512 log.Println("failed to get issue", err) 545 513 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") ··· 554 522 return 555 523 } 556 524 557 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 525 + comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 558 526 if err != nil { 559 527 http.Error(w, "bad comment id", http.StatusBadRequest) 560 528 return ··· 572 540 573 541 // optimistic deletion 574 542 deleted := time.Now() 575 - err = db.DeleteComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 543 + err = db.DeleteComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 576 544 if err != nil { 577 545 log.Println("failed to delete comment") 578 546 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") ··· 598 566 } 599 567 600 568 // optimistic update for htmx 601 - didHandleMap := map[string]string{ 602 - user.Did: user.Handle, 603 - } 604 569 comment.Body = "" 605 570 comment.Deleted = &deleted 606 571 ··· 608 573 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 609 574 LoggedInUser: user, 610 575 RepoInfo: f.RepoInfo(user), 611 - DidHandleMap: didHandleMap, 612 576 Issue: issue, 613 577 Comment: comment, 614 578 }) 615 - return 616 579 } 617 580 618 581 func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { ··· 641 604 return 642 605 } 643 606 644 - issues, err := db.GetIssues(rp.db, f.RepoAt, isOpen, page) 607 + issues, err := db.GetIssuesPaginated(rp.db, f.RepoAt(), isOpen, page) 645 608 if err != nil { 646 609 log.Println("failed to get issues", err) 647 610 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 648 611 return 649 612 } 650 613 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 614 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 666 615 LoggedInUser: rp.oauth.GetUser(r), 667 616 RepoInfo: f.RepoInfo(user), 668 617 Issues: issues, 669 - DidHandleMap: didHandleMap, 670 618 FilteringByOpen: isOpen, 671 619 Page: page, 672 620 }) 673 - return 674 621 } 675 622 676 623 func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { ··· 697 644 return 698 645 } 699 646 647 + sanitizer := markup.NewSanitizer() 648 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" { 649 + rp.pages.Notice(w, "issues", "Title is empty after HTML sanitization") 650 + return 651 + } 652 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" { 653 + rp.pages.Notice(w, "issues", "Body is empty after HTML sanitization") 654 + return 655 + } 656 + 700 657 tx, err := rp.db.BeginTx(r.Context(), nil) 701 658 if err != nil { 702 659 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") ··· 704 661 } 705 662 706 663 issue := &db.Issue{ 707 - RepoAt: f.RepoAt, 664 + RepoAt: f.RepoAt(), 665 + Rkey: tid.TID(), 708 666 Title: title, 709 667 Body: body, 710 668 OwnerDid: user.Did, ··· 722 680 rp.pages.Notice(w, "issues", "Failed to create issue.") 723 681 return 724 682 } 725 - atUri := f.RepoAt.String() 726 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 683 + atUri := f.RepoAt().String() 684 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 727 685 Collection: tangled.RepoIssueNSID, 728 686 Repo: user.Did, 729 - Rkey: tid.TID(), 687 + Rkey: issue.Rkey, 730 688 Record: &lexutil.LexiconTypeDecoder{ 731 689 Val: &tangled.RepoIssue{ 732 690 Repo: atUri, ··· 739 697 }) 740 698 if err != nil { 741 699 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 700 rp.pages.Notice(w, "issues", "Failed to create issue.") 750 701 return 751 702 }
+443 -232
appview/knots/knots.go
··· 1 1 package knots 2 2 3 3 import ( 4 - "context" 5 - "crypto/hmac" 6 - "crypto/sha256" 7 - "encoding/hex" 4 + "errors" 8 5 "fmt" 6 + "log" 9 7 "log/slog" 10 8 "net/http" 11 - "strings" 9 + "slices" 12 10 "time" 13 11 14 12 "github.com/go-chi/chi/v5" ··· 18 16 "tangled.sh/tangled.sh/core/appview/middleware" 19 17 "tangled.sh/tangled.sh/core/appview/oauth" 20 18 "tangled.sh/tangled.sh/core/appview/pages" 19 + "tangled.sh/tangled.sh/core/appview/serververify" 21 20 "tangled.sh/tangled.sh/core/eventconsumer" 22 21 "tangled.sh/tangled.sh/core/idresolver" 23 - "tangled.sh/tangled.sh/core/knotclient" 24 22 "tangled.sh/tangled.sh/core/rbac" 25 23 "tangled.sh/tangled.sh/core/tid" 26 24 ··· 39 37 Knotstream *eventconsumer.Consumer 40 38 } 41 39 42 - func (k *Knots) Router(mw *middleware.Middleware) http.Handler { 40 + func (k *Knots) Router() http.Handler { 43 41 r := chi.NewRouter() 44 42 45 - r.Use(middleware.AuthMiddleware(k.OAuth)) 43 + r.With(middleware.AuthMiddleware(k.OAuth)).Get("/", k.knots) 44 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/register", k.register) 45 + 46 + r.With(middleware.AuthMiddleware(k.OAuth)).Get("/{domain}", k.dashboard) 47 + r.With(middleware.AuthMiddleware(k.OAuth)).Delete("/{domain}", k.delete) 46 48 47 - r.Get("/", k.index) 48 - r.Post("/key", k.generateKey) 49 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/retry", k.retry) 50 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/add", k.addMember) 51 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/remove", k.removeMember) 49 52 50 - r.Route("/{domain}", func(r chi.Router) { 51 - r.Post("/init", k.init) 52 - r.Get("/", k.dashboard) 53 - r.Route("/member", func(r chi.Router) { 54 - r.Use(mw.KnotOwner()) 55 - r.Get("/", k.members) 56 - r.Put("/", k.addMember) 57 - r.Delete("/", k.removeMember) 58 - }) 59 - }) 53 + r.With(middleware.AuthMiddleware(k.OAuth)).Get("/upgradeBanner", k.banner) 60 54 61 55 return r 62 56 } 63 57 64 - // get knots registered by this user 65 - func (k *Knots) index(w http.ResponseWriter, r *http.Request) { 66 - l := k.Logger.With("handler", "index") 67 - 58 + func (k *Knots) knots(w http.ResponseWriter, r *http.Request) { 68 59 user := k.OAuth.GetUser(r) 69 - registrations, err := db.RegistrationsByDid(k.Db, user.Did) 60 + registrations, err := db.GetRegistrations( 61 + k.Db, 62 + db.FilterEq("did", user.Did), 63 + ) 70 64 if err != nil { 71 - l.Error("failed to get registrations by did", "err", err) 65 + k.Logger.Error("failed to fetch knot registrations", "err", err) 66 + w.WriteHeader(http.StatusInternalServerError) 67 + return 72 68 } 73 69 74 70 k.Pages.Knots(w, pages.KnotsParams{ ··· 77 73 }) 78 74 } 79 75 80 - // requires auth 81 - func (k *Knots) generateKey(w http.ResponseWriter, r *http.Request) { 82 - l := k.Logger.With("handler", "generateKey") 76 + func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { 77 + l := k.Logger.With("handler", "dashboard") 83 78 84 79 user := k.OAuth.GetUser(r) 85 - did := user.Did 86 - l = l.With("did", did) 80 + l = l.With("user", user.Did) 87 81 88 - // check if domain is valid url, and strip extra bits down to just host 89 - domain := r.FormValue("domain") 82 + domain := chi.URLParam(r, "domain") 90 83 if domain == "" { 91 - l.Error("empty domain") 92 - http.Error(w, "Invalid form", http.StatusBadRequest) 93 84 return 94 85 } 95 86 l = l.With("domain", domain) 96 87 97 - noticeId := "registration-error" 98 - fail := func() { 99 - k.Pages.Notice(w, noticeId, "Failed to generate registration key.") 88 + registrations, err := db.GetRegistrations( 89 + k.Db, 90 + db.FilterEq("did", user.Did), 91 + db.FilterEq("domain", domain), 92 + ) 93 + if err != nil { 94 + l.Error("failed to get registrations", "err", err) 95 + http.Error(w, "Not found", http.StatusNotFound) 96 + return 100 97 } 98 + if len(registrations) != 1 { 99 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 100 + return 101 + } 102 + registration := registrations[0] 101 103 102 - key, err := db.GenerateRegistrationKey(k.Db, domain, did) 104 + members, err := k.Enforcer.GetUserByRole("server:member", domain) 103 105 if err != nil { 104 - l.Error("failed to generate registration key", "err", err) 105 - fail() 106 + l.Error("failed to get knot members", "err", err) 107 + http.Error(w, "Not found", http.StatusInternalServerError) 106 108 return 107 109 } 110 + slices.Sort(members) 108 111 109 - allRegs, err := db.RegistrationsByDid(k.Db, did) 112 + repos, err := db.GetRepos( 113 + k.Db, 114 + 0, 115 + db.FilterEq("knot", domain), 116 + ) 110 117 if err != nil { 111 - l.Error("failed to generate registration key", "err", err) 112 - fail() 118 + l.Error("failed to get knot repos", "err", err) 119 + http.Error(w, "Not found", http.StatusInternalServerError) 113 120 return 114 121 } 115 122 116 - k.Pages.KnotListingFull(w, pages.KnotListingFullParams{ 117 - Registrations: allRegs, 118 - }) 119 - k.Pages.KnotSecret(w, pages.KnotSecretParams{ 120 - Secret: key, 123 + // organize repos by did 124 + repoMap := make(map[string][]db.Repo) 125 + for _, r := range repos { 126 + repoMap[r.Did] = append(repoMap[r.Did], r) 127 + } 128 + 129 + k.Pages.Knot(w, pages.KnotParams{ 130 + LoggedInUser: user, 131 + Registration: &registration, 132 + Members: members, 133 + Repos: repoMap, 134 + IsOwner: true, 121 135 }) 122 136 } 123 137 124 - // create a signed request and check if a node responds to that 125 - func (k *Knots) init(w http.ResponseWriter, r *http.Request) { 126 - l := k.Logger.With("handler", "init") 138 + func (k *Knots) register(w http.ResponseWriter, r *http.Request) { 127 139 user := k.OAuth.GetUser(r) 140 + l := k.Logger.With("handler", "register") 128 141 129 - noticeId := "operation-error" 130 - defaultErr := "Failed to initialize knot. Try again later." 142 + noticeId := "register-error" 143 + defaultErr := "Failed to register knot. Try again later." 131 144 fail := func() { 132 145 k.Pages.Notice(w, noticeId, defaultErr) 133 146 } 134 147 135 - domain := chi.URLParam(r, "domain") 148 + domain := r.FormValue("domain") 136 149 if domain == "" { 137 - http.Error(w, "malformed url", http.StatusBadRequest) 150 + k.Pages.Notice(w, noticeId, "Incomplete form.") 138 151 return 139 152 } 140 153 l = l.With("domain", domain) 154 + l = l.With("user", user.Did) 141 155 142 - l.Info("checking domain") 156 + tx, err := k.Db.Begin() 157 + if err != nil { 158 + l.Error("failed to start transaction", "err", err) 159 + fail() 160 + return 161 + } 162 + defer func() { 163 + tx.Rollback() 164 + k.Enforcer.E.LoadPolicy() 165 + }() 143 166 144 - registration, err := db.RegistrationByDomain(k.Db, domain) 167 + err = db.AddKnot(tx, domain, user.Did) 145 168 if err != nil { 146 - l.Error("failed to get registration for domain", "err", err) 169 + l.Error("failed to insert", "err", err) 147 170 fail() 148 171 return 149 172 } 150 - if registration.ByDid != user.Did { 151 - l.Error("unauthorized", "wantedDid", registration.ByDid, "gotDid", user.Did) 152 - w.WriteHeader(http.StatusUnauthorized) 173 + 174 + err = k.Enforcer.AddKnot(domain) 175 + if err != nil { 176 + l.Error("failed to create knot", "err", err) 177 + fail() 153 178 return 154 179 } 155 180 156 - secret, err := db.GetRegistrationKey(k.Db, domain) 181 + // create record on pds 182 + client, err := k.OAuth.AuthorizedClient(r) 157 183 if err != nil { 158 - l.Error("failed to get registration key for domain", "err", err) 184 + l.Error("failed to authorize client", "err", err) 159 185 fail() 160 186 return 161 187 } 162 188 163 - client, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev) 189 + ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 190 + var exCid *string 191 + if ex != nil { 192 + exCid = ex.Cid 193 + } 194 + 195 + // re-announce by registering under same rkey 196 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 197 + Collection: tangled.KnotNSID, 198 + Repo: user.Did, 199 + Rkey: domain, 200 + Record: &lexutil.LexiconTypeDecoder{ 201 + Val: &tangled.Knot{ 202 + CreatedAt: time.Now().Format(time.RFC3339), 203 + }, 204 + }, 205 + SwapRecord: exCid, 206 + }) 207 + 164 208 if err != nil { 165 - l.Error("failed to create knotclient", "err", err) 209 + l.Error("failed to put record", "err", err) 166 210 fail() 167 211 return 168 212 } 169 213 170 - resp, err := client.Init(user.Did) 214 + err = tx.Commit() 171 215 if err != nil { 172 - k.Pages.Notice(w, noticeId, fmt.Sprintf("Failed to make request: %s", err.Error())) 173 - l.Error("failed to make init request", "err", err) 216 + l.Error("failed to commit transaction", "err", err) 217 + fail() 174 218 return 175 219 } 176 220 177 - if resp.StatusCode == http.StatusConflict { 178 - k.Pages.Notice(w, noticeId, "This knot is already registered") 179 - l.Error("knot already registered", "statuscode", resp.StatusCode) 221 + err = k.Enforcer.E.SavePolicy() 222 + if err != nil { 223 + l.Error("failed to update ACL", "err", err) 224 + k.Pages.HxRefresh(w) 180 225 return 181 226 } 182 227 183 - if resp.StatusCode != http.StatusNoContent { 184 - k.Pages.Notice(w, noticeId, fmt.Sprintf("Received status %d from knot, expected %d", resp.StatusCode, http.StatusNoContent)) 185 - l.Error("incorrect statuscode returned", "statuscode", resp.StatusCode, "expected", http.StatusNoContent) 228 + // begin verification 229 + err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 230 + if err != nil { 231 + l.Error("verification failed", "err", err) 232 + k.Pages.HxRefresh(w) 186 233 return 187 234 } 188 235 189 - // verify response mac 190 - signature := resp.Header.Get("X-Signature") 191 - signatureBytes, err := hex.DecodeString(signature) 236 + err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 192 237 if err != nil { 238 + l.Error("failed to mark verified", "err", err) 239 + k.Pages.HxRefresh(w) 193 240 return 194 241 } 195 242 196 - expectedMac := hmac.New(sha256.New, []byte(secret)) 197 - expectedMac.Write([]byte("ok")) 243 + // add this knot to knotstream 244 + go k.Knotstream.AddSource( 245 + r.Context(), 246 + eventconsumer.NewKnotSource(domain), 247 + ) 248 + 249 + // ok 250 + k.Pages.HxRefresh(w) 251 + } 252 + 253 + func (k *Knots) delete(w http.ResponseWriter, r *http.Request) { 254 + user := k.OAuth.GetUser(r) 255 + l := k.Logger.With("handler", "delete") 256 + 257 + noticeId := "operation-error" 258 + defaultErr := "Failed to delete knot. Try again later." 259 + fail := func() { 260 + k.Pages.Notice(w, noticeId, defaultErr) 261 + } 198 262 199 - if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) { 200 - k.Pages.Notice(w, noticeId, "Response signature mismatch, consider regenerating the secret and retrying.") 201 - l.Error("signature mismatch", "bytes", signatureBytes) 263 + domain := chi.URLParam(r, "domain") 264 + if domain == "" { 265 + l.Error("empty domain") 266 + fail() 202 267 return 203 268 } 204 269 205 - tx, err := k.Db.BeginTx(r.Context(), nil) 270 + // get record from db first 271 + registrations, err := db.GetRegistrations( 272 + k.Db, 273 + db.FilterEq("did", user.Did), 274 + db.FilterEq("domain", domain), 275 + ) 206 276 if err != nil { 207 - l.Error("failed to start tx", "err", err) 277 + l.Error("failed to get registration", "err", err) 278 + fail() 279 + return 280 + } 281 + if len(registrations) != 1 { 282 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 283 + fail() 284 + return 285 + } 286 + registration := registrations[0] 287 + 288 + tx, err := k.Db.Begin() 289 + if err != nil { 290 + l.Error("failed to start txn", "err", err) 208 291 fail() 209 292 return 210 293 } 211 294 defer func() { 212 295 tx.Rollback() 213 - err = k.Enforcer.E.LoadPolicy() 214 - if err != nil { 215 - l.Error("rollback failed", "err", err) 216 - } 296 + k.Enforcer.E.LoadPolicy() 217 297 }() 218 298 219 - // mark as registered 220 - err = db.Register(tx, domain) 299 + err = db.DeleteKnot( 300 + tx, 301 + db.FilterEq("did", user.Did), 302 + db.FilterEq("domain", domain), 303 + ) 221 304 if err != nil { 222 - l.Error("failed to register domain", "err", err) 305 + l.Error("failed to delete registration", "err", err) 223 306 fail() 224 307 return 225 308 } 226 309 227 - // set permissions for this did as owner 228 - reg, err := db.RegistrationByDomain(tx, domain) 229 - if err != nil { 230 - l.Error("failed get registration by domain", "err", err) 231 - fail() 232 - return 310 + // delete from enforcer if it was registered 311 + if registration.Registered != nil { 312 + err = k.Enforcer.RemoveKnot(domain) 313 + if err != nil { 314 + l.Error("failed to update ACL", "err", err) 315 + fail() 316 + return 317 + } 233 318 } 234 319 235 - // add basic acls for this domain 236 - err = k.Enforcer.AddKnot(domain) 320 + client, err := k.OAuth.AuthorizedClient(r) 237 321 if err != nil { 238 - l.Error("failed to add knot to enforcer", "err", err) 322 + l.Error("failed to authorize client", "err", err) 239 323 fail() 240 324 return 241 325 } 242 326 243 - // add this did as owner of this domain 244 - err = k.Enforcer.AddKnotOwner(domain, reg.ByDid) 327 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 328 + Collection: tangled.KnotNSID, 329 + Repo: user.Did, 330 + Rkey: domain, 331 + }) 245 332 if err != nil { 246 - l.Error("failed to add knot owner to enforcer", "err", err) 247 - fail() 248 - return 333 + // non-fatal 334 + l.Error("failed to delete record", "err", err) 249 335 } 250 336 251 337 err = tx.Commit() 252 338 if err != nil { 253 - l.Error("failed to commit changes", "err", err) 339 + l.Error("failed to delete knot", "err", err) 254 340 fail() 255 341 return 256 342 } 257 343 258 344 err = k.Enforcer.E.SavePolicy() 259 345 if err != nil { 260 - l.Error("failed to update ACLs", "err", err) 261 - fail() 346 + l.Error("failed to update ACL", "err", err) 347 + k.Pages.HxRefresh(w) 262 348 return 263 349 } 264 350 265 - // add this knot to knotstream 266 - go k.Knotstream.AddSource( 267 - context.Background(), 268 - eventconsumer.NewKnotSource(domain), 269 - ) 351 + shouldRedirect := r.Header.Get("shouldRedirect") 352 + if shouldRedirect == "true" { 353 + k.Pages.HxRedirect(w, "/knots") 354 + return 355 + } 270 356 271 - k.Pages.KnotListing(w, pages.KnotListingParams{ 272 - Registration: *reg, 273 - }) 357 + w.Write([]byte{}) 274 358 } 275 359 276 - func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { 277 - l := k.Logger.With("handler", "dashboard") 360 + func (k *Knots) retry(w http.ResponseWriter, r *http.Request) { 361 + user := k.OAuth.GetUser(r) 362 + l := k.Logger.With("handler", "retry") 363 + 364 + noticeId := "operation-error" 365 + defaultErr := "Failed to verify knot. Try again later." 278 366 fail := func() { 279 - w.WriteHeader(http.StatusInternalServerError) 367 + k.Pages.Notice(w, noticeId, defaultErr) 280 368 } 281 369 282 370 domain := chi.URLParam(r, "domain") 283 371 if domain == "" { 284 - http.Error(w, "malformed url", http.StatusBadRequest) 372 + l.Error("empty domain") 373 + fail() 285 374 return 286 375 } 287 376 l = l.With("domain", domain) 377 + l = l.With("user", user.Did) 288 378 289 - user := k.OAuth.GetUser(r) 290 - l = l.With("did", user.Did) 291 - 292 - // dashboard is only available to owners 293 - ok, err := k.Enforcer.IsKnotOwner(user.Did, domain) 379 + // get record from db first 380 + registrations, err := db.GetRegistrations( 381 + k.Db, 382 + db.FilterEq("did", user.Did), 383 + db.FilterEq("domain", domain), 384 + ) 294 385 if err != nil { 295 - l.Error("failed to query enforcer", "err", err) 386 + l.Error("failed to get registration", "err", err) 296 387 fail() 388 + return 297 389 } 298 - if !ok { 299 - http.Error(w, "only owners can view dashboards", http.StatusUnauthorized) 390 + if len(registrations) != 1 { 391 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 392 + fail() 300 393 return 301 394 } 395 + registration := registrations[0] 302 396 303 - reg, err := db.RegistrationByDomain(k.Db, domain) 397 + // begin verification 398 + err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 304 399 if err != nil { 305 - l.Error("failed to get registration by domain", "err", err) 400 + l.Error("verification failed", "err", err) 401 + 402 + if errors.Is(err, serververify.FetchError) { 403 + k.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.") 404 + return 405 + } 406 + 407 + if e, ok := err.(*serververify.OwnerMismatch); ok { 408 + k.Pages.Notice(w, noticeId, e.Error()) 409 + return 410 + } 411 + 306 412 fail() 307 413 return 308 414 } 309 415 310 - var members []string 311 - if reg.Registered != nil { 312 - members, err = k.Enforcer.GetUserByRole("server:member", domain) 416 + err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 417 + if err != nil { 418 + l.Error("failed to mark verified", "err", err) 419 + k.Pages.Notice(w, noticeId, err.Error()) 420 + return 421 + } 422 + 423 + // if this knot was previously read-only, then emit a record too 424 + // 425 + // this is part of migrating from the old knot system to the new one 426 + if registration.ReadOnly { 427 + // re-announce by registering under same rkey 428 + client, err := k.OAuth.AuthorizedClient(r) 313 429 if err != nil { 314 - l.Error("failed to get members list", "err", err) 430 + l.Error("failed to authorize client", "err", err) 315 431 fail() 316 432 return 317 433 } 434 + 435 + ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 436 + var exCid *string 437 + if ex != nil { 438 + exCid = ex.Cid 439 + } 440 + 441 + // ignore the error here 442 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 443 + Collection: tangled.KnotNSID, 444 + Repo: user.Did, 445 + Rkey: domain, 446 + Record: &lexutil.LexiconTypeDecoder{ 447 + Val: &tangled.Knot{ 448 + CreatedAt: time.Now().Format(time.RFC3339), 449 + }, 450 + }, 451 + SwapRecord: exCid, 452 + }) 453 + if err != nil { 454 + l.Error("non-fatal: failed to reannouce knot", "err", err) 455 + } 318 456 } 319 457 320 - repos, err := db.GetRepos( 458 + // add this knot to knotstream 459 + go k.Knotstream.AddSource( 460 + r.Context(), 461 + eventconsumer.NewKnotSource(domain), 462 + ) 463 + 464 + shouldRefresh := r.Header.Get("shouldRefresh") 465 + if shouldRefresh == "true" { 466 + k.Pages.HxRefresh(w) 467 + return 468 + } 469 + 470 + // Get updated registration to show 471 + registrations, err = db.GetRegistrations( 321 472 k.Db, 322 - 0, 323 - db.FilterEq("knot", domain), 324 - db.FilterIn("did", members), 473 + db.FilterEq("did", user.Did), 474 + db.FilterEq("domain", domain), 325 475 ) 326 476 if err != nil { 327 - l.Error("failed to get repos list", "err", err) 477 + l.Error("failed to get registration", "err", err) 328 478 fail() 329 479 return 330 480 } 331 - // convert to map 332 - repoByMember := make(map[string][]db.Repo) 333 - for _, r := range repos { 334 - repoByMember[r.Did] = append(repoByMember[r.Did], r) 481 + if len(registrations) != 1 { 482 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 483 + fail() 484 + return 335 485 } 486 + updatedRegistration := registrations[0] 336 487 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 - } 488 + log.Println(updatedRegistration) 351 489 352 - k.Pages.Knot(w, pages.KnotParams{ 353 - LoggedInUser: user, 354 - DidHandleMap: didHandleMap, 355 - Registration: reg, 356 - Members: members, 357 - Repos: repoByMember, 358 - IsOwner: true, 490 + w.Header().Set("HX-Reswap", "outerHTML") 491 + k.Pages.KnotListing(w, pages.KnotListingParams{ 492 + Registration: &updatedRegistration, 359 493 }) 360 494 } 361 495 362 - // list members of domain, requires auth and requires owner status 363 - func (k *Knots) members(w http.ResponseWriter, r *http.Request) { 364 - l := k.Logger.With("handler", "members") 496 + func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { 497 + user := k.OAuth.GetUser(r) 498 + l := k.Logger.With("handler", "addMember") 365 499 366 500 domain := chi.URLParam(r, "domain") 367 501 if domain == "" { 368 - http.Error(w, "malformed url", http.StatusBadRequest) 502 + l.Error("empty domain") 503 + http.Error(w, "Not found", http.StatusNotFound) 369 504 return 370 505 } 371 506 l = l.With("domain", domain) 507 + l = l.With("user", user.Did) 372 508 373 - // list all members for this domain 374 - memberDids, err := k.Enforcer.GetUserByRole("server:member", domain) 509 + registrations, err := db.GetRegistrations( 510 + k.Db, 511 + db.FilterEq("did", user.Did), 512 + db.FilterEq("domain", domain), 513 + db.FilterIsNot("registered", "null"), 514 + ) 375 515 if err != nil { 376 - w.Write([]byte("failed to fetch member list")) 377 - return 378 - } 379 - 380 - w.Write([]byte(strings.Join(memberDids, "\n"))) 381 - } 382 - 383 - // add member to domain, requires auth and requires invite access 384 - func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { 385 - l := k.Logger.With("handler", "members") 386 - 387 - domain := chi.URLParam(r, "domain") 388 - if domain == "" { 389 - http.Error(w, "malformed url", http.StatusBadRequest) 516 + l.Error("failed to get registration", "err", err) 390 517 return 391 518 } 392 - l = l.With("domain", domain) 393 - 394 - reg, err := db.RegistrationByDomain(k.Db, domain) 395 - if err != nil { 396 - l.Error("failed to get registration by domain", "err", err) 397 - http.Error(w, "malformed url", http.StatusBadRequest) 519 + if len(registrations) != 1 { 520 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 398 521 return 399 522 } 523 + registration := registrations[0] 400 524 401 - noticeId := fmt.Sprintf("add-member-error-%d", reg.Id) 402 - l = l.With("notice-id", noticeId) 525 + noticeId := fmt.Sprintf("add-member-error-%d", registration.Id) 403 526 defaultErr := "Failed to add member. Try again later." 404 527 fail := func() { 405 528 k.Pages.Notice(w, noticeId, defaultErr) 406 529 } 407 530 408 - subjectIdentifier := r.FormValue("subject") 409 - if subjectIdentifier == "" { 410 - http.Error(w, "malformed form", http.StatusBadRequest) 531 + member := r.FormValue("member") 532 + if member == "" { 533 + l.Error("empty member") 534 + k.Pages.Notice(w, noticeId, "Failed to add member, empty form.") 411 535 return 412 536 } 413 - l = l.With("subjectIdentifier", subjectIdentifier) 537 + l = l.With("member", member) 414 538 415 - subjectIdentity, err := k.IdResolver.ResolveIdent(r.Context(), subjectIdentifier) 539 + memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 416 540 if err != nil { 417 - l.Error("failed to resolve identity", "err", err) 541 + l.Error("failed to resolve member identity to handle", "err", err) 418 542 k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 419 543 return 420 544 } 421 - l = l.With("subjectDid", subjectIdentity.DID) 422 - 423 - l.Info("adding member to knot") 545 + if memberId.Handle.IsInvalidHandle() { 546 + l.Error("failed to resolve member identity to handle") 547 + k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 548 + return 549 + } 424 550 425 - // announce this relation into the firehose, store into owners' pds 551 + // write to pds 426 552 client, err := k.OAuth.AuthorizedClient(r) 427 553 if err != nil { 428 - l.Error("failed to create client", "err", err) 554 + l.Error("failed to authorize client", "err", err) 429 555 fail() 430 556 return 431 557 } 432 558 433 - currentUser := k.OAuth.GetUser(r) 434 - createdAt := time.Now().Format(time.RFC3339) 435 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 559 + rkey := tid.TID() 560 + 561 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 436 562 Collection: tangled.KnotMemberNSID, 437 - Repo: currentUser.Did, 438 - Rkey: tid.TID(), 563 + Repo: user.Did, 564 + Rkey: rkey, 439 565 Record: &lexutil.LexiconTypeDecoder{ 440 566 Val: &tangled.KnotMember{ 441 - Subject: subjectIdentity.DID.String(), 567 + CreatedAt: time.Now().Format(time.RFC3339), 442 568 Domain: domain, 443 - CreatedAt: createdAt, 444 - }}, 569 + Subject: memberId.DID.String(), 570 + }, 571 + }, 445 572 }) 446 - // invalid record 447 573 if err != nil { 448 - l.Error("failed to write to PDS", "err", err) 449 - fail() 574 + l.Error("failed to add record to PDS", "err", err) 575 + k.Pages.Notice(w, noticeId, "Failed to add record to PDS, try again later.") 450 576 return 451 577 } 452 - l = l.With("at-uri", resp.Uri) 453 - l.Info("wrote record to PDS") 454 578 455 - secret, err := db.GetRegistrationKey(k.Db, domain) 579 + err = k.Enforcer.AddKnotMember(domain, memberId.DID.String()) 456 580 if err != nil { 457 - l.Error("failed to get registration key", "err", err) 581 + l.Error("failed to add member to ACLs", "err", err) 458 582 fail() 459 583 return 460 584 } 461 585 462 - ksClient, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev) 586 + err = k.Enforcer.E.SavePolicy() 463 587 if err != nil { 464 - l.Error("failed to create client", "err", err) 588 + l.Error("failed to save ACL policy", "err", err) 465 589 fail() 466 590 return 467 591 } 468 592 469 - ksResp, err := ksClient.AddMember(subjectIdentity.DID.String()) 593 + // success 594 + k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) 595 + } 596 + 597 + func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 598 + user := k.OAuth.GetUser(r) 599 + l := k.Logger.With("handler", "removeMember") 600 + 601 + noticeId := "operation-error" 602 + defaultErr := "Failed to remove member. Try again later." 603 + fail := func() { 604 + k.Pages.Notice(w, noticeId, defaultErr) 605 + } 606 + 607 + domain := chi.URLParam(r, "domain") 608 + if domain == "" { 609 + l.Error("empty domain") 610 + fail() 611 + return 612 + } 613 + l = l.With("domain", domain) 614 + l = l.With("user", user.Did) 615 + 616 + registrations, err := db.GetRegistrations( 617 + k.Db, 618 + db.FilterEq("did", user.Did), 619 + db.FilterEq("domain", domain), 620 + db.FilterIsNot("registered", "null"), 621 + ) 470 622 if err != nil { 471 - l.Error("failed to reach knotserver", "err", err) 472 - k.Pages.Notice(w, noticeId, "Failed to reach to knotserver.") 623 + l.Error("failed to get registration", "err", err) 624 + return 625 + } 626 + if len(registrations) != 1 { 627 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 473 628 return 474 629 } 475 630 476 - if ksResp.StatusCode != http.StatusNoContent { 477 - l.Error("status mismatch", "got", ksResp.StatusCode, "expected", http.StatusNoContent) 478 - k.Pages.Notice(w, noticeId, fmt.Sprintf("Unexpected status code from knotserver %d, expected %d", ksResp.StatusCode, http.StatusNoContent)) 631 + member := r.FormValue("member") 632 + if member == "" { 633 + l.Error("empty member") 634 + k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") 479 635 return 480 636 } 637 + l = l.With("member", member) 481 638 482 - err = k.Enforcer.AddKnotMember(domain, subjectIdentity.DID.String()) 639 + memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 483 640 if err != nil { 484 - l.Error("failed to add member to enforcer", "err", err) 641 + l.Error("failed to resolve member identity to handle", "err", err) 642 + k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 643 + return 644 + } 645 + if memberId.Handle.IsInvalidHandle() { 646 + l.Error("failed to resolve member identity to handle") 647 + k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 648 + return 649 + } 650 + 651 + // remove from enforcer 652 + err = k.Enforcer.RemoveKnotMember(domain, memberId.DID.String()) 653 + if err != nil { 654 + l.Error("failed to update ACLs", "err", err) 485 655 fail() 486 656 return 487 657 } 488 658 489 - // success 490 - k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) 659 + client, err := k.OAuth.AuthorizedClient(r) 660 + if err != nil { 661 + l.Error("failed to authorize client", "err", err) 662 + fail() 663 + return 664 + } 665 + 666 + // TODO: We need to track the rkey for knot members to delete the record 667 + // For now, just remove from ACLs 668 + _ = client 669 + 670 + // commit everything 671 + err = k.Enforcer.E.SavePolicy() 672 + if err != nil { 673 + l.Error("failed to save ACLs", "err", err) 674 + fail() 675 + return 676 + } 677 + 678 + // ok 679 + k.Pages.HxRefresh(w) 491 680 } 492 681 493 - func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 682 + func (k *Knots) banner(w http.ResponseWriter, r *http.Request) { 683 + user := k.OAuth.GetUser(r) 684 + l := k.Logger.With("handler", "removeMember") 685 + l = l.With("did", user.Did) 686 + l = l.With("handle", user.Handle) 687 + 688 + registrations, err := db.GetRegistrations( 689 + k.Db, 690 + db.FilterEq("did", user.Did), 691 + db.FilterEq("read_only", 1), 692 + ) 693 + if err != nil { 694 + l.Error("non-fatal: failed to get registrations") 695 + return 696 + } 697 + 698 + if registrations == nil { 699 + return 700 + } 701 + 702 + k.Pages.KnotBanner(w, pages.KnotBannerParams{ 703 + Registrations: registrations, 704 + }) 494 705 }
+17 -14
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 } ··· 183 190 id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) 184 191 if err != nil { 185 192 // invalid did or handle 186 - log.Println("failed to resolve did/handle:", err) 193 + log.Printf("failed to resolve did/handle '%s': %s\n", didOrHandle, err) 187 194 mw.pages.Error404(w) 188 195 return 189 196 } ··· 210 217 if err != nil { 211 218 // invalid did or handle 212 219 log.Println("failed to resolve repo") 213 - mw.pages.Error404(w) 220 + mw.pages.ErrorKnot404(w) 214 221 return 215 222 } 216 223 217 - ctx := context.WithValue(req.Context(), "knot", repo.Knot) 218 - ctx = context.WithValue(ctx, "repoAt", repo.AtUri) 219 - ctx = context.WithValue(ctx, "repoDescription", repo.Description) 220 - ctx = context.WithValue(ctx, "repoSpindle", repo.Spindle) 221 - ctx = context.WithValue(ctx, "repoAddedAt", repo.Created.Format(time.RFC3339)) 224 + ctx := context.WithValue(req.Context(), "repo", repo) 222 225 next.ServeHTTP(w, req.WithContext(ctx)) 223 226 }) 224 227 } ··· 231 234 f, err := mw.repoResolver.Resolve(r) 232 235 if err != nil { 233 236 log.Println("failed to fully resolve repo", err) 234 - http.Error(w, "invalid repo url", http.StatusNotFound) 237 + mw.pages.ErrorKnot404(w) 235 238 return 236 239 } 237 240 ··· 243 246 return 244 247 } 245 248 246 - pr, err := db.GetPull(mw.db, f.RepoAt, prIdInt) 249 + pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt) 247 250 if err != nil { 248 251 log.Println("failed to get pull and comments", err) 249 252 return ··· 280 283 f, err := mw.repoResolver.Resolve(r) 281 284 if err != nil { 282 285 log.Println("failed to fully resolve repo", err) 283 - http.Error(w, "invalid repo url", http.StatusNotFound) 286 + mw.pages.ErrorKnot404(w) 284 287 return 285 288 } 286 289 287 - fullName := f.OwnerHandle() + "/" + f.RepoName 290 + fullName := f.OwnerHandle() + "/" + f.Name 288 291 289 292 if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 290 293 if r.URL.Query().Get("go-get") == "1" {
+115 -84
appview/oauth/handler/handler.go
··· 8 8 "log" 9 9 "net/http" 10 10 "net/url" 11 + "slices" 11 12 "strings" 12 13 "time" 13 14 ··· 25 26 "tangled.sh/tangled.sh/core/appview/oauth/client" 26 27 "tangled.sh/tangled.sh/core/appview/pages" 27 28 "tangled.sh/tangled.sh/core/idresolver" 28 - "tangled.sh/tangled.sh/core/knotclient" 29 29 "tangled.sh/tangled.sh/core/rbac" 30 30 "tangled.sh/tangled.sh/core/tid" 31 31 ) ··· 109 109 func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) { 110 110 switch r.Method { 111 111 case http.MethodGet: 112 - 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 + }) 113 116 case http.MethodPost: 114 117 handle := r.FormValue("handle") 115 118 ··· 194 197 DpopAuthserverNonce: parResp.DpopAuthserverNonce, 195 198 DpopPrivateJwk: string(dpopKeyJson), 196 199 State: parResp.State, 200 + ReturnUrl: r.FormValue("return_url"), 197 201 }) 198 202 if err != nil { 199 203 log.Println("failed to save oauth request:", err) ··· 249 253 return 250 254 } 251 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 + 252 262 self := o.oauth.ClientMetadata() 253 263 254 264 oauthClient, err := client.NewClient( ··· 311 321 } 312 322 } 313 323 314 - 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) 315 330 } 316 331 317 332 func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) { ··· 338 353 return pubKey, nil 339 354 } 340 355 356 + var ( 357 + tangledHandle = "tangled.sh" 358 + tangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli" 359 + defaultSpindle = "spindle.tangled.sh" 360 + defaultKnot = "knot1.tangled.sh" 361 + ) 362 + 341 363 func (o *OAuthHandler) addToDefaultSpindle(did string) { 342 364 // use the tangled.sh app password to get an accessJwt 343 365 // and create an sh.tangled.spindle.member record with that 344 - 345 - defaultSpindle := "spindle.tangled.sh" 346 - appPassword := o.config.Core.AppPassword 347 - 348 366 spindleMembers, err := db.GetSpindleMembers( 349 367 o.db, 350 368 db.FilterEq("instance", "spindle.tangled.sh"), ··· 360 378 return 361 379 } 362 380 363 - // TODO: hardcoded tangled handle and did for now 364 - tangledHandle := "tangled.sh" 365 - tangledDid := "did:plc:wshs7t2adsemcrrd4snkeqli" 381 + log.Printf("adding %s to default spindle", did) 382 + session, err := o.createAppPasswordSession() 383 + if err != nil { 384 + log.Printf("failed to create session: %s", err) 385 + return 386 + } 366 387 367 - if appPassword == "" { 368 - log.Println("no app password configured, skipping spindle member addition") 388 + record := tangled.SpindleMember{ 389 + LexiconTypeID: "sh.tangled.spindle.member", 390 + Subject: did, 391 + Instance: defaultSpindle, 392 + CreatedAt: time.Now().Format(time.RFC3339), 393 + } 394 + 395 + if err := session.putRecord(record); err != nil { 396 + log.Printf("failed to add member to default knot: %s", err) 369 397 return 370 398 } 371 399 372 - log.Printf("adding %s to default spindle", did) 400 + log.Printf("successfully added %s to default spindle", did) 401 + } 402 + 403 + func (o *OAuthHandler) addToDefaultKnot(did string) { 404 + // use the tangled.sh app password to get an accessJwt 405 + // and create an sh.tangled.spindle.member record with that 406 + 407 + allKnots, err := o.enforcer.GetKnotsForUser(did) 408 + if err != nil { 409 + log.Printf("failed to get knot members for did %s: %v", did, err) 410 + return 411 + } 412 + 413 + if slices.Contains(allKnots, defaultKnot) { 414 + log.Printf("did %s is already a member of the default knot", did) 415 + return 416 + } 417 + 418 + log.Printf("adding %s to default knot", did) 419 + session, err := o.createAppPasswordSession() 420 + if err != nil { 421 + log.Printf("failed to create session: %s", err) 422 + return 423 + } 424 + 425 + record := tangled.KnotMember{ 426 + LexiconTypeID: "sh.tangled.knot.member", 427 + Subject: did, 428 + Domain: defaultKnot, 429 + CreatedAt: time.Now().Format(time.RFC3339), 430 + } 431 + 432 + if err := session.putRecord(record); err != nil { 433 + log.Printf("failed to add member to default knot: %s", err) 434 + return 435 + } 436 + 437 + log.Printf("successfully added %s to default Knot", did) 438 + } 439 + 440 + // create a session using apppasswords 441 + type session struct { 442 + AccessJwt string `json:"accessJwt"` 443 + PdsEndpoint string 444 + } 445 + 446 + func (o *OAuthHandler) createAppPasswordSession() (*session, error) { 447 + appPassword := o.config.Core.AppPassword 448 + if appPassword == "" { 449 + return nil, fmt.Errorf("no app password configured, skipping member addition") 450 + } 373 451 374 452 resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid) 375 453 if err != nil { 376 - log.Printf("failed to resolve tangled.sh DID %s: %v", tangledDid, err) 377 - return 454 + return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", tangledDid, err) 378 455 } 379 456 380 457 pdsEndpoint := resolved.PDSEndpoint() 381 458 if pdsEndpoint == "" { 382 - log.Printf("no PDS endpoint found for tangled.sh DID %s", tangledDid) 383 - return 459 + return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", tangledDid) 384 460 } 385 461 386 462 sessionPayload := map[string]string{ ··· 389 465 } 390 466 sessionBytes, err := json.Marshal(sessionPayload) 391 467 if err != nil { 392 - log.Printf("failed to marshal session payload: %v", err) 393 - return 468 + return nil, fmt.Errorf("failed to marshal session payload: %v", err) 394 469 } 395 470 396 471 sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 397 472 sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 398 473 if err != nil { 399 - log.Printf("failed to create session request: %v", err) 400 - return 474 + return nil, fmt.Errorf("failed to create session request: %v", err) 401 475 } 402 476 sessionReq.Header.Set("Content-Type", "application/json") 403 477 404 478 client := &http.Client{Timeout: 30 * time.Second} 405 479 sessionResp, err := client.Do(sessionReq) 406 480 if err != nil { 407 - log.Printf("failed to create session: %v", err) 408 - return 481 + return nil, fmt.Errorf("failed to create session: %v", err) 409 482 } 410 483 defer sessionResp.Body.Close() 411 484 412 485 if sessionResp.StatusCode != http.StatusOK { 413 - log.Printf("failed to create session: HTTP %d", sessionResp.StatusCode) 414 - return 486 + return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 415 487 } 416 488 417 - var session struct { 418 - AccessJwt string `json:"accessJwt"` 419 - } 489 + var session session 420 490 if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 421 - log.Printf("failed to decode session response: %v", err) 422 - return 491 + return nil, fmt.Errorf("failed to decode session response: %v", err) 423 492 } 424 493 425 - record := tangled.SpindleMember{ 426 - LexiconTypeID: "sh.tangled.spindle.member", 427 - Subject: did, 428 - Instance: defaultSpindle, 429 - CreatedAt: time.Now().Format(time.RFC3339), 430 - } 494 + session.PdsEndpoint = pdsEndpoint 495 + 496 + return &session, nil 497 + } 431 498 499 + func (s *session) putRecord(record any) error { 432 500 recordBytes, err := json.Marshal(record) 433 501 if err != nil { 434 - log.Printf("failed to marshal spindle member record: %v", err) 435 - return 502 + return fmt.Errorf("failed to marshal knot member record: %w", err) 436 503 } 437 504 438 - payload := map[string]interface{}{ 505 + payload := map[string]any{ 439 506 "repo": tangledDid, 440 - "collection": tangled.SpindleMemberNSID, 507 + "collection": tangled.KnotMemberNSID, 441 508 "rkey": tid.TID(), 442 509 "record": json.RawMessage(recordBytes), 443 510 } 444 511 445 512 payloadBytes, err := json.Marshal(payload) 446 513 if err != nil { 447 - log.Printf("failed to marshal request payload: %v", err) 448 - return 514 + return fmt.Errorf("failed to marshal request payload: %w", err) 449 515 } 450 516 451 - url := pdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 517 + url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 452 518 req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 453 519 if err != nil { 454 - log.Printf("failed to create HTTP request: %v", err) 455 - return 520 + return fmt.Errorf("failed to create HTTP request: %w", err) 456 521 } 457 522 458 523 req.Header.Set("Content-Type", "application/json") 459 - req.Header.Set("Authorization", "Bearer "+session.AccessJwt) 524 + req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 460 525 526 + client := &http.Client{Timeout: 30 * time.Second} 461 527 resp, err := client.Do(req) 462 528 if err != nil { 463 - log.Printf("failed to add user to default spindle: %v", err) 464 - return 529 + return fmt.Errorf("failed to add user to default Knot: %w", err) 465 530 } 466 531 defer resp.Body.Close() 467 532 468 533 if resp.StatusCode != http.StatusOK { 469 - log.Printf("failed to add user to default spindle: HTTP %d", resp.StatusCode) 470 - return 534 + return fmt.Errorf("failed to add user to default Knot: HTTP %d", resp.StatusCode) 471 535 } 472 536 473 - log.Printf("successfully added %s to default spindle", did) 474 - } 475 - 476 - func (o *OAuthHandler) addToDefaultKnot(did string) { 477 - defaultKnot := "knot1.tangled.sh" 478 - 479 - log.Printf("adding %s to default knot", did) 480 - err := o.enforcer.AddKnotMember(defaultKnot, did) 481 - if err != nil { 482 - log.Println("failed to add user to knot1.tangled.sh: ", err) 483 - return 484 - } 485 - err = o.enforcer.E.SavePolicy() 486 - if err != nil { 487 - log.Println("failed to add user to knot1.tangled.sh: ", err) 488 - return 489 - } 490 - 491 - secret, err := db.GetRegistrationKey(o.db, defaultKnot) 492 - if err != nil { 493 - log.Println("failed to get registration key for knot1.tangled.sh") 494 - return 495 - } 496 - signedClient, err := knotclient.NewSignedClient(defaultKnot, secret, o.config.Core.Dev) 497 - resp, err := signedClient.AddMember(did) 498 - if err != nil { 499 - log.Println("failed to add user to knot1.tangled.sh: ", err) 500 - return 501 - } 502 - 503 - if resp.StatusCode != http.StatusNoContent { 504 - log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode) 505 - return 506 - } 537 + return nil 507 538 }
+16 -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 ··· 276 286 AccessJwt: resp.Token, 277 287 }, 278 288 Host: opts.Host(), 289 + Client: &http.Client{ 290 + Timeout: time.Second * 5, 291 + }, 279 292 }, nil 280 293 } 281 294 ··· 305 318 redirectURIs := makeRedirectURIs(clientURI) 306 319 307 320 if o.config.Core.Dev { 308 - clientURI = fmt.Sprintf("http://127.0.0.1:3000") 321 + clientURI = "http://127.0.0.1:3000" 309 322 redirectURIs = makeRedirectURIs(clientURI) 310 323 311 324 query := url.Values{}
+42 -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 + "tangled.sh/tangled.sh/core/crypto" 24 25 ) 25 26 26 27 func (p *Pages) funcMap() template.FuncMap { ··· 28 29 "split": func(s string) []string { 29 30 return strings.Split(s, "\n") 30 31 }, 32 + "resolve": func(s string) string { 33 + identity, err := p.resolver.ResolveIdent(context.Background(), s) 34 + 35 + if err != nil { 36 + return s 37 + } 38 + 39 + if identity.Handle.IsInvalidHandle() { 40 + return "handle.invalid" 41 + } 42 + 43 + return "@" + identity.Handle.String() 44 + }, 31 45 "truncateAt30": func(s string) string { 32 46 if len(s) <= 30 { 33 47 return s ··· 74 88 "negf64": func(a float64) float64 { 75 89 return -a 76 90 }, 77 - "cond": func(cond interface{}, a, b string) string { 91 + "cond": func(cond any, a, b string) string { 78 92 if cond == nil { 79 93 return b 80 94 } ··· 167 181 return html.UnescapeString(s) 168 182 }, 169 183 "nl2br": func(text string) template.HTML { 170 - return template.HTML(strings.Replace(template.HTMLEscapeString(text), "\n", "<br>", -1)) 184 + return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(text), "\n", "<br>")) 171 185 }, 172 186 "unwrapText": func(text string) string { 173 187 paragraphs := strings.Split(text, "\n\n") ··· 193 207 } 194 208 return v.Slice(0, min(n, v.Len())).Interface() 195 209 }, 196 - 197 210 "markdown": func(text string) template.HTML { 198 - rctx := &markup.RenderContext{RendererType: markup.RendererTypeDefault} 199 - return template.HTML(bluemonday.UGCPolicy().Sanitize(rctx.RenderMarkdown(text))) 211 + p.rctx.RendererType = markup.RendererTypeDefault 212 + htmlString := p.rctx.RenderMarkdown(text) 213 + sanitized := p.rctx.SanitizeDefault(htmlString) 214 + return template.HTML(sanitized) 215 + }, 216 + "description": func(text string) template.HTML { 217 + p.rctx.RendererType = markup.RendererTypeDefault 218 + htmlString := p.rctx.RenderMarkdown(text) 219 + sanitized := p.rctx.SanitizeDescription(htmlString) 220 + return template.HTML(sanitized) 200 221 }, 201 222 "isNil": func(t any) bool { 202 223 // returns false for other "zero" values ··· 236 257 }, 237 258 "cssContentHash": CssContentHash, 238 259 "fileTree": filetree.FileTree, 260 + "pathEscape": func(s string) string { 261 + return url.PathEscape(s) 262 + }, 239 263 "pathUnescape": func(s string) string { 240 264 u, _ := url.PathUnescape(s) 241 265 return u ··· 253 277 }, 254 278 "layoutCenter": func() string { 255 279 return "col-span-1 md:col-span-8 lg:col-span-6" 280 + }, 281 + 282 + "normalizeForHtmlId": func(s string) string { 283 + // TODO: extend this to handle other cases? 284 + return strings.ReplaceAll(s, ":", "_") 285 + }, 286 + "sshFingerprint": func(pubKey string) string { 287 + fp, err := crypto.SSHFingerprint(pubKey) 288 + if err != nil { 289 + return "error" 290 + } 291 + return fp 256 292 }, 257 293 } 258 294 }
+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 + }
+133 -84
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 ··· 45 46 t map[string]*template.Template 46 47 47 48 avatar config.AvatarConfig 49 + resolver *idresolver.Resolver 48 50 dev bool 49 51 embedFS embed.FS 50 52 templateDir string // Path to templates on disk for dev mode 51 53 rctx *markup.RenderContext 52 54 } 53 55 54 - func NewPages(config *config.Config) *Pages { 56 + func NewPages(config *config.Config, res *idresolver.Resolver) *Pages { 55 57 // initialized with safe defaults, can be overriden per use 56 58 rctx := &markup.RenderContext{ 57 59 IsDev: config.Core.Dev, 58 60 CamoUrl: config.Camo.Host, 59 61 CamoSecret: config.Camo.SharedSecret, 62 + Sanitizer: markup.NewSanitizer(), 60 63 } 61 64 62 65 p := &Pages{ ··· 66 69 avatar: config.Avatar, 67 70 embedFS: Files, 68 71 rctx: rctx, 72 + resolver: res, 69 73 templateDir: "appview/pages", 70 74 } 71 75 ··· 256 260 return p.executeOrReload(name, w, "layouts/repobase", params) 257 261 } 258 262 263 + func (p *Pages) Favicon(w io.Writer) error { 264 + return p.executePlain("favicon", w, nil) 265 + } 266 + 259 267 type LoginParams struct { 268 + ReturnUrl string 260 269 } 261 270 262 271 func (p *Pages) Login(w io.Writer, params LoginParams) error { ··· 290 299 type TimelineParams struct { 291 300 LoggedInUser *oauth.User 292 301 Timeline []db.TimelineEvent 293 - DidHandleMap map[string]string 302 + Repos []db.Repo 294 303 } 295 304 296 305 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 297 - return p.execute("timeline", w, params) 306 + return p.execute("timeline/timeline", w, params) 298 307 } 299 308 300 - type SettingsParams struct { 309 + type UserProfileSettingsParams struct { 310 + LoggedInUser *oauth.User 311 + Tabs []map[string]any 312 + Tab string 313 + } 314 + 315 + func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error { 316 + return p.execute("user/settings/profile", w, params) 317 + } 318 + 319 + type UserKeysSettingsParams struct { 301 320 LoggedInUser *oauth.User 302 321 PubKeys []db.PublicKey 322 + Tabs []map[string]any 323 + Tab string 324 + } 325 + 326 + func (p *Pages) UserKeysSettings(w io.Writer, params UserKeysSettingsParams) error { 327 + return p.execute("user/settings/keys", w, params) 328 + } 329 + 330 + type UserEmailsSettingsParams struct { 331 + LoggedInUser *oauth.User 303 332 Emails []db.Email 333 + Tabs []map[string]any 334 + Tab string 304 335 } 305 336 306 - func (p *Pages) Settings(w io.Writer, params SettingsParams) error { 307 - return p.execute("settings", w, params) 337 + func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error { 338 + return p.execute("user/settings/emails", w, params) 339 + } 340 + 341 + type KnotBannerParams struct { 342 + Registrations []db.Registration 343 + } 344 + 345 + func (p *Pages) KnotBanner(w io.Writer, params KnotBannerParams) error { 346 + return p.executePlain("knots/fragments/banner", w, params) 308 347 } 309 348 310 349 type KnotsParams struct { ··· 318 357 319 358 type KnotParams struct { 320 359 LoggedInUser *oauth.User 321 - DidHandleMap map[string]string 322 360 Registration *db.Registration 323 361 Members []string 324 362 Repos map[string][]db.Repo ··· 330 368 } 331 369 332 370 type KnotListingParams struct { 333 - db.Registration 371 + *db.Registration 334 372 } 335 373 336 374 func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error { 337 375 return p.executePlain("knots/fragments/knotListing", w, params) 338 376 } 339 377 340 - type KnotListingFullParams struct { 341 - Registrations []db.Registration 342 - } 343 - 344 - func (p *Pages) KnotListingFull(w io.Writer, params KnotListingFullParams) error { 345 - return p.executePlain("knots/fragments/knotListingFull", w, params) 346 - } 347 - 348 - type KnotSecretParams struct { 349 - Secret string 350 - } 351 - 352 - func (p *Pages) KnotSecret(w io.Writer, params KnotSecretParams) error { 353 - return p.executePlain("knots/fragments/secret", w, params) 354 - } 355 - 356 378 type SpindlesParams struct { 357 379 LoggedInUser *oauth.User 358 380 Spindles []db.Spindle ··· 375 397 Spindle db.Spindle 376 398 Members []string 377 399 Repos map[string][]db.Repo 378 - DidHandleMap map[string]string 379 400 } 380 401 381 402 func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { ··· 401 422 return p.execute("repo/fork", w, params) 402 423 } 403 424 404 - type ProfilePageParams struct { 425 + type ProfileHomePageParams struct { 405 426 LoggedInUser *oauth.User 406 427 Repos []db.Repo 407 428 CollaboratingRepos []db.Repo 408 429 ProfileTimeline *db.ProfileTimeline 409 430 Card ProfileCard 410 431 Punchcard db.Punchcard 411 - 412 - DidHandleMap map[string]string 413 432 } 414 433 415 434 type ProfileCard struct { 416 - UserDid string 417 - UserHandle string 418 - FollowStatus db.FollowStatus 419 - Followers int 420 - Following int 435 + UserDid string 436 + UserHandle string 437 + FollowStatus db.FollowStatus 438 + FollowersCount int 439 + FollowingCount int 421 440 422 441 Profile *db.Profile 423 442 } 424 443 425 - func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error { 444 + func (p *Pages) ProfileHomePage(w io.Writer, params ProfileHomePageParams) error { 426 445 return p.execute("user/profile", w, params) 427 446 } 428 447 ··· 430 449 LoggedInUser *oauth.User 431 450 Repos []db.Repo 432 451 Card ProfileCard 433 - 434 - DidHandleMap map[string]string 435 452 } 436 453 437 454 func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error { 438 455 return p.execute("user/repos", w, params) 439 456 } 440 457 458 + type FollowCard struct { 459 + UserDid string 460 + FollowStatus db.FollowStatus 461 + FollowersCount int 462 + FollowingCount int 463 + Profile *db.Profile 464 + } 465 + 466 + type FollowersPageParams struct { 467 + LoggedInUser *oauth.User 468 + Followers []FollowCard 469 + Card ProfileCard 470 + } 471 + 472 + func (p *Pages) FollowersPage(w io.Writer, params FollowersPageParams) error { 473 + return p.execute("user/followers", w, params) 474 + } 475 + 476 + type FollowingPageParams struct { 477 + LoggedInUser *oauth.User 478 + Following []FollowCard 479 + Card ProfileCard 480 + } 481 + 482 + func (p *Pages) FollowingPage(w io.Writer, params FollowingPageParams) error { 483 + return p.execute("user/following", w, params) 484 + } 485 + 441 486 type FollowFragmentParams struct { 442 487 UserDid string 443 488 FollowStatus db.FollowStatus ··· 460 505 LoggedInUser *oauth.User 461 506 Profile *db.Profile 462 507 AllRepos []PinnedRepo 463 - DidHandleMap map[string]string 464 508 } 465 509 466 510 type PinnedRepo struct { ··· 495 539 } 496 540 497 541 type RepoIndexParams struct { 498 - LoggedInUser *oauth.User 499 - RepoInfo repoinfo.RepoInfo 500 - Active string 501 - TagMap map[string][]string 502 - CommitsTrunc []*object.Commit 503 - TagsTrunc []*types.TagReference 504 - BranchesTrunc []types.Branch 505 - ForkInfo *types.ForkInfo 542 + LoggedInUser *oauth.User 543 + RepoInfo repoinfo.RepoInfo 544 + Active string 545 + TagMap map[string][]string 546 + CommitsTrunc []*object.Commit 547 + TagsTrunc []*types.TagReference 548 + BranchesTrunc []types.Branch 549 + // ForkInfo *types.ForkInfo 506 550 HTMLReadme template.HTML 507 551 Raw bool 508 552 EmailToDidOrHandle map[string]string ··· 519 563 } 520 564 521 565 p.rctx.RepoInfo = params.RepoInfo 566 + p.rctx.RepoInfo.Ref = params.Ref 522 567 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 523 568 524 569 if params.ReadmeFileName != "" { 525 - var htmlString string 526 570 ext := filepath.Ext(params.ReadmeFileName) 527 571 switch ext { 528 572 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 529 - htmlString = p.rctx.Sanitize(htmlString) 530 - htmlString = p.rctx.RenderMarkdown(params.Readme) 531 573 params.Raw = false 532 - params.HTMLReadme = template.HTML(htmlString) 574 + htmlString := p.rctx.RenderMarkdown(params.Readme) 575 + sanitized := p.rctx.SanitizeDefault(htmlString) 576 + params.HTMLReadme = template.HTML(sanitized) 533 577 default: 534 578 params.Raw = true 535 579 } ··· 668 712 p.rctx.RepoInfo = params.RepoInfo 669 713 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 670 714 htmlString := p.rctx.RenderMarkdown(params.Contents) 671 - params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString)) 715 + sanitized := p.rctx.SanitizeDefault(htmlString) 716 + params.RenderedContents = template.HTML(sanitized) 672 717 } 673 718 } 674 719 675 - if params.Lines < 5000 { 676 - c := params.Contents 677 - formatter := chromahtml.New( 678 - chromahtml.InlineCode(false), 679 - chromahtml.WithLineNumbers(true), 680 - chromahtml.WithLinkableLineNumbers(true, "L"), 681 - chromahtml.Standalone(false), 682 - chromahtml.WithClasses(true), 683 - ) 684 - 685 - lexer := lexers.Get(filepath.Base(params.Path)) 686 - if lexer == nil { 687 - lexer = lexers.Fallback 688 - } 720 + c := params.Contents 721 + formatter := chromahtml.New( 722 + chromahtml.InlineCode(false), 723 + chromahtml.WithLineNumbers(true), 724 + chromahtml.WithLinkableLineNumbers(true, "L"), 725 + chromahtml.Standalone(false), 726 + chromahtml.WithClasses(true), 727 + ) 689 728 690 - iterator, err := lexer.Tokenise(nil, c) 691 - if err != nil { 692 - return fmt.Errorf("chroma tokenize: %w", err) 693 - } 729 + lexer := lexers.Get(filepath.Base(params.Path)) 730 + if lexer == nil { 731 + lexer = lexers.Fallback 732 + } 694 733 695 - var code bytes.Buffer 696 - err = formatter.Format(&code, style, iterator) 697 - if err != nil { 698 - return fmt.Errorf("chroma format: %w", err) 699 - } 734 + iterator, err := lexer.Tokenise(nil, c) 735 + if err != nil { 736 + return fmt.Errorf("chroma tokenize: %w", err) 737 + } 700 738 701 - params.Contents = code.String() 739 + var code bytes.Buffer 740 + err = formatter.Format(&code, style, iterator) 741 + if err != nil { 742 + return fmt.Errorf("chroma format: %w", err) 702 743 } 703 744 745 + params.Contents = code.String() 704 746 params.Active = "overview" 705 747 return p.executeRepo("repo/blob", w, params) 706 748 } ··· 779 821 RepoInfo repoinfo.RepoInfo 780 822 Active string 781 823 Issues []db.Issue 782 - DidHandleMap map[string]string 783 824 Page pagination.Page 784 825 FilteringByOpen bool 785 826 } ··· 793 834 LoggedInUser *oauth.User 794 835 RepoInfo repoinfo.RepoInfo 795 836 Active string 796 - Issue db.Issue 837 + Issue *db.Issue 797 838 Comments []db.Comment 798 839 IssueOwnerHandle string 799 - DidHandleMap map[string]string 800 840 801 841 OrderedReactionKinds []db.ReactionKind 802 842 Reactions map[db.ReactionKind]int ··· 850 890 851 891 type SingleIssueCommentParams struct { 852 892 LoggedInUser *oauth.User 853 - DidHandleMap map[string]string 854 893 RepoInfo repoinfo.RepoInfo 855 894 Issue *db.Issue 856 895 Comment *db.Comment ··· 882 921 RepoInfo repoinfo.RepoInfo 883 922 Pulls []*db.Pull 884 923 Active string 885 - DidHandleMap map[string]string 886 924 FilteringBy db.PullState 887 925 Stacks map[string]db.Stack 888 926 Pipelines map[string]db.Pipeline ··· 915 953 LoggedInUser *oauth.User 916 954 RepoInfo repoinfo.RepoInfo 917 955 Active string 918 - DidHandleMap map[string]string 919 956 Pull *db.Pull 920 957 Stack db.Stack 921 958 AbandonedPulls []*db.Pull ··· 935 972 936 973 type RepoPullPatchParams struct { 937 974 LoggedInUser *oauth.User 938 - DidHandleMap map[string]string 939 975 RepoInfo repoinfo.RepoInfo 940 976 Pull *db.Pull 941 977 Stack db.Stack ··· 953 989 954 990 type RepoPullInterdiffParams struct { 955 991 LoggedInUser *oauth.User 956 - DidHandleMap map[string]string 957 992 RepoInfo repoinfo.RepoInfo 958 993 Pull *db.Pull 959 994 Round int ··· 1166 1201 return p.execute("strings/dashboard", w, params) 1167 1202 } 1168 1203 1204 + type StringTimelineParams struct { 1205 + LoggedInUser *oauth.User 1206 + Strings []db.String 1207 + } 1208 + 1209 + func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error { 1210 + return p.execute("strings/timeline", w, params) 1211 + } 1212 + 1169 1213 type SingleStringParams struct { 1170 1214 LoggedInUser *oauth.User 1171 1215 ShowRendered bool ··· 1182 1226 if params.ShowRendered { 1183 1227 switch markup.GetFormat(params.String.Filename) { 1184 1228 case markup.FormatMarkdown: 1185 - p.rctx.RendererType = markup.RendererTypeDefault 1229 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 1186 1230 htmlString := p.rctx.RenderMarkdown(params.String.Contents) 1187 - params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString)) 1231 + sanitized := p.rctx.SanitizeDefault(htmlString) 1232 + params.RenderedContents = template.HTML(sanitized) 1188 1233 } 1189 1234 } 1190 1235 ··· 1267 1312 1268 1313 func (p *Pages) Error404(w io.Writer) error { 1269 1314 return p.execute("errors/404", w, nil) 1315 + } 1316 + 1317 + func (p *Pages) ErrorKnot404(w io.Writer) error { 1318 + return p.execute("errors/knot404", w, nil) 1270 1319 } 1271 1320 1272 1321 func (p *Pages) Error503(w io.Writer) error {
+24 -4
appview/pages/templates/errors/404.html
··· 1 1 {{ define "title" }}404 &middot; tangled{{ end }} 2 2 3 3 {{ define "content" }} 4 - <h1>404 &mdash; nothing like that here!</h1> 5 - <p> 6 - It seems we couldn't find what you were looking for. Sorry about that! 7 - </p> 4 + <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 + <div class="mb-6"> 7 + <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center"> 8 + {{ i "search-x" "w-8 h-8 text-gray-400 dark:text-gray-500" }} 9 + </div> 10 + </div> 11 + 12 + <div class="space-y-4"> 13 + <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 + 404 &mdash; page not found 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-300"> 17 + The page you're looking for doesn't exist. It may have been moved, deleted, or you have the wrong URL. 18 + </p> 19 + <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 + <a href="javascript:history.back()" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"> 21 + {{ i "arrow-left" "w-4 h-4" }} 22 + go back 23 + </a> 24 + </div> 25 + </div> 26 + </div> 27 + </div> 8 28 {{ end }}
+36 -3
appview/pages/templates/errors/500.html
··· 1 1 {{ define "title" }}500 &middot; tangled{{ end }} 2 2 3 3 {{ define "content" }} 4 - <h1>500 &mdash; something broke!</h1> 5 - <p>We're working on getting service back up. Hang tight!</p> 6 - {{ end }} 4 + <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 + <div class="mb-6"> 7 + <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"> 8 + {{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }} 9 + </div> 10 + </div> 11 + 12 + <div class="space-y-4"> 13 + <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 + 500 &mdash; internal server error 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-300"> 17 + Something went wrong on our end. We've been notified and are working to fix the issue. 18 + </p> 19 + <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200"> 20 + <div class="flex items-center gap-2"> 21 + {{ i "info" "w-4 h-4" }} 22 + <span class="font-medium">we're on it!</span> 23 + </div> 24 + <p class="mt-1">Our team has been automatically notified about this error.</p> 25 + </div> 26 + <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 27 + <button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white"> 28 + {{ i "refresh-cw" "w-4 h-4" }} 29 + try again 30 + </button> 31 + <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"> 32 + {{ i "home" "w-4 h-4" }} 33 + back to home 34 + </a> 35 + </div> 36 + </div> 37 + </div> 38 + </div> 39 + {{ end }}
+28 -5
appview/pages/templates/errors/503.html
··· 1 1 {{ define "title" }}503 &middot; tangled{{ end }} 2 2 3 3 {{ define "content" }} 4 - <h1>503 &mdash; unable to reach knot</h1> 5 - <p> 6 - We were unable to reach the knot hosting this repository. Try again 7 - later. 8 - </p> 4 + <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 + <div class="mb-6"> 7 + <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center"> 8 + {{ i "server-off" "w-8 h-8 text-blue-500 dark:text-blue-400" }} 9 + </div> 10 + </div> 11 + 12 + <div class="space-y-4"> 13 + <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 + 503 &mdash; service unavailable 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-300"> 17 + We were unable to reach the knot hosting this repository. The service may be temporarily unavailable. 18 + </p> 19 + <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 + <button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white"> 21 + {{ i "refresh-cw" "w-4 h-4" }} 22 + try again 23 + </button> 24 + <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"> 25 + {{ i "arrow-left" "w-4 h-4" }} 26 + back to timeline 27 + </a> 28 + </div> 29 + </div> 30 + </div> 31 + </div> 9 32 {{ end }}
+28
appview/pages/templates/errors/knot404.html
··· 1 + {{ define "title" }}404 &middot; tangled{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 + <div class="mb-6"> 7 + <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center"> 8 + {{ i "book-x" "w-8 h-8 text-orange-500 dark:text-orange-400" }} 9 + </div> 10 + </div> 11 + 12 + <div class="space-y-4"> 13 + <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 + 404 &mdash; repository not found 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-300"> 17 + The repository you were looking for could not be found. The knot serving the repository may be unavailable. 18 + </p> 19 + <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 + <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline"> 21 + {{ i "arrow-left" "w-4 h-4" }} 22 + back to timeline 23 + </a> 24 + </div> 25 + </div> 26 + </div> 27 + </div> 28 + {{ end }}
+26
appview/pages/templates/favicon.html
··· 1 + {{ define "favicon" }} 2 + <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"> 3 + <style> 4 + .favicon-text { 5 + fill: #000000; 6 + stroke: none; 7 + } 8 + 9 + @media (prefers-color-scheme: dark) { 10 + .favicon-text { 11 + fill: #ffffff; 12 + stroke: none; 13 + } 14 + } 15 + </style> 16 + 17 + <g style="display:inline"> 18 + <path d="M0-2.117h62.177v25.135H0z" style="display:inline;fill:none;fill-opacity:1;stroke-width:.396875" transform="translate(11.01 6.9)"/> 19 + <path d="M3.64 22.787c-1.697 0-2.943-.45-3.74-1.35-.77-.9-1.156-2.094-1.156-3.585 0-.36.013-.72.038-1.08.052-.385.129-.873.232-1.464L.44 6.826h-5.089l.733-4.394h3.2c.822 0 1.439-.168 1.85-.502.437-.334.72-.938.848-1.812l.771-4.703h5.243L6.84 2.432h7.787l-.733 4.394H6.107L4.257 17.93l.77.27 6.015-4.742 2.775 3.161-2.313 2.005c-.822.694-1.568 1.31-2.236 1.85-.668.515-1.31.952-1.927 1.311a7.406 7.406 0 0 1-1.774.733c-.59.18-1.233.27-1.927.27z" 20 + aria-label="tangled.sh" 21 + class="favicon-text" 22 + style="font-size:16.2278px;font-family:'IBM Plex Mono';-inkscape-font-specification:'IBM Plex Mono, Normal';display:inline;fill-opacity:1" 23 + transform="translate(11.01 6.9)"/> 24 + </g> 25 + </svg> 26 + {{ end }}
+96 -32
appview/pages/templates/knots/dashboard.html
··· 1 - {{ define "title" }}{{ .Registration.Domain }}{{ end }} 1 + {{ define "title" }}{{ .Registration.Domain }} &middot; knots{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="px-6 py-4"> 5 - <div class="flex justify-between items-center"> 6 - <div id="left-side" class="flex gap-2 items-center"> 7 - <h1 class="text-xl font-bold dark:text-white"> 8 - {{ .Registration.Domain }} 9 - </h1> 10 - <span class="text-gray-500 text-base"> 11 - {{ template "repo/fragments/shortTimeAgo" .Registration.Created }} 12 - </span> 13 - </div> 14 - <div id="right-side" class="flex gap-2"> 15 - {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 16 - {{ if .Registration.Registered }} 17 - <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 4 + <div class="px-6 py-4"> 5 + <div class="flex justify-between items-center"> 6 + <h1 class="text-xl font-bold dark:text-white">{{ .Registration.Domain }}</h1> 7 + <div id="right-side" class="flex gap-2"> 8 + {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 9 + {{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Registration.ByDid) }} 10 + {{ if .Registration.IsRegistered }} 11 + <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 12 + {{ if $isOwner }} 18 13 {{ template "knots/fragments/addMemberModal" .Registration }} 19 - {{ else }} 20 - <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} pending</span> 21 14 {{ end }} 22 - </div> 15 + {{ else if .Registration.IsReadOnly }} 16 + <span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> 17 + {{ i "shield-alert" "w-4 h-4" }} read-only 18 + </span> 19 + {{ if $isOwner }} 20 + {{ block "retryButton" .Registration }} {{ end }} 21 + {{ end }} 22 + {{ else }} 23 + <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span> 24 + {{ if $isOwner }} 25 + {{ block "retryButton" .Registration }} {{ end }} 26 + {{ end }} 27 + {{ end }} 28 + 29 + {{ if $isOwner }} 30 + {{ block "deleteButton" .Registration }} {{ end }} 31 + {{ end }} 23 32 </div> 24 - <div id="operation-error" class="dark:text-red-400"></div> 25 33 </div> 34 + <div id="operation-error" class="dark:text-red-400"></div> 35 + </div> 26 36 27 - {{ if .Members }} 28 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 29 - <div class="flex flex-col gap-2"> 30 - {{ block "knotMember" . }} {{ end }} 31 - </div> 32 - </section> 33 - {{ end }} 37 + {{ if .Members }} 38 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 39 + <div class="flex flex-col gap-2"> 40 + {{ block "member" . }} {{ end }} 41 + </div> 42 + </section> 43 + {{ end }} 34 44 {{ end }} 35 45 36 - {{ define "knotMember" }} 46 + 47 + {{ define "member" }} 37 48 {{ range .Members }} 38 49 <div> 39 50 <div class="flex justify-between items-center"> 40 51 <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> 52 + {{ template "user/fragments/picHandleLink" . }} 53 + <span class="ml-2 font-mono text-gray-500">{{.}}</span> 44 54 </div> 55 + {{ if ne $.LoggedInUser.Did . }} 56 + {{ block "removeMemberButton" (list $ . ) }} {{ end }} 57 + {{ end }} 45 58 </div> 46 59 <div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700"> 47 60 {{ $repos := index $.Repos . }} 48 61 {{ range $repos }} 49 62 <div class="flex gap-2 items-center"> 50 63 {{ i "book-marked" "size-4" }} 51 - <a href="/{{ .Did }}/{{ .Name }}"> 64 + <a href="/{{ resolve .Did }}/{{ .Name }}"> 52 65 {{ .Name }} 53 66 </a> 54 67 </div> 55 68 {{ else }} 56 69 <div class="text-gray-500 dark:text-gray-400"> 57 - No repositories created yet. 70 + No repositories configured yet. 58 71 </div> 59 72 {{ end }} 60 73 </div> 61 74 </div> 62 75 {{ end }} 63 76 {{ end }} 77 + 78 + {{ define "deleteButton" }} 79 + <button 80 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 81 + title="Delete knot" 82 + hx-delete="/knots/{{ .Domain }}" 83 + hx-swap="outerHTML" 84 + hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?" 85 + hx-headers='{"shouldRedirect": "true"}' 86 + > 87 + {{ i "trash-2" "w-5 h-5" }} 88 + <span class="hidden md:inline">delete</span> 89 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 90 + </button> 91 + {{ end }} 92 + 93 + 94 + {{ define "retryButton" }} 95 + <button 96 + class="btn gap-2 group" 97 + title="Retry knot verification" 98 + hx-post="/knots/{{ .Domain }}/retry" 99 + hx-swap="none" 100 + hx-headers='{"shouldRefresh": "true"}' 101 + > 102 + {{ i "rotate-ccw" "w-5 h-5" }} 103 + <span class="hidden md:inline">retry</span> 104 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 105 + </button> 106 + {{ end }} 107 + 108 + 109 + {{ define "removeMemberButton" }} 110 + {{ $root := index . 0 }} 111 + {{ $member := index . 1 }} 112 + {{ $memberHandle := resolve $member }} 113 + <button 114 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 115 + title="Remove member" 116 + hx-post="/knots/{{ $root.Registration.Domain }}/remove" 117 + hx-swap="none" 118 + hx-vals='{"member": "{{$member}}" }' 119 + hx-confirm="Are you sure you want to remove {{ $memberHandle }} from this knot?" 120 + > 121 + {{ i "user-minus" "w-4 h-4" }} 122 + remove 123 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 124 + </button> 125 + {{ end }} 126 + 127 +
+6 -7
appview/pages/templates/knots/fragments/addMemberModal.html
··· 1 1 {{ define "knots/fragments/addMemberModal" }} 2 2 <button 3 3 class="btn gap-2 group" 4 - title="Add member to this spindle" 4 + title="Add member to this knot" 5 5 popovertarget="add-member-{{ .Id }}" 6 6 popovertargetaction="toggle" 7 7 > ··· 20 20 21 21 {{ define "addKnotMemberPopover" }} 22 22 <form 23 - hx-put="/knots/{{ .Domain }}/member" 23 + hx-post="/knots/{{ .Domain }}/add" 24 24 hx-indicator="#spinner" 25 25 hx-swap="none" 26 26 class="flex flex-col gap-2" ··· 28 28 <label for="member-did-{{ .Id }}" class="uppercase p-0"> 29 29 ADD MEMBER 30 30 </label> 31 - <p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories on this knot.</p> 31 + <p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p> 32 32 <input 33 33 type="text" 34 34 id="member-did-{{ .Id }}" 35 - name="subject" 35 + name="member" 36 36 required 37 37 placeholder="@foo.bsky.social" 38 38 /> 39 39 <div class="flex gap-2 pt-2"> 40 - <button 40 + <button 41 41 type="button" 42 42 popovertarget="add-member-{{ .Id }}" 43 43 popovertargetaction="hide" ··· 54 54 </div> 55 55 <div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div> 56 56 </form> 57 - {{ end }} 58 - 57 + {{ end }}
+9
appview/pages/templates/knots/fragments/banner.html
··· 1 + {{ define "knots/fragments/banner" }} 2 + <div class="w-full px-6 py-2 -z-15 bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 border border-yellow-200 dark:border-yellow-800 rounded-b drop-shadow-sm"> 3 + A knot ({{range $i, $r := .Registrations}}{{if ne $i 0}}, {{end}}{{ $r.Domain }}{{ end }}) 4 + that you administer is presently read-only. Consider upgrading this knot to 5 + continue creating repositories on it. 6 + <a href="https://tangled.sh/@tangled.sh/core/tree/master/docs/migrations">Click to read the upgrade guide</a>. 7 + </div> 8 + {{ end }} 9 +
+57 -25
appview/pages/templates/knots/fragments/knotListing.html
··· 1 1 {{ define "knots/fragments/knotListing" }} 2 - <div 3 - id="knot-{{.Id}}" 4 - hx-swap-oob="true" 5 - class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 6 - {{ block "listLeftSide" . }} {{ end }} 7 - {{ block "listRightSide" . }} {{ end }} 2 + <div id="knot-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 3 + {{ block "knotLeftSide" . }} {{ end }} 4 + {{ block "knotRightSide" . }} {{ end }} 8 5 </div> 9 6 {{ end }} 10 7 11 - {{ define "listLeftSide" }} 8 + {{ define "knotLeftSide" }} 9 + {{ if .Registered }} 10 + <a href="/knots/{{ .Domain }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 + {{ i "hard-drive" "w-4 h-4" }} 12 + <span class="hover:underline"> 13 + {{ .Domain }} 14 + </span> 15 + <span class="text-gray-500"> 16 + {{ template "repo/fragments/shortTimeAgo" .Created }} 17 + </span> 18 + </a> 19 + {{ else }} 12 20 <div class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 13 21 {{ i "hard-drive" "w-4 h-4" }} 14 - {{ if .Registered }} 15 - <a href="/knots/{{ .Domain }}"> 16 - {{ .Domain }} 17 - </a> 18 - {{ else }} 19 - {{ .Domain }} 20 - {{ end }} 22 + {{ .Domain }} 21 23 <span class="text-gray-500"> 22 24 {{ template "repo/fragments/shortTimeAgo" .Created }} 23 25 </span> 24 26 </div> 27 + {{ end }} 25 28 {{ end }} 26 29 27 - {{ define "listRightSide" }} 30 + {{ define "knotRightSide" }} 28 31 <div id="right-side" class="flex gap-2"> 29 32 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 30 - {{ if .Registered }} 31 - <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 33 + {{ if .IsRegistered }} 34 + <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}"> 35 + {{ i "shield-check" "w-4 h-4" }} verified 36 + </span> 32 37 {{ template "knots/fragments/addMemberModal" . }} 38 + {{ block "knotDeleteButton" . }} {{ end }} 39 + {{ else if .IsReadOnly }} 40 + <span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> 41 + {{ i "shield-alert" "w-4 h-4" }} read-only 42 + </span> 43 + {{ block "knotRetryButton" . }} {{ end }} 44 + {{ block "knotDeleteButton" . }} {{ end }} 33 45 {{ else }} 34 - <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} pending</span> 35 - {{ block "initializeButton" . }} {{ end }} 46 + <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}"> 47 + {{ i "shield-off" "w-4 h-4" }} unverified 48 + </span> 49 + {{ block "knotRetryButton" . }} {{ end }} 50 + {{ block "knotDeleteButton" . }} {{ end }} 36 51 {{ end }} 37 52 </div> 38 53 {{ end }} 39 54 40 - {{ define "initializeButton" }} 55 + {{ define "knotDeleteButton" }} 56 + <button 57 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 58 + title="Delete knot" 59 + hx-delete="/knots/{{ .Domain }}" 60 + hx-swap="outerHTML" 61 + hx-target="#knot-{{.Id}}" 62 + hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?" 63 + > 64 + {{ i "trash-2" "w-5 h-5" }} 65 + <span class="hidden md:inline">delete</span> 66 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 67 + </button> 68 + {{ end }} 69 + 70 + 71 + {{ define "knotRetryButton" }} 41 72 <button 42 - class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center group" 43 - hx-post="/knots/{{ .Domain }}/init" 73 + class="btn gap-2 group" 74 + title="Retry knot verification" 75 + hx-post="/knots/{{ .Domain }}/retry" 44 76 hx-swap="none" 77 + hx-target="#knot-{{.Id}}" 45 78 > 46 - {{ i "square-play" "w-5 h-5" }} 47 - <span class="hidden md:inline">initialize</span> 79 + {{ i "rotate-ccw" "w-5 h-5" }} 80 + <span class="hidden md:inline">retry</span> 48 81 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 49 82 </button> 50 83 {{ end }} 51 -
-18
appview/pages/templates/knots/fragments/knotListingFull.html
··· 1 - {{ define "knots/fragments/knotListingFull" }} 2 - <section 3 - id="knot-listing-full" 4 - hx-swap-oob="true" 5 - class="rounded w-full flex flex-col gap-2"> 6 - <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your knots</h2> 7 - <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full"> 8 - {{ range $knot := .Registrations }} 9 - {{ template "knots/fragments/knotListing" . }} 10 - {{ else }} 11 - <div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500"> 12 - no knots registered yet 13 - </div> 14 - {{ end }} 15 - </div> 16 - <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 17 - </section> 18 - {{ end }}
-10
appview/pages/templates/knots/fragments/secret.html
··· 1 - {{ define "knots/fragments/secret" }} 2 - <div 3 - id="secret" 4 - hx-swap-oob="true" 5 - class="bg-gray-50 dark:bg-gray-700 border border-black dark:border-gray-500 rounded px-6 py-2 w-full lg:w-3xl"> 6 - <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">generated secret</h2> 7 - <p class="pb-2">Configure your knot to use this secret, and then hit initialize.</p> 8 - <span class="font-mono overflow-x">{{ .Secret }}</span> 9 - </div> 10 - {{ end }}
+23 -8
appview/pages/templates/knots/index.html
··· 8 8 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 9 9 <div class="flex flex-col gap-6"> 10 10 {{ block "about" . }} {{ end }} 11 - {{ template "knots/fragments/knotListingFull" . }} 11 + {{ block "list" . }} {{ end }} 12 12 {{ block "register" . }} {{ end }} 13 13 </div> 14 14 </section> ··· 27 27 </section> 28 28 {{ end }} 29 29 30 + {{ define "list" }} 31 + <section class="rounded w-full flex flex-col gap-2"> 32 + <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your knots</h2> 33 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full"> 34 + {{ range $registration := .Registrations }} 35 + {{ template "knots/fragments/knotListing" . }} 36 + {{ else }} 37 + <div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500"> 38 + no knots registered yet 39 + </div> 40 + {{ end }} 41 + </div> 42 + <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 43 + </section> 44 + {{ end }} 45 + 30 46 {{ define "register" }} 31 - <section class="rounded max-w-2xl flex flex-col gap-2"> 47 + <section class="rounded w-full lg:w-fit flex flex-col gap-2"> 32 48 <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a knot</h2> 33 - <p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to generate a key.</p> 49 + <p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to get started.</p> 34 50 <form 35 - hx-post="/knots/key" 36 - class="space-y-4" 51 + hx-post="/knots/register" 52 + class="max-w-2xl mb-2 space-y-4" 37 53 hx-indicator="#register-button" 38 54 hx-swap="none" 39 55 > ··· 53 69 > 54 70 <span class="inline-flex items-center gap-2"> 55 71 {{ i "plus" "w-4 h-4" }} 56 - generate 72 + register 57 73 </span> 58 74 <span class="pl-2 hidden group-[.htmx-request]:inline"> 59 75 {{ i "loader-circle" "w-4 h-4 animate-spin" }} ··· 61 77 </button> 62 78 </div> 63 79 64 - <div id="registration-error" class="error dark:text-red-400"></div> 80 + <div id="register-error" class="error dark:text-red-400"></div> 65 81 </form> 66 82 67 - <div id="secret"></div> 68 83 </section> 69 84 {{ end }}
-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 }}
+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" . }}
+21 -3
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> ··· 21 21 </div> 22 22 </div> 23 23 </nav> 24 + {{ if .LoggedInUser }} 25 + <div id="upgrade-banner" 26 + hx-get="/knots/upgradeBanner" 27 + hx-trigger="load" 28 + hx-swap="innerHTML"> 29 + </div> 30 + {{ end }} 24 31 {{ end }} 25 32 26 33 {{ define "newButton" }} 27 - <details class="relative inline-block text-left"> 34 + <details class="relative inline-block text-left nav-dropdown"> 28 35 <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 29 36 {{ i "plus" "w-4 h-4" }} new 30 37 </summary> ··· 42 49 {{ end }} 43 50 44 51 {{ define "dropDown" }} 45 - <details class="relative inline-block text-left"> 52 + <details class="relative inline-block text-left nav-dropdown"> 46 53 <summary 47 54 class="cursor-pointer list-none flex items-center" 48 55 > ··· 66 73 </a> 67 74 </div> 68 75 </details> 76 + 77 + <script> 78 + document.addEventListener('click', function(event) { 79 + const dropdowns = document.querySelectorAll('.nav-dropdown'); 80 + dropdowns.forEach(function(dropdown) { 81 + if (!dropdown.contains(event.target)) { 82 + dropdown.removeAttribute('open'); 83 + } 84 + }); 85 + }); 86 + </script> 69 87 {{ end }}
+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 }}
+8 -2
appview/pages/templates/repo/fork.html
··· 5 5 <p class="text-xl font-bold dark:text-white">Fork {{ .RepoInfo.FullName }}</p> 6 6 </div> 7 7 <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 - <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none"> 8 + <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 9 <fieldset class="space-y-3"> 10 10 <legend class="dark:text-white">Select a knot to fork into</legend> 11 11 <div class="space-y-2"> ··· 30 30 </fieldset> 31 31 32 32 <div class="space-y-2"> 33 - <button type="submit" class="btn">fork repo</button> 33 + <button type="submit" class="btn-create flex items-center gap-2"> 34 + {{ i "git-fork" "w-4 h-4" }} 35 + fork repo 36 + <span id="spinner" class="group"> 37 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 38 + </span> 39 + </button> 34 40 <div id="repo" class="error"></div> 35 41 </div> 36 42 </form>
+104
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 1 + {{ define "repo/fragments/cloneDropdown" }} 2 + {{ $knot := .RepoInfo.Knot }} 3 + {{ if eq $knot "knot1.tangled.sh" }} 4 + {{ $knot = "tangled.sh" }} 5 + {{ end }} 6 + 7 + <details id="clone-dropdown" class="relative inline-block text-left group"> 8 + <summary class="btn-create cursor-pointer list-none flex items-center gap-2"> 9 + {{ i "download" "w-4 h-4" }} 10 + <span class="hidden md:inline">code</span> 11 + <span class="group-open:hidden"> 12 + {{ i "chevron-down" "w-4 h-4" }} 13 + </span> 14 + <span class="hidden group-open:flex"> 15 + {{ i "chevron-up" "w-4 h-4" }} 16 + </span> 17 + </summary> 18 + 19 + <div class="absolute right-0 mt-2 w-96 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 drop-shadow-sm dark:text-white z-[9999]"> 20 + <div class="p-4"> 21 + <div class="mb-3"> 22 + <h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">Clone this repository</h3> 23 + </div> 24 + 25 + <!-- HTTPS Clone --> 26 + <div class="mb-3"> 27 + <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">HTTPS</label> 28 + <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 29 + <code 30 + class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 31 + onclick="window.getSelection().selectAllChildren(this)" 32 + data-url="https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}" 33 + >https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code> 34 + <button 35 + onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 36 + class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" 37 + title="Copy to clipboard" 38 + > 39 + {{ i "copy" "w-4 h-4" }} 40 + </button> 41 + </div> 42 + </div> 43 + 44 + <!-- SSH Clone --> 45 + <div class="mb-3"> 46 + <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">SSH</label> 47 + <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 48 + <code 49 + class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 50 + onclick="window.getSelection().selectAllChildren(this)" 51 + data-url="git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}" 52 + >git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code> 53 + <button 54 + onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 55 + class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" 56 + title="Copy to clipboard" 57 + > 58 + {{ i "copy" "w-4 h-4" }} 59 + </button> 60 + </div> 61 + </div> 62 + 63 + <!-- Note for self-hosted --> 64 + <p class="text-xs text-gray-500 dark:text-gray-400"> 65 + For self-hosted knots, clone URLs may differ based on your setup. 66 + </p> 67 + 68 + <!-- Download Archive --> 69 + <div class="pt-2 mt-2 border-t border-gray-200 dark:border-gray-700"> 70 + <a 71 + href="/{{ .RepoInfo.FullName }}/archive/{{ .Ref | urlquery }}" 72 + class="flex items-center gap-2 px-3 py-2 text-sm" 73 + > 74 + {{ i "download" "w-4 h-4" }} 75 + Download tar.gz 76 + </a> 77 + </div> 78 + 79 + </div> 80 + </div> 81 + </details> 82 + 83 + <script> 84 + function copyToClipboard(button, text) { 85 + navigator.clipboard.writeText(text).then(() => { 86 + const originalContent = button.innerHTML; 87 + button.innerHTML = `{{ i "check" "w-4 h-4" }}`; 88 + setTimeout(() => { 89 + button.innerHTML = originalContent; 90 + }, 2000); 91 + }); 92 + } 93 + 94 + // Close clone dropdown when clicking outside 95 + document.addEventListener('click', function(event) { 96 + const cloneDropdown = document.getElementById('clone-dropdown'); 97 + if (cloneDropdown && cloneDropdown.hasAttribute('open')) { 98 + if (!cloneDropdown.contains(event.target)) { 99 + cloneDropdown.removeAttribute('open'); 100 + } 101 + } 102 + }); 103 + </script> 104 + {{ end }}
-55
appview/pages/templates/repo/fragments/cloneInstructions.html
··· 1 - {{ define "repo/fragments/cloneInstructions" }} 2 - {{ $knot := .RepoInfo.Knot }} 3 - {{ if eq $knot "knot1.tangled.sh" }} 4 - {{ $knot = "tangled.sh" }} 5 - {{ end }} 6 - <section 7 - class="mt-4 p-6 rounded drop-shadow-sm bg-white dark:bg-gray-800 dark:text-white w-full mx-auto overflow-auto flex flex-col gap-4" 8 - > 9 - <div class="flex flex-col gap-2"> 10 - <strong>push</strong> 11 - <div class="md:pl-4 overflow-x-auto whitespace-nowrap"> 12 - <code class="dark:text-gray-100" 13 - >git remote add origin 14 - git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code 15 - > 16 - </div> 17 - </div> 18 - 19 - <div class="flex flex-col gap-2"> 20 - <strong>clone</strong> 21 - <div class="md:pl-4 flex flex-col gap-2"> 22 - <div class="flex items-center gap-3"> 23 - <span 24 - class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white" 25 - >HTTP</span 26 - > 27 - <div class="overflow-x-auto whitespace-nowrap flex-1"> 28 - <code class="dark:text-gray-100" 29 - >git clone 30 - https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code 31 - > 32 - </div> 33 - </div> 34 - 35 - <div class="flex items-center gap-3"> 36 - <span 37 - class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white" 38 - >SSH</span 39 - > 40 - <div class="overflow-x-auto whitespace-nowrap flex-1"> 41 - <code class="dark:text-gray-100" 42 - >git clone 43 - git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code 44 - > 45 - </div> 46 - </div> 47 - </div> 48 - </div> 49 - 50 - <p class="py-2 text-gray-500 dark:text-gray-400"> 51 - Note that for self-hosted knots, clone URLs may be different based 52 - on your setup. 53 - </p> 54 - </section> 55 - {{ end }}
+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 }}
+91 -109
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"> 85 - {{ $isOwner := and .LoggedInUser .RepoInfo.Roles.IsOwner }} 86 - {{ $isCollaborator := and .LoggedInUser .RepoInfo.Roles.IsCollaborator }} 87 - {{ if and (or $isOwner $isCollaborator) .ForkInfo .ForkInfo.IsFork }} 88 - {{ $disabled := "" }} 89 - {{ $title := "" }} 90 - {{ if eq .ForkInfo.Status 0 }} 91 - {{ $disabled = "disabled" }} 92 - {{ $title = "This branch is not behind the upstream" }} 93 - {{ else if eq .ForkInfo.Status 2 }} 94 - {{ $disabled = "disabled" }} 95 - {{ $title = "This branch has conflicts that must be resolved" }} 96 - {{ else if eq .ForkInfo.Status 3 }} 97 - {{ $disabled = "disabled" }} 98 - {{ $title = "This branch does not exist on the upstream" }} 99 - {{ end }} 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"> 87 + <a 88 + href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}" 89 + class="btn flex items-center gap-2 no-underline hover:no-underline" 90 + title="Compare branches or tags" 91 + > 92 + {{ i "git-compare" "w-4 h-4" }} 93 + </a> 94 + </div> 95 + </div> 100 96 101 - <button 102 - id="syncBtn" 103 - {{ $disabled }} 104 - {{ if $title }}title="{{ $title }}"{{ end }} 105 - class="btn flex gap-2 items-center disabled:opacity-50 disabled:cursor-not-allowed" 106 - hx-post="/{{ .RepoInfo.FullName }}/fork/sync" 107 - hx-trigger="click" 108 - hx-swap="none" 109 - > 110 - {{ if $disabled }} 111 - {{ i "refresh-cw-off" "w-4 h-4" }} 112 - {{ else }} 113 - {{ i "refresh-cw" "w-4 h-4" }} 114 - {{ end }} 115 - <span>sync</span> 116 - </button> 117 - {{ end }} 118 - <a 119 - href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}" 120 - class="btn flex items-center gap-2 no-underline hover:no-underline" 121 - title="Compare branches or tags" 122 - > 123 - {{ i "git-compare" "w-4 h-4" }} 124 - </a> 97 + <!-- Clone dropdown in top right --> 98 + <div class="hidden md:flex items-center "> 99 + {{ template "repo/fragments/cloneDropdown" . }} 125 100 </div> 126 - </div> 101 + </div> 127 102 {{ end }} 128 103 129 104 {{ define "fileTree" }} ··· 131 106 {{ $linkstyle := "no-underline hover:underline dark:text-white" }} 132 107 133 108 {{ range .Files }} 134 - <div class="grid grid-cols-2 gap-4 items-center py-1"> 135 - <div class="col-span-1"> 109 + <div class="grid grid-cols-3 gap-4 items-center py-1"> 110 + <div class="col-span-2"> 136 111 {{ $link := printf "/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) .Name }} 137 112 {{ $icon := "folder" }} 138 113 {{ $iconStyle := "size-4 fill-current" }} ··· 144 119 {{ end }} 145 120 <a href="{{ $link }}" class="{{ $linkstyle }}"> 146 121 <div class="flex items-center gap-2"> 147 - {{ i $icon $iconStyle }}{{ .Name }} 122 + {{ i $icon $iconStyle "flex-shrink-0" }} 123 + <span class="truncate">{{ .Name }}</span> 148 124 </div> 149 125 </a> 150 126 </div> 151 127 152 - <div class="text-xs col-span-1 text-right"> 128 + <div class="text-sm col-span-1 text-right"> 153 129 {{ with .LastCommit }} 154 130 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a> 155 131 {{ end }} ··· 210 186 </div> 211 187 212 188 <!-- commit info bar --> 213 - <div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center"> 189 + <div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center flex-wrap"> 214 190 {{ $verified := $.VerifiedCommits.IsVerified .Hash.String }} 215 191 {{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }} 216 192 {{ if $verified }} ··· 280 256 </a> 281 257 <div class="flex flex-col gap-1"> 282 258 {{ range .BranchesTrunc }} 283 - <div class="text-base flex items-center justify-between"> 284 - <div class="flex items-center gap-2"> 259 + <div class="text-base flex items-center justify-between overflow-hidden"> 260 + <div class="flex items-center gap-2 min-w-0 flex-1"> 285 261 <a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Reference.Name | urlquery }}" 286 - class="inline no-underline hover:underline dark:text-white"> 262 + class="inline-block truncate no-underline hover:underline dark:text-white"> 287 263 {{ .Reference.Name }} 288 264 </a> 289 265 {{ 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> 266 + <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท'] shrink-0"></span> 267 + <span class="whitespace-nowrap text-xs text-gray-500 dark:text-gray-400 shrink-0">{{ template "repo/fragments/time" .Commit.Committer.When }}</span> 292 268 {{ end }} 293 269 {{ 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> 270 + <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท'] shrink-0"></span> 271 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-xs font-mono shrink-0">default</span> 296 272 {{ end }} 297 273 </div> 298 274 {{ if ne $.Ref .Reference.Name }} 299 275 <a href="/{{ $.RepoInfo.FullName }}/compare/{{ $.Ref | urlquery }}...{{ .Reference.Name | urlquery }}" 300 - class="text-xs flex gap-2 items-center" 276 + class="text-xs flex gap-2 items-center shrink-0 ml-2" 301 277 title="Compare branches or tags"> 302 278 {{ i "git-compare" "w-3 h-3" }} compare 303 279 </a> 304 - {{end}} 280 + {{ end }} 305 281 </div> 306 282 {{ end }} 307 283 </div> ··· 347 323 348 324 {{ define "repoAfter" }} 349 325 {{- if or .HTMLReadme .Readme -}} 350 - <section 351 - class="p-6 mt-4 rounded-br rounded-bl bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm w-full mx-auto overflow-auto {{ if not .Raw }} 352 - prose dark:prose-invert dark:[&_pre]:bg-gray-900 353 - dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 354 - dark:[&_pre]:border dark:[&_pre]:border-gray-700 355 - {{ end }}" 356 - > 357 - <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto"> 358 - {{- .Readme -}} 359 - </pre> 360 - {{- else -}} 361 - {{ .HTMLReadme }} 362 - {{- end -}}</article> 363 - </section> 326 + <div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden"> 327 + {{- if .ReadmeFileName -}} 328 + <div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2"> 329 + {{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }} 330 + <span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span> 331 + </div> 332 + {{- end -}} 333 + <section 334 + class="p-6 overflow-auto {{ if not .Raw }} 335 + prose dark:prose-invert dark:[&_pre]:bg-gray-900 336 + dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 337 + dark:[&_pre]:border dark:[&_pre]:border-gray-700 338 + {{ end }}" 339 + > 340 + <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto"> 341 + {{- .Readme -}} 342 + </pre> 343 + {{- else -}} 344 + {{ .HTMLReadme }} 345 + {{- end -}}</article> 346 + </section> 347 + </div> 364 348 {{- end -}} 365 - 366 - {{ template "repo/fragments/cloneInstructions" . }} 367 349 {{ 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-['ยท']">
+1 -1
appview/pages/templates/repo/new.html
··· 63 63 <button type="submit" class="btn-create flex items-center gap-2"> 64 64 {{ i "book-plus" "w-4 h-4" }} 65 65 create repo 66 - <span id="create-pull-spinner" class="group"> 66 + <span id="spinner" class="group"> 67 67 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 68 68 </span> 69 69 </button>
+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-['ยท']">
+3 -1
appview/pages/templates/repo/settings/general.html
··· 8 8 <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 9 {{ template "branchSettings" . }} 10 10 {{ template "deleteRepo" . }} 11 + <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 11 12 </div> 12 13 </section> 13 14 {{ end }} ··· 22 23 unless you specify a different branch. 23 24 </p> 24 25 </div> 25 - <form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 26 + <form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" hx-swap="none" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 26 27 <select id="branch" name="branch" required class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 27 28 <option value="" disabled selected > 28 29 Choose a default branch ··· 54 55 <button 55 56 class="btn group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center" 56 57 type="button" 58 + hx-swap="none" 57 59 hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 58 60 hx-confirm="Are you sure you want to delete {{ $.RepoInfo.FullName }}?"> 59 61 {{ i "trash-2" "size-4" }}
+9 -4
appview/pages/templates/repo/settings/pipelines.html
··· 34 34 {{ else }} 35 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 36 <select 37 - id="spindle" 37 + id="spindle" 38 38 name="spindle" 39 - required 39 + required 40 40 class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 41 - <option value="" disabled> 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 }} 42 44 Choose a spindle 45 + {{ else }} 46 + Disable pipelines 47 + {{ end }} 43 48 </option> 44 49 {{ range $.Spindles }} 45 50 <option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}> ··· 82 87 {{ end }} 83 88 84 89 {{ define "addSecretButton" }} 85 - <button 90 + <button 86 91 class="btn flex items-center gap-2" 87 92 popovertarget="add-secret-modal" 88 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 -5
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" }} 61 61 62 62 {{ if .IsFile }} 63 63 {{ $icon = "file" }} 64 - {{ $iconStyle = "flex-shrink-0 size-4" }} 64 + {{ $iconStyle = "size-4" }} 65 65 {{ end }} 66 66 <a href="{{ $link }}" class="{{ $linkstyle }}"> 67 67 <div class="flex items-center gap-2"> 68 - {{ i $icon $iconStyle }} 68 + {{ i $icon $iconStyle "flex-shrink-0" }} 69 69 <span class="truncate">{{ .Name }}</span> 70 70 </div> 71 71 </a> 72 72 </div> 73 73 74 - <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"> 75 75 {{ with .LastCommit }} 76 76 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400 block truncate">{{ .Message }}</a> 77 77 {{ end }} 78 78 </div> 79 79 80 - <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"> 81 81 {{ with .LastCommit }} 82 82 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a> 83 83 {{ end }}
-192
appview/pages/templates/settings.html
··· 1 - {{ define "title" }}settings{{ end }} 2 - 3 - {{ define "content" }} 4 - <div class="p-6"> 5 - <p class="text-xl font-bold dark:text-white">Settings</p> 6 - </div> 7 - <div class="flex flex-col"> 8 - {{ block "profile" . }} {{ end }} 9 - {{ block "keys" . }} {{ end }} 10 - {{ block "emails" . }} {{ end }} 11 - </div> 12 - {{ end }} 13 - 14 - {{ define "profile" }} 15 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">profile</h2> 16 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 17 - <dl class="grid grid-cols-[auto_1fr] gap-x-4 dark:text-gray-200"> 18 - {{ if .LoggedInUser.Handle }} 19 - <dt class="font-bold">handle</dt> 20 - <dd>@{{ .LoggedInUser.Handle }}</dd> 21 - {{ end }} 22 - <dt class="font-bold">did</dt> 23 - <dd>{{ .LoggedInUser.Did }}</dd> 24 - <dt class="font-bold">pds</dt> 25 - <dd>{{ .LoggedInUser.Pds }}</dd> 26 - </dl> 27 - </section> 28 - {{ end }} 29 - 30 - {{ define "keys" }} 31 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">ssh keys</h2> 32 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 33 - <p class="mb-8 dark:text-gray-300">SSH public keys added here will be broadcasted to knots that you are a member of, <br> allowing you to push to repositories there.</p> 34 - <div id="key-list" class="flex flex-col gap-6 mb-8"> 35 - {{ range $index, $key := .PubKeys }} 36 - <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 37 - <div class="flex flex-col gap-1"> 38 - <div class="inline-flex items-center gap-4"> 39 - {{ i "key" "w-3 h-3 dark:text-gray-300" }} 40 - <p class="font-bold dark:text-white">{{ .Name }}</p> 41 - </div> 42 - <p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .Created }}</p> 43 - <div class="overflow-x-auto whitespace-nowrap flex-1 max-w-full"> 44 - <code class="text-sm text-gray-500 dark:text-gray-400">{{ .Key }}</code> 45 - </div> 46 - </div> 47 - <button 48 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 49 - title="Delete key" 50 - hx-delete="/settings/keys?name={{urlquery .Name}}&rkey={{urlquery .Rkey}}&key={{urlquery .Key}}" 51 - hx-confirm="Are you sure you want to delete the key '{{ .Name }}'?" 52 - > 53 - {{ i "trash-2" "w-5 h-5" }} 54 - <span class="hidden md:inline">delete</span> 55 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 56 - </button> 57 - </div> 58 - {{ end }} 59 - </div> 60 - <form 61 - hx-put="/settings/keys" 62 - hx-indicator="#add-sshkey-spinner" 63 - hx-swap="none" 64 - class="max-w-2xl mb-8 space-y-4" 65 - > 66 - <input 67 - type="text" 68 - id="name" 69 - name="name" 70 - placeholder="key name" 71 - required 72 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/> 73 - 74 - <input 75 - id="key" 76 - name="key" 77 - placeholder="ssh-rsa AAAAAA..." 78 - required 79 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/> 80 - 81 - <button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center" type="submit"> 82 - <span>add key</span> 83 - <span id="add-sshkey-spinner" class="group"> 84 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 85 - </span> 86 - </button> 87 - 88 - <div id="settings-keys" class="error dark:text-red-400"></div> 89 - </form> 90 - </section> 91 - {{ end }} 92 - 93 - {{ define "emails" }} 94 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">email addresses</h2> 95 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 96 - <p class="mb-8 dark:text-gray-300">Commits authored using emails listed here will be associated with your Tangled profile.</p> 97 - <div id="email-list" class="flex flex-col gap-6 mb-8"> 98 - {{ range $index, $email := .Emails }} 99 - <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 100 - <div class="flex flex-col gap-2"> 101 - <div class="inline-flex items-center gap-4"> 102 - {{ i "mail" "w-3 h-3 dark:text-gray-300" }} 103 - <p class="font-bold dark:text-white">{{ .Address }}</p> 104 - <div class="inline-flex items-center gap-1"> 105 - {{ if .Verified }} 106 - <span class="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">verified</span> 107 - {{ else }} 108 - <span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">unverified</span> 109 - {{ end }} 110 - {{ if .Primary }} 111 - <span class="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded">primary</span> 112 - {{ end }} 113 - </div> 114 - </div> 115 - <p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .CreatedAt }}</p> 116 - </div> 117 - <div class="flex gap-2 items-center"> 118 - {{ if not .Verified }} 119 - <button 120 - class="btn flex gap-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" 121 - hx-post="/settings/emails/verify/resend" 122 - hx-swap="none" 123 - href="#" 124 - hx-vals='{"email": "{{ .Address }}"}'> 125 - {{ i "rotate-cw" "w-5 h-5" }} 126 - <span class="hidden md:inline">resend</span> 127 - </button> 128 - {{ end }} 129 - {{ if and (not .Primary) .Verified }} 130 - <a 131 - class="text-sm dark:text-blue-400 dark:hover:text-blue-300" 132 - hx-post="/settings/emails/primary" 133 - hx-swap="none" 134 - href="#" 135 - hx-vals='{"email": "{{ .Address }}"}'> 136 - set as primary 137 - </a> 138 - {{ end }} 139 - {{ if not .Primary }} 140 - <form 141 - hx-delete="/settings/emails" 142 - hx-confirm="Are you sure you wish to delete the email '{{ .Address }}'?" 143 - hx-indicator="#delete-email-{{ $index }}-spinner" 144 - > 145 - <input type="hidden" name="email" value="{{ .Address }}"> 146 - <button 147 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center" 148 - title="Delete email" 149 - type="submit" 150 - > 151 - {{ i "trash-2" "w-5 h-5" }} 152 - <span class="hidden md:inline">delete</span> 153 - <span id="delete-email-{{ $index }}-spinner" class="group"> 154 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 155 - </span> 156 - </button> 157 - </form> 158 - {{ end }} 159 - </div> 160 - </div> 161 - {{ end }} 162 - </div> 163 - <form 164 - hx-put="/settings/emails" 165 - hx-swap="none" 166 - class="max-w-2xl mb-8 space-y-4" 167 - hx-indicator="#add-email-spinner" 168 - > 169 - <input 170 - type="email" 171 - id="email" 172 - name="email" 173 - placeholder="your@email.com" 174 - required 175 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 176 - > 177 - 178 - <button 179 - class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center" 180 - type="submit" 181 - > 182 - <span>add email</span> 183 - <span id="add-email-spinner" class="group"> 184 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 185 - </span> 186 - </button> 187 - 188 - <div id="settings-emails-error" class="error dark:text-red-400"></div> 189 - <div id="settings-emails-success" class="success dark:text-green-400"></div> 190 - </form> 191 - </section> 192 - {{ end }}
+2 -4
appview/pages/templates/spindles/dashboard.html
··· 42 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
+2 -2
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 14 14 id="add-member-{{ .Instance }}" 15 15 popover 16 16 class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 17 - {{ block "addMemberPopover" . }} {{ end }} 17 + {{ block "addSpindleMemberPopover" . }} {{ end }} 18 18 </div> 19 19 {{ end }} 20 20 21 - {{ define "addMemberPopover" }} 21 + {{ define "addSpindleMemberPopover" }} 22 22 <form 23 23 hx-post="/spindles/{{ .Instance }}/add" 24 24 hx-indicator="#spinner"
+11 -9
appview/pages/templates/spindles/fragments/spindleListing.html
··· 1 1 {{ define "spindles/fragments/spindleListing" }} 2 2 <div id="spindle-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 3 - {{ block "leftSide" . }} {{ end }} 4 - {{ block "rightSide" . }} {{ end }} 3 + {{ block "spindleLeftSide" . }} {{ end }} 4 + {{ block "spindleRightSide" . }} {{ end }} 5 5 </div> 6 6 {{ end }} 7 7 8 - {{ define "leftSide" }} 8 + {{ define "spindleLeftSide" }} 9 9 {{ if .Verified }} 10 10 <a href="/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 11 {{ i "hard-drive" "w-4 h-4" }} 12 - {{ .Instance }} 12 + <span class="hover:underline"> 13 + {{ .Instance }} 14 + </span> 13 15 <span class="text-gray-500"> 14 16 {{ template "repo/fragments/shortTimeAgo" .Created }} 15 17 </span> ··· 25 27 {{ end }} 26 28 {{ end }} 27 29 28 - {{ define "rightSide" }} 30 + {{ define "spindleRightSide" }} 29 31 <div id="right-side" class="flex gap-2"> 30 32 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 31 33 {{ if .Verified }} ··· 33 35 {{ template "spindles/fragments/addMemberModal" . }} 34 36 {{ else }} 35 37 <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span> 36 - {{ block "retryButton" . }} {{ end }} 38 + {{ block "spindleRetryButton" . }} {{ end }} 37 39 {{ end }} 38 - {{ block "deleteButton" . }} {{ end }} 40 + {{ block "spindleDeleteButton" . }} {{ end }} 39 41 </div> 40 42 {{ end }} 41 43 42 - {{ define "deleteButton" }} 44 + {{ define "spindleDeleteButton" }} 43 45 <button 44 46 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 45 47 title="Delete spindle" ··· 55 57 {{ end }} 56 58 57 59 58 - {{ define "retryButton" }} 60 + {{ define "spindleRetryButton" }} 59 61 <button 60 62 class="btn gap-2 group" 61 63 title="Retry spindle verification"
+3 -2
appview/pages/templates/strings/fragments/form.html
··· 13 13 type="text" 14 14 id="filename" 15 15 name="filename" 16 - placeholder="Filename with extension" 16 + placeholder="Filename" 17 17 required 18 18 value="{{ .String.Filename }}" 19 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" ··· 31 31 name="content" 32 32 id="content-textarea" 33 33 wrap="off" 34 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 34 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 font-mono" 35 35 rows="20" 36 + spellcheck="false" 36 37 placeholder="Paste your string here!" 37 38 required>{{ .String.Contents }}</textarea> 38 39 <div class="flex justify-between items-center">
+15 -12
appview/pages/templates/strings/string.html
··· 19 19 <div> 20 20 <a href="/strings/{{ $ownerId }}">{{ $ownerId }}</a> 21 21 <span class="select-none">/</span> 22 - <a href="/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 22 + <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 23 23 </div> 24 24 {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 25 25 <div class="flex gap-2 text-base"> ··· 35 35 title="Delete string" 36 36 hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/" 37 37 hx-swap="none" 38 - hx-confirm="Are you sure you want to delete the gist `{{ .String.Filename }}`?" 38 + hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?" 39 39 > 40 40 {{ i "trash-2" "size-4" }} 41 41 <span class="hidden md:inline">delete</span> ··· 44 44 </div> 45 45 {{ end }} 46 46 </div> 47 - <span class="flex items-center"> 47 + <span> 48 48 {{ with .String.Description }} 49 49 {{ . }} 50 - <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 51 - {{ end }} 52 - 53 - {{ with .String.Edited }} 54 - <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 55 - {{ else }} 56 - {{ template "repo/fragments/shortTimeAgo" .String.Created }} 57 50 {{ end }} 58 51 </span> 59 52 </section> 60 53 <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white"> 61 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"> 62 - <span>{{ .String.Filename }}</span> 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> 63 66 <div> 64 67 <span>{{ .Stats.LineCount }} lines</span> 65 68 <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> ··· 74 77 {{ end }} 75 78 </div> 76 79 </div> 77 - <div class="overflow-auto relative"> 80 + <div class="overflow-x-auto overflow-y-hidden relative"> 78 81 {{ if .ShowRendered }} 79 82 <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 80 83 {{ else }}
+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"> 175 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 176 + <span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span> 177 + <span class="select-none after:content-['ยท']"></span> 178 + <span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></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="/signup" class="no-underline hover:no-underline "> 38 - <button class="btn-create 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 }}
+30
appview/pages/templates/user/followers.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท followers {{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s followers" /> 5 + <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=followers" /> 7 + <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 + <div class="md:col-span-3 order-1 md:order-1"> 13 + {{ template "user/fragments/profileCard" .Card }} 14 + </div> 15 + <div id="all-followers" class="md:col-span-8 order-2 md:order-2"> 16 + {{ block "followers" . }}{{ end }} 17 + </div> 18 + </div> 19 + {{ end }} 20 + 21 + {{ define "followers" }} 22 + <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p> 23 + <div id="followers" class="grid grid-cols-1 gap-4 mb-6"> 24 + {{ range .Followers }} 25 + {{ template "user/fragments/followCard" . }} 26 + {{ else }} 27 + <p class="px-6 dark:text-white">This user does not have any followers yet.</p> 28 + {{ end }} 29 + </div> 30 + {{ end }}
+30
appview/pages/templates/user/following.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท following {{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s following" /> 5 + <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=following" /> 7 + <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 + <div class="md:col-span-3 order-1 md:order-1"> 13 + {{ template "user/fragments/profileCard" .Card }} 14 + </div> 15 + <div id="all-following" class="md:col-span-8 order-2 md:order-2"> 16 + {{ block "following" . }}{{ end }} 17 + </div> 18 + </div> 19 + {{ end }} 20 + 21 + {{ define "following" }} 22 + <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p> 23 + <div id="following" class="grid grid-cols-1 gap-4 mb-6"> 24 + {{ range .Following }} 25 + {{ template "user/fragments/followCard" . }} 26 + {{ else }} 27 + <p class="px-6 dark:text-white">This user does not follow anyone yet.</p> 28 + {{ end }} 29 + </div> 30 + {{ end }}
+1 -1
appview/pages/templates/user/fragments/editPins.html
··· 27 27 <input type="checkbox" id="repo-{{$idx}}" name="pinnedRepo{{$idx}}" value="{{.RepoAt}}" {{if .IsPinned}}checked{{end}}> 28 28 <label for="repo-{{$idx}}" class="my-0 py-0 normal-case font-normal w-full"> 29 29 <div class="flex justify-between items-center w-full"> 30 - <span class="flex-shrink-0 overflow-hidden text-ellipsis ">{{ index $.DidHandleMap .Did }}/{{.Name}}</span> 30 + <span class="flex-shrink-0 overflow-hidden text-ellipsis ">{{ resolve .Did }}/{{.Name}}</span> 31 31 <div class="flex gap-1 items-center"> 32 32 {{ i "star" "size-4 fill-current" }} 33 33 <span>{{ .RepoStats.StarCount }}</span>
+2 -2
appview/pages/templates/user/fragments/follow.html
··· 1 1 {{ define "user/fragments/follow" }} 2 - <button id="followBtn" 2 + <button id="{{ normalizeForHtmlId .UserDid }}" 3 3 class="btn mt-2 w-full flex gap-2 items-center group" 4 4 5 5 {{ if eq .FollowStatus.String "IsNotFollowing" }} ··· 9 9 {{ end }} 10 10 11 11 hx-trigger="click" 12 - hx-target="#followBtn" 12 + hx-target="#{{ normalizeForHtmlId .UserDid }}" 13 13 hx-swap="outerHTML" 14 14 > 15 15 {{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }}
+29
appview/pages/templates/user/fragments/followCard.html
··· 1 + {{ define "user/fragments/followCard" }} 2 + {{ $userIdent := resolve .UserDid }} 3 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 4 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 + <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" /> 7 + </div> 8 + 9 + <div class="flex-1 min-h-0 justify-around flex flex-col"> 10 + <a href="/{{ $userIdent }}"> 11 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 12 + </a> 13 + <p class="text-sm pb-2 md:pb-2">{{.Profile.Description}}</p> 14 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 15 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 16 + <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 17 + <span class="select-none after:content-['ยท']"></span> 18 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 19 + </div> 20 + </div> 21 + 22 + {{ if ne .FollowStatus.String "IsSelf" }} 23 + <div class="max-w-24"> 24 + {{ template "user/fragments/follow" . }} 25 + </div> 26 + {{ end }} 27 + </div> 28 + </div> 29 + {{ end }}
+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 }}
+21 -15
appview/pages/templates/user/fragments/profileCard.html
··· 1 1 {{ define "user/fragments/profileCard" }} 2 + {{ $userIdent := didOrHandle .UserDid .UserHandle }} 2 3 <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit"> 3 4 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 5 <div id="avatar" class="col-span-1 flex justify-center items-center"> ··· 7 8 </div> 8 9 </div> 9 10 <div class="col-span-2"> 10 - <p title="{{ didOrHandle .UserDid .UserHandle }}" 11 - class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 12 - {{ didOrHandle .UserDid .UserHandle }} 13 - </p> 11 + <div class="flex items-center flex-row flex-nowrap gap-2"> 12 + <p title="{{ $userIdent }}" 13 + class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 14 + {{ $userIdent }} 15 + </p> 16 + <a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a> 17 + </div> 14 18 15 19 <div class="md:hidden"> 16 - {{ block "followerFollowing" (list .Followers .Following) }} {{ end }} 20 + {{ block "followerFollowing" (list . $userIdent) }} {{ end }} 17 21 </div> 18 22 </div> 19 23 <div class="col-span-3 md:col-span-full"> ··· 26 30 {{ end }} 27 31 28 32 <div class="hidden md:block"> 29 - {{ block "followerFollowing" (list $.Followers $.Following) }} {{ end }} 33 + {{ block "followerFollowing" (list $ $userIdent) }} {{ end }} 30 34 </div> 31 35 32 36 <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> ··· 39 43 {{ if .IncludeBluesky }} 40 44 <div class="flex items-center gap-2"> 41 45 <span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" }}</span> 42 - <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ didOrHandle $.UserDid $.UserHandle }}</a> 46 + <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ $userIdent }}</a> 43 47 </div> 44 48 {{ end }} 45 49 {{ range $link := .Links }} ··· 85 89 {{ end }} 86 90 87 91 {{ define "followerFollowing" }} 88 - {{ $followers := index . 0 }} 89 - {{ $following := index . 1 }} 90 - <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 91 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 92 - <span id="followers">{{ $followers }} followers</span> 93 - <span class="select-none after:content-['ยท']"></span> 94 - <span id="following">{{ $following }} following</span> 95 - </div> 92 + {{ $root := index . 0 }} 93 + {{ $userIdent := index . 1 }} 94 + {{ with $root }} 95 + <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 96 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 97 + <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 98 + <span class="select-none after:content-['ยท']"></span> 99 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 100 + </div> 101 + {{ end }} 96 102 {{ end }} 97 103
+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 -
+1
appview/pages/templates/user/login.html
··· 41 41 your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 42 42 </span> 43 43 </div> 44 + <input type="hidden" name="return_url" value="{{ .ReturnUrl }}"> 44 45 45 46 <button 46 47 class="btn w-full my-2 mt-6 text-base "
+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
+1 -1
appview/pages/templates/user/repos.html
··· 3 3 {{ define "extrameta" }} 4 4 <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s repos" /> 5 5 <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}/repos" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=repos" /> 7 7 <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 8 {{ end }} 9 9
+94
appview/pages/templates/user/settings/emails.html
··· 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "emailSettings" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "emailSettings" }} 20 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 + <div class="col-span-1 md:col-span-2"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">Email Addresses</h2> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + Commits authored using emails listed here will be associated with your Tangled profile. 25 + </p> 26 + </div> 27 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 28 + {{ template "addEmailButton" . }} 29 + </div> 30 + </div> 31 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 32 + {{ range .Emails }} 33 + {{ template "user/settings/fragments/emailListing" (list $ .) }} 34 + {{ else }} 35 + <div class="flex items-center justify-center p-2 text-gray-500"> 36 + no emails added yet 37 + </div> 38 + {{ end }} 39 + </div> 40 + {{ end }} 41 + 42 + {{ define "addEmailButton" }} 43 + <button 44 + class="btn flex items-center gap-2" 45 + popovertarget="add-email-modal" 46 + popovertargetaction="toggle"> 47 + {{ i "plus" "size-4" }} 48 + add email 49 + </button> 50 + <div 51 + id="add-email-modal" 52 + popover 53 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 54 + {{ template "addEmailModal" . }} 55 + </div> 56 + {{ end}} 57 + 58 + {{ define "addEmailModal" }} 59 + <form 60 + hx-put="/settings/emails" 61 + hx-indicator="#spinner" 62 + hx-swap="none" 63 + class="flex flex-col gap-2" 64 + > 65 + <p class="uppercase p-0">ADD EMAIL</p> 66 + <p class="text-sm text-gray-500 dark:text-gray-400">Commits using this email will be associated with your profile.</p> 67 + <input 68 + type="email" 69 + id="email-address" 70 + name="email" 71 + required 72 + placeholder="your@email.com" 73 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 74 + /> 75 + <div class="flex gap-2 pt-2"> 76 + <button 77 + type="button" 78 + popovertarget="add-email-modal" 79 + popovertargetaction="hide" 80 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 81 + > 82 + {{ i "x" "size-4" }} cancel 83 + </button> 84 + <button type="submit" class="btn w-1/2 flex items-center"> 85 + <span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span> 86 + <span id="spinner" class="group"> 87 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 88 + </span> 89 + </button> 90 + </div> 91 + <div id="settings-emails-error" class="text-red-500 dark:text-red-400"></div> 92 + <div id="settings-emails-success" class="text-green-500 dark:text-green-400"></div> 93 + </form> 94 + {{ end }}
+62
appview/pages/templates/user/settings/fragments/emailListing.html
··· 1 + {{ define "user/settings/fragments/emailListing" }} 2 + {{ $root := index . 0 }} 3 + {{ $email := index . 1 }} 4 + <div id="email-{{$email.Address}}" class="flex items-center justify-between p-2"> 5 + <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 6 + <div class="flex items-center gap-2"> 7 + {{ i "mail" "w-4 h-4 text-gray-500 dark:text-gray-400" }} 8 + <span class="font-bold"> 9 + {{ $email.Address }} 10 + </span> 11 + <div class="inline-flex items-center gap-1"> 12 + {{ if $email.Verified }} 13 + <span class="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">verified</span> 14 + {{ else }} 15 + <span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">unverified</span> 16 + {{ end }} 17 + {{ if $email.Primary }} 18 + <span class="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded">primary</span> 19 + {{ end }} 20 + </div> 21 + </div> 22 + <div class="flex text-sm flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 23 + <span>added {{ template "repo/fragments/time" $email.CreatedAt }}</span> 24 + </div> 25 + </div> 26 + <div class="flex gap-2 items-center"> 27 + {{ if not $email.Verified }} 28 + <button 29 + class="btn flex gap-2 text-sm px-2 py-1" 30 + hx-post="/settings/emails/verify/resend" 31 + hx-swap="none" 32 + hx-vals='{"email": "{{ $email.Address }}"}'> 33 + {{ i "rotate-cw" "w-4 h-4" }} 34 + <span class="hidden md:inline">resend</span> 35 + </button> 36 + {{ end }} 37 + {{ if and (not $email.Primary) $email.Verified }} 38 + <button 39 + class="btn text-sm px-2 py-1 text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" 40 + hx-post="/settings/emails/primary" 41 + hx-swap="none" 42 + hx-vals='{"email": "{{ $email.Address }}"}'> 43 + set as primary 44 + </button> 45 + {{ end }} 46 + {{ if not $email.Primary }} 47 + <button 48 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 49 + title="Delete email" 50 + hx-delete="/settings/emails" 51 + hx-swap="none" 52 + hx-vals='{"email": "{{ $email.Address }}"}' 53 + hx-confirm="Are you sure you want to delete the email {{ $email.Address }}?" 54 + > 55 + {{ i "trash-2" "w-5 h-5" }} 56 + <span class="hidden md:inline">delete</span> 57 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 58 + </button> 59 + {{ end }} 60 + </div> 61 + </div> 62 + {{ end }}
+31
appview/pages/templates/user/settings/fragments/keyListing.html
··· 1 + {{ define "user/settings/fragments/keyListing" }} 2 + {{ $root := index . 0 }} 3 + {{ $key := index . 1 }} 4 + <div id="key-{{$key.Name}}" class="flex items-center justify-between p-2"> 5 + <div class="hover:no-underline flex flex-col gap-1 text min-w-0 max-w-[80%]"> 6 + <div class="flex items-center gap-2"> 7 + <span>{{ i "key" "w-4" "h-4" }}</span> 8 + <span class="font-bold"> 9 + {{ $key.Name }} 10 + </span> 11 + </div> 12 + <span class="font-mono text-sm text-gray-500 dark:text-gray-400"> 13 + {{ sshFingerprint $key.Key }} 14 + </span> 15 + <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 16 + <span>added {{ template "repo/fragments/time" $key.Created }}</span> 17 + </div> 18 + </div> 19 + <button 20 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 21 + title="Delete key" 22 + hx-delete="/settings/keys?name={{urlquery $key.Name}}&rkey={{urlquery $key.Rkey}}&key={{urlquery $key.Key}}" 23 + hx-swap="none" 24 + hx-confirm="Are you sure you want to delete the key {{ $key.Name }}?" 25 + > 26 + {{ i "trash-2" "w-5 h-5" }} 27 + <span class="hidden md:inline">delete</span> 28 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 29 + </button> 30 + </div> 31 + {{ end }}
+16
appview/pages/templates/user/settings/fragments/sidebar.html
··· 1 + {{ define "user/settings/fragments/sidebar" }} 2 + {{ $active := .Tab }} 3 + {{ $tabs := .Tabs }} 4 + <div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 shadow-inner"> 5 + {{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }} 6 + {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }} 7 + {{ range $tabs }} 8 + <a href="/settings/{{.Name}}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 9 + <div class="flex gap-3 items-center p-2 {{ if eq .Name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 10 + {{ i .Icon "size-4" }} 11 + {{ .Name }} 12 + </div> 13 + </a> 14 + {{ end }} 15 + </div> 16 + {{ end }}
+101
appview/pages/templates/user/settings/keys.html
··· 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "sshKeysSettings" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "sshKeysSettings" }} 20 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 + <div class="col-span-1 md:col-span-2"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">SSH Keys</h2> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + SSH public keys added here will be broadcasted to knots that you are a member of, 25 + allowing you to push to repositories there. 26 + </p> 27 + </div> 28 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 29 + {{ template "addKeyButton" . }} 30 + </div> 31 + </div> 32 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 33 + {{ range .PubKeys }} 34 + {{ template "user/settings/fragments/keyListing" (list $ .) }} 35 + {{ else }} 36 + <div class="flex items-center justify-center p-2 text-gray-500"> 37 + no keys added yet 38 + </div> 39 + {{ end }} 40 + </div> 41 + {{ end }} 42 + 43 + {{ define "addKeyButton" }} 44 + <button 45 + class="btn flex items-center gap-2" 46 + popovertarget="add-key-modal" 47 + popovertargetaction="toggle"> 48 + {{ i "plus" "size-4" }} 49 + add key 50 + </button> 51 + <div 52 + id="add-key-modal" 53 + popover 54 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 55 + {{ template "addKeyModal" . }} 56 + </div> 57 + {{ end}} 58 + 59 + {{ define "addKeyModal" }} 60 + <form 61 + hx-put="/settings/keys" 62 + hx-indicator="#spinner" 63 + hx-swap="none" 64 + class="flex flex-col gap-2" 65 + > 66 + <p class="uppercase p-0">ADD SSH KEY</p> 67 + <p class="text-sm text-gray-500 dark:text-gray-400">SSH keys allow you to push to repositories in knots you're a member of.</p> 68 + <input 69 + type="text" 70 + id="key-name" 71 + name="name" 72 + required 73 + placeholder="key name" 74 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 75 + /> 76 + <textarea 77 + type="text" 78 + id="key-value" 79 + name="key" 80 + required 81 + placeholder="ssh-rsa AAAAB3NzaC1yc2E..." 82 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"></textarea> 83 + <div class="flex gap-2 pt-2"> 84 + <button 85 + type="button" 86 + popovertarget="add-key-modal" 87 + popovertargetaction="hide" 88 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 89 + > 90 + {{ i "x" "size-4" }} cancel 91 + </button> 92 + <button type="submit" class="btn w-1/2 flex items-center"> 93 + <span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span> 94 + <span id="spinner" class="group"> 95 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 96 + </span> 97 + </button> 98 + </div> 99 + <div id="settings-keys" class="text-red-500 dark:text-red-400"></div> 100 + </form> 101 + {{ end }}
+64
appview/pages/templates/user/settings/profile.html
··· 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "profileInfo" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "profileInfo" }} 20 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 + <div class="col-span-1 md:col-span-2"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">Profile</h2> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + Your account information from your AT Protocol identity. 25 + </p> 26 + </div> 27 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 28 + </div> 29 + </div> 30 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 31 + <div class="flex items-center justify-between p-4"> 32 + <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 33 + {{ if .LoggedInUser.Handle }} 34 + <span class="font-bold"> 35 + @{{ .LoggedInUser.Handle }} 36 + </span> 37 + <div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 38 + <span>Handle</span> 39 + </div> 40 + {{ end }} 41 + </div> 42 + </div> 43 + <div class="flex items-center justify-between p-4"> 44 + <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 45 + <span class="font-mono text-xs"> 46 + {{ .LoggedInUser.Did }} 47 + </span> 48 + <div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 49 + <span>Decentralized Identifier (DID)</span> 50 + </div> 51 + </div> 52 + </div> 53 + <div class="flex items-center justify-between p-4"> 54 + <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 55 + <span class="font-bold"> 56 + {{ .LoggedInUser.Pds }} 57 + </span> 58 + <div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 59 + <span>Personal Data Server (PDS)</span> 60 + </div> 61 + </div> 62 + </div> 63 + </div> 64 + {{ end }}
+1 -1
appview/pages/templates/user/signup.html
··· 42 42 </button> 43 43 </form> 44 44 <p class="text-sm text-gray-500"> 45 - Already have an account? <a href="/login" class="underline">Login to Tangled</a>. 45 + Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>. 46 46 </p> 47 47 48 48 <p id="signup-msg" class="error w-full"></p>
+151 -180
appview/pulls/pulls.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 - "encoding/json" 6 5 "errors" 7 6 "fmt" 8 - "io" 9 7 "log" 10 8 "net/http" 11 9 "sort" ··· 19 17 "tangled.sh/tangled.sh/core/appview/notify" 20 18 "tangled.sh/tangled.sh/core/appview/oauth" 21 19 "tangled.sh/tangled.sh/core/appview/pages" 20 + "tangled.sh/tangled.sh/core/appview/pages/markup" 22 21 "tangled.sh/tangled.sh/core/appview/reporesolver" 22 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 23 23 "tangled.sh/tangled.sh/core/idresolver" 24 24 "tangled.sh/tangled.sh/core/knotclient" 25 25 "tangled.sh/tangled.sh/core/patchutil" ··· 28 28 29 29 "github.com/bluekeyes/go-gitdiff/gitdiff" 30 30 comatproto "github.com/bluesky-social/indigo/api/atproto" 31 - "github.com/bluesky-social/indigo/atproto/syntax" 32 31 lexutil "github.com/bluesky-social/indigo/lex/util" 32 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 33 33 "github.com/go-chi/chi/v5" 34 34 "github.com/google/uuid" 35 35 ) ··· 96 96 return 97 97 } 98 98 99 - mergeCheckResponse := s.mergeCheck(f, pull, stack) 99 + mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 100 100 resubmitResult := pages.Unknown 101 101 if user.Did == pull.OwnerDid { 102 102 resubmitResult = s.resubmitCheck(f, pull, stack) ··· 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 - mergeCheckResponse := s.mergeCheck(f, pull, stack) 154 + mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 165 155 resubmitResult := pages.Unknown 166 156 if user != nil && user.Did == pull.OwnerDid { 167 157 resubmitResult = s.resubmitCheck(f, pull, stack) ··· 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, ··· 226 215 }) 227 216 } 228 217 229 - func (s *Pulls) mergeCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse { 218 + func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse { 230 219 if pull.State == db.PullMerged { 231 220 return types.MergeCheckResponse{} 232 221 } 233 222 234 - secret, err := db.GetRegistrationKey(s.db, f.Knot) 235 - if err != nil { 236 - log.Printf("failed to get registration key: %v", err) 237 - return types.MergeCheckResponse{ 238 - Error: "failed to check merge status: this knot is unregistered", 239 - } 223 + scheme := "https" 224 + if s.config.Core.Dev { 225 + scheme = "http" 240 226 } 227 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 241 228 242 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 243 - if err != nil { 244 - log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err) 245 - return types.MergeCheckResponse{ 246 - Error: "failed to check merge status", 247 - } 229 + xrpcc := indigoxrpc.Client{ 230 + Host: host, 248 231 } 249 232 250 233 patch := pull.LatestPatch() ··· 257 240 patch = mergeable.CombinedPatch() 258 241 } 259 242 260 - resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch) 261 - if err != nil { 262 - log.Println("failed to check for mergeability:", err) 243 + resp, xe := tangled.RepoMergeCheck( 244 + r.Context(), 245 + &xrpcc, 246 + &tangled.RepoMergeCheck_Input{ 247 + Did: f.OwnerDid(), 248 + Name: f.Name, 249 + Branch: pull.TargetBranch, 250 + Patch: patch, 251 + }, 252 + ) 253 + if err := xrpcclient.HandleXrpcErr(xe); err != nil { 254 + log.Println("failed to check for mergeability", "err", err) 263 255 return types.MergeCheckResponse{ 264 - Error: "failed to check merge status", 256 + Error: fmt.Sprintf("failed to check merge status: %s", err.Error()), 265 257 } 266 258 } 267 - switch resp.StatusCode { 268 - case 404: 269 - return types.MergeCheckResponse{ 270 - Error: "failed to check merge status: this knot does not support PRs", 271 - } 272 - case 400: 273 - return types.MergeCheckResponse{ 274 - Error: "failed to check merge status: does this knot support PRs?", 259 + 260 + // convert xrpc response to internal types 261 + conflicts := make([]types.ConflictInfo, len(resp.Conflicts)) 262 + for i, conflict := range resp.Conflicts { 263 + conflicts[i] = types.ConflictInfo{ 264 + Filename: conflict.Filename, 265 + Reason: conflict.Reason, 275 266 } 276 267 } 277 268 278 - respBody, err := io.ReadAll(resp.Body) 279 - if err != nil { 280 - log.Println("failed to read merge check response body") 281 - return types.MergeCheckResponse{ 282 - Error: "failed to check merge status: knot is not speaking the right language", 283 - } 269 + result := types.MergeCheckResponse{ 270 + IsConflicted: resp.Is_conflicted, 271 + Conflicts: conflicts, 284 272 } 285 - defer resp.Body.Close() 286 273 287 - var mergeCheckResponse types.MergeCheckResponse 288 - err = json.Unmarshal(respBody, &mergeCheckResponse) 289 - if err != nil { 290 - log.Println("failed to unmarshal merge check response", err) 291 - return types.MergeCheckResponse{ 292 - Error: "failed to check merge status: knot is not speaking the right language", 293 - } 274 + if resp.Message != nil { 275 + result.Message = *resp.Message 276 + } 277 + 278 + if resp.Error != nil { 279 + result.Error = *resp.Error 294 280 } 295 281 296 - return mergeCheckResponse 282 + return result 297 283 } 298 284 299 285 func (s *Pulls) resubmitCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult { ··· 318 304 // pulls within the same repo 319 305 knot = f.Knot 320 306 ownerDid = f.OwnerDid() 321 - repoName = f.RepoName 307 + repoName = f.Name 322 308 } 323 309 324 310 us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) ··· 377 363 return 378 364 } 379 365 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 366 patch := pull.Submissions[roundIdInt].Patch 392 367 diff := patchutil.AsNiceDiff(patch, pull.TargetBranch) 393 368 394 369 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 395 370 LoggedInUser: user, 396 - DidHandleMap: didHandleMap, 397 371 RepoInfo: f.RepoInfo(user), 398 372 Pull: pull, 399 373 Stack: stack, ··· 440 414 return 441 415 } 442 416 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 417 currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch) 455 418 if err != nil { 456 419 log.Println("failed to interdiff; current patch malformed") ··· 472 435 RepoInfo: f.RepoInfo(user), 473 436 Pull: pull, 474 437 Round: roundIdInt, 475 - DidHandleMap: didHandleMap, 476 438 Interdiff: interdiff, 477 439 DiffOpts: diffOpts, 478 440 }) ··· 494 456 return 495 457 } 496 458 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 459 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 509 460 w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 510 461 } ··· 529 480 530 481 pulls, err := db.GetPulls( 531 482 s.db, 532 - db.FilterEq("repo_at", f.RepoAt), 483 + db.FilterEq("repo_at", f.RepoAt()), 533 484 db.FilterEq("state", state), 534 485 ) 535 486 if err != nil { ··· 595 546 m[p.Sha] = p 596 547 } 597 548 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 549 s.pages.RepoPulls(w, pages.RepoPullsParams{ 613 550 LoggedInUser: s.oauth.GetUser(r), 614 551 RepoInfo: f.RepoInfo(user), 615 552 Pulls: pulls, 616 - DidHandleMap: didHandleMap, 617 553 FilteringBy: state, 618 554 Stacks: stacks, 619 555 Pipelines: m, ··· 671 607 createdAt := time.Now().Format(time.RFC3339) 672 608 ownerDid := user.Did 673 609 674 - pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId) 610 + pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId) 675 611 if err != nil { 676 612 log.Println("failed to get pull at", err) 677 613 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 678 614 return 679 615 } 680 616 681 - atUri := f.RepoAt.String() 617 + atUri := f.RepoAt().String() 682 618 client, err := s.oauth.AuthorizedClient(r) 683 619 if err != nil { 684 620 log.Println("failed to get authorized client", err) ··· 707 643 708 644 comment := &db.PullComment{ 709 645 OwnerDid: user.Did, 710 - RepoAt: f.RepoAt.String(), 646 + RepoAt: f.RepoAt().String(), 711 647 PullId: pull.PullId, 712 648 Body: body, 713 649 CommentAt: atResp.Uri, ··· 753 689 return 754 690 } 755 691 756 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 692 + result, err := us.Branches(f.OwnerDid(), f.Name) 757 693 if err != nil { 758 694 log.Println("failed to fetch branches", err) 759 695 return ··· 801 737 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 802 738 return 803 739 } 740 + sanitizer := markup.NewSanitizer() 741 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 742 + s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 743 + return 744 + } 804 745 } 805 746 806 747 // Validate we have at least one valid PR creation method ··· 877 818 return 878 819 } 879 820 880 - comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch) 821 + comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, targetBranch, sourceBranch) 881 822 if err != nil { 882 823 log.Println("failed to compare", err) 883 824 s.pages.Notice(w, "pull", err.Error()) ··· 923 864 return 924 865 } 925 866 926 - secret, err := db.GetRegistrationKey(s.db, fork.Knot) 867 + client, err := s.oauth.ServiceClient( 868 + r, 869 + oauth.WithService(fork.Knot), 870 + oauth.WithLxm(tangled.RepoHiddenRefNSID), 871 + oauth.WithDev(s.config.Core.Dev), 872 + ) 927 873 if err != nil { 928 - log.Println("failed to fetch registration key:", err) 929 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 930 - return 931 - } 932 - 933 - sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev) 934 - if err != nil { 935 - log.Println("failed to create signed client:", err) 874 + log.Printf("failed to connect to knot server: %v", err) 936 875 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 937 876 return 938 877 } ··· 944 883 return 945 884 } 946 885 947 - resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch) 948 - if err != nil { 949 - log.Println("failed to create hidden ref:", err, resp.StatusCode) 950 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 886 + resp, err := tangled.RepoHiddenRef( 887 + r.Context(), 888 + client, 889 + &tangled.RepoHiddenRef_Input{ 890 + ForkRef: sourceBranch, 891 + RemoteRef: targetBranch, 892 + Repo: fork.RepoAt().String(), 893 + }, 894 + ) 895 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 896 + s.pages.Notice(w, "pull", err.Error()) 951 897 return 952 898 } 953 899 954 - switch resp.StatusCode { 955 - case 404: 956 - case 400: 957 - s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 900 + if !resp.Success { 901 + errorMsg := "Failed to create pull request" 902 + if resp.Error != nil { 903 + errorMsg = fmt.Sprintf("Failed to create pull request: %s", *resp.Error) 904 + } 905 + s.pages.Notice(w, "pull", errorMsg) 958 906 return 959 907 } 960 908 ··· 979 927 return 980 928 } 981 929 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 - } 930 + forkAtUri := fork.RepoAt() 931 + forkAtUriStr := forkAtUri.String() 988 932 989 933 pullSource := &db.PullSource{ 990 934 Branch: sourceBranch, ··· 992 936 } 993 937 recordPullSource := &tangled.RepoPull_Source{ 994 938 Branch: sourceBranch, 995 - Repo: &fork.AtUri, 939 + Repo: &forkAtUriStr, 996 940 Sha: sourceRev, 997 941 } 998 942 ··· 1068 1012 Body: body, 1069 1013 TargetBranch: targetBranch, 1070 1014 OwnerDid: user.Did, 1071 - RepoAt: f.RepoAt, 1015 + RepoAt: f.RepoAt(), 1072 1016 Rkey: rkey, 1073 1017 Submissions: []*db.PullSubmission{ 1074 1018 &initialSubmission, ··· 1081 1025 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1082 1026 return 1083 1027 } 1084 - pullId, err := db.NextPullId(tx, f.RepoAt) 1028 + pullId, err := db.NextPullId(tx, f.RepoAt()) 1085 1029 if err != nil { 1086 1030 log.Println("failed to get pull id", err) 1087 1031 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 1096 1040 Val: &tangled.RepoPull{ 1097 1041 Title: title, 1098 1042 PullId: int64(pullId), 1099 - TargetRepo: string(f.RepoAt), 1043 + TargetRepo: string(f.RepoAt()), 1100 1044 TargetBranch: targetBranch, 1101 1045 Patch: patch, 1102 1046 Source: recordPullSource, ··· 1274 1218 return 1275 1219 } 1276 1220 1277 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1221 + result, err := us.Branches(f.OwnerDid(), f.Name) 1278 1222 if err != nil { 1279 1223 log.Println("failed to reach knotserver", err) 1280 1224 return ··· 1358 1302 return 1359 1303 } 1360 1304 1361 - targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName) 1305 + targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.Name) 1362 1306 if err != nil { 1363 1307 log.Println("failed to reach knotserver for target branches", err) 1364 1308 return ··· 1474 1418 return 1475 1419 } 1476 1420 1477 - comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch) 1421 + comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, pull.TargetBranch, pull.PullSource.Branch) 1478 1422 if err != nil { 1479 1423 log.Printf("compare request failed: %s", err) 1480 1424 s.pages.Notice(w, "resubmit-error", err.Error()) ··· 1524 1468 return 1525 1469 } 1526 1470 1527 - secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot) 1471 + // update the hidden tracking branch to latest 1472 + client, err := s.oauth.ServiceClient( 1473 + r, 1474 + oauth.WithService(forkRepo.Knot), 1475 + oauth.WithLxm(tangled.RepoHiddenRefNSID), 1476 + oauth.WithDev(s.config.Core.Dev), 1477 + ) 1528 1478 if err != nil { 1529 - log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err) 1530 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1479 + log.Printf("failed to connect to knot server: %v", err) 1531 1480 return 1532 1481 } 1533 1482 1534 - // update the hidden tracking branch to latest 1535 - signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev) 1536 - if err != nil { 1537 - log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err) 1538 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1483 + resp, err := tangled.RepoHiddenRef( 1484 + r.Context(), 1485 + client, 1486 + &tangled.RepoHiddenRef_Input{ 1487 + ForkRef: pull.PullSource.Branch, 1488 + RemoteRef: pull.TargetBranch, 1489 + Repo: forkRepo.RepoAt().String(), 1490 + }, 1491 + ) 1492 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1493 + s.pages.Notice(w, "resubmit-error", err.Error()) 1539 1494 return 1540 1495 } 1541 - 1542 - resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch) 1543 - if err != nil || resp.StatusCode != http.StatusNoContent { 1544 - log.Printf("failed to update tracking branch: %s", err) 1545 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1496 + if !resp.Success { 1497 + log.Println("Failed to update tracking ref.", "err", resp.Error) 1498 + s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.") 1546 1499 return 1547 1500 } 1548 1501 ··· 1658 1611 Val: &tangled.RepoPull{ 1659 1612 Title: pull.Title, 1660 1613 PullId: int64(pull.PullId), 1661 - TargetRepo: string(f.RepoAt), 1614 + TargetRepo: string(f.RepoAt()), 1662 1615 TargetBranch: pull.TargetBranch, 1663 1616 Patch: patch, // new patch 1664 1617 Source: recordPullSource, ··· 1774 1727 1775 1728 // deleted pulls are marked as deleted in the DB 1776 1729 for _, p := range deletions { 1730 + // do not do delete already merged PRs 1731 + if p.State == db.PullMerged { 1732 + continue 1733 + } 1734 + 1777 1735 err := db.DeletePull(tx, p.RepoAt, p.PullId) 1778 1736 if err != nil { 1779 1737 log.Println("failed to delete pull", err, p.PullId) ··· 1813 1771 for id := range updated { 1814 1772 op, _ := origById[id] 1815 1773 np, _ := newById[id] 1774 + 1775 + // do not update already merged PRs 1776 + if op.State == db.PullMerged { 1777 + continue 1778 + } 1816 1779 1817 1780 submission := np.Submissions[np.LastRoundNumber()] 1818 1781 ··· 1958 1921 1959 1922 patch := pullsToMerge.CombinedPatch() 1960 1923 1961 - secret, err := db.GetRegistrationKey(s.db, f.Knot) 1962 - if err != nil { 1963 - log.Printf("no registration key found for domain %s: %s\n", f.Knot, err) 1964 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1965 - return 1966 - } 1967 - 1968 1924 ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 1969 1925 if err != nil { 1970 1926 log.Printf("resolving identity: %s", err) ··· 1977 1933 log.Printf("failed to get primary email: %s", err) 1978 1934 } 1979 1935 1980 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 1981 - if err != nil { 1982 - log.Printf("failed to create signed client for %s: %s", f.Knot, err) 1983 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1984 - return 1936 + authorName := ident.Handle.String() 1937 + mergeInput := &tangled.RepoMerge_Input{ 1938 + Did: f.OwnerDid(), 1939 + Name: f.Name, 1940 + Branch: pull.TargetBranch, 1941 + Patch: patch, 1942 + CommitMessage: &pull.Title, 1943 + AuthorName: &authorName, 1985 1944 } 1986 1945 1987 - // Merge the pull request 1988 - resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address) 1946 + if pull.Body != "" { 1947 + mergeInput.CommitBody = &pull.Body 1948 + } 1949 + 1950 + if email.Address != "" { 1951 + mergeInput.AuthorEmail = &email.Address 1952 + } 1953 + 1954 + client, err := s.oauth.ServiceClient( 1955 + r, 1956 + oauth.WithService(f.Knot), 1957 + oauth.WithLxm(tangled.RepoMergeNSID), 1958 + oauth.WithDev(s.config.Core.Dev), 1959 + ) 1989 1960 if err != nil { 1990 - log.Printf("failed to merge pull request: %s", err) 1961 + log.Printf("failed to connect to knot server: %v", err) 1991 1962 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1992 1963 return 1993 1964 } 1994 1965 1995 - if resp.StatusCode != http.StatusOK { 1996 - log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode) 1997 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1966 + err = tangled.RepoMerge(r.Context(), client, mergeInput) 1967 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1968 + s.pages.Notice(w, "pull-merge-error", err.Error()) 1998 1969 return 1999 1970 } 2000 1971 ··· 2007 1978 defer tx.Rollback() 2008 1979 2009 1980 for _, p := range pullsToMerge { 2010 - err := db.MergePull(tx, f.RepoAt, p.PullId) 1981 + err := db.MergePull(tx, f.RepoAt(), p.PullId) 2011 1982 if err != nil { 2012 1983 log.Printf("failed to update pull request status in database: %s", err) 2013 1984 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 2023 1994 return 2024 1995 } 2025 1996 2026 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId)) 1997 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 2027 1998 } 2028 1999 2029 2000 func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { ··· 2075 2046 2076 2047 for _, p := range pullsToClose { 2077 2048 // Close the pull in the database 2078 - err = db.ClosePull(tx, f.RepoAt, p.PullId) 2049 + err = db.ClosePull(tx, f.RepoAt(), p.PullId) 2079 2050 if err != nil { 2080 2051 log.Println("failed to close pull", err) 2081 2052 s.pages.Notice(w, "pull-close", "Failed to close pull.") ··· 2143 2114 2144 2115 for _, p := range pullsToReopen { 2145 2116 // Close the pull in the database 2146 - err = db.ReopenPull(tx, f.RepoAt, p.PullId) 2117 + err = db.ReopenPull(tx, f.RepoAt(), p.PullId) 2147 2118 if err != nil { 2148 2119 log.Println("failed to close pull", err) 2149 2120 s.pages.Notice(w, "pull-close", "Failed to close pull.") ··· 2195 2166 Body: body, 2196 2167 TargetBranch: targetBranch, 2197 2168 OwnerDid: user.Did, 2198 - RepoAt: f.RepoAt, 2169 + RepoAt: f.RepoAt(), 2199 2170 Rkey: rkey, 2200 2171 Submissions: []*db.PullSubmission{ 2201 2172 &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 + }
+17 -104
appview/repo/index.go
··· 1 1 package repo 2 2 3 3 import ( 4 - "encoding/json" 5 - "fmt" 6 4 "log" 7 5 "net/http" 8 6 "slices" ··· 11 9 12 10 "tangled.sh/tangled.sh/core/appview/commitverify" 13 11 "tangled.sh/tangled.sh/core/appview/db" 14 - "tangled.sh/tangled.sh/core/appview/oauth" 15 12 "tangled.sh/tangled.sh/core/appview/pages" 16 - "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 17 13 "tangled.sh/tangled.sh/core/appview/reporesolver" 18 14 "tangled.sh/tangled.sh/core/knotclient" 19 15 "tangled.sh/tangled.sh/core/types" ··· 24 20 25 21 func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 26 22 ref := chi.URLParam(r, "ref") 23 + 27 24 f, err := rp.repoResolver.Resolve(r) 28 25 if err != nil { 29 26 log.Println("failed to fully resolve repo", err) ··· 37 34 return 38 35 } 39 36 40 - result, err := us.Index(f.OwnerDid(), f.RepoName, ref) 37 + result, err := us.Index(f.OwnerDid(), f.Name, ref) 41 38 if err != nil { 42 39 rp.pages.Error503(w) 43 40 log.Println("failed to reach knotserver", err) ··· 104 101 user := rp.oauth.GetUser(r) 105 102 repoInfo := f.RepoInfo(user) 106 103 107 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 108 - if err != nil { 109 - log.Printf("failed to get registration key for %s: %s", f.Knot, err) 110 - rp.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 111 - } 112 - 113 - signedClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 114 - if err != nil { 115 - log.Printf("failed to create signed client for %s: %s", f.Knot, err) 116 - return 117 - } 118 - 119 - var forkInfo *types.ForkInfo 120 - if user != nil && (repoInfo.Roles.IsOwner() || repoInfo.Roles.IsCollaborator()) { 121 - forkInfo, err = getForkInfo(repoInfo, rp, f, user, signedClient) 122 - if err != nil { 123 - log.Printf("Failed to fetch fork information: %v", err) 124 - return 125 - } 126 - } 127 - 128 104 // TODO: a bit dirty 129 - languageInfo, err := rp.getLanguageInfo(f, signedClient, chi.URLParam(r, "ref") == "") 105 + languageInfo, err := rp.getLanguageInfo(f, us, result.Ref, ref == "") 130 106 if err != nil { 131 107 log.Printf("failed to compute language percentages: %s", err) 132 108 // non-fatal ··· 143 119 } 144 120 145 121 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 146 - LoggedInUser: user, 147 - RepoInfo: repoInfo, 148 - TagMap: tagMap, 149 - RepoIndexResponse: *result, 150 - CommitsTrunc: commitsTrunc, 151 - TagsTrunc: tagsTrunc, 152 - ForkInfo: forkInfo, 122 + LoggedInUser: user, 123 + RepoInfo: repoInfo, 124 + TagMap: tagMap, 125 + RepoIndexResponse: *result, 126 + CommitsTrunc: commitsTrunc, 127 + TagsTrunc: tagsTrunc, 128 + // ForkInfo: forkInfo, // TODO: reinstate this after xrpc properly lands 153 129 BranchesTrunc: branchesTrunc, 154 130 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 155 131 VerifiedCommits: vc, ··· 160 136 161 137 func (rp *Repo) getLanguageInfo( 162 138 f *reporesolver.ResolvedRepo, 163 - signedClient *knotclient.SignedClient, 139 + us *knotclient.UnsignedClient, 140 + currentRef string, 164 141 isDefaultRef bool, 165 142 ) ([]types.RepoLanguageDetails, error) { 166 143 // first attempt to fetch from db 167 144 langs, err := db.GetRepoLanguages( 168 145 rp.db, 169 - db.FilterEq("repo_at", f.RepoAt), 170 - db.FilterEq("ref", f.Ref), 146 + db.FilterEq("repo_at", f.RepoAt()), 147 + db.FilterEq("ref", currentRef), 171 148 ) 172 149 173 150 if err != nil || langs == nil { 174 151 // non-fatal, fetch langs from ks 175 - ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, f.Ref) 152 + ls, err := us.RepoLanguages(f.OwnerDid(), f.Name, currentRef) 176 153 if err != nil { 177 154 return nil, err 178 155 } ··· 182 159 183 160 for l, s := range ls.Languages { 184 161 langs = append(langs, db.RepoLanguage{ 185 - RepoAt: f.RepoAt, 186 - Ref: f.Ref, 162 + RepoAt: f.RepoAt(), 163 + Ref: currentRef, 187 164 IsDefaultRef: isDefaultRef, 188 165 Language: l, 189 166 Bytes: s, ··· 229 206 230 207 return languageStats, nil 231 208 } 232 - 233 - func getForkInfo( 234 - repoInfo repoinfo.RepoInfo, 235 - rp *Repo, 236 - f *reporesolver.ResolvedRepo, 237 - user *oauth.User, 238 - signedClient *knotclient.SignedClient, 239 - ) (*types.ForkInfo, error) { 240 - if user == nil { 241 - return nil, nil 242 - } 243 - 244 - forkInfo := types.ForkInfo{ 245 - IsFork: repoInfo.Source != nil, 246 - Status: types.UpToDate, 247 - } 248 - 249 - if !forkInfo.IsFork { 250 - forkInfo.IsFork = false 251 - return &forkInfo, nil 252 - } 253 - 254 - us, err := knotclient.NewUnsignedClient(repoInfo.Source.Knot, rp.config.Core.Dev) 255 - if err != nil { 256 - log.Printf("failed to create unsigned client for %s", repoInfo.Source.Knot) 257 - return nil, err 258 - } 259 - 260 - result, err := us.Branches(repoInfo.Source.Did, repoInfo.Source.Name) 261 - if err != nil { 262 - log.Println("failed to reach knotserver", err) 263 - return nil, err 264 - } 265 - 266 - if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool { 267 - return branch.Name == f.Ref 268 - }) { 269 - forkInfo.Status = types.MissingBranch 270 - return &forkInfo, nil 271 - } 272 - 273 - newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, f.Ref, f.Ref) 274 - if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent { 275 - log.Printf("failed to update tracking branch: %s", err) 276 - return nil, err 277 - } 278 - 279 - hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref) 280 - 281 - var status types.AncestorCheckResponse 282 - forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt), repoInfo.Name, f.Ref, hiddenRef) 283 - if err != nil { 284 - log.Printf("failed to check if fork is ahead/behind: %s", err) 285 - return nil, err 286 - } 287 - 288 - if err := json.NewDecoder(forkSyncableResp.Body).Decode(&status); err != nil { 289 - log.Printf("failed to decode fork status: %s", err) 290 - return nil, err 291 - } 292 - 293 - forkInfo.Status = status.Status 294 - return &forkInfo, nil 295 - }
+327 -258
appview/repo/repo.go
··· 17 17 "strings" 18 18 "time" 19 19 20 + comatproto "github.com/bluesky-social/indigo/api/atproto" 21 + lexutil "github.com/bluesky-social/indigo/lex/util" 20 22 "tangled.sh/tangled.sh/core/api/tangled" 21 23 "tangled.sh/tangled.sh/core/appview/commitverify" 22 24 "tangled.sh/tangled.sh/core/appview/config" ··· 26 28 "tangled.sh/tangled.sh/core/appview/pages" 27 29 "tangled.sh/tangled.sh/core/appview/pages/markup" 28 30 "tangled.sh/tangled.sh/core/appview/reporesolver" 31 + xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 29 32 "tangled.sh/tangled.sh/core/eventconsumer" 30 33 "tangled.sh/tangled.sh/core/idresolver" 31 34 "tangled.sh/tangled.sh/core/knotclient" ··· 33 36 "tangled.sh/tangled.sh/core/rbac" 34 37 "tangled.sh/tangled.sh/core/tid" 35 38 "tangled.sh/tangled.sh/core/types" 39 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 36 40 37 41 securejoin "github.com/cyphar/filepath-securejoin" 38 42 "github.com/go-chi/chi/v5" 39 43 "github.com/go-git/go-git/v5/plumbing" 40 44 41 - comatproto "github.com/bluesky-social/indigo/api/atproto" 42 45 "github.com/bluesky-social/indigo/atproto/syntax" 43 - lexutil "github.com/bluesky-social/indigo/lex/util" 44 46 ) 45 47 46 48 type Repo struct { ··· 54 56 enforcer *rbac.Enforcer 55 57 notifier notify.Notifier 56 58 logger *slog.Logger 59 + serviceAuth *serviceauth.ServiceAuth 57 60 } 58 61 59 62 func New( ··· 81 84 } 82 85 } 83 86 87 + func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 88 + refParam := chi.URLParam(r, "ref") 89 + f, err := rp.repoResolver.Resolve(r) 90 + if err != nil { 91 + log.Println("failed to get repo and knot", err) 92 + return 93 + } 94 + 95 + var uri string 96 + if rp.config.Core.Dev { 97 + uri = "http" 98 + } else { 99 + uri = "https" 100 + } 101 + url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.Name, url.PathEscape(refParam)) 102 + 103 + http.Redirect(w, r, url, http.StatusFound) 104 + } 105 + 84 106 func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { 85 107 f, err := rp.repoResolver.Resolve(r) 86 108 if err != nil { ··· 104 126 return 105 127 } 106 128 107 - repolog, err := us.Log(f.OwnerDid(), f.RepoName, ref, page) 129 + repolog, err := us.Log(f.OwnerDid(), f.Name, ref, page) 108 130 if err != nil { 131 + rp.pages.Error503(w) 109 132 log.Println("failed to reach knotserver", err) 110 133 return 111 134 } 112 135 113 - tagResult, err := us.Tags(f.OwnerDid(), f.RepoName) 136 + tagResult, err := us.Tags(f.OwnerDid(), f.Name) 114 137 if err != nil { 138 + rp.pages.Error503(w) 115 139 log.Println("failed to reach knotserver", err) 116 140 return 117 141 } ··· 125 149 tagMap[hash] = append(tagMap[hash], tag.Name) 126 150 } 127 151 128 - branchResult, err := us.Branches(f.OwnerDid(), f.RepoName) 152 + branchResult, err := us.Branches(f.OwnerDid(), f.Name) 129 153 if err != nil { 154 + rp.pages.Error503(w) 130 155 log.Println("failed to reach knotserver", err) 131 156 return 132 157 } ··· 193 218 return 194 219 } 195 220 196 - repoAt := f.RepoAt 221 + repoAt := f.RepoAt() 197 222 rkey := repoAt.RecordKey().String() 198 223 if rkey == "" { 199 224 log.Println("invalid aturi for repo", err) ··· 243 268 Record: &lexutil.LexiconTypeDecoder{ 244 269 Val: &tangled.Repo{ 245 270 Knot: f.Knot, 246 - Name: f.RepoName, 271 + Name: f.Name, 247 272 Owner: user.Did, 248 - CreatedAt: f.CreatedAt, 273 + CreatedAt: f.Created.Format(time.RFC3339), 249 274 Description: &newDescription, 250 275 Spindle: &f.Spindle, 251 276 }, ··· 291 316 return 292 317 } 293 318 294 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)) 319 + resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref)) 295 320 if err != nil { 321 + rp.pages.Error503(w) 296 322 log.Println("failed to reach knotserver", err) 297 323 return 298 324 } ··· 356 382 if !rp.config.Core.Dev { 357 383 protocol = "https" 358 384 } 359 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath)) 385 + 386 + // if the tree path has a trailing slash, let's strip it 387 + // so we don't 404 388 + treePath = strings.TrimSuffix(treePath, "/") 389 + 390 + resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, treePath)) 360 391 if err != nil { 392 + rp.pages.Error503(w) 361 393 log.Println("failed to reach knotserver", err) 362 394 return 363 395 } 364 396 397 + // uhhh so knotserver returns a 500 if the entry isn't found in 398 + // the requested tree path, so let's stick to not-OK here. 399 + // we can fix this once we build out the xrpc apis for these operations. 400 + if resp.StatusCode != http.StatusOK { 401 + rp.pages.Error404(w) 402 + return 403 + } 404 + 365 405 body, err := io.ReadAll(resp.Body) 366 406 if err != nil { 367 407 log.Printf("Error reading response body: %v", err) ··· 386 426 user := rp.oauth.GetUser(r) 387 427 388 428 var breadcrumbs [][]string 389 - breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 429 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 390 430 if treePath != "" { 391 431 for idx, elem := range strings.Split(treePath, "/") { 392 432 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) ··· 417 457 return 418 458 } 419 459 420 - result, err := us.Tags(f.OwnerDid(), f.RepoName) 460 + result, err := us.Tags(f.OwnerDid(), f.Name) 421 461 if err != nil { 462 + rp.pages.Error503(w) 422 463 log.Println("failed to reach knotserver", err) 423 464 return 424 465 } 425 466 426 - artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt)) 467 + artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 427 468 if err != nil { 428 469 log.Println("failed grab artifacts", err) 429 470 return ··· 474 515 return 475 516 } 476 517 477 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 518 + result, err := us.Branches(f.OwnerDid(), f.Name) 478 519 if err != nil { 520 + rp.pages.Error503(w) 479 521 log.Println("failed to reach knotserver", err) 480 522 return 481 523 } ··· 503 545 if !rp.config.Core.Dev { 504 546 protocol = "https" 505 547 } 506 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 548 + resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath)) 507 549 if err != nil { 550 + rp.pages.Error503(w) 508 551 log.Println("failed to reach knotserver", err) 509 552 return 510 553 } 511 554 555 + if resp.StatusCode == http.StatusNotFound { 556 + rp.pages.Error404(w) 557 + return 558 + } 559 + 512 560 body, err := io.ReadAll(resp.Body) 513 561 if err != nil { 514 562 log.Printf("Error reading response body: %v", err) ··· 523 571 } 524 572 525 573 var breadcrumbs [][]string 526 - breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 574 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 527 575 if filePath != "" { 528 576 for idx, elem := range strings.Split(filePath, "/") { 529 577 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) ··· 556 604 557 605 // fetch the actual binary content like in RepoBlobRaw 558 606 559 - blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath) 607 + blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Name, ref, filePath) 560 608 contentSrc = blobURL 561 609 if !rp.config.Core.Dev { 562 610 contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) ··· 593 641 if !rp.config.Core.Dev { 594 642 protocol = "https" 595 643 } 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) 644 + 645 + blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath) 646 + 647 + req, err := http.NewRequest("GET", blobURL, nil) 648 + if err != nil { 649 + log.Println("failed to create request", err) 650 + return 651 + } 652 + 653 + // forward the If-None-Match header 654 + if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 655 + req.Header.Set("If-None-Match", clientETag) 656 + } 657 + 658 + client := &http.Client{} 659 + resp, err := client.Do(req) 598 660 if err != nil { 599 - log.Println("failed to reach knotserver:", err) 661 + log.Println("failed to reach knotserver", err) 600 662 rp.pages.Error503(w) 601 663 return 602 664 } 603 665 defer resp.Body.Close() 666 + 667 + // forward 304 not modified 668 + if resp.StatusCode == http.StatusNotModified { 669 + w.WriteHeader(http.StatusNotModified) 670 + return 671 + } 604 672 605 673 if resp.StatusCode != http.StatusOK { 606 674 log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) ··· 649 717 return 650 718 } 651 719 652 - repoAt := f.RepoAt 720 + repoAt := f.RepoAt() 653 721 rkey := repoAt.RecordKey().String() 654 722 if rkey == "" { 655 723 fail("Failed to resolve repo. Try again later", err) ··· 657 725 } 658 726 659 727 newSpindle := r.FormValue("spindle") 728 + removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value 660 729 client, err := rp.oauth.AuthorizedClient(r) 661 730 if err != nil { 662 731 fail("Failed to authorize. Try again later.", err) 663 732 return 664 733 } 665 734 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 735 + if !removingSpindle { 736 + // ensure that this is a valid spindle for this user 737 + validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 738 + if err != nil { 739 + fail("Failed to find spindles. Try again later.", err) 740 + return 741 + } 742 + 743 + if !slices.Contains(validSpindles, newSpindle) { 744 + fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles)) 745 + return 746 + } 671 747 } 672 748 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 749 + spindlePtr := &newSpindle 750 + if removingSpindle { 751 + spindlePtr = nil 676 752 } 677 753 678 754 // optimistic update 679 - err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle) 755 + err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr) 680 756 if err != nil { 681 757 fail("Failed to update spindle. Try again later.", err) 682 758 return ··· 695 771 Record: &lexutil.LexiconTypeDecoder{ 696 772 Val: &tangled.Repo{ 697 773 Knot: f.Knot, 698 - Name: f.RepoName, 774 + Name: f.Name, 699 775 Owner: user.Did, 700 - CreatedAt: f.CreatedAt, 776 + CreatedAt: f.Created.Format(time.RFC3339), 701 777 Description: &f.Description, 702 - Spindle: &newSpindle, 778 + Spindle: spindlePtr, 703 779 }, 704 780 }, 705 781 }) ··· 709 785 return 710 786 } 711 787 712 - // add this spindle to spindle stream 713 - rp.spindlestream.AddSource( 714 - context.Background(), 715 - eventconsumer.NewSpindleSource(newSpindle), 716 - ) 788 + if !removingSpindle { 789 + // add this spindle to spindle stream 790 + rp.spindlestream.AddSource( 791 + context.Background(), 792 + eventconsumer.NewSpindleSource(newSpindle), 793 + ) 794 + } 717 795 718 796 rp.pages.HxRefresh(w) 719 797 } ··· 776 854 Record: &lexutil.LexiconTypeDecoder{ 777 855 Val: &tangled.RepoCollaborator{ 778 856 Subject: collaboratorIdent.DID.String(), 779 - Repo: string(f.RepoAt), 857 + Repo: string(f.RepoAt()), 780 858 CreatedAt: createdAt.Format(time.RFC3339), 781 859 }}, 782 860 }) ··· 785 863 fail("Failed to write record to PDS.", err) 786 864 return 787 865 } 788 - l = l.With("at-uri", resp.Uri) 866 + 867 + aturi := resp.Uri 868 + l = l.With("at-uri", aturi) 789 869 l.Info("wrote record to PDS") 790 870 791 - l.Info("adding to knot") 792 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 871 + tx, err := rp.db.BeginTx(r.Context(), nil) 793 872 if err != nil { 794 - fail("Failed to add to knot.", err) 873 + fail("Failed to add collaborator.", err) 795 874 return 796 875 } 797 876 798 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 799 - if err != nil { 800 - fail("Failed to add to knot.", err) 801 - return 802 - } 877 + rollback := func() { 878 + err1 := tx.Rollback() 879 + err2 := rp.enforcer.E.LoadPolicy() 880 + err3 := rollbackRecord(context.Background(), aturi, client) 803 881 804 - ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 805 - if err != nil { 806 - fail("Knot was unreachable.", err) 807 - return 808 - } 882 + // ignore txn complete errors, this is okay 883 + if errors.Is(err1, sql.ErrTxDone) { 884 + err1 = nil 885 + } 809 886 810 - if ksResp.StatusCode != http.StatusNoContent { 811 - fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil) 812 - return 813 - } 814 - 815 - tx, err := rp.db.BeginTx(r.Context(), nil) 816 - if err != nil { 817 - fail("Failed to add collaborator.", err) 818 - return 819 - } 820 - defer func() { 821 - tx.Rollback() 822 - err = rp.enforcer.E.LoadPolicy() 823 - if err != nil { 824 - fail("Failed to add collaborator.", err) 887 + if errs := errors.Join(err1, err2, err3); errs != nil { 888 + l.Error("failed to rollback changes", "errs", errs) 889 + return 825 890 } 826 - }() 891 + } 892 + defer rollback() 827 893 828 894 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 829 895 if err != nil { ··· 835 901 Did: syntax.DID(currentUser.Did), 836 902 Rkey: rkey, 837 903 SubjectDid: collaboratorIdent.DID, 838 - RepoAt: f.RepoAt, 904 + RepoAt: f.RepoAt(), 839 905 Created: createdAt, 840 906 }) 841 907 if err != nil { ··· 855 921 return 856 922 } 857 923 924 + // clear aturi to when everything is successful 925 + aturi = "" 926 + 858 927 rp.pages.HxRefresh(w) 859 928 } 860 929 861 930 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 862 931 user := rp.oauth.GetUser(r) 863 932 933 + noticeId := "operation-error" 864 934 f, err := rp.repoResolver.Resolve(r) 865 935 if err != nil { 866 936 log.Println("failed to get repo and knot", err) ··· 873 943 log.Println("failed to get authorized client", err) 874 944 return 875 945 } 876 - repoRkey := f.RepoAt.RecordKey().String() 877 946 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 878 947 Collection: tangled.RepoNSID, 879 948 Repo: user.Did, 880 - Rkey: repoRkey, 949 + Rkey: f.Rkey, 881 950 }) 882 951 if err != nil { 883 952 log.Printf("failed to delete record: %s", err) 884 - rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 885 - return 886 - } 887 - log.Println("removed repo record ", f.RepoAt.String()) 888 - 889 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 890 - if err != nil { 891 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 953 + rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.") 892 954 return 893 955 } 956 + log.Println("removed repo record ", f.RepoAt().String()) 894 957 895 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 958 + client, err := rp.oauth.ServiceClient( 959 + r, 960 + oauth.WithService(f.Knot), 961 + oauth.WithLxm(tangled.RepoDeleteNSID), 962 + oauth.WithDev(rp.config.Core.Dev), 963 + ) 896 964 if err != nil { 897 - log.Println("failed to create client to ", f.Knot) 965 + log.Println("failed to connect to knot server:", err) 898 966 return 899 967 } 900 968 901 - ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName) 902 - if err != nil { 903 - log.Printf("failed to make request to %s: %s", f.Knot, err) 969 + err = tangled.RepoDelete( 970 + r.Context(), 971 + client, 972 + &tangled.RepoDelete_Input{ 973 + Did: f.OwnerDid(), 974 + Name: f.Name, 975 + Rkey: f.Rkey, 976 + }, 977 + ) 978 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 979 + rp.pages.Notice(w, noticeId, err.Error()) 904 980 return 905 981 } 906 - 907 - if ksResp.StatusCode != http.StatusNoContent { 908 - log.Println("failed to remove repo from knot, continuing anyway ", f.Knot) 909 - } else { 910 - log.Println("removed repo from knot ", f.Knot) 911 - } 982 + log.Println("deleted repo from knot") 912 983 913 984 tx, err := rp.db.BeginTx(r.Context(), nil) 914 985 if err != nil { ··· 927 998 // remove collaborator RBAC 928 999 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 929 1000 if err != nil { 930 - rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators") 1001 + rp.pages.Notice(w, noticeId, "Failed to remove collaborators") 931 1002 return 932 1003 } 933 1004 for _, c := range repoCollaborators { ··· 939 1010 // remove repo RBAC 940 1011 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 941 1012 if err != nil { 942 - rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") 1013 + rp.pages.Notice(w, noticeId, "Failed to update RBAC rules") 943 1014 return 944 1015 } 945 1016 946 1017 // remove repo from db 947 - err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName) 1018 + err = db.RemoveRepo(tx, f.OwnerDid(), f.Name) 948 1019 if err != nil { 949 - rp.pages.Notice(w, "settings-delete", "Failed to update appview") 1020 + rp.pages.Notice(w, noticeId, "Failed to update appview") 950 1021 return 951 1022 } 952 1023 log.Println("removed repo from db") ··· 975 1046 return 976 1047 } 977 1048 1049 + noticeId := "operation-error" 978 1050 branch := r.FormValue("branch") 979 1051 if branch == "" { 980 1052 http.Error(w, "malformed form", http.StatusBadRequest) 981 1053 return 982 1054 } 983 1055 984 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 985 - if err != nil { 986 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 987 - return 988 - } 989 - 990 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 1056 + client, err := rp.oauth.ServiceClient( 1057 + r, 1058 + oauth.WithService(f.Knot), 1059 + oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), 1060 + oauth.WithDev(rp.config.Core.Dev), 1061 + ) 991 1062 if err != nil { 992 - log.Println("failed to create client to ", f.Knot) 1063 + log.Println("failed to connect to knot server:", err) 1064 + rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 993 1065 return 994 1066 } 995 1067 996 - ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch) 997 - if err != nil { 998 - log.Printf("failed to make request to %s: %s", f.Knot, err) 1068 + xe := tangled.RepoSetDefaultBranch( 1069 + r.Context(), 1070 + client, 1071 + &tangled.RepoSetDefaultBranch_Input{ 1072 + Repo: f.RepoAt().String(), 1073 + DefaultBranch: branch, 1074 + }, 1075 + ) 1076 + if err := xrpcclient.HandleXrpcErr(xe); err != nil { 1077 + log.Println("xrpc failed", "err", xe) 1078 + rp.pages.Notice(w, noticeId, err.Error()) 999 1079 return 1000 1080 } 1001 1081 1002 - if ksResp.StatusCode != http.StatusNoContent { 1003 - rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.") 1004 - return 1005 - } 1006 - 1007 - w.Write(fmt.Append(nil, "default branch set to: ", branch)) 1082 + rp.pages.HxRefresh(w) 1008 1083 } 1009 1084 1010 1085 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { ··· 1033 1108 r, 1034 1109 oauth.WithService(f.Spindle), 1035 1110 oauth.WithLxm(lxm), 1111 + oauth.WithExp(60), 1036 1112 oauth.WithDev(rp.config.Core.Dev), 1037 1113 ) 1038 1114 if err != nil { ··· 1060 1136 r.Context(), 1061 1137 spindleClient, 1062 1138 &tangled.RepoAddSecret_Input{ 1063 - Repo: f.RepoAt.String(), 1139 + Repo: f.RepoAt().String(), 1064 1140 Key: key, 1065 1141 Value: value, 1066 1142 }, ··· 1078 1154 r.Context(), 1079 1155 spindleClient, 1080 1156 &tangled.RepoRemoveSecret_Input{ 1081 - Repo: f.RepoAt.String(), 1157 + Repo: f.RepoAt().String(), 1082 1158 Key: key, 1083 1159 }, 1084 1160 ) ··· 1119 1195 case "pipelines": 1120 1196 rp.pipelineSettings(w, r) 1121 1197 } 1122 - 1123 - // user := rp.oauth.GetUser(r) 1124 - // repoCollaborators, err := f.Collaborators(r.Context()) 1125 - // if err != nil { 1126 - // log.Println("failed to get collaborators", err) 1127 - // } 1128 - 1129 - // isCollaboratorInviteAllowed := false 1130 - // if user != nil { 1131 - // ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 1132 - // if err == nil && ok { 1133 - // isCollaboratorInviteAllowed = true 1134 - // } 1135 - // } 1136 - 1137 - // us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1138 - // if err != nil { 1139 - // log.Println("failed to create unsigned client", err) 1140 - // return 1141 - // } 1142 - 1143 - // result, err := us.Branches(f.OwnerDid(), f.RepoName) 1144 - // if err != nil { 1145 - // log.Println("failed to reach knotserver", err) 1146 - // return 1147 - // } 1148 - 1149 - // // all spindles that this user is a member of 1150 - // spindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 1151 - // if err != nil { 1152 - // log.Println("failed to fetch spindles", err) 1153 - // return 1154 - // } 1155 - 1156 - // var secrets []*tangled.RepoListSecrets_Secret 1157 - // if f.Spindle != "" { 1158 - // if spindleClient, err := rp.oauth.ServiceClient( 1159 - // r, 1160 - // oauth.WithService(f.Spindle), 1161 - // oauth.WithLxm(tangled.RepoListSecretsNSID), 1162 - // oauth.WithDev(rp.config.Core.Dev), 1163 - // ); err != nil { 1164 - // log.Println("failed to create spindle client", err) 1165 - // } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil { 1166 - // log.Println("failed to fetch secrets", err) 1167 - // } else { 1168 - // secrets = resp.Secrets 1169 - // } 1170 - // } 1171 - 1172 - // rp.pages.RepoSettings(w, pages.RepoSettingsParams{ 1173 - // LoggedInUser: user, 1174 - // RepoInfo: f.RepoInfo(user), 1175 - // Collaborators: repoCollaborators, 1176 - // IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 1177 - // Branches: result.Branches, 1178 - // Spindles: spindles, 1179 - // CurrentSpindle: f.Spindle, 1180 - // Secrets: secrets, 1181 - // }) 1182 1198 } 1183 1199 1184 1200 func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { ··· 1191 1207 return 1192 1208 } 1193 1209 1194 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1210 + result, err := us.Branches(f.OwnerDid(), f.Name) 1195 1211 if err != nil { 1212 + rp.pages.Error503(w) 1196 1213 log.Println("failed to reach knotserver", err) 1197 1214 return 1198 1215 } ··· 1241 1258 r, 1242 1259 oauth.WithService(f.Spindle), 1243 1260 oauth.WithLxm(tangled.RepoListSecretsNSID), 1261 + oauth.WithExp(60), 1244 1262 oauth.WithDev(rp.config.Core.Dev), 1245 1263 ); err != nil { 1246 1264 log.Println("failed to create spindle client", err) 1247 - } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil { 1265 + } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 1248 1266 log.Println("failed to fetch secrets", err) 1249 1267 } else { 1250 1268 secrets = resp.Secrets ··· 1285 1303 } 1286 1304 1287 1305 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1306 + ref := chi.URLParam(r, "ref") 1307 + 1288 1308 user := rp.oauth.GetUser(r) 1289 1309 f, err := rp.repoResolver.Resolve(r) 1290 1310 if err != nil { ··· 1294 1314 1295 1315 switch r.Method { 1296 1316 case http.MethodPost: 1297 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 1317 + client, err := rp.oauth.ServiceClient( 1318 + r, 1319 + oauth.WithService(f.Knot), 1320 + oauth.WithLxm(tangled.RepoForkSyncNSID), 1321 + oauth.WithDev(rp.config.Core.Dev), 1322 + ) 1298 1323 if err != nil { 1299 - rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot)) 1324 + rp.pages.Notice(w, "repo", "Failed to connect to knot server.") 1300 1325 return 1301 1326 } 1302 1327 1303 - client, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 1304 - if err != nil { 1305 - rp.pages.Notice(w, "repo", "Failed to reach knot server.") 1328 + repoInfo := f.RepoInfo(user) 1329 + if repoInfo.Source == nil { 1330 + rp.pages.Notice(w, "repo", "This repository is not a fork.") 1306 1331 return 1307 1332 } 1308 1333 1309 - var uri string 1310 - if rp.config.Core.Dev { 1311 - uri = "http" 1312 - } else { 1313 - uri = "https" 1314 - } 1315 - forkName := fmt.Sprintf("%s", f.RepoName) 1316 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1317 - 1318 - _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref) 1319 - if err != nil { 1320 - rp.pages.Notice(w, "repo", "Failed to sync repository fork.") 1334 + err = tangled.RepoForkSync( 1335 + r.Context(), 1336 + client, 1337 + &tangled.RepoForkSync_Input{ 1338 + Did: user.Did, 1339 + Name: f.Name, 1340 + Source: repoInfo.Source.RepoAt().String(), 1341 + Branch: ref, 1342 + }, 1343 + ) 1344 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1345 + rp.pages.Notice(w, "repo", err.Error()) 1321 1346 return 1322 1347 } 1323 1348 ··· 1350 1375 }) 1351 1376 1352 1377 case http.MethodPost: 1378 + l := rp.logger.With("handler", "ForkRepo") 1353 1379 1354 - knot := r.FormValue("knot") 1355 - if knot == "" { 1380 + targetKnot := r.FormValue("knot") 1381 + if targetKnot == "" { 1356 1382 rp.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 1357 1383 return 1358 1384 } 1385 + l = l.With("targetKnot", targetKnot) 1359 1386 1360 - ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") 1387 + ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create") 1361 1388 if err != nil || !ok { 1362 1389 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1363 1390 return 1364 1391 } 1365 1392 1366 - forkName := fmt.Sprintf("%s", f.RepoName) 1367 - 1393 + // choose a name for a fork 1394 + forkName := f.Name 1368 1395 // this check is *only* to see if the forked repo name already exists 1369 1396 // in the user's account. 1370 - existingRepo, err := db.GetRepo(rp.db, user.Did, f.RepoName) 1397 + existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name) 1371 1398 if err != nil { 1372 1399 if errors.Is(err, sql.ErrNoRows) { 1373 1400 // no existing repo with this name found, we can use the name as is ··· 1380 1407 // repo with this name already exists, append random string 1381 1408 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1382 1409 } 1383 - secret, err := db.GetRegistrationKey(rp.db, knot) 1384 - if err != nil { 1385 - rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot)) 1386 - return 1387 - } 1410 + l = l.With("forkName", forkName) 1388 1411 1389 - client, err := knotclient.NewSignedClient(knot, secret, rp.config.Core.Dev) 1390 - if err != nil { 1391 - rp.pages.Notice(w, "repo", "Failed to reach knot server.") 1392 - return 1393 - } 1394 - 1395 - var uri string 1412 + uri := "https" 1396 1413 if rp.config.Core.Dev { 1397 1414 uri = "http" 1398 - } else { 1399 - uri = "https" 1400 1415 } 1401 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1402 - sourceAt := f.RepoAt.String() 1403 1416 1417 + forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1418 + l = l.With("cloneUrl", forkSourceUrl) 1419 + 1420 + sourceAt := f.RepoAt().String() 1421 + 1422 + // create an atproto record for this fork 1404 1423 rkey := tid.TID() 1405 1424 repo := &db.Repo{ 1406 1425 Did: user.Did, 1407 1426 Name: forkName, 1408 - Knot: knot, 1427 + Knot: targetKnot, 1409 1428 Rkey: rkey, 1410 1429 Source: sourceAt, 1411 1430 } 1412 1431 1413 - tx, err := rp.db.BeginTx(r.Context(), nil) 1414 - if err != nil { 1415 - log.Println(err) 1416 - rp.pages.Notice(w, "repo", "Failed to save repository information.") 1417 - return 1418 - } 1419 - defer func() { 1420 - tx.Rollback() 1421 - err = rp.enforcer.E.LoadPolicy() 1422 - if err != nil { 1423 - log.Println("failed to rollback policies") 1424 - } 1425 - }() 1426 - 1427 - resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName) 1428 - if err != nil { 1429 - rp.pages.Notice(w, "repo", "Failed to create repository on knot server.") 1430 - return 1431 - } 1432 - 1433 - switch resp.StatusCode { 1434 - case http.StatusConflict: 1435 - rp.pages.Notice(w, "repo", "A repository with that name already exists.") 1436 - return 1437 - case http.StatusInternalServerError: 1438 - rp.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 1439 - case http.StatusNoContent: 1440 - // continue 1441 - } 1442 - 1443 1432 xrpcClient, err := rp.oauth.AuthorizedClient(r) 1444 1433 if err != nil { 1445 - log.Println("failed to get authorized client", err) 1446 - rp.pages.Notice(w, "repo", "Failed to create repository.") 1434 + l.Error("failed to create xrpcclient", "err", err) 1435 + rp.pages.Notice(w, "repo", "Failed to fork repository.") 1447 1436 return 1448 1437 } 1449 1438 ··· 1462 1451 }}, 1463 1452 }) 1464 1453 if err != nil { 1465 - log.Printf("failed to create record: %s", err) 1454 + l.Error("failed to write to PDS", "err", err) 1466 1455 rp.pages.Notice(w, "repo", "Failed to announce repository creation.") 1467 1456 return 1468 1457 } 1469 - log.Println("created repo record: ", atresp.Uri) 1458 + 1459 + aturi := atresp.Uri 1460 + l = l.With("aturi", aturi) 1461 + l.Info("wrote to PDS") 1470 1462 1471 - repo.AtUri = atresp.Uri 1463 + tx, err := rp.db.BeginTx(r.Context(), nil) 1464 + if err != nil { 1465 + l.Info("txn failed", "err", err) 1466 + rp.pages.Notice(w, "repo", "Failed to save repository information.") 1467 + return 1468 + } 1469 + 1470 + // The rollback function reverts a few things on failure: 1471 + // - the pending txn 1472 + // - the ACLs 1473 + // - the atproto record created 1474 + rollback := func() { 1475 + err1 := tx.Rollback() 1476 + err2 := rp.enforcer.E.LoadPolicy() 1477 + err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 1478 + 1479 + // ignore txn complete errors, this is okay 1480 + if errors.Is(err1, sql.ErrTxDone) { 1481 + err1 = nil 1482 + } 1483 + 1484 + if errs := errors.Join(err1, err2, err3); errs != nil { 1485 + l.Error("failed to rollback changes", "errs", errs) 1486 + return 1487 + } 1488 + } 1489 + defer rollback() 1490 + 1491 + client, err := rp.oauth.ServiceClient( 1492 + r, 1493 + oauth.WithService(targetKnot), 1494 + oauth.WithLxm(tangled.RepoCreateNSID), 1495 + oauth.WithDev(rp.config.Core.Dev), 1496 + ) 1497 + if err != nil { 1498 + l.Error("could not create service client", "err", err) 1499 + rp.pages.Notice(w, "repo", "Failed to connect to knot server.") 1500 + return 1501 + } 1502 + 1503 + err = tangled.RepoCreate( 1504 + r.Context(), 1505 + client, 1506 + &tangled.RepoCreate_Input{ 1507 + Rkey: rkey, 1508 + Source: &forkSourceUrl, 1509 + }, 1510 + ) 1511 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1512 + rp.pages.Notice(w, "repo", err.Error()) 1513 + return 1514 + } 1515 + 1472 1516 err = db.AddRepo(tx, repo) 1473 1517 if err != nil { 1474 1518 log.Println(err) ··· 1478 1522 1479 1523 // acls 1480 1524 p, _ := securejoin.SecureJoin(user.Did, forkName) 1481 - err = rp.enforcer.AddRepo(user.Did, knot, p) 1525 + err = rp.enforcer.AddRepo(user.Did, targetKnot, p) 1482 1526 if err != nil { 1483 1527 log.Println(err) 1484 1528 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") ··· 1499 1543 return 1500 1544 } 1501 1545 1546 + // reset the ATURI because the transaction completed successfully 1547 + aturi = "" 1548 + 1549 + rp.notifier.NewRepo(r.Context(), repo) 1502 1550 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1503 - return 1504 1551 } 1505 1552 } 1506 1553 1554 + // this is used to rollback changes made to the PDS 1555 + // 1556 + // it is a no-op if the provided ATURI is empty 1557 + func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 1558 + if aturi == "" { 1559 + return nil 1560 + } 1561 + 1562 + parsed := syntax.ATURI(aturi) 1563 + 1564 + collection := parsed.Collection().String() 1565 + repo := parsed.Authority().String() 1566 + rkey := parsed.RecordKey().String() 1567 + 1568 + _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 1569 + Collection: collection, 1570 + Repo: repo, 1571 + Rkey: rkey, 1572 + }) 1573 + return err 1574 + } 1575 + 1507 1576 func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 1508 1577 user := rp.oauth.GetUser(r) 1509 1578 f, err := rp.repoResolver.Resolve(r) ··· 1519 1588 return 1520 1589 } 1521 1590 1522 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1591 + result, err := us.Branches(f.OwnerDid(), f.Name) 1523 1592 if err != nil { 1524 1593 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1525 1594 log.Println("failed to reach knotserver", err) ··· 1549 1618 head = queryHead 1550 1619 } 1551 1620 1552 - tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1621 + tags, err := us.Tags(f.OwnerDid(), f.Name) 1553 1622 if err != nil { 1554 1623 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1555 1624 log.Println("failed to reach knotserver", err) ··· 1611 1680 return 1612 1681 } 1613 1682 1614 - branches, err := us.Branches(f.OwnerDid(), f.RepoName) 1683 + branches, err := us.Branches(f.OwnerDid(), f.Name) 1615 1684 if err != nil { 1616 1685 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1617 1686 log.Println("failed to reach knotserver", err) 1618 1687 return 1619 1688 } 1620 1689 1621 - tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1690 + tags, err := us.Tags(f.OwnerDid(), f.Name) 1622 1691 if err != nil { 1623 1692 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1624 1693 log.Println("failed to reach knotserver", err) 1625 1694 return 1626 1695 } 1627 1696 1628 - formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head) 1697 + formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head) 1629 1698 if err != nil { 1630 1699 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1631 1700 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 ""
+164
appview/serververify/verify.go
··· 1 + package serververify 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "strings" 10 + "time" 11 + 12 + "tangled.sh/tangled.sh/core/appview/db" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + ) 15 + 16 + var ( 17 + FetchError = errors.New("failed to fetch owner") 18 + ) 19 + 20 + // fetchOwner fetches the owner DID from a server's /owner endpoint 21 + func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) { 22 + scheme := "https" 23 + if dev { 24 + scheme = "http" 25 + } 26 + 27 + url := fmt.Sprintf("%s://%s/owner", scheme, domain) 28 + req, err := http.NewRequest("GET", url, nil) 29 + if err != nil { 30 + return "", err 31 + } 32 + 33 + client := &http.Client{ 34 + Timeout: 1 * time.Second, 35 + } 36 + 37 + resp, err := client.Do(req.WithContext(ctx)) 38 + if err != nil || resp.StatusCode != 200 { 39 + return "", fmt.Errorf("failed to fetch /owner") 40 + } 41 + 42 + body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data 43 + if err != nil { 44 + return "", fmt.Errorf("failed to read /owner response: %w", err) 45 + } 46 + 47 + did := strings.TrimSpace(string(body)) 48 + if did == "" { 49 + return "", fmt.Errorf("empty DID in /owner response") 50 + } 51 + 52 + return did, nil 53 + } 54 + 55 + type OwnerMismatch struct { 56 + expected string 57 + observed string 58 + } 59 + 60 + func (e *OwnerMismatch) Error() string { 61 + return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed) 62 + } 63 + 64 + // RunVerification verifies that the server at the given domain has the expected owner 65 + func RunVerification(ctx context.Context, domain, expectedOwner string, dev bool) error { 66 + observedOwner, err := fetchOwner(ctx, domain, dev) 67 + if err != nil { 68 + return fmt.Errorf("%w: %w", FetchError, err) 69 + } 70 + 71 + if observedOwner != expectedOwner { 72 + return &OwnerMismatch{ 73 + expected: expectedOwner, 74 + observed: observedOwner, 75 + } 76 + } 77 + 78 + return nil 79 + } 80 + 81 + // MarkSpindleVerified marks a spindle as verified in the DB and adds the user as its owner 82 + func MarkSpindleVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) { 83 + tx, err := d.Begin() 84 + if err != nil { 85 + return 0, fmt.Errorf("failed to create txn: %w", err) 86 + } 87 + defer func() { 88 + tx.Rollback() 89 + e.E.LoadPolicy() 90 + }() 91 + 92 + // mark this spindle as verified in the db 93 + rowId, err := db.VerifySpindle( 94 + tx, 95 + db.FilterEq("owner", owner), 96 + db.FilterEq("instance", instance), 97 + ) 98 + if err != nil { 99 + return 0, fmt.Errorf("failed to write to DB: %w", err) 100 + } 101 + 102 + err = e.AddSpindleOwner(instance, owner) 103 + if err != nil { 104 + return 0, fmt.Errorf("failed to update ACL: %w", err) 105 + } 106 + 107 + err = tx.Commit() 108 + if err != nil { 109 + return 0, fmt.Errorf("failed to commit txn: %w", err) 110 + } 111 + 112 + err = e.E.SavePolicy() 113 + if err != nil { 114 + return 0, fmt.Errorf("failed to update ACL: %w", err) 115 + } 116 + 117 + return rowId, nil 118 + } 119 + 120 + // MarkKnotVerified marks a knot as verified and sets up ownership/permissions 121 + func MarkKnotVerified(d *db.DB, e *rbac.Enforcer, domain, owner string) error { 122 + tx, err := d.BeginTx(context.Background(), nil) 123 + if err != nil { 124 + return fmt.Errorf("failed to start tx: %w", err) 125 + } 126 + defer func() { 127 + tx.Rollback() 128 + e.E.LoadPolicy() 129 + }() 130 + 131 + // mark as registered 132 + err = db.MarkRegistered( 133 + tx, 134 + db.FilterEq("did", owner), 135 + db.FilterEq("domain", domain), 136 + ) 137 + if err != nil { 138 + return fmt.Errorf("failed to register domain: %w", err) 139 + } 140 + 141 + // add basic acls for this domain 142 + err = e.AddKnot(domain) 143 + if err != nil { 144 + return fmt.Errorf("failed to add knot to enforcer: %w", err) 145 + } 146 + 147 + // add this did as owner of this domain 148 + err = e.AddKnotOwner(domain, owner) 149 + if err != nil { 150 + return fmt.Errorf("failed to add knot owner to enforcer: %w", err) 151 + } 152 + 153 + err = tx.Commit() 154 + if err != nil { 155 + return fmt.Errorf("failed to commit changes: %w", err) 156 + } 157 + 158 + err = e.E.SavePolicy() 159 + if err != nil { 160 + return fmt.Errorf("failed to update ACLs: %w", err) 161 + } 162 + 163 + return nil 164 + }
+44 -9
appview/settings/settings.go
··· 33 33 Config *config.Config 34 34 } 35 35 36 + type tab = map[string]any 37 + 38 + var ( 39 + settingsTabs []tab = []tab{ 40 + {"Name": "profile", "Icon": "user"}, 41 + {"Name": "keys", "Icon": "key"}, 42 + {"Name": "emails", "Icon": "mail"}, 43 + } 44 + ) 45 + 36 46 func (s *Settings) Router() http.Handler { 37 47 r := chi.NewRouter() 38 48 39 49 r.Use(middleware.AuthMiddleware(s.OAuth)) 40 50 41 - r.Get("/", s.settings) 51 + // settings pages 52 + r.Get("/", s.profileSettings) 53 + r.Get("/profile", s.profileSettings) 42 54 43 55 r.Route("/keys", func(r chi.Router) { 56 + r.Get("/", s.keysSettings) 44 57 r.Put("/", s.keys) 45 58 r.Delete("/", s.keys) 46 59 }) 47 60 48 61 r.Route("/emails", func(r chi.Router) { 62 + r.Get("/", s.emailsSettings) 49 63 r.Put("/", s.emails) 50 64 r.Delete("/", s.emails) 51 65 r.Get("/verify", s.emailsVerify) ··· 56 70 return r 57 71 } 58 72 59 - func (s *Settings) settings(w http.ResponseWriter, r *http.Request) { 73 + func (s *Settings) profileSettings(w http.ResponseWriter, r *http.Request) { 74 + user := s.OAuth.GetUser(r) 75 + 76 + s.Pages.UserProfileSettings(w, pages.UserProfileSettingsParams{ 77 + LoggedInUser: user, 78 + Tabs: settingsTabs, 79 + Tab: "profile", 80 + }) 81 + } 82 + 83 + func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) { 60 84 user := s.OAuth.GetUser(r) 61 85 pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did) 62 86 if err != nil { 63 87 log.Println(err) 64 88 } 65 89 90 + s.Pages.UserKeysSettings(w, pages.UserKeysSettingsParams{ 91 + LoggedInUser: user, 92 + PubKeys: pubKeys, 93 + Tabs: settingsTabs, 94 + Tab: "keys", 95 + }) 96 + } 97 + 98 + func (s *Settings) emailsSettings(w http.ResponseWriter, r *http.Request) { 99 + user := s.OAuth.GetUser(r) 66 100 emails, err := db.GetAllEmails(s.Db, user.Did) 67 101 if err != nil { 68 102 log.Println(err) 69 103 } 70 104 71 - s.Pages.Settings(w, pages.SettingsParams{ 105 + s.Pages.UserEmailsSettings(w, pages.UserEmailsSettingsParams{ 72 106 LoggedInUser: user, 73 - PubKeys: pubKeys, 74 107 Emails: emails, 108 + Tabs: settingsTabs, 109 + Tab: "emails", 75 110 }) 76 111 } 77 112 ··· 201 236 return 202 237 } 203 238 204 - s.Pages.HxLocation(w, "/settings") 239 + s.Pages.HxLocation(w, "/settings/emails") 205 240 return 206 241 } 207 242 } ··· 244 279 return 245 280 } 246 281 247 - http.Redirect(w, r, "/settings", http.StatusSeeOther) 282 + http.Redirect(w, r, "/settings/emails", http.StatusSeeOther) 248 283 } 249 284 250 285 func (s *Settings) emailsVerifyResend(w http.ResponseWriter, r *http.Request) { ··· 339 374 return 340 375 } 341 376 342 - s.Pages.HxLocation(w, "/settings") 377 + s.Pages.HxLocation(w, "/settings/emails") 343 378 } 344 379 345 380 func (s *Settings) keys(w http.ResponseWriter, r *http.Request) { ··· 410 445 return 411 446 } 412 447 413 - s.Pages.HxLocation(w, "/settings") 448 + s.Pages.HxLocation(w, "/settings/keys") 414 449 return 415 450 416 451 case http.MethodDelete: ··· 455 490 } 456 491 log.Println("deleted successfully") 457 492 458 - s.Pages.HxLocation(w, "/settings") 493 + s.Pages.HxLocation(w, "/settings/keys") 459 494 return 460 495 } 461 496 }
+8 -21
appview/spindles/spindles.go
··· 15 15 "tangled.sh/tangled.sh/core/appview/middleware" 16 16 "tangled.sh/tangled.sh/core/appview/oauth" 17 17 "tangled.sh/tangled.sh/core/appview/pages" 18 - verify "tangled.sh/tangled.sh/core/appview/spindleverify" 18 + "tangled.sh/tangled.sh/core/appview/serververify" 19 19 "tangled.sh/tangled.sh/core/idresolver" 20 20 "tangled.sh/tangled.sh/core/rbac" 21 21 "tangled.sh/tangled.sh/core/tid" ··· 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 ··· 240 227 } 241 228 242 229 // begin verification 243 - err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 230 + err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 244 231 if err != nil { 245 232 l.Error("verification failed", "err", err) 246 233 s.Pages.HxRefresh(w) 247 234 return 248 235 } 249 236 250 - _, err = verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did) 237 + _, err = serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 251 238 if err != nil { 252 239 l.Error("failed to mark verified", "err", err) 253 240 s.Pages.HxRefresh(w) ··· 413 400 } 414 401 415 402 // begin verification 416 - err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 403 + err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 417 404 if err != nil { 418 405 l.Error("verification failed", "err", err) 419 406 420 - if errors.Is(err, verify.FetchError) { 421 - s.Pages.Notice(w, noticeId, err.Error()) 407 + if errors.Is(err, serververify.FetchError) { 408 + s.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.") 422 409 return 423 410 } 424 411 425 - if e, ok := err.(*verify.OwnerMismatch); ok { 412 + if e, ok := err.(*serververify.OwnerMismatch); ok { 426 413 s.Pages.Notice(w, noticeId, e.Error()) 427 414 return 428 415 } ··· 431 418 return 432 419 } 433 420 434 - rowId, err := verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did) 421 + rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 435 422 if err != nil { 436 423 l.Error("failed to mark verified", "err", err) 437 424 s.Pages.Notice(w, noticeId, err.Error())
-118
appview/spindleverify/verify.go
··· 1 - package spindleverify 2 - 3 - import ( 4 - "context" 5 - "errors" 6 - "fmt" 7 - "io" 8 - "net/http" 9 - "strings" 10 - "time" 11 - 12 - "tangled.sh/tangled.sh/core/appview/db" 13 - "tangled.sh/tangled.sh/core/rbac" 14 - ) 15 - 16 - var ( 17 - FetchError = errors.New("failed to fetch owner") 18 - ) 19 - 20 - // TODO: move this to "spindleclient" or similar 21 - func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) { 22 - scheme := "https" 23 - if dev { 24 - scheme = "http" 25 - } 26 - 27 - url := fmt.Sprintf("%s://%s/owner", scheme, domain) 28 - req, err := http.NewRequest("GET", url, nil) 29 - if err != nil { 30 - return "", err 31 - } 32 - 33 - client := &http.Client{ 34 - Timeout: 1 * time.Second, 35 - } 36 - 37 - resp, err := client.Do(req.WithContext(ctx)) 38 - if err != nil || resp.StatusCode != 200 { 39 - return "", fmt.Errorf("failed to fetch /owner") 40 - } 41 - 42 - body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data 43 - if err != nil { 44 - return "", fmt.Errorf("failed to read /owner response: %w", err) 45 - } 46 - 47 - did := strings.TrimSpace(string(body)) 48 - if did == "" { 49 - return "", fmt.Errorf("empty DID in /owner response") 50 - } 51 - 52 - return did, nil 53 - } 54 - 55 - type OwnerMismatch struct { 56 - expected string 57 - observed string 58 - } 59 - 60 - func (e *OwnerMismatch) Error() string { 61 - return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed) 62 - } 63 - 64 - func RunVerification(ctx context.Context, instance, expectedOwner string, dev bool) error { 65 - // begin verification 66 - observedOwner, err := fetchOwner(ctx, instance, dev) 67 - if err != nil { 68 - return fmt.Errorf("%w: %w", FetchError, err) 69 - } 70 - 71 - if observedOwner != expectedOwner { 72 - return &OwnerMismatch{ 73 - expected: expectedOwner, 74 - observed: observedOwner, 75 - } 76 - } 77 - 78 - return nil 79 - } 80 - 81 - // mark this spindle as verified in the DB and add this user as its owner 82 - func MarkVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) { 83 - tx, err := d.Begin() 84 - if err != nil { 85 - return 0, fmt.Errorf("failed to create txn: %w", err) 86 - } 87 - defer func() { 88 - tx.Rollback() 89 - e.E.LoadPolicy() 90 - }() 91 - 92 - // mark this spindle as verified in the db 93 - rowId, err := db.VerifySpindle( 94 - tx, 95 - db.FilterEq("owner", owner), 96 - db.FilterEq("instance", instance), 97 - ) 98 - if err != nil { 99 - return 0, fmt.Errorf("failed to write to DB: %w", err) 100 - } 101 - 102 - err = e.AddSpindleOwner(instance, owner) 103 - if err != nil { 104 - return 0, fmt.Errorf("failed to update ACL: %w", err) 105 - } 106 - 107 - err = tx.Commit() 108 - if err != nil { 109 - return 0, fmt.Errorf("failed to commit txn: %w", err) 110 - } 111 - 112 - err = e.E.SavePolicy() 113 - if err != nil { 114 - return 0, fmt.Errorf("failed to update ACL: %w", err) 115 - } 116 - 117 - return rowId, nil 118 - }
+9 -12
appview/state/git_http.go
··· 3 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)
+5 -2
appview/state/knotstream.go
··· 24 24 ) 25 25 26 26 func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) { 27 - knots, err := db.GetCompletedRegistrations(d) 27 + knots, err := db.GetRegistrations( 28 + d, 29 + db.FilterIsNot("registered", "null"), 30 + ) 28 31 if err != nil { 29 32 return nil, err 30 33 } 31 34 32 35 srcs := make(map[ec.Source]struct{}) 33 36 for _, k := range knots { 34 - s := ec.NewKnotSource(k) 37 + s := ec.NewKnotSource(k.Domain) 35 38 srcs[s] = struct{}{} 36 39 } 37 40
+340 -92
appview/state/profile.go
··· 1 1 package state 2 2 3 3 import ( 4 + "context" 4 5 "fmt" 5 6 "log" 6 7 "net/http" ··· 13 14 "github.com/bluesky-social/indigo/atproto/syntax" 14 15 lexutil "github.com/bluesky-social/indigo/lex/util" 15 16 "github.com/go-chi/chi/v5" 17 + "github.com/gorilla/feeds" 16 18 "tangled.sh/tangled.sh/core/api/tangled" 17 19 "tangled.sh/tangled.sh/core/appview/db" 20 + "tangled.sh/tangled.sh/core/appview/oauth" 18 21 "tangled.sh/tangled.sh/core/appview/pages" 19 22 ) 20 23 ··· 22 25 tabVal := r.URL.Query().Get("tab") 23 26 switch tabVal { 24 27 case "": 25 - s.profilePage(w, r) 28 + s.profileHomePage(w, r) 26 29 case "repos": 27 30 s.reposPage(w, r) 31 + case "followers": 32 + s.followersPage(w, r) 33 + case "following": 34 + s.followingPage(w, r) 28 35 } 29 36 } 30 37 31 - func (s *State) profilePage(w http.ResponseWriter, r *http.Request) { 38 + type ProfilePageParams struct { 39 + Id identity.Identity 40 + LoggedInUser *oauth.User 41 + Card pages.ProfileCard 42 + } 43 + 44 + func (s *State) profilePage(w http.ResponseWriter, r *http.Request) *ProfilePageParams { 32 45 didOrHandle := chi.URLParam(r, "user") 33 46 if didOrHandle == "" { 34 - http.Error(w, "Bad request", http.StatusBadRequest) 35 - return 47 + http.Error(w, "bad request", http.StatusBadRequest) 48 + return nil 36 49 } 37 50 38 51 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 39 52 if !ok { 40 - s.pages.Error404(w) 41 - return 53 + log.Printf("malformed middleware") 54 + w.WriteHeader(http.StatusInternalServerError) 55 + return nil 42 56 } 57 + did := ident.DID.String() 43 58 44 - profile, err := db.GetProfile(s.db, ident.DID.String()) 59 + profile, err := db.GetProfile(s.db, did) 45 60 if err != nil { 46 - log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 61 + log.Printf("getting profile data for %s: %s", did, err) 62 + s.pages.Error500(w) 63 + return nil 64 + } 65 + 66 + followStats, err := db.GetFollowerFollowingCount(s.db, did) 67 + if err != nil { 68 + log.Printf("getting follow stats for %s: %s", did, err) 69 + } 70 + 71 + loggedInUser := s.oauth.GetUser(r) 72 + followStatus := db.IsNotFollowing 73 + if loggedInUser != nil { 74 + followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 47 75 } 48 76 77 + return &ProfilePageParams{ 78 + Id: ident, 79 + LoggedInUser: loggedInUser, 80 + Card: pages.ProfileCard{ 81 + UserDid: did, 82 + UserHandle: ident.Handle.String(), 83 + Profile: profile, 84 + FollowStatus: followStatus, 85 + FollowersCount: followStats.Followers, 86 + FollowingCount: followStats.Following, 87 + }, 88 + } 89 + } 90 + 91 + func (s *State) profileHomePage(w http.ResponseWriter, r *http.Request) { 92 + pageWithProfile := s.profilePage(w, r) 93 + if pageWithProfile == nil { 94 + return 95 + } 96 + 97 + id := pageWithProfile.Id 49 98 repos, err := db.GetRepos( 50 99 s.db, 51 100 0, 52 - db.FilterEq("did", ident.DID.String()), 101 + db.FilterEq("did", id.DID), 53 102 ) 54 103 if err != nil { 55 - log.Printf("getting repos for %s: %s", ident.DID.String(), err) 104 + log.Printf("getting repos for %s: %s", id.DID, err) 56 105 } 57 106 107 + profile := pageWithProfile.Card.Profile 58 108 // filter out ones that are pinned 59 109 pinnedRepos := []db.Repo{} 60 110 for i, r := range repos { ··· 69 119 } 70 120 } 71 121 72 - collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String()) 122 + collaboratingRepos, err := db.CollaboratingIn(s.db, id.DID.String()) 73 123 if err != nil { 74 - log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err) 124 + log.Printf("getting collaborating repos for %s: %s", id.DID, err) 75 125 } 76 126 77 127 pinnedCollaboratingRepos := []db.Repo{} ··· 82 132 } 83 133 } 84 134 85 - timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String()) 135 + timeline, err := db.MakeProfileTimeline(s.db, id.DID.String()) 86 136 if err != nil { 87 - log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err) 137 + log.Printf("failed to create profile timeline for %s: %s", id.DID, err) 88 138 } 89 139 90 140 var didsToResolve []string ··· 106 156 } 107 157 } 108 158 109 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 110 - didHandleMap := make(map[string]string) 111 - for _, identity := range resolvedIds { 112 - if !identity.Handle.IsInvalidHandle() { 113 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 114 - } else { 115 - didHandleMap[identity.DID.String()] = identity.DID.String() 116 - } 117 - } 118 - 119 - followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String()) 120 - if err != nil { 121 - log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 122 - } 123 - 124 - loggedInUser := s.oauth.GetUser(r) 125 - followStatus := db.IsNotFollowing 126 - if loggedInUser != nil { 127 - followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 128 - } 129 - 130 159 now := time.Now() 131 160 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 132 161 punchcard, err := db.MakePunchcard( 133 162 s.db, 134 - db.FilterEq("did", ident.DID.String()), 163 + db.FilterEq("did", id.DID), 135 164 db.FilterGte("date", startOfYear.Format(time.DateOnly)), 136 165 db.FilterLte("date", now.Format(time.DateOnly)), 137 166 ) 138 167 if err != nil { 139 - log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err) 168 + log.Println("failed to get punchcard for did", "did", id.DID, "err", err) 140 169 } 141 170 142 - s.pages.ProfilePage(w, pages.ProfilePageParams{ 143 - LoggedInUser: loggedInUser, 171 + s.pages.ProfileHomePage(w, pages.ProfileHomePageParams{ 172 + LoggedInUser: pageWithProfile.LoggedInUser, 144 173 Repos: pinnedRepos, 145 174 CollaboratingRepos: pinnedCollaboratingRepos, 146 - DidHandleMap: didHandleMap, 147 - Card: pages.ProfileCard{ 148 - UserDid: ident.DID.String(), 149 - UserHandle: ident.Handle.String(), 150 - Profile: profile, 151 - FollowStatus: followStatus, 152 - Followers: followers, 153 - Following: following, 154 - }, 155 - Punchcard: punchcard, 156 - ProfileTimeline: timeline, 175 + Card: pageWithProfile.Card, 176 + Punchcard: punchcard, 177 + ProfileTimeline: timeline, 157 178 }) 158 179 } 159 180 160 181 func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 161 - ident, ok := r.Context().Value("resolvedId").(identity.Identity) 162 - if !ok { 163 - s.pages.Error404(w) 182 + pageWithProfile := s.profilePage(w, r) 183 + if pageWithProfile == nil { 164 184 return 165 185 } 166 186 167 - profile, err := db.GetProfile(s.db, ident.DID.String()) 168 - if err != nil { 169 - log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 170 - } 171 - 187 + id := pageWithProfile.Id 172 188 repos, err := db.GetRepos( 173 189 s.db, 174 190 0, 175 - db.FilterEq("did", ident.DID.String()), 191 + db.FilterEq("did", id.DID), 176 192 ) 177 193 if err != nil { 178 - log.Printf("getting repos for %s: %s", ident.DID.String(), err) 194 + log.Printf("getting repos for %s: %s", id.DID, err) 179 195 } 180 196 181 - loggedInUser := s.oauth.GetUser(r) 182 - followStatus := db.IsNotFollowing 183 - if loggedInUser != nil { 184 - followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 197 + s.pages.ReposPage(w, pages.ReposPageParams{ 198 + LoggedInUser: pageWithProfile.LoggedInUser, 199 + Repos: repos, 200 + Card: pageWithProfile.Card, 201 + }) 202 + } 203 + 204 + type FollowsPageParams struct { 205 + LoggedInUser *oauth.User 206 + Follows []pages.FollowCard 207 + Card pages.ProfileCard 208 + } 209 + 210 + func (s *State) followPage(w http.ResponseWriter, r *http.Request, fetchFollows func(db.Execer, string) ([]db.Follow, error), extractDid func(db.Follow) string) (FollowsPageParams, error) { 211 + pageWithProfile := s.profilePage(w, r) 212 + if pageWithProfile == nil { 213 + return FollowsPageParams{}, nil 185 214 } 186 215 187 - followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String()) 216 + id := pageWithProfile.Id 217 + loggedInUser := pageWithProfile.LoggedInUser 218 + 219 + follows, err := fetchFollows(s.db, id.DID.String()) 188 220 if err != nil { 189 - log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 221 + log.Printf("getting followers for %s: %s", id.DID, err) 222 + return FollowsPageParams{}, err 223 + } 224 + 225 + if len(follows) == 0 { 226 + return FollowsPageParams{ 227 + LoggedInUser: loggedInUser, 228 + Follows: []pages.FollowCard{}, 229 + Card: pageWithProfile.Card, 230 + }, nil 231 + } 232 + 233 + followDids := make([]string, 0, len(follows)) 234 + for _, follow := range follows { 235 + followDids = append(followDids, extractDid(follow)) 236 + } 237 + 238 + profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids)) 239 + if err != nil { 240 + log.Printf("getting profile for %s: %s", followDids, err) 241 + return FollowsPageParams{}, err 190 242 } 191 243 192 - s.pages.ReposPage(w, pages.ReposPageParams{ 244 + followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids) 245 + if err != nil { 246 + log.Printf("getting follow counts for %s: %s", followDids, err) 247 + } 248 + 249 + var loggedInUserFollowing map[string]struct{} 250 + if loggedInUser != nil { 251 + following, err := db.GetFollowing(s.db, loggedInUser.Did) 252 + if err != nil { 253 + return FollowsPageParams{}, err 254 + } 255 + if len(following) > 0 { 256 + loggedInUserFollowing = make(map[string]struct{}, len(following)) 257 + for _, follow := range following { 258 + loggedInUserFollowing[follow.SubjectDid] = struct{}{} 259 + } 260 + } 261 + } 262 + 263 + followCards := make([]pages.FollowCard, 0, len(follows)) 264 + for _, did := range followDids { 265 + followStats, exists := followStatsMap[did] 266 + if !exists { 267 + followStats = db.FollowStats{} 268 + } 269 + followStatus := db.IsNotFollowing 270 + if loggedInUserFollowing != nil { 271 + if _, exists := loggedInUserFollowing[did]; exists { 272 + followStatus = db.IsFollowing 273 + } else if loggedInUser.Did == did { 274 + followStatus = db.IsSelf 275 + } 276 + } 277 + var profile *db.Profile 278 + if p, exists := profiles[did]; exists { 279 + profile = p 280 + } else { 281 + profile = &db.Profile{} 282 + profile.Did = did 283 + } 284 + followCards = append(followCards, pages.FollowCard{ 285 + UserDid: did, 286 + FollowStatus: followStatus, 287 + FollowersCount: followStats.Followers, 288 + FollowingCount: followStats.Following, 289 + Profile: profile, 290 + }) 291 + } 292 + 293 + return FollowsPageParams{ 193 294 LoggedInUser: loggedInUser, 194 - Repos: repos, 195 - DidHandleMap: map[string]string{ident.DID.String(): ident.Handle.String()}, 196 - Card: pages.ProfileCard{ 197 - UserDid: ident.DID.String(), 198 - UserHandle: ident.Handle.String(), 199 - Profile: profile, 200 - FollowStatus: followStatus, 201 - Followers: followers, 202 - Following: following, 203 - }, 295 + Follows: followCards, 296 + Card: pageWithProfile.Card, 297 + }, nil 298 + } 299 + 300 + func (s *State) followersPage(w http.ResponseWriter, r *http.Request) { 301 + followPage, err := s.followPage(w, r, db.GetFollowers, func(f db.Follow) string { return f.UserDid }) 302 + if err != nil { 303 + s.pages.Notice(w, "all-followers", "Failed to load followers") 304 + return 305 + } 306 + 307 + s.pages.FollowersPage(w, pages.FollowersPageParams{ 308 + LoggedInUser: followPage.LoggedInUser, 309 + Followers: followPage.Follows, 310 + Card: followPage.Card, 311 + }) 312 + } 313 + 314 + func (s *State) followingPage(w http.ResponseWriter, r *http.Request) { 315 + followPage, err := s.followPage(w, r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid }) 316 + if err != nil { 317 + s.pages.Notice(w, "all-following", "Failed to load following") 318 + return 319 + } 320 + 321 + s.pages.FollowingPage(w, pages.FollowingPageParams{ 322 + LoggedInUser: followPage.LoggedInUser, 323 + Following: followPage.Follows, 324 + Card: followPage.Card, 325 + }) 326 + } 327 + 328 + func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) { 329 + ident, ok := r.Context().Value("resolvedId").(identity.Identity) 330 + if !ok { 331 + s.pages.Error404(w) 332 + return 333 + } 334 + 335 + feed, err := s.getProfileFeed(r.Context(), &ident) 336 + if err != nil { 337 + s.pages.Error500(w) 338 + return 339 + } 340 + 341 + if feed == nil { 342 + return 343 + } 344 + 345 + atom, err := feed.ToAtom() 346 + if err != nil { 347 + s.pages.Error500(w) 348 + return 349 + } 350 + 351 + w.Header().Set("content-type", "application/atom+xml") 352 + w.Write([]byte(atom)) 353 + } 354 + 355 + func (s *State) getProfileFeed(ctx context.Context, id *identity.Identity) (*feeds.Feed, error) { 356 + timeline, err := db.MakeProfileTimeline(s.db, id.DID.String()) 357 + if err != nil { 358 + return nil, err 359 + } 360 + 361 + author := &feeds.Author{ 362 + Name: fmt.Sprintf("@%s", id.Handle), 363 + } 364 + 365 + feed := feeds.Feed{ 366 + Title: fmt.Sprintf("%s's timeline", author.Name), 367 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, id.Handle), Type: "text/html", Rel: "alternate"}, 368 + Items: make([]*feeds.Item, 0), 369 + Updated: time.UnixMilli(0), 370 + Author: author, 371 + } 372 + 373 + for _, byMonth := range timeline.ByMonth { 374 + if err := s.addPullRequestItems(ctx, &feed, byMonth.PullEvents.Items, author); err != nil { 375 + return nil, err 376 + } 377 + if err := s.addIssueItems(ctx, &feed, byMonth.IssueEvents.Items, author); err != nil { 378 + return nil, err 379 + } 380 + if err := s.addRepoItems(ctx, &feed, byMonth.RepoEvents, author); err != nil { 381 + return nil, err 382 + } 383 + } 384 + 385 + slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int { 386 + return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli()) 204 387 }) 388 + 389 + if len(feed.Items) > 0 { 390 + feed.Updated = feed.Items[0].Created 391 + } 392 + 393 + return &feed, nil 394 + } 395 + 396 + func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error { 397 + for _, pull := range pulls { 398 + owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 399 + if err != nil { 400 + return err 401 + } 402 + 403 + // Add pull request creation item 404 + feed.Items = append(feed.Items, s.createPullRequestItem(pull, owner, author)) 405 + } 406 + return nil 407 + } 408 + 409 + func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error { 410 + for _, issue := range issues { 411 + owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did) 412 + if err != nil { 413 + return err 414 + } 415 + 416 + feed.Items = append(feed.Items, s.createIssueItem(issue, owner, author)) 417 + } 418 + return nil 419 + } 420 + 421 + func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error { 422 + for _, repo := range repos { 423 + item, err := s.createRepoItem(ctx, repo, author) 424 + if err != nil { 425 + return err 426 + } 427 + feed.Items = append(feed.Items, item) 428 + } 429 + return nil 430 + } 431 + 432 + func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item { 433 + return &feeds.Item{ 434 + Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), 435 + 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"}, 436 + Created: pull.Created, 437 + Author: author, 438 + } 439 + } 440 + 441 + func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 442 + return &feeds.Item{ 443 + Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name), 444 + 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"}, 445 + Created: issue.Created, 446 + Author: author, 447 + } 448 + } 449 + 450 + func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) { 451 + var title string 452 + if repo.Source != nil { 453 + sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did) 454 + if err != nil { 455 + return nil, err 456 + } 457 + title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, sourceOwner.Handle, repo.Source.Name, repo.Repo.Name) 458 + } else { 459 + title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name) 460 + } 461 + 462 + return &feeds.Item{ 463 + Title: title, 464 + 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 465 + Created: repo.Repo.Created, 466 + Author: author, 467 + }, nil 205 468 } 206 469 207 470 func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { ··· 406 669 }) 407 670 } 408 671 409 - var didsToResolve []string 410 - for _, r := range allRepos { 411 - didsToResolve = append(didsToResolve, r.Did) 412 - } 413 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 414 - didHandleMap := make(map[string]string) 415 - for _, identity := range resolvedIds { 416 - if !identity.Handle.IsInvalidHandle() { 417 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 418 - } else { 419 - didHandleMap[identity.DID.String()] = identity.DID.String() 420 - } 421 - } 422 - 423 672 s.pages.EditPinsFragment(w, pages.EditPinsParams{ 424 673 LoggedInUser: user, 425 674 Profile: profile, 426 675 AllRepos: allRepos, 427 - DidHandleMap: didHandleMap, 428 676 }) 429 677 }
+18 -6
appview/state/router.go
··· 32 32 s.pages, 33 33 ) 34 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 + 35 41 router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { 36 42 pat := chi.URLParam(r, "*") 37 43 if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") { 38 - s.UserRouter(&middleware).ServeHTTP(w, r) 44 + userRouter.ServeHTTP(w, r) 39 45 } else { 40 46 // Check if the first path element is a valid handle without '@' or a flattened DID 41 47 pathParts := strings.SplitN(pat, "/", 2) ··· 58 64 return 59 65 } 60 66 } 61 - s.StandardRouter(&middleware).ServeHTTP(w, r) 67 + standardRouter.ServeHTTP(w, r) 62 68 } 63 69 }) 64 70 ··· 70 76 71 77 r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) { 72 78 r.Get("/", s.Profile) 79 + r.Get("/feed.atom", s.AtomFeedPage) 80 + 81 + // redirect /@handle/repo.git -> /@handle/repo 82 + r.Get("/{repo}.git", func(w http.ResponseWriter, r *http.Request) { 83 + nonDotGitPath := strings.TrimSuffix(r.URL.Path, ".git") 84 + http.Redirect(w, r, nonDotGitPath, http.StatusMovedPermanently) 85 + }) 73 86 74 87 r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { 75 88 r.Use(mw.GoImport()) 76 - 77 89 r.Mount("/", s.RepoRouter(mw)) 78 90 r.Mount("/issues", s.IssuesRouter(mw)) 79 91 r.Mount("/pulls", s.PullsRouter(mw)) ··· 135 147 136 148 r.Mount("/settings", s.SettingsRouter()) 137 149 r.Mount("/strings", s.StringsRouter(mw)) 138 - r.Mount("/knots", s.KnotsRouter(mw)) 150 + r.Mount("/knots", s.KnotsRouter()) 139 151 r.Mount("/spindles", s.SpindlesRouter()) 140 152 r.Mount("/signup", s.SignupRouter()) 141 153 r.Mount("/", s.OAuthRouter()) ··· 183 195 return spindles.Router() 184 196 } 185 197 186 - func (s *State) KnotsRouter(mw *middleware.Middleware) http.Handler { 198 + func (s *State) KnotsRouter() http.Handler { 187 199 logger := log.New("knots") 188 200 189 201 knots := &knots.Knots{ ··· 197 209 Logger: logger, 198 210 } 199 211 200 - return knots.Router(mw) 212 + return knots.Router() 201 213 } 202 214 203 215 func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {
+121 -68
appview/state/state.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "database/sql" 6 + "errors" 5 7 "fmt" 6 8 "log" 7 9 "log/slog" ··· 10 12 "time" 11 13 12 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 15 + "github.com/bluesky-social/indigo/atproto/syntax" 13 16 lexutil "github.com/bluesky-social/indigo/lex/util" 14 17 securejoin "github.com/cyphar/filepath-securejoin" 15 18 "github.com/go-chi/chi/v5" ··· 25 28 "tangled.sh/tangled.sh/core/appview/pages" 26 29 posthogService "tangled.sh/tangled.sh/core/appview/posthog" 27 30 "tangled.sh/tangled.sh/core/appview/reporesolver" 31 + xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 28 32 "tangled.sh/tangled.sh/core/eventconsumer" 29 33 "tangled.sh/tangled.sh/core/idresolver" 30 34 "tangled.sh/tangled.sh/core/jetstream" 31 - "tangled.sh/tangled.sh/core/knotclient" 32 35 tlog "tangled.sh/tangled.sh/core/log" 33 36 "tangled.sh/tangled.sh/core/rbac" 34 37 "tangled.sh/tangled.sh/core/tid" 38 + // xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 35 39 ) 36 40 37 41 type State struct { ··· 48 52 repoResolver *reporesolver.RepoResolver 49 53 knotstream *eventconsumer.Consumer 50 54 spindlestream *eventconsumer.Consumer 55 + logger *slog.Logger 51 56 } 52 57 53 58 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 61 66 return nil, fmt.Errorf("failed to create enforcer: %w", err) 62 67 } 63 68 64 - pgs := pages.NewPages(config) 65 - 66 69 res, err := idresolver.RedisResolver(config.Redis.ToURL()) 67 70 if err != nil { 68 71 log.Printf("failed to create redis resolver: %v", err) 69 72 res = idresolver.DefaultResolver() 70 73 } 74 + 75 + pgs := pages.NewPages(config, res) 71 76 72 77 cache := cache.New(config.Redis.Addr) 73 78 sess := session.New(cache) ··· 152 157 repoResolver, 153 158 knotstream, 154 159 spindlestream, 160 + slog.Default(), 155 161 } 156 162 157 163 return state, nil 158 164 } 159 165 166 + func (s *State) Favicon(w http.ResponseWriter, r *http.Request) { 167 + w.Header().Set("Content-Type", "image/svg+xml") 168 + w.Header().Set("Cache-Control", "public, max-age=31536000") // one year 169 + w.Header().Set("ETag", `"favicon-svg-v1"`) 170 + 171 + if match := r.Header.Get("If-None-Match"); match == `"favicon-svg-v1"` { 172 + w.WriteHeader(http.StatusNotModified) 173 + return 174 + } 175 + 176 + s.pages.Favicon(w) 177 + } 178 + 160 179 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 161 180 user := s.oauth.GetUser(r) 162 181 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 180 199 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 181 200 } 182 201 183 - var didsToResolve []string 184 - for _, ev := range timeline { 185 - if ev.Repo != nil { 186 - didsToResolve = append(didsToResolve, ev.Repo.Did) 187 - if ev.Source != nil { 188 - didsToResolve = append(didsToResolve, ev.Source.Did) 189 - } 190 - } 191 - if ev.Follow != nil { 192 - didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid) 193 - } 194 - if ev.Star != nil { 195 - didsToResolve = append(didsToResolve, ev.Star.StarredByDid, ev.Star.Repo.Did) 196 - } 197 - } 198 - 199 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 200 - didHandleMap := make(map[string]string) 201 - for _, identity := range resolvedIds { 202 - if !identity.Handle.IsInvalidHandle() { 203 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 204 - } else { 205 - didHandleMap[identity.DID.String()] = identity.DID.String() 206 - } 202 + repos, err := db.GetTopStarredReposLastWeek(s.db) 203 + if err != nil { 204 + log.Println(err) 205 + s.pages.Notice(w, "topstarredrepos", "Unable to load.") 206 + return 207 207 } 208 208 209 209 s.pages.Timeline(w, pages.TimelineParams{ 210 210 LoggedInUser: user, 211 211 Timeline: timeline, 212 - DidHandleMap: didHandleMap, 212 + Repos: repos, 213 213 }) 214 - 215 - return 216 214 } 217 215 218 216 func (s *State) Keys(w http.ResponseWriter, r *http.Request) { ··· 279 277 return nil 280 278 } 281 279 280 + func stripGitExt(name string) string { 281 + return strings.TrimSuffix(name, ".git") 282 + } 283 + 282 284 func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { 283 285 switch r.Method { 284 286 case http.MethodGet: ··· 295 297 }) 296 298 297 299 case http.MethodPost: 300 + l := s.logger.With("handler", "NewRepo") 301 + 298 302 user := s.oauth.GetUser(r) 303 + l = l.With("did", user.Did) 304 + l = l.With("handle", user.Handle) 299 305 306 + // form validation 300 307 domain := r.FormValue("domain") 301 308 if domain == "" { 302 309 s.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 303 310 return 304 311 } 312 + l = l.With("knot", domain) 305 313 306 314 repoName := r.FormValue("name") 307 315 if repoName == "" { ··· 313 321 s.pages.Notice(w, "repo", err.Error()) 314 322 return 315 323 } 324 + repoName = stripGitExt(repoName) 325 + l = l.With("repoName", repoName) 316 326 317 327 defaultBranch := r.FormValue("branch") 318 328 if defaultBranch == "" { 319 329 defaultBranch = "main" 320 330 } 331 + l = l.With("defaultBranch", defaultBranch) 321 332 322 333 description := r.FormValue("description") 323 334 335 + // ACL validation 324 336 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create") 325 337 if err != nil || !ok { 338 + l.Info("unauthorized") 326 339 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 327 340 return 328 341 } 329 342 343 + // Check for existing repos 330 344 existingRepo, err := db.GetRepo(s.db, user.Did, repoName) 331 345 if err == nil && existingRepo != nil { 332 - s.pages.Notice(w, "repo", fmt.Sprintf("A repo by this name already exists on %s", existingRepo.Knot)) 333 - return 334 - } 335 - 336 - secret, err := db.GetRegistrationKey(s.db, domain) 337 - if err != nil { 338 - s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain)) 346 + l.Info("repo exists") 347 + s.pages.Notice(w, "repo", fmt.Sprintf("You already have a repository by this name on %s", existingRepo.Knot)) 339 348 return 340 349 } 341 350 342 - client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 343 - if err != nil { 344 - s.pages.Notice(w, "repo", "Failed to connect to knot server.") 345 - return 346 - } 347 - 351 + // create atproto record for this repo 348 352 rkey := tid.TID() 349 353 repo := &db.Repo{ 350 354 Did: user.Did, ··· 356 360 357 361 xrpcClient, err := s.oauth.AuthorizedClient(r) 358 362 if err != nil { 363 + l.Info("PDS write failed", "err", err) 359 364 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 360 365 return 361 366 } ··· 374 379 }}, 375 380 }) 376 381 if err != nil { 377 - log.Printf("failed to create record: %s", err) 382 + l.Info("PDS write failed", "err", err) 378 383 s.pages.Notice(w, "repo", "Failed to announce repository creation.") 379 384 return 380 385 } 381 - log.Println("created repo record: ", atresp.Uri) 386 + 387 + aturi := atresp.Uri 388 + l = l.With("aturi", aturi) 389 + l.Info("wrote to PDS") 382 390 383 391 tx, err := s.db.BeginTx(r.Context(), nil) 384 392 if err != nil { 385 - log.Println(err) 393 + l.Info("txn failed", "err", err) 386 394 s.pages.Notice(w, "repo", "Failed to save repository information.") 387 395 return 388 396 } 389 - defer func() { 390 - tx.Rollback() 391 - err = s.enforcer.E.LoadPolicy() 392 - if err != nil { 393 - log.Println("failed to rollback policies") 397 + 398 + // The rollback function reverts a few things on failure: 399 + // - the pending txn 400 + // - the ACLs 401 + // - the atproto record created 402 + rollback := func() { 403 + err1 := tx.Rollback() 404 + err2 := s.enforcer.E.LoadPolicy() 405 + err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 406 + 407 + // ignore txn complete errors, this is okay 408 + if errors.Is(err1, sql.ErrTxDone) { 409 + err1 = nil 410 + } 411 + 412 + if errs := errors.Join(err1, err2, err3); errs != nil { 413 + l.Error("failed to rollback changes", "errs", errs) 414 + return 394 415 } 395 - }() 416 + } 417 + defer rollback() 396 418 397 - resp, err := client.NewRepo(user.Did, repoName, defaultBranch) 419 + client, err := s.oauth.ServiceClient( 420 + r, 421 + oauth.WithService(domain), 422 + oauth.WithLxm(tangled.RepoCreateNSID), 423 + oauth.WithDev(s.config.Core.Dev), 424 + ) 398 425 if err != nil { 399 - s.pages.Notice(w, "repo", "Failed to create repository on knot server.") 426 + l.Error("service auth failed", "err", err) 427 + s.pages.Notice(w, "repo", "Failed to reach PDS.") 400 428 return 401 429 } 402 430 403 - switch resp.StatusCode { 404 - case http.StatusConflict: 405 - s.pages.Notice(w, "repo", "A repository with that name already exists.") 431 + xe := tangled.RepoCreate( 432 + r.Context(), 433 + client, 434 + &tangled.RepoCreate_Input{ 435 + Rkey: rkey, 436 + }, 437 + ) 438 + if err := xrpcclient.HandleXrpcErr(xe); err != nil { 439 + l.Error("xrpc error", "xe", xe) 440 + s.pages.Notice(w, "repo", err.Error()) 406 441 return 407 - case http.StatusInternalServerError: 408 - s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 409 - case http.StatusNoContent: 410 - // continue 411 442 } 412 443 413 - repo.AtUri = atresp.Uri 414 444 err = db.AddRepo(tx, repo) 415 445 if err != nil { 416 - log.Println(err) 446 + l.Error("db write failed", "err", err) 417 447 s.pages.Notice(w, "repo", "Failed to save repository information.") 418 448 return 419 449 } ··· 422 452 p, _ := securejoin.SecureJoin(user.Did, repoName) 423 453 err = s.enforcer.AddRepo(user.Did, domain, p) 424 454 if err != nil { 425 - log.Println(err) 455 + l.Error("acl setup failed", "err", err) 426 456 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 427 457 return 428 458 } 429 459 430 460 err = tx.Commit() 431 461 if err != nil { 432 - log.Println("failed to commit changes", err) 462 + l.Error("txn commit failed", "err", err) 433 463 http.Error(w, err.Error(), http.StatusInternalServerError) 434 464 return 435 465 } 436 466 437 467 err = s.enforcer.E.SavePolicy() 438 468 if err != nil { 439 - log.Println("failed to update ACLs", err) 469 + l.Error("acl save failed", "err", err) 440 470 http.Error(w, err.Error(), http.StatusInternalServerError) 441 471 return 442 472 } 443 473 474 + // reset the ATURI because the transaction completed successfully 475 + aturi = "" 476 + 444 477 s.notifier.NewRepo(r.Context(), repo) 478 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 479 + } 480 + } 445 481 446 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 447 - return 482 + // this is used to rollback changes made to the PDS 483 + // 484 + // it is a no-op if the provided ATURI is empty 485 + func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 486 + if aturi == "" { 487 + return nil 448 488 } 489 + 490 + parsed := syntax.ATURI(aturi) 491 + 492 + collection := parsed.Collection().String() 493 + repo := parsed.Authority().String() 494 + rkey := parsed.RecordKey().String() 495 + 496 + _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 497 + Collection: collection, 498 + Repo: repo, 499 + Rkey: rkey, 500 + }) 501 + return err 449 502 }
+30 -19
appview/strings/strings.go
··· 7 7 "path" 8 8 "slices" 9 9 "strconv" 10 - "strings" 11 10 "time" 12 11 13 12 "tangled.sh/tangled.sh/core/api/tangled" ··· 44 43 r := chi.NewRouter() 45 44 46 45 r. 46 + Get("/", s.timeline) 47 + 48 + r. 47 49 With(mw.ResolveIdent()). 48 50 Route("/{user}", func(r chi.Router) { 49 51 r.Get("/", s.dashboard) ··· 70 72 return r 71 73 } 72 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 + 73 91 func (s *Strings) contents(w http.ResponseWriter, r *http.Request) { 74 92 l := s.Logger.With("handler", "contents") 75 93 ··· 91 109 92 110 strings, err := db.GetStrings( 93 111 s.Db, 112 + 0, 94 113 db.FilterEq("did", id.DID), 95 114 db.FilterEq("rkey", rkey), 96 115 ) ··· 154 173 155 174 all, err := db.GetStrings( 156 175 s.Db, 176 + 0, 157 177 db.FilterEq("did", id.DID), 158 178 ) 159 179 if err != nil { ··· 182 202 followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String()) 183 203 } 184 204 185 - followers, following, err := db.GetFollowerFollowing(s.Db, id.DID.String()) 205 + followStats, err := db.GetFollowerFollowingCount(s.Db, id.DID.String()) 186 206 if err != nil { 187 207 l.Error("failed to get follow stats", "err", err) 188 208 } ··· 190 210 s.Pages.StringsDashboard(w, pages.StringsDashboardParams{ 191 211 LoggedInUser: s.OAuth.GetUser(r), 192 212 Card: pages.ProfileCard{ 193 - UserDid: id.DID.String(), 194 - UserHandle: id.Handle.String(), 195 - Profile: profile, 196 - FollowStatus: followStatus, 197 - Followers: followers, 198 - Following: following, 213 + UserDid: id.DID.String(), 214 + UserHandle: id.Handle.String(), 215 + Profile: profile, 216 + FollowStatus: followStatus, 217 + FollowersCount: followStats.Followers, 218 + FollowingCount: followStats.Following, 199 219 }, 200 220 Strings: all, 201 221 }) ··· 225 245 // get the string currently being edited 226 246 all, err := db.GetStrings( 227 247 s.Db, 248 + 0, 228 249 db.FilterEq("did", id.DID), 229 250 db.FilterEq("rkey", rkey), 230 251 ) ··· 266 287 fail("Empty filename.", nil) 267 288 return 268 289 } 269 - if !strings.Contains(filename, ".") { 270 - // TODO: make this a htmx form validation 271 - fail("No extension provided for filename.", nil) 272 - return 273 - } 274 290 275 291 content := r.FormValue("content") 276 292 if content == "" { ··· 353 369 fail("Empty filename.", nil) 354 370 return 355 371 } 356 - if !strings.Contains(filename, ".") { 357 - // TODO: make this a htmx form validation 358 - fail("No extension provided for filename.", nil) 359 - return 360 - } 361 372 362 373 content := r.FormValue("content") 363 374 if content == "" { ··· 434 445 } 435 446 436 447 if user.Did != id.DID.String() { 437 - fail("You cannot delete this gist", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) 448 + fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) 438 449 return 439 450 } 440 451
+25
appview/xrpcclient/xrpc.go
··· 3 3 import ( 4 4 "bytes" 5 5 "context" 6 + "errors" 7 + "fmt" 6 8 "io" 9 + "net/http" 7 10 8 11 "github.com/bluesky-social/indigo/api/atproto" 9 12 "github.com/bluesky-social/indigo/xrpc" 13 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 10 14 oauth "tangled.sh/icyphox.sh/atproto-oauth" 11 15 ) 12 16 ··· 102 106 103 107 return &out, nil 104 108 } 109 + 110 + // produces a more manageable error 111 + func HandleXrpcErr(err error) error { 112 + if err == nil { 113 + return nil 114 + } 115 + 116 + var xrpcerr *indigoxrpc.Error 117 + if ok := errors.As(err, &xrpcerr); !ok { 118 + return fmt.Errorf("Recieved invalid XRPC error response.") 119 + } 120 + 121 + switch xrpcerr.StatusCode { 122 + case http.StatusNotFound: 123 + return fmt.Errorf("XRPC is unsupported on this knot, consider upgrading your knot.") 124 + case http.StatusUnauthorized: 125 + return fmt.Errorf("Unauthorized XRPC request.") 126 + default: 127 + return fmt.Errorf("Failed to perform operation. Try again later.") 128 + } 129 + }
+1 -2
cmd/gen.go
··· 24 24 tangled.GitRefUpdate_Meta_LangBreakdown{}, 25 25 tangled.GitRefUpdate_Pair{}, 26 26 tangled.GraphFollow{}, 27 + tangled.Knot{}, 27 28 tangled.KnotMember{}, 28 29 tangled.Pipeline{}, 29 30 tangled.Pipeline_CloneOpts{}, 30 - tangled.Pipeline_Dependency{}, 31 31 tangled.Pipeline_ManualTriggerData{}, 32 32 tangled.Pipeline_Pair{}, 33 33 tangled.Pipeline_PullRequestTriggerData{}, 34 34 tangled.Pipeline_PushTriggerData{}, 35 35 tangled.PipelineStatus{}, 36 - tangled.Pipeline_Step{}, 37 36 tangled.Pipeline_TriggerMetadata{}, 38 37 tangled.Pipeline_TriggerRepo{}, 39 38 tangled.Pipeline_Workflow{},
+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.
+23 -17
docs/hacking.md
··· 55 55 quite cumbersome. So the nix flake provides a 56 56 `nixosConfiguration` to do so. 57 57 58 - To begin, head to `http://localhost:3000/knots` in the browser 59 - and generate a knot secret. Set `$TANGLED_KNOT_SECRET` to it, 60 - ideally in a `.envrc` with [direnv](https://direnv.net) so you 61 - don't lose it. 58 + To begin, grab your DID from http://localhost:3000/settings. 59 + Then, set `TANGLED_VM_KNOT_OWNER` and 60 + `TANGLED_VM_SPINDLE_OWNER` to your DID. 62 61 63 - You can now start a lightweight NixOS VM using 64 - `nixos-shell` like so: 62 + If you don't want to [set up a spindle](#running-a-spindle), 63 + you can use any placeholder value. 64 + 65 + You can now start a lightweight NixOS VM like so: 65 66 66 67 ```bash 67 - nix run .#vm 68 - # or nixos-shell --flake .#vm 68 + nix run --impure .#vm 69 69 70 - # hit Ctrl-a + c + q to exit the VM 70 + # type `poweroff` at the shell to exit the VM 71 71 ``` 72 72 73 73 This starts a knot on port 6000, a spindle on port 6555 74 - with `ssh` exposed on port 2222. You can push repositories 75 - to this VM with this ssh config block on your main machine: 74 + with `ssh` exposed on port 2222. 75 + 76 + Once the services are running, head to 77 + http://localhost:3000/knots and hit verify (and similarly, 78 + http://localhost:3000/spindles to verify your spindle). It 79 + should verify the ownership of the services instantly if 80 + everything went smoothly. 81 + 82 + You can push repositories to this VM with this ssh config 83 + block on your main machine: 76 84 77 85 ```bash 78 86 Host nixos-shell ··· 91 99 92 100 ## running a spindle 93 101 94 - Be sure to set `$TANGLED_SPINDLE_OWNER` to your own DID. 95 - The above VM should already be running a spindle on `localhost:6555`. 96 - You can head to the spindle dashboard on `http://localhost:3000/spindles`, 97 - and register a spindle with hostname `localhost:6555`. It should instantly 98 - be verified. You can then configure each repository to use this spindle 99 - and run CI jobs. 102 + The above VM should already be running a spindle on 103 + `localhost:6555`. Head to http://localhost:3000/spindles and 104 + hit verify. You can then configure each repository to use 105 + this spindle and run CI jobs. 100 106 101 107 Of interest when debugging spindles: 102 108
+14 -6
docs/knot-hosting.md
··· 2 2 3 3 So you want to run your own knot server? Great! Here are a few prerequisites: 4 4 5 - 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux of some kind. 5 + 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind. 6 6 2. A (sub)domain name. People generally use `knot.example.com`. 7 7 3. A valid SSL certificate for your domain. 8 8 ··· 59 59 EOF 60 60 ``` 61 61 62 + Then, reload `sshd`: 63 + 64 + ``` 65 + sudo systemctl reload ssh 66 + ``` 67 + 62 68 Next, create the `git` user. We'll use the `git` user's home directory 63 69 to store repositories: 64 70 ··· 67 73 ``` 68 74 69 75 Create `/home/git/.knot.env` with the following, updating the values as 70 - necessary. The `KNOT_SERVER_SECRET` can be obtaind from the 71 - [/knots](/knots) page on Tangled. 76 + necessary. The `KNOT_SERVER_OWNER` should be set to your 77 + DID, you can find your DID in the [Settings](https://tangled.sh/settings) page. 72 78 73 79 ``` 74 80 KNOT_REPO_SCAN_PATH=/home/git 75 81 KNOT_SERVER_HOSTNAME=knot.example.com 76 82 APPVIEW_ENDPOINT=https://tangled.sh 77 - KNOT_SERVER_SECRET=secret 83 + KNOT_SERVER_OWNER=did:plc:foobar 78 84 KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444 79 85 KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 80 86 ``` ··· 122 128 Remember to use Let's Encrypt or similar to procure a certificate for your 123 129 knot domain. 124 130 125 - You should now have a running knot server! You can finalize your registration by hitting the 126 - `initialize` button on the [/knots](/knots) page. 131 + You should now have a running knot server! You can finalize 132 + your registration by hitting the `verify` button on the 133 + [/knots](https://tangled.sh/knots) page. This simply creates 134 + a record on your PDS to announce the existence of the knot. 127 135 128 136 ### custom paths 129 137
+39
docs/migrations/knot-1.7.0.md
··· 1 + # Upgrading from v1.7.0 2 + 3 + After v1.7.0, knot secrets have been deprecated. You no 4 + longer need a secret from the appview to run a knot. All 5 + authorized commands between services to knots are managed 6 + via [Service 7 + Auth](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 8 + Knots will be read-only until upgraded. 9 + 10 + Upgrading is quite easy, in essence: 11 + 12 + - `KNOT_SERVER_SECRET` is no more, you can remove this 13 + environment variable entirely 14 + - `KNOT_SERVER_OWNER` is now required on boot, set this to 15 + your DID. You can find your DID in the 16 + [settings](https://tangled.sh/settings) page. 17 + - Restart your knot once you have replace the environment 18 + variable 19 + - Head to the [knot dashboard](https://tangled.sh/knots) and 20 + hit the "retry" button to verify your knot. This simply 21 + writes a `sh.tangled.knot` record to your PDS. 22 + 23 + ## Nix 24 + 25 + If you use the nix module, simply bump the flake to the 26 + latest revision, and change your config block like so: 27 + 28 + ```diff 29 + services.tangled-knot = { 30 + enable = true; 31 + server = { 32 + - secretFile = /path/to/secret; 33 + + owner = "did:plc:foo"; 34 + . 35 + . 36 + . 37 + }; 38 + }; 39 + ```
+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 }
+3 -3
flake.lock
··· 26 26 ] 27 27 }, 28 28 "locked": { 29 - "lastModified": 1751702058, 30 - "narHash": "sha256-/GTdqFzFw/Y9DSNAfzvzyCMlKjUyRKMPO+apIuaTU4A=", 29 + "lastModified": 1754078208, 30 + "narHash": "sha256-YVoIFDCDpYuU3riaDEJ3xiGdPOtsx4sR5eTzHTytPV8=", 31 31 "owner": "nix-community", 32 32 "repo": "gomod2nix", 33 - "rev": "664ad7a2df4623037e315e4094346bff5c44e9ee", 33 + "rev": "7f963246a71626c7fc70b431a315c4388a0c95cf", 34 34 "type": "github" 35 35 }, 36 36 "original": {
+57 -31
flake.nix
··· 106 106 pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot; 107 107 pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped; 108 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 + }; 109 136 }); 110 137 defaultPackage = forAllSystems (system: self.packages.${system}.appview); 111 - formatter = forAllSystems (system: nixpkgsFor.${system}.alejandra); 112 138 devShells = forAllSystems (system: let 113 139 pkgs = nixpkgsFor.${system}; 114 140 packages' = self.packages.${system}; ··· 129 155 pkgs.redis 130 156 pkgs.coreutils # for those of us who are on systems that use busybox (alpine) 131 157 packages'.lexgen 158 + packages'.treefmt-wrapper 132 159 ]; 133 160 shellHook = '' 134 161 mkdir -p appview/pages/static 135 162 # no preserve is needed because watch-tailwind will want to be able to overwrite 136 - cp -frv --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 163 + cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 137 164 export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)" 138 165 ''; 139 166 env.CGO_ENABLED = 1; ··· 158 185 ${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css 159 186 ''; 160 187 in { 188 + fmt = { 189 + type = "app"; 190 + program = pkgs.lib.getExe packages'.treefmt-wrapper; 191 + }; 161 192 watch-appview = { 162 193 type = "app"; 163 194 program = toString (pkgs.writeShellScript "watch-appview" '' 164 195 echo "copying static files to appview/pages/static..." 165 - ${pkgs.coreutils}/bin/cp -frv --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 196 + ${pkgs.coreutils}/bin/cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 166 197 ${air-watcher "appview" ""}/bin/run 167 198 ''); 168 199 }; ··· 175 206 program = ''${tailwind-watcher}/bin/run''; 176 207 }; 177 208 vm = let 178 - system = 209 + guestSystem = 179 210 if pkgs.stdenv.hostPlatform.isAarch64 180 - then "aarch64" 181 - else "x86_64"; 182 - 183 - nixos-shell = pkgs.nixos-shell.overrideAttrs (old: { 184 - patches = 185 - (old.patches or []) 186 - ++ [ 187 - # https://github.com/Mic92/nixos-shell/pull/94 188 - (pkgs.fetchpatch { 189 - name = "fix-foreign-vm.patch"; 190 - url = "https://github.com/Mic92/nixos-shell/commit/113e4cc55ae236b5b0b1fbd8b321e9b67c77580e.patch"; 191 - hash = "sha256-eauetBK0wXAOcd9PYbExokNCiwz2QyFnZ4FnwGi9VCo="; 192 - }) 193 - ]; 194 - }); 211 + then "aarch64-linux" 212 + else "x86_64-linux"; 195 213 in { 196 214 type = "app"; 197 - program = toString (pkgs.writeShellScript "vm" '' 198 - ${nixos-shell}/bin/nixos-shell --flake .#vm-${system} --guest-system ${system}-linux 199 - ''); 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; 200 234 }; 201 235 gomod2nix = { 202 236 type = "app"; ··· 218 252 rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 219 253 cd "$rootDir" 220 254 221 - rm api/tangled/* 255 + rm -f api/tangled/* 222 256 lexgen --build-file lexicon-build-config.json lexicons 223 257 sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/* 224 258 ${pkgs.gotools}/bin/goimports -w api/tangled/* ··· 257 291 imports = [./nix/modules/spindle.nix]; 258 292 259 293 services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 260 - }; 261 - nixosConfigurations.vm-x86_64 = import ./nix/vm.nix { 262 - inherit self nixpkgs; 263 - system = "x86_64-linux"; 264 - }; 265 - nixosConfigurations.vm-aarch64 = import ./nix/vm.nix { 266 - inherit self nixpkgs; 267 - system = "aarch64-linux"; 268 294 }; 269 295 }; 270 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 {
-336
knotclient/signer.go
··· 1 - package knotclient 2 - 3 - import ( 4 - "bytes" 5 - "crypto/hmac" 6 - "crypto/sha256" 7 - "encoding/hex" 8 - "encoding/json" 9 - "fmt" 10 - "io" 11 - "log" 12 - "net/http" 13 - "net/url" 14 - "time" 15 - 16 - "tangled.sh/tangled.sh/core/types" 17 - ) 18 - 19 - type SignerTransport struct { 20 - Secret string 21 - } 22 - 23 - func (s SignerTransport) RoundTrip(req *http.Request) (*http.Response, error) { 24 - timestamp := time.Now().Format(time.RFC3339) 25 - mac := hmac.New(sha256.New, []byte(s.Secret)) 26 - message := req.Method + req.URL.Path + timestamp 27 - mac.Write([]byte(message)) 28 - signature := hex.EncodeToString(mac.Sum(nil)) 29 - req.Header.Set("X-Signature", signature) 30 - req.Header.Set("X-Timestamp", timestamp) 31 - return http.DefaultTransport.RoundTrip(req) 32 - } 33 - 34 - type SignedClient struct { 35 - Secret string 36 - Url *url.URL 37 - client *http.Client 38 - } 39 - 40 - func NewSignedClient(domain, secret string, dev bool) (*SignedClient, error) { 41 - client := &http.Client{ 42 - Timeout: 5 * time.Second, 43 - Transport: SignerTransport{ 44 - Secret: secret, 45 - }, 46 - } 47 - 48 - scheme := "https" 49 - if dev { 50 - scheme = "http" 51 - } 52 - url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain)) 53 - if err != nil { 54 - return nil, err 55 - } 56 - 57 - signedClient := &SignedClient{ 58 - Secret: secret, 59 - client: client, 60 - Url: url, 61 - } 62 - 63 - return signedClient, nil 64 - } 65 - 66 - func (s *SignedClient) newRequest(method, endpoint string, body []byte) (*http.Request, error) { 67 - return http.NewRequest(method, s.Url.JoinPath(endpoint).String(), bytes.NewReader(body)) 68 - } 69 - 70 - func (s *SignedClient) Init(did string) (*http.Response, error) { 71 - const ( 72 - Method = "POST" 73 - Endpoint = "/init" 74 - ) 75 - 76 - body, _ := json.Marshal(map[string]any{ 77 - "did": did, 78 - }) 79 - 80 - req, err := s.newRequest(Method, Endpoint, body) 81 - if err != nil { 82 - return nil, err 83 - } 84 - 85 - return s.client.Do(req) 86 - } 87 - 88 - func (s *SignedClient) NewRepo(did, repoName, defaultBranch string) (*http.Response, error) { 89 - const ( 90 - Method = "PUT" 91 - Endpoint = "/repo/new" 92 - ) 93 - 94 - body, _ := json.Marshal(map[string]any{ 95 - "did": did, 96 - "name": repoName, 97 - "default_branch": defaultBranch, 98 - }) 99 - 100 - req, err := s.newRequest(Method, Endpoint, body) 101 - if err != nil { 102 - return nil, err 103 - } 104 - 105 - return s.client.Do(req) 106 - } 107 - 108 - func (s *SignedClient) RepoLanguages(ownerDid, repoName, ref string) (*types.RepoLanguageResponse, error) { 109 - const ( 110 - Method = "GET" 111 - ) 112 - endpoint := fmt.Sprintf("/%s/%s/languages/%s", ownerDid, repoName, url.PathEscape(ref)) 113 - 114 - req, err := s.newRequest(Method, endpoint, nil) 115 - if err != nil { 116 - return nil, err 117 - } 118 - 119 - resp, err := s.client.Do(req) 120 - if err != nil { 121 - return nil, err 122 - } 123 - 124 - var result types.RepoLanguageResponse 125 - if resp.StatusCode != http.StatusOK { 126 - log.Println("failed to calculate languages", resp.Status) 127 - return &types.RepoLanguageResponse{}, nil 128 - } 129 - 130 - body, err := io.ReadAll(resp.Body) 131 - if err != nil { 132 - return nil, err 133 - } 134 - 135 - err = json.Unmarshal(body, &result) 136 - if err != nil { 137 - return nil, err 138 - } 139 - 140 - return &result, nil 141 - } 142 - 143 - func (s *SignedClient) RepoForkAheadBehind(ownerDid, source, name, branch, hiddenRef string) (*http.Response, error) { 144 - const ( 145 - Method = "GET" 146 - ) 147 - endpoint := fmt.Sprintf("/repo/fork/sync/%s", url.PathEscape(branch)) 148 - 149 - body, _ := json.Marshal(map[string]any{ 150 - "did": ownerDid, 151 - "source": source, 152 - "name": name, 153 - "hiddenref": hiddenRef, 154 - }) 155 - 156 - req, err := s.newRequest(Method, endpoint, body) 157 - if err != nil { 158 - return nil, err 159 - } 160 - 161 - return s.client.Do(req) 162 - } 163 - 164 - func (s *SignedClient) SyncRepoFork(ownerDid, source, name, branch string) (*http.Response, error) { 165 - const ( 166 - Method = "POST" 167 - ) 168 - endpoint := fmt.Sprintf("/repo/fork/sync/%s", url.PathEscape(branch)) 169 - 170 - body, _ := json.Marshal(map[string]any{ 171 - "did": ownerDid, 172 - "source": source, 173 - "name": name, 174 - }) 175 - 176 - req, err := s.newRequest(Method, endpoint, body) 177 - if err != nil { 178 - return nil, err 179 - } 180 - 181 - return s.client.Do(req) 182 - } 183 - 184 - func (s *SignedClient) ForkRepo(ownerDid, source, name string) (*http.Response, error) { 185 - const ( 186 - Method = "POST" 187 - Endpoint = "/repo/fork" 188 - ) 189 - 190 - body, _ := json.Marshal(map[string]any{ 191 - "did": ownerDid, 192 - "source": source, 193 - "name": name, 194 - }) 195 - 196 - req, err := s.newRequest(Method, Endpoint, body) 197 - if err != nil { 198 - return nil, err 199 - } 200 - 201 - return s.client.Do(req) 202 - } 203 - 204 - func (s *SignedClient) RemoveRepo(did, repoName string) (*http.Response, error) { 205 - const ( 206 - Method = "DELETE" 207 - Endpoint = "/repo" 208 - ) 209 - 210 - body, _ := json.Marshal(map[string]any{ 211 - "did": did, 212 - "name": repoName, 213 - }) 214 - 215 - req, err := s.newRequest(Method, Endpoint, body) 216 - if err != nil { 217 - return nil, err 218 - } 219 - 220 - return s.client.Do(req) 221 - } 222 - 223 - func (s *SignedClient) AddMember(did string) (*http.Response, error) { 224 - const ( 225 - Method = "PUT" 226 - Endpoint = "/member/add" 227 - ) 228 - 229 - body, _ := json.Marshal(map[string]any{ 230 - "did": did, 231 - }) 232 - 233 - req, err := s.newRequest(Method, Endpoint, body) 234 - if err != nil { 235 - return nil, err 236 - } 237 - 238 - return s.client.Do(req) 239 - } 240 - 241 - func (s *SignedClient) SetDefaultBranch(ownerDid, repoName, branch string) (*http.Response, error) { 242 - const ( 243 - Method = "PUT" 244 - ) 245 - endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName) 246 - 247 - body, _ := json.Marshal(map[string]any{ 248 - "branch": branch, 249 - }) 250 - 251 - req, err := s.newRequest(Method, endpoint, body) 252 - if err != nil { 253 - return nil, err 254 - } 255 - 256 - return s.client.Do(req) 257 - } 258 - 259 - func (s *SignedClient) AddCollaborator(ownerDid, repoName, memberDid string) (*http.Response, error) { 260 - const ( 261 - Method = "POST" 262 - ) 263 - endpoint := fmt.Sprintf("/%s/%s/collaborator/add", ownerDid, repoName) 264 - 265 - body, _ := json.Marshal(map[string]any{ 266 - "did": memberDid, 267 - }) 268 - 269 - req, err := s.newRequest(Method, endpoint, body) 270 - if err != nil { 271 - return nil, err 272 - } 273 - 274 - return s.client.Do(req) 275 - } 276 - 277 - func (s *SignedClient) Merge( 278 - patch []byte, 279 - ownerDid, targetRepo, branch, commitMessage, commitBody, authorName, authorEmail string, 280 - ) (*http.Response, error) { 281 - const ( 282 - Method = "POST" 283 - ) 284 - endpoint := fmt.Sprintf("/%s/%s/merge", ownerDid, targetRepo) 285 - 286 - mr := types.MergeRequest{ 287 - Branch: branch, 288 - CommitMessage: commitMessage, 289 - CommitBody: commitBody, 290 - AuthorName: authorName, 291 - AuthorEmail: authorEmail, 292 - Patch: string(patch), 293 - } 294 - 295 - body, _ := json.Marshal(mr) 296 - 297 - req, err := s.newRequest(Method, endpoint, body) 298 - if err != nil { 299 - return nil, err 300 - } 301 - 302 - return s.client.Do(req) 303 - } 304 - 305 - func (s *SignedClient) MergeCheck(patch []byte, ownerDid, targetRepo, branch string) (*http.Response, error) { 306 - const ( 307 - Method = "POST" 308 - ) 309 - endpoint := fmt.Sprintf("/%s/%s/merge/check", ownerDid, targetRepo) 310 - 311 - body, _ := json.Marshal(map[string]any{ 312 - "patch": string(patch), 313 - "branch": branch, 314 - }) 315 - 316 - req, err := s.newRequest(Method, endpoint, body) 317 - if err != nil { 318 - return nil, err 319 - } 320 - 321 - return s.client.Do(req) 322 - } 323 - 324 - func (s *SignedClient) NewHiddenRef(ownerDid, targetRepo, forkBranch, remoteBranch string) (*http.Response, error) { 325 - const ( 326 - Method = "POST" 327 - ) 328 - endpoint := fmt.Sprintf("/%s/%s/hidden-ref/%s/%s", ownerDid, targetRepo, url.PathEscape(forkBranch), url.PathEscape(remoteBranch)) 329 - 330 - req, err := s.newRequest(Method, endpoint, nil) 331 - if err != nil { 332 - return nil, err 333 - } 334 - 335 - return s.client.Do(req) 336 - }
+35
knotclient/unsigned.go
··· 248 248 249 249 return &formatPatchResponse, nil 250 250 } 251 + 252 + func (s *UnsignedClient) RepoLanguages(ownerDid, repoName, ref string) (*types.RepoLanguageResponse, error) { 253 + const ( 254 + Method = "GET" 255 + ) 256 + endpoint := fmt.Sprintf("/%s/%s/languages/%s", ownerDid, repoName, url.PathEscape(ref)) 257 + 258 + req, err := s.newRequest(Method, endpoint, nil, nil) 259 + if err != nil { 260 + return nil, err 261 + } 262 + 263 + resp, err := s.client.Do(req) 264 + if err != nil { 265 + return nil, err 266 + } 267 + 268 + var result types.RepoLanguageResponse 269 + if resp.StatusCode != http.StatusOK { 270 + log.Println("failed to calculate languages", resp.Status) 271 + return &types.RepoLanguageResponse{}, nil 272 + } 273 + 274 + body, err := io.ReadAll(resp.Body) 275 + if err != nil { 276 + return nil, err 277 + } 278 + 279 + err = json.Unmarshal(body, &result) 280 + if err != nil { 281 + return nil, err 282 + } 283 + 284 + return &result, nil 285 + }
+1 -1
knotserver/config/config.go
··· 17 17 type Server struct { 18 18 ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:5555"` 19 19 InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"` 20 - Secret string `env:"SECRET, required"` 21 20 DBPath string `env:"DB_PATH, default=knotserver.db"` 22 21 Hostname string `env:"HOSTNAME, required"` 23 22 JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 23 + Owner string `env:"OWNER, required"` 24 24 LogDids bool `env:"LOG_DIDS, default=true"` 25 25 26 26 // This disables signature verification so use with caution.
+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 -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
+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)
+1008 -150
knotserver/handler.go
··· 1 1 package knotserver 2 2 3 3 import ( 4 + "compress/gzip" 4 5 "context" 6 + "crypto/sha256" 7 + "encoding/json" 8 + "errors" 5 9 "fmt" 6 - "log/slog" 10 + "log" 7 11 "net/http" 8 - "runtime/debug" 12 + "net/url" 13 + "path/filepath" 14 + "strconv" 15 + "strings" 16 + "sync" 17 + "time" 9 18 19 + securejoin "github.com/cyphar/filepath-securejoin" 20 + "github.com/gliderlabs/ssh" 10 21 "github.com/go-chi/chi/v5" 11 - "tangled.sh/tangled.sh/core/idresolver" 12 - "tangled.sh/tangled.sh/core/jetstream" 13 - "tangled.sh/tangled.sh/core/knotserver/config" 22 + "github.com/go-git/go-git/v5/plumbing" 23 + "github.com/go-git/go-git/v5/plumbing/object" 14 24 "tangled.sh/tangled.sh/core/knotserver/db" 15 - "tangled.sh/tangled.sh/core/knotserver/xrpc" 16 - tlog "tangled.sh/tangled.sh/core/log" 17 - "tangled.sh/tangled.sh/core/notifier" 18 - "tangled.sh/tangled.sh/core/rbac" 25 + "tangled.sh/tangled.sh/core/knotserver/git" 26 + "tangled.sh/tangled.sh/core/types" 19 27 ) 20 28 21 - type Handle struct { 22 - c *config.Config 23 - db *db.DB 24 - jc *jetstream.JetstreamClient 25 - e *rbac.Enforcer 26 - l *slog.Logger 27 - n *notifier.Notifier 28 - resolver *idresolver.Resolver 29 + func (h *Handle) Index(w http.ResponseWriter, r *http.Request) { 30 + w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 31 + } 32 + 33 + func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) { 34 + w.Header().Set("Content-Type", "application/json") 35 + 36 + capabilities := map[string]any{ 37 + "pull_requests": map[string]any{ 38 + "format_patch": true, 39 + "patch_submissions": true, 40 + "branch_submissions": true, 41 + "fork_submissions": true, 42 + }, 43 + "xrpc": true, 44 + } 29 45 30 - // init is a channel that is closed when the knot has been initailized 31 - // i.e. when the first user (knot owner) has been added. 32 - init chan struct{} 33 - knotInitialized bool 46 + jsonData, err := json.Marshal(capabilities) 47 + if err != nil { 48 + http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError) 49 + return 50 + } 51 + 52 + w.Write(jsonData) 34 53 } 35 54 36 - func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) { 37 - r := chi.NewRouter() 55 + func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) { 56 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 57 + l := h.l.With("path", path, "handler", "RepoIndex") 58 + ref := chi.URLParam(r, "ref") 59 + ref, _ = url.PathUnescape(ref) 60 + 61 + gr, err := git.Open(path, ref) 62 + if err != nil { 63 + plain, err2 := git.PlainOpen(path) 64 + if err2 != nil { 65 + l.Error("opening repo", "error", err2.Error()) 66 + notFound(w) 67 + return 68 + } 69 + branches, _ := plain.Branches() 38 70 39 - h := Handle{ 40 - c: c, 41 - db: db, 42 - e: e, 43 - l: l, 44 - jc: jc, 45 - n: n, 46 - resolver: idresolver.DefaultResolver(), 47 - init: make(chan struct{}), 71 + log.Println(err) 72 + 73 + if errors.Is(err, plumbing.ErrReferenceNotFound) { 74 + resp := types.RepoIndexResponse{ 75 + IsEmpty: true, 76 + Branches: branches, 77 + } 78 + writeJSON(w, resp) 79 + return 80 + } else { 81 + l.Error("opening repo", "error", err.Error()) 82 + notFound(w) 83 + return 84 + } 48 85 } 49 86 50 - err := e.AddKnot(rbac.ThisServer) 87 + var ( 88 + commits []*object.Commit 89 + total int 90 + branches []types.Branch 91 + files []types.NiceTree 92 + tags []object.Tag 93 + ) 94 + 95 + var wg sync.WaitGroup 96 + errorsCh := make(chan error, 5) 97 + 98 + wg.Add(1) 99 + go func() { 100 + defer wg.Done() 101 + cs, err := gr.Commits(0, 60) 102 + if err != nil { 103 + errorsCh <- fmt.Errorf("commits: %w", err) 104 + return 105 + } 106 + commits = cs 107 + }() 108 + 109 + wg.Add(1) 110 + go func() { 111 + defer wg.Done() 112 + t, err := gr.TotalCommits() 113 + if err != nil { 114 + errorsCh <- fmt.Errorf("calculating total: %w", err) 115 + return 116 + } 117 + total = t 118 + }() 119 + 120 + wg.Add(1) 121 + go func() { 122 + defer wg.Done() 123 + bs, err := gr.Branches() 124 + if err != nil { 125 + errorsCh <- fmt.Errorf("fetching branches: %w", err) 126 + return 127 + } 128 + branches = bs 129 + }() 130 + 131 + wg.Add(1) 132 + go func() { 133 + defer wg.Done() 134 + ts, err := gr.Tags() 135 + if err != nil { 136 + errorsCh <- fmt.Errorf("fetching tags: %w", err) 137 + return 138 + } 139 + tags = ts 140 + }() 141 + 142 + wg.Add(1) 143 + go func() { 144 + defer wg.Done() 145 + fs, err := gr.FileTree(r.Context(), "") 146 + if err != nil { 147 + errorsCh <- fmt.Errorf("fetching filetree: %w", err) 148 + return 149 + } 150 + files = fs 151 + }() 152 + 153 + wg.Wait() 154 + close(errorsCh) 155 + 156 + // show any errors 157 + for err := range errorsCh { 158 + l.Error("loading repo", "error", err.Error()) 159 + writeError(w, err.Error(), http.StatusInternalServerError) 160 + return 161 + } 162 + 163 + rtags := []*types.TagReference{} 164 + for _, tag := range tags { 165 + var target *object.Tag 166 + if tag.Target != plumbing.ZeroHash { 167 + target = &tag 168 + } 169 + tr := types.TagReference{ 170 + Tag: target, 171 + } 172 + 173 + tr.Reference = types.Reference{ 174 + Name: tag.Name, 175 + Hash: tag.Hash.String(), 176 + } 177 + 178 + if tag.Message != "" { 179 + tr.Message = tag.Message 180 + } 181 + 182 + rtags = append(rtags, &tr) 183 + } 184 + 185 + var readmeContent string 186 + var readmeFile string 187 + for _, readme := range h.c.Repo.Readme { 188 + content, _ := gr.FileContent(readme) 189 + if len(content) > 0 { 190 + readmeContent = string(content) 191 + readmeFile = readme 192 + } 193 + } 194 + 195 + if ref == "" { 196 + mainBranch, err := gr.FindMainBranch() 197 + if err != nil { 198 + writeError(w, err.Error(), http.StatusInternalServerError) 199 + l.Error("finding main branch", "error", err.Error()) 200 + return 201 + } 202 + ref = mainBranch 203 + } 204 + 205 + resp := types.RepoIndexResponse{ 206 + IsEmpty: false, 207 + Ref: ref, 208 + Commits: commits, 209 + Description: getDescription(path), 210 + Readme: readmeContent, 211 + ReadmeFileName: readmeFile, 212 + Files: files, 213 + Branches: branches, 214 + Tags: rtags, 215 + TotalCommits: total, 216 + } 217 + 218 + writeJSON(w, resp) 219 + } 220 + 221 + func (h *Handle) RepoTree(w http.ResponseWriter, r *http.Request) { 222 + treePath := chi.URLParam(r, "*") 223 + ref := chi.URLParam(r, "ref") 224 + ref, _ = url.PathUnescape(ref) 225 + 226 + l := h.l.With("handler", "RepoTree", "ref", ref, "treePath", treePath) 227 + 228 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 229 + gr, err := git.Open(path, ref) 51 230 if err != nil { 52 - return nil, fmt.Errorf("failed to setup enforcer: %w", err) 231 + notFound(w) 232 + return 53 233 } 54 234 55 - err = h.jc.StartJetstream(ctx, h.processMessages) 235 + files, err := gr.FileTree(r.Context(), treePath) 56 236 if err != nil { 57 - return nil, fmt.Errorf("failed to start jetstream: %w", err) 237 + writeError(w, err.Error(), http.StatusInternalServerError) 238 + l.Error("file tree", "error", err.Error()) 239 + return 58 240 } 59 241 60 - // Check if the knot knows about any Dids; 61 - // if it does, it is already initialized and we can repopulate the 62 - // Jetstream subscriptions. 63 - dids, err := db.GetAllDids() 242 + resp := types.RepoTreeResponse{ 243 + Ref: ref, 244 + Parent: treePath, 245 + Description: getDescription(path), 246 + DotDot: filepath.Dir(treePath), 247 + Files: files, 248 + } 249 + 250 + writeJSON(w, resp) 251 + } 252 + 253 + func (h *Handle) BlobRaw(w http.ResponseWriter, r *http.Request) { 254 + treePath := chi.URLParam(r, "*") 255 + ref := chi.URLParam(r, "ref") 256 + ref, _ = url.PathUnescape(ref) 257 + 258 + l := h.l.With("handler", "BlobRaw", "ref", ref, "treePath", treePath) 259 + 260 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 261 + gr, err := git.Open(path, ref) 64 262 if err != nil { 65 - return nil, fmt.Errorf("failed to get all Dids: %w", err) 263 + notFound(w) 264 + return 265 + } 266 + 267 + contents, err := gr.RawContent(treePath) 268 + if err != nil { 269 + writeError(w, err.Error(), http.StatusBadRequest) 270 + l.Error("file content", "error", err.Error()) 271 + return 272 + } 273 + 274 + mimeType := http.DetectContentType(contents) 275 + 276 + // exception for svg 277 + if filepath.Ext(treePath) == ".svg" { 278 + mimeType = "image/svg+xml" 66 279 } 67 280 68 - if len(dids) > 0 { 69 - h.knotInitialized = true 70 - close(h.init) 71 - for _, d := range dids { 72 - h.jc.AddDid(d) 281 + contentHash := sha256.Sum256(contents) 282 + eTag := fmt.Sprintf("\"%x\"", contentHash) 283 + 284 + // allow image, video, and text/plain files to be served directly 285 + switch { 286 + case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"): 287 + if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag { 288 + w.WriteHeader(http.StatusNotModified) 289 + return 73 290 } 291 + w.Header().Set("ETag", eTag) 292 + 293 + case strings.HasPrefix(mimeType, "text/plain"): 294 + w.Header().Set("Cache-Control", "public, no-cache") 295 + 296 + default: 297 + l.Error("attempted to serve disallowed file type", "mimetype", mimeType) 298 + writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden) 299 + return 74 300 } 75 301 76 - r.Get("/", h.Index) 77 - r.Get("/capabilities", h.Capabilities) 78 - r.Get("/version", h.Version) 79 - r.Route("/{did}", func(r chi.Router) { 80 - // Repo routes 81 - r.Route("/{name}", func(r chi.Router) { 82 - r.Route("/collaborator", func(r chi.Router) { 83 - r.Use(h.VerifySignature) 84 - r.Post("/add", h.AddRepoCollaborator) 85 - }) 302 + w.Header().Set("Content-Type", mimeType) 303 + w.Write(contents) 304 + } 305 + 306 + func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) { 307 + treePath := chi.URLParam(r, "*") 308 + ref := chi.URLParam(r, "ref") 309 + ref, _ = url.PathUnescape(ref) 310 + 311 + l := h.l.With("handler", "Blob", "ref", ref, "treePath", treePath) 312 + 313 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 314 + gr, err := git.Open(path, ref) 315 + if err != nil { 316 + notFound(w) 317 + return 318 + } 319 + 320 + var isBinaryFile bool = false 321 + contents, err := gr.FileContent(treePath) 322 + if errors.Is(err, git.ErrBinaryFile) { 323 + isBinaryFile = true 324 + } else if errors.Is(err, object.ErrFileNotFound) { 325 + notFound(w) 326 + return 327 + } else if err != nil { 328 + writeError(w, err.Error(), http.StatusInternalServerError) 329 + return 330 + } 331 + 332 + bytes := []byte(contents) 333 + // safe := string(sanitize(bytes)) 334 + sizeHint := len(bytes) 335 + 336 + resp := types.RepoBlobResponse{ 337 + Ref: ref, 338 + Contents: string(bytes), 339 + Path: treePath, 340 + IsBinary: isBinaryFile, 341 + SizeHint: uint64(sizeHint), 342 + } 343 + 344 + h.showFile(resp, w, l) 345 + } 346 + 347 + func (h *Handle) Archive(w http.ResponseWriter, r *http.Request) { 348 + name := chi.URLParam(r, "name") 349 + file := chi.URLParam(r, "file") 86 350 87 - r.Route("/languages", func(r chi.Router) { 88 - r.With(h.VerifySignature) 89 - r.Get("/", h.RepoLanguages) 90 - r.Get("/{ref}", h.RepoLanguages) 91 - }) 351 + l := h.l.With("handler", "Archive", "name", name, "file", file) 92 352 93 - r.Get("/", h.RepoIndex) 94 - r.Get("/info/refs", h.InfoRefs) 95 - r.Post("/git-upload-pack", h.UploadPack) 96 - r.Post("/git-receive-pack", h.ReceivePack) 97 - r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects 353 + // TODO: extend this to add more files compression (e.g.: xz) 354 + if !strings.HasSuffix(file, ".tar.gz") { 355 + notFound(w) 356 + return 357 + } 98 358 99 - r.With(h.VerifySignature).Post("/hidden-ref/{forkRef}/{remoteRef}", h.NewHiddenRef) 359 + ref := strings.TrimSuffix(file, ".tar.gz") 100 360 101 - r.Route("/merge", func(r chi.Router) { 102 - r.With(h.VerifySignature) 103 - r.Post("/", h.Merge) 104 - r.Post("/check", h.MergeCheck) 105 - }) 361 + unescapedRef, err := url.PathUnescape(ref) 362 + if err != nil { 363 + notFound(w) 364 + return 365 + } 366 + 367 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-") 368 + 369 + // This allows the browser to use a proper name for the file when 370 + // downloading 371 + filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename) 372 + setContentDisposition(w, filename) 373 + setGZipMIME(w) 374 + 375 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 376 + gr, err := git.Open(path, unescapedRef) 377 + if err != nil { 378 + notFound(w) 379 + return 380 + } 381 + 382 + gw := gzip.NewWriter(w) 383 + defer gw.Close() 384 + 385 + prefix := fmt.Sprintf("%s-%s", name, safeRefFilename) 386 + err = gr.WriteTar(gw, prefix) 387 + if err != nil { 388 + // once we start writing to the body we can't report error anymore 389 + // so we are only left with printing the error. 390 + l.Error("writing tar file", "error", err.Error()) 391 + return 392 + } 393 + 394 + err = gw.Flush() 395 + if err != nil { 396 + // once we start writing to the body we can't report error anymore 397 + // so we are only left with printing the error. 398 + l.Error("flushing?", "error", err.Error()) 399 + return 400 + } 401 + } 106 402 107 - r.Route("/tree/{ref}", func(r chi.Router) { 108 - r.Get("/", h.RepoIndex) 109 - r.Get("/*", h.RepoTree) 110 - }) 403 + func (h *Handle) Log(w http.ResponseWriter, r *http.Request) { 404 + ref := chi.URLParam(r, "ref") 405 + ref, _ = url.PathUnescape(ref) 111 406 112 - r.Route("/blob/{ref}", func(r chi.Router) { 113 - r.Get("/*", h.Blob) 114 - }) 407 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 115 408 116 - r.Route("/raw/{ref}", func(r chi.Router) { 117 - r.Get("/*", h.BlobRaw) 118 - }) 409 + l := h.l.With("handler", "Log", "ref", ref, "path", path) 119 410 120 - r.Get("/log/{ref}", h.Log) 121 - r.Get("/archive/{file}", h.Archive) 122 - r.Get("/commit/{ref}", h.Diff) 123 - r.Get("/tags", h.Tags) 124 - r.Route("/branches", func(r chi.Router) { 125 - r.Get("/", h.Branches) 126 - r.Get("/{branch}", h.Branch) 127 - r.Route("/default", func(r chi.Router) { 128 - r.Get("/", h.DefaultBranch) 129 - r.With(h.VerifySignature).Put("/", h.SetDefaultBranch) 130 - }) 131 - }) 132 - }) 133 - }) 411 + gr, err := git.Open(path, ref) 412 + if err != nil { 413 + notFound(w) 414 + return 415 + } 134 416 135 - // xrpc apis 136 - r.Mount("/xrpc", h.XrpcRouter()) 417 + // Get page parameters 418 + page := 1 419 + pageSize := 30 137 420 138 - // Create a new repository. 139 - r.Route("/repo", func(r chi.Router) { 140 - r.Use(h.VerifySignature) 141 - r.Put("/new", h.NewRepo) 142 - r.Delete("/", h.RemoveRepo) 143 - r.Route("/fork", func(r chi.Router) { 144 - r.Post("/", h.RepoFork) 145 - r.Post("/sync/{branch}", h.RepoForkSync) 146 - r.Get("/sync/{branch}", h.RepoForkAheadBehind) 147 - }) 148 - }) 421 + if pageParam := r.URL.Query().Get("page"); pageParam != "" { 422 + if p, err := strconv.Atoi(pageParam); err == nil && p > 0 { 423 + page = p 424 + } 425 + } 149 426 150 - r.Route("/member", func(r chi.Router) { 151 - r.Use(h.VerifySignature) 152 - r.Put("/add", h.AddMember) 153 - }) 427 + if pageSizeParam := r.URL.Query().Get("per_page"); pageSizeParam != "" { 428 + if ps, err := strconv.Atoi(pageSizeParam); err == nil && ps > 0 { 429 + pageSize = ps 430 + } 431 + } 154 432 155 - // Socket that streams git oplogs 156 - r.Get("/events", h.Events) 433 + // convert to offset/limit 434 + offset := (page - 1) * pageSize 435 + limit := pageSize 157 436 158 - // Initialize the knot with an owner and public key. 159 - r.With(h.VerifySignature).Post("/init", h.Init) 437 + commits, err := gr.Commits(offset, limit) 438 + if err != nil { 439 + writeError(w, err.Error(), http.StatusInternalServerError) 440 + l.Error("fetching commits", "error", err.Error()) 441 + return 442 + } 160 443 161 - // Health check. Used for two-way verification with appview. 162 - r.With(h.VerifySignature).Get("/health", h.Health) 444 + total := len(commits) 163 445 164 - // All public keys on the knot. 165 - r.Get("/keys", h.Keys) 446 + resp := types.RepoLogResponse{ 447 + Commits: commits, 448 + Ref: ref, 449 + Description: getDescription(path), 450 + Log: true, 451 + Total: total, 452 + Page: page, 453 + PerPage: pageSize, 454 + } 166 455 167 - return r, nil 456 + writeJSON(w, resp) 168 457 } 169 458 170 - func (h *Handle) XrpcRouter() http.Handler { 171 - logger := tlog.New("knots") 459 + func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) { 460 + ref := chi.URLParam(r, "ref") 461 + ref, _ = url.PathUnescape(ref) 172 462 173 - xrpc := &xrpc.Xrpc{ 174 - Config: h.c, 175 - Db: h.db, 176 - Ingester: h.jc, 177 - Enforcer: h.e, 178 - Logger: logger, 179 - Notifier: h.n, 180 - Resolver: h.resolver, 463 + l := h.l.With("handler", "Diff", "ref", ref) 464 + 465 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 466 + gr, err := git.Open(path, ref) 467 + if err != nil { 468 + notFound(w) 469 + return 181 470 } 182 - return xrpc.Router() 471 + 472 + diff, err := gr.Diff() 473 + if err != nil { 474 + writeError(w, err.Error(), http.StatusInternalServerError) 475 + l.Error("getting diff", "error", err.Error()) 476 + return 477 + } 478 + 479 + resp := types.RepoCommitResponse{ 480 + Ref: ref, 481 + Diff: diff, 482 + } 483 + 484 + writeJSON(w, resp) 183 485 } 184 486 185 - // version is set during build time. 186 - var version string 487 + func (h *Handle) Tags(w http.ResponseWriter, r *http.Request) { 488 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 489 + l := h.l.With("handler", "Refs") 187 490 188 - func (h *Handle) Version(w http.ResponseWriter, r *http.Request) { 189 - if version == "" { 190 - info, ok := debug.ReadBuildInfo() 191 - if !ok { 192 - http.Error(w, "failed to read build info", http.StatusInternalServerError) 491 + gr, err := git.Open(path, "") 492 + if err != nil { 493 + notFound(w) 494 + return 495 + } 496 + 497 + tags, err := gr.Tags() 498 + if err != nil { 499 + // Non-fatal, we *should* have at least one branch to show. 500 + l.Warn("getting tags", "error", err.Error()) 501 + } 502 + 503 + rtags := []*types.TagReference{} 504 + for _, tag := range tags { 505 + var target *object.Tag 506 + if tag.Target != plumbing.ZeroHash { 507 + target = &tag 508 + } 509 + tr := types.TagReference{ 510 + Tag: target, 511 + } 512 + 513 + tr.Reference = types.Reference{ 514 + Name: tag.Name, 515 + Hash: tag.Hash.String(), 516 + } 517 + 518 + if tag.Message != "" { 519 + tr.Message = tag.Message 520 + } 521 + 522 + rtags = append(rtags, &tr) 523 + } 524 + 525 + resp := types.RepoTagsResponse{ 526 + Tags: rtags, 527 + } 528 + 529 + writeJSON(w, resp) 530 + } 531 + 532 + func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) { 533 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 534 + 535 + gr, err := git.PlainOpen(path) 536 + if err != nil { 537 + notFound(w) 538 + return 539 + } 540 + 541 + branches, _ := gr.Branches() 542 + 543 + resp := types.RepoBranchesResponse{ 544 + Branches: branches, 545 + } 546 + 547 + writeJSON(w, resp) 548 + } 549 + 550 + func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) { 551 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 552 + branchName := chi.URLParam(r, "branch") 553 + branchName, _ = url.PathUnescape(branchName) 554 + 555 + l := h.l.With("handler", "Branch") 556 + 557 + gr, err := git.PlainOpen(path) 558 + if err != nil { 559 + notFound(w) 560 + return 561 + } 562 + 563 + ref, err := gr.Branch(branchName) 564 + if err != nil { 565 + l.Error("getting branch", "error", err.Error()) 566 + writeError(w, err.Error(), http.StatusInternalServerError) 567 + return 568 + } 569 + 570 + commit, err := gr.Commit(ref.Hash()) 571 + if err != nil { 572 + l.Error("getting commit object", "error", err.Error()) 573 + writeError(w, err.Error(), http.StatusInternalServerError) 574 + return 575 + } 576 + 577 + defaultBranch, err := gr.FindMainBranch() 578 + isDefault := false 579 + if err != nil { 580 + l.Error("getting default branch", "error", err.Error()) 581 + // do not quit though 582 + } else if defaultBranch == branchName { 583 + isDefault = true 584 + } 585 + 586 + resp := types.RepoBranchResponse{ 587 + Branch: types.Branch{ 588 + Reference: types.Reference{ 589 + Name: ref.Name().Short(), 590 + Hash: ref.Hash().String(), 591 + }, 592 + Commit: commit, 593 + IsDefault: isDefault, 594 + }, 595 + } 596 + 597 + writeJSON(w, resp) 598 + } 599 + 600 + func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) { 601 + l := h.l.With("handler", "Keys") 602 + 603 + switch r.Method { 604 + case http.MethodGet: 605 + keys, err := h.db.GetAllPublicKeys() 606 + if err != nil { 607 + writeError(w, err.Error(), http.StatusInternalServerError) 608 + l.Error("getting public keys", "error", err.Error()) 193 609 return 194 610 } 195 611 196 - var modVer string 197 - for _, mod := range info.Deps { 198 - if mod.Path == "tangled.sh/tangled.sh/knotserver" { 199 - version = mod.Version 200 - break 201 - } 612 + data := make([]map[string]any, 0) 613 + for _, key := range keys { 614 + j := key.JSON() 615 + data = append(data, j) 202 616 } 617 + writeJSON(w, data) 618 + return 203 619 204 - if modVer == "" { 205 - version = "unknown" 620 + case http.MethodPut: 621 + pk := db.PublicKey{} 622 + if err := json.NewDecoder(r.Body).Decode(&pk); err != nil { 623 + writeError(w, "invalid request body", http.StatusBadRequest) 624 + return 206 625 } 626 + 627 + _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key)) 628 + if err != nil { 629 + writeError(w, "invalid pubkey", http.StatusBadRequest) 630 + } 631 + 632 + if err := h.db.AddPublicKey(pk); err != nil { 633 + writeError(w, err.Error(), http.StatusInternalServerError) 634 + l.Error("adding public key", "error", err.Error()) 635 + return 636 + } 637 + 638 + w.WriteHeader(http.StatusNoContent) 639 + return 640 + } 641 + } 642 + 643 + // func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) { 644 + // l := h.l.With("handler", "RepoForkSync") 645 + // 646 + // data := struct { 647 + // Did string `json:"did"` 648 + // Source string `json:"source"` 649 + // Name string `json:"name,omitempty"` 650 + // HiddenRef string `json:"hiddenref"` 651 + // }{} 652 + // 653 + // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 654 + // writeError(w, "invalid request body", http.StatusBadRequest) 655 + // return 656 + // } 657 + // 658 + // did := data.Did 659 + // source := data.Source 660 + // 661 + // if did == "" || source == "" { 662 + // l.Error("invalid request body, empty did or name") 663 + // w.WriteHeader(http.StatusBadRequest) 664 + // return 665 + // } 666 + // 667 + // var name string 668 + // if data.Name != "" { 669 + // name = data.Name 670 + // } else { 671 + // name = filepath.Base(source) 672 + // } 673 + // 674 + // branch := chi.URLParam(r, "branch") 675 + // branch, _ = url.PathUnescape(branch) 676 + // 677 + // relativeRepoPath := filepath.Join(did, name) 678 + // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 679 + // 680 + // gr, err := git.PlainOpen(repoPath) 681 + // if err != nil { 682 + // log.Println(err) 683 + // notFound(w) 684 + // return 685 + // } 686 + // 687 + // forkCommit, err := gr.ResolveRevision(branch) 688 + // if err != nil { 689 + // l.Error("error resolving ref revision", "msg", err.Error()) 690 + // writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest) 691 + // return 692 + // } 693 + // 694 + // sourceCommit, err := gr.ResolveRevision(data.HiddenRef) 695 + // if err != nil { 696 + // l.Error("error resolving hidden ref revision", "msg", err.Error()) 697 + // writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest) 698 + // return 699 + // } 700 + // 701 + // status := types.UpToDate 702 + // if forkCommit.Hash.String() != sourceCommit.Hash.String() { 703 + // isAncestor, err := forkCommit.IsAncestor(sourceCommit) 704 + // if err != nil { 705 + // log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err) 706 + // return 707 + // } 708 + // 709 + // if isAncestor { 710 + // status = types.FastForwardable 711 + // } else { 712 + // status = types.Conflict 713 + // } 714 + // } 715 + // 716 + // w.Header().Set("Content-Type", "application/json") 717 + // json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status}) 718 + // } 719 + 720 + func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) { 721 + repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 722 + ref := chi.URLParam(r, "ref") 723 + ref, _ = url.PathUnescape(ref) 724 + 725 + l := h.l.With("handler", "RepoLanguages") 726 + 727 + gr, err := git.Open(repoPath, ref) 728 + if err != nil { 729 + l.Error("opening repo", "error", err.Error()) 730 + notFound(w) 731 + return 207 732 } 208 733 209 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 210 - fmt.Fprintf(w, "knotserver/%s", version) 734 + ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 735 + defer cancel() 736 + 737 + sizes, err := gr.AnalyzeLanguages(ctx) 738 + if err != nil { 739 + l.Error("failed to analyze languages", "error", err.Error()) 740 + writeError(w, err.Error(), http.StatusNoContent) 741 + return 742 + } 743 + 744 + resp := types.RepoLanguageResponse{Languages: sizes} 745 + 746 + writeJSON(w, resp) 747 + } 748 + 749 + // func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) { 750 + // l := h.l.With("handler", "RepoForkSync") 751 + // 752 + // data := struct { 753 + // Did string `json:"did"` 754 + // Source string `json:"source"` 755 + // Name string `json:"name,omitempty"` 756 + // }{} 757 + // 758 + // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 759 + // writeError(w, "invalid request body", http.StatusBadRequest) 760 + // return 761 + // } 762 + // 763 + // did := data.Did 764 + // source := data.Source 765 + // 766 + // if did == "" || source == "" { 767 + // l.Error("invalid request body, empty did or name") 768 + // w.WriteHeader(http.StatusBadRequest) 769 + // return 770 + // } 771 + // 772 + // var name string 773 + // if data.Name != "" { 774 + // name = data.Name 775 + // } else { 776 + // name = filepath.Base(source) 777 + // } 778 + // 779 + // branch := chi.URLParam(r, "branch") 780 + // branch, _ = url.PathUnescape(branch) 781 + // 782 + // relativeRepoPath := filepath.Join(did, name) 783 + // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 784 + // 785 + // gr, err := git.Open(repoPath, branch) 786 + // if err != nil { 787 + // log.Println(err) 788 + // notFound(w) 789 + // return 790 + // } 791 + // 792 + // err = gr.Sync() 793 + // if err != nil { 794 + // l.Error("error syncing repo fork", "error", err.Error()) 795 + // writeError(w, err.Error(), http.StatusInternalServerError) 796 + // return 797 + // } 798 + // 799 + // w.WriteHeader(http.StatusNoContent) 800 + // } 801 + 802 + // func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) { 803 + // l := h.l.With("handler", "RepoFork") 804 + // 805 + // data := struct { 806 + // Did string `json:"did"` 807 + // Source string `json:"source"` 808 + // Name string `json:"name,omitempty"` 809 + // }{} 810 + // 811 + // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 812 + // writeError(w, "invalid request body", http.StatusBadRequest) 813 + // return 814 + // } 815 + // 816 + // did := data.Did 817 + // source := data.Source 818 + // 819 + // if did == "" || source == "" { 820 + // l.Error("invalid request body, empty did or name") 821 + // w.WriteHeader(http.StatusBadRequest) 822 + // return 823 + // } 824 + // 825 + // var name string 826 + // if data.Name != "" { 827 + // name = data.Name 828 + // } else { 829 + // name = filepath.Base(source) 830 + // } 831 + // 832 + // relativeRepoPath := filepath.Join(did, name) 833 + // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 834 + // 835 + // err := git.Fork(repoPath, source) 836 + // if err != nil { 837 + // l.Error("forking repo", "error", err.Error()) 838 + // writeError(w, err.Error(), http.StatusInternalServerError) 839 + // return 840 + // } 841 + // 842 + // // add perms for this user to access the repo 843 + // err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 844 + // if err != nil { 845 + // l.Error("adding repo permissions", "error", err.Error()) 846 + // writeError(w, err.Error(), http.StatusInternalServerError) 847 + // return 848 + // } 849 + // 850 + // hook.SetupRepo( 851 + // hook.Config( 852 + // hook.WithScanPath(h.c.Repo.ScanPath), 853 + // hook.WithInternalApi(h.c.Server.InternalListenAddr), 854 + // ), 855 + // repoPath, 856 + // ) 857 + // 858 + // w.WriteHeader(http.StatusNoContent) 859 + // } 860 + 861 + // func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) { 862 + // l := h.l.With("handler", "RemoveRepo") 863 + // 864 + // data := struct { 865 + // Did string `json:"did"` 866 + // Name string `json:"name"` 867 + // }{} 868 + // 869 + // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 870 + // writeError(w, "invalid request body", http.StatusBadRequest) 871 + // return 872 + // } 873 + // 874 + // did := data.Did 875 + // name := data.Name 876 + // 877 + // if did == "" || name == "" { 878 + // l.Error("invalid request body, empty did or name") 879 + // w.WriteHeader(http.StatusBadRequest) 880 + // return 881 + // } 882 + // 883 + // relativeRepoPath := filepath.Join(did, name) 884 + // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 885 + // err := os.RemoveAll(repoPath) 886 + // if err != nil { 887 + // l.Error("removing repo", "error", err.Error()) 888 + // writeError(w, err.Error(), http.StatusInternalServerError) 889 + // return 890 + // } 891 + // 892 + // w.WriteHeader(http.StatusNoContent) 893 + // 894 + // } 895 + 896 + // func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) { 897 + // path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 898 + // 899 + // data := types.MergeRequest{} 900 + // 901 + // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 902 + // writeError(w, err.Error(), http.StatusBadRequest) 903 + // h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err) 904 + // return 905 + // } 906 + // 907 + // mo := &git.MergeOptions{ 908 + // AuthorName: data.AuthorName, 909 + // AuthorEmail: data.AuthorEmail, 910 + // CommitBody: data.CommitBody, 911 + // CommitMessage: data.CommitMessage, 912 + // } 913 + // 914 + // patch := data.Patch 915 + // branch := data.Branch 916 + // gr, err := git.Open(path, branch) 917 + // if err != nil { 918 + // notFound(w) 919 + // return 920 + // } 921 + // 922 + // mo.FormatPatch = patchutil.IsFormatPatch(patch) 923 + // 924 + // if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil { 925 + // var mergeErr *git.ErrMerge 926 + // if errors.As(err, &mergeErr) { 927 + // conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 928 + // for i, conflict := range mergeErr.Conflicts { 929 + // conflicts[i] = types.ConflictInfo{ 930 + // Filename: conflict.Filename, 931 + // Reason: conflict.Reason, 932 + // } 933 + // } 934 + // response := types.MergeCheckResponse{ 935 + // IsConflicted: true, 936 + // Conflicts: conflicts, 937 + // Message: mergeErr.Message, 938 + // } 939 + // writeConflict(w, response) 940 + // h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr) 941 + // } else { 942 + // writeError(w, err.Error(), http.StatusBadRequest) 943 + // h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error()) 944 + // } 945 + // return 946 + // } 947 + // 948 + // w.WriteHeader(http.StatusOK) 949 + // } 950 + 951 + // func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) { 952 + // path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 953 + // 954 + // var data struct { 955 + // Patch string `json:"patch"` 956 + // Branch string `json:"branch"` 957 + // } 958 + // 959 + // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 960 + // writeError(w, err.Error(), http.StatusBadRequest) 961 + // h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err) 962 + // return 963 + // } 964 + // 965 + // patch := data.Patch 966 + // branch := data.Branch 967 + // gr, err := git.Open(path, branch) 968 + // if err != nil { 969 + // notFound(w) 970 + // return 971 + // } 972 + // 973 + // err = gr.MergeCheck([]byte(patch), branch) 974 + // if err == nil { 975 + // response := types.MergeCheckResponse{ 976 + // IsConflicted: false, 977 + // } 978 + // writeJSON(w, response) 979 + // return 980 + // } 981 + // 982 + // var mergeErr *git.ErrMerge 983 + // if errors.As(err, &mergeErr) { 984 + // conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 985 + // for i, conflict := range mergeErr.Conflicts { 986 + // conflicts[i] = types.ConflictInfo{ 987 + // Filename: conflict.Filename, 988 + // Reason: conflict.Reason, 989 + // } 990 + // } 991 + // response := types.MergeCheckResponse{ 992 + // IsConflicted: true, 993 + // Conflicts: conflicts, 994 + // Message: mergeErr.Message, 995 + // } 996 + // writeConflict(w, response) 997 + // h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error()) 998 + // return 999 + // } 1000 + // writeError(w, err.Error(), http.StatusInternalServerError) 1001 + // h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error()) 1002 + // } 1003 + 1004 + func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) { 1005 + rev1 := chi.URLParam(r, "rev1") 1006 + rev1, _ = url.PathUnescape(rev1) 1007 + 1008 + rev2 := chi.URLParam(r, "rev2") 1009 + rev2, _ = url.PathUnescape(rev2) 1010 + 1011 + l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2) 1012 + 1013 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1014 + gr, err := git.PlainOpen(path) 1015 + if err != nil { 1016 + notFound(w) 1017 + return 1018 + } 1019 + 1020 + commit1, err := gr.ResolveRevision(rev1) 1021 + if err != nil { 1022 + l.Error("error resolving revision 1", "msg", err.Error()) 1023 + writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest) 1024 + return 1025 + } 1026 + 1027 + commit2, err := gr.ResolveRevision(rev2) 1028 + if err != nil { 1029 + l.Error("error resolving revision 2", "msg", err.Error()) 1030 + writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest) 1031 + return 1032 + } 1033 + 1034 + rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 1035 + if err != nil { 1036 + l.Error("error comparing revisions", "msg", err.Error()) 1037 + writeError(w, "error comparing revisions", http.StatusBadRequest) 1038 + return 1039 + } 1040 + 1041 + writeJSON(w, types.RepoFormatPatchResponse{ 1042 + Rev1: commit1.Hash.String(), 1043 + Rev2: commit2.Hash.String(), 1044 + FormatPatch: formatPatch, 1045 + Patch: rawPatch, 1046 + }) 1047 + } 1048 + 1049 + func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) { 1050 + l := h.l.With("handler", "DefaultBranch") 1051 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1052 + 1053 + gr, err := git.Open(path, "") 1054 + if err != nil { 1055 + notFound(w) 1056 + return 1057 + } 1058 + 1059 + branch, err := gr.FindMainBranch() 1060 + if err != nil { 1061 + writeError(w, err.Error(), http.StatusInternalServerError) 1062 + l.Error("getting default branch", "error", err.Error()) 1063 + return 1064 + } 1065 + 1066 + writeJSON(w, types.RepoDefaultBranchResponse{ 1067 + Branch: branch, 1068 + }) 211 1069 }
-10
knotserver/http_util.go
··· 20 20 func notFound(w http.ResponseWriter) { 21 21 writeError(w, "not found", http.StatusNotFound) 22 22 } 23 - 24 - func writeMsg(w http.ResponseWriter, msg string) { 25 - writeJSON(w, map[string]string{"msg": msg}) 26 - } 27 - 28 - func writeConflict(w http.ResponseWriter, data interface{}) { 29 - w.Header().Set("Content-Type", "application/json") 30 - w.WriteHeader(http.StatusConflict) 31 - json.NewEncoder(w).Encode(data) 32 - }
+71 -86
knotserver/ingester.go
··· 8 8 "net/http" 9 9 "net/url" 10 10 "path/filepath" 11 - "slices" 12 11 "strings" 13 12 14 13 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 25 24 "tangled.sh/tangled.sh/core/workflow" 26 25 ) 27 26 28 - func (h *Handle) processPublicKey(ctx context.Context, did string, record tangled.PublicKey) error { 27 + func (h *Handle) processPublicKey(ctx context.Context, event *models.Event) error { 29 28 l := log.FromContext(ctx) 29 + raw := json.RawMessage(event.Commit.Record) 30 + did := event.Did 31 + 32 + var record tangled.PublicKey 33 + if err := json.Unmarshal(raw, &record); err != nil { 34 + return fmt.Errorf("failed to unmarshal record: %w", err) 35 + } 36 + 30 37 pk := db.PublicKey{ 31 38 Did: did, 32 39 PublicKey: record, ··· 39 46 return nil 40 47 } 41 48 42 - func (h *Handle) processKnotMember(ctx context.Context, did string, record tangled.KnotMember) error { 49 + func (h *Handle) processKnotMember(ctx context.Context, event *models.Event) error { 43 50 l := log.FromContext(ctx) 51 + raw := json.RawMessage(event.Commit.Record) 52 + did := event.Did 53 + 54 + var record tangled.KnotMember 55 + if err := json.Unmarshal(raw, &record); err != nil { 56 + return fmt.Errorf("failed to unmarshal record: %w", err) 57 + } 44 58 45 59 if record.Domain != h.c.Server.Hostname { 46 60 l.Error("domain mismatch", "domain", record.Domain, "expected", h.c.Server.Hostname) ··· 59 73 } 60 74 l.Info("added member from firehose", "member", record.Subject) 61 75 62 - if err := h.db.AddDid(did); err != nil { 76 + if err := h.db.AddDid(record.Subject); err != nil { 63 77 l.Error("failed to add did", "error", err) 64 78 return fmt.Errorf("failed to add did: %w", err) 65 79 } 66 - h.jc.AddDid(did) 80 + h.jc.AddDid(record.Subject) 67 81 68 - if err := h.fetchAndAddKeys(ctx, did); err != nil { 82 + if err := h.fetchAndAddKeys(ctx, record.Subject); err != nil { 69 83 return fmt.Errorf("failed to fetch and add keys: %w", err) 70 84 } 71 85 72 86 return nil 73 87 } 74 88 75 - func (h *Handle) processPull(ctx context.Context, did string, record tangled.RepoPull) error { 89 + func (h *Handle) processPull(ctx context.Context, event *models.Event) error { 90 + raw := json.RawMessage(event.Commit.Record) 91 + did := event.Did 92 + 93 + var record tangled.RepoPull 94 + if err := json.Unmarshal(raw, &record); err != nil { 95 + return fmt.Errorf("failed to unmarshal record: %w", err) 96 + } 97 + 76 98 l := log.FromContext(ctx) 77 99 l = l.With("handler", "processPull") 78 100 l = l.With("did", did) ··· 80 102 l = l.With("target_branch", record.TargetBranch) 81 103 82 104 if record.Source == nil { 83 - reason := "not a branch-based pull request" 84 - l.Info("ignoring pull record", "reason", reason) 85 - return fmt.Errorf("ignoring pull record: %s", reason) 105 + return fmt.Errorf("ignoring pull record: not a branch-based pull request") 86 106 } 87 107 88 108 if record.Source.Repo != nil { 89 - reason := "fork based pull" 90 - l.Info("ignoring pull record", "reason", reason) 91 - return fmt.Errorf("ignoring pull record: %s", reason) 92 - } 93 - 94 - allDids, err := h.db.GetAllDids() 95 - if err != nil { 96 - return err 97 - } 98 - 99 - // presently: we only process PRs from collaborators for pipelines 100 - if !slices.Contains(allDids, did) { 101 - reason := "not a known did" 102 - l.Info("rejecting pull record", "reason", reason) 103 - return fmt.Errorf("rejected pull record: %s, %s", reason, did) 109 + return fmt.Errorf("ignoring pull record: fork based pull") 104 110 } 105 111 106 112 repoAt, err := syntax.ParseATURI(record.TargetRepo) 107 113 if err != nil { 108 - return err 114 + return fmt.Errorf("failed to parse ATURI: %w", err) 109 115 } 110 116 111 117 // resolve this aturi to extract the repo record ··· 121 127 122 128 resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 123 129 if err != nil { 124 - return err 130 + return fmt.Errorf("failed to resolver repo: %w", err) 125 131 } 126 132 127 133 repo := resp.Value.Val.(*tangled.Repo) 128 134 129 135 if repo.Knot != h.c.Server.Hostname { 130 - reason := "not this knot" 131 - l.Info("rejecting pull record", "reason", reason) 132 - return fmt.Errorf("rejected pull record: %s", reason) 136 + return fmt.Errorf("rejected pull record: not this knot, %s != %s", repo.Knot, h.c.Server.Hostname) 133 137 } 134 138 135 139 didSlashRepo, err := securejoin.SecureJoin(repo.Owner, repo.Name) 136 140 if err != nil { 137 - return err 141 + return fmt.Errorf("failed to construct relative repo path: %w", err) 138 142 } 139 143 140 144 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 141 145 if err != nil { 142 - return err 146 + return fmt.Errorf("failed to construct absolute repo path: %w", err) 143 147 } 144 148 145 149 gr, err := git.Open(repoPath, record.Source.Branch) 146 150 if err != nil { 147 - return err 151 + return fmt.Errorf("failed to open git repository: %w", err) 148 152 } 149 153 150 154 workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir) 151 155 if err != nil { 152 - return err 156 + return fmt.Errorf("failed to open workflow directory: %w", err) 153 157 } 154 158 155 - var pipeline workflow.Pipeline 159 + var pipeline workflow.RawPipeline 156 160 for _, e := range workflowDir { 157 161 if !e.IsFile { 158 162 continue ··· 164 168 continue 165 169 } 166 170 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) 171 + pipeline = append(pipeline, workflow.RawWorkflow{ 172 + Name: e.Name, 173 + Contents: contents, 174 + }) 175 175 } 176 176 177 177 trigger := tangled.Pipeline_PullRequestTriggerData{ ··· 193 193 }, 194 194 } 195 195 196 - cp := compiler.Compile(pipeline) 196 + cp := compiler.Compile(compiler.Parse(pipeline)) 197 197 eventJson, err := json.Marshal(cp) 198 198 if err != nil { 199 - return err 199 + return fmt.Errorf("failed to marshal pipeline event: %w", err) 200 200 } 201 201 202 202 // do not run empty pipelines ··· 204 204 return nil 205 205 } 206 206 207 - event := db.Event{ 207 + ev := db.Event{ 208 208 Rkey: TID(), 209 209 Nsid: tangled.PipelineNSID, 210 210 EventJson: string(eventJson), 211 211 } 212 212 213 - return h.db.InsertEvent(event, h.n) 213 + return h.db.InsertEvent(ev, h.n) 214 214 } 215 215 216 216 // duplicated from add collaborator 217 - func (h *Handle) processCollaborator(ctx context.Context, did string, record tangled.RepoCollaborator) error { 217 + func (h *Handle) processCollaborator(ctx context.Context, event *models.Event) error { 218 + raw := json.RawMessage(event.Commit.Record) 219 + did := event.Did 220 + 221 + var record tangled.RepoCollaborator 222 + if err := json.Unmarshal(raw, &record); err != nil { 223 + return fmt.Errorf("failed to unmarshal record: %w", err) 224 + } 225 + 218 226 repoAt, err := syntax.ParseATURI(record.Repo) 219 227 if err != nil { 220 228 return err ··· 247 255 didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 248 256 249 257 // check perms for this user 250 - if ok, err := h.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil { 251 - return fmt.Errorf("insufficient permissions: %w", err) 258 + ok, err := h.e.IsCollaboratorInviteAllowed(did, rbac.ThisServer, didSlashRepo) 259 + if err != nil { 260 + return fmt.Errorf("failed to check permissions: %w", err) 261 + } 262 + if !ok { 263 + return fmt.Errorf("insufficient permissions: %s, %s, %s", did, "IsCollaboratorInviteAllowed", didSlashRepo) 252 264 } 253 265 254 266 if err := h.db.AddDid(subjectId.DID.String()); err != nil { ··· 290 302 return fmt.Errorf("error reading response body: %w", err) 291 303 } 292 304 293 - for _, key := range strings.Split(string(plaintext), "\n") { 305 + for key := range strings.SplitSeq(string(plaintext), "\n") { 294 306 if key == "" { 295 307 continue 296 308 } ··· 307 319 } 308 320 309 321 func (h *Handle) processMessages(ctx context.Context, event *models.Event) error { 310 - did := event.Did 311 322 if event.Kind != models.EventKindCommit { 312 323 return nil 313 324 } ··· 321 332 } 322 333 }() 323 334 324 - raw := json.RawMessage(event.Commit.Record) 325 - 326 335 switch event.Commit.Collection { 327 336 case tangled.PublicKeyNSID: 328 - var record tangled.PublicKey 329 - if err := json.Unmarshal(raw, &record); err != nil { 330 - return fmt.Errorf("failed to unmarshal record: %w", err) 331 - } 332 - if err := h.processPublicKey(ctx, did, record); err != nil { 333 - return fmt.Errorf("failed to process public key: %w", err) 334 - } 335 - 337 + err = h.processPublicKey(ctx, event) 336 338 case tangled.KnotMemberNSID: 337 - var record tangled.KnotMember 338 - if err := json.Unmarshal(raw, &record); err != nil { 339 - return fmt.Errorf("failed to unmarshal record: %w", err) 340 - } 341 - if err := h.processKnotMember(ctx, did, record); err != nil { 342 - return fmt.Errorf("failed to process knot member: %w", err) 343 - } 344 - 339 + err = h.processKnotMember(ctx, event) 345 340 case tangled.RepoPullNSID: 346 - var record tangled.RepoPull 347 - if err := json.Unmarshal(raw, &record); err != nil { 348 - return fmt.Errorf("failed to unmarshal record: %w", err) 349 - } 350 - if err := h.processPull(ctx, did, record); err != nil { 351 - return fmt.Errorf("failed to process knot member: %w", err) 352 - } 353 - 341 + err = h.processPull(ctx, event) 354 342 case tangled.RepoCollaboratorNSID: 355 - var record tangled.RepoCollaborator 356 - if err := json.Unmarshal(raw, &record); err != nil { 357 - return fmt.Errorf("failed to unmarshal record: %w", err) 358 - } 359 - if err := h.processCollaborator(ctx, did, record); err != nil { 360 - return fmt.Errorf("failed to process knot member: %w", err) 361 - } 343 + err = h.processCollaborator(ctx, event) 344 + } 362 345 346 + if err != nil { 347 + h.l.Debug("failed to process event", "nsid", event.Commit.Collection, "err", err) 363 348 } 364 349 365 - return err 350 + return nil 366 351 }
+15 -37
knotserver/internal.go
··· 47 47 } 48 48 49 49 w.WriteHeader(http.StatusNoContent) 50 - return 51 50 } 52 51 53 52 func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) { ··· 63 62 data = append(data, j) 64 63 } 65 64 writeJSON(w, data) 66 - return 67 65 } 68 66 69 67 type PushOptions struct { ··· 200 198 return err 201 199 } 202 200 203 - pipelineParseErrors := []string{} 204 - 205 - var pipeline workflow.Pipeline 201 + var pipeline workflow.RawPipeline 206 202 for _, e := range workflowDir { 207 203 if !e.IsFile { 208 204 continue ··· 214 210 continue 215 211 } 216 212 217 - wf, err := workflow.FromFile(e.Name, contents) 218 - if err != nil { 219 - h.l.Error("failed to parse workflow", "err", err, "path", fpath) 220 - pipelineParseErrors = append(pipelineParseErrors, fmt.Sprintf("- at %s: %s\n", fpath, err)) 221 - continue 222 - } 223 - 224 - pipeline = append(pipeline, wf) 213 + pipeline = append(pipeline, workflow.RawWorkflow{ 214 + Name: e.Name, 215 + Contents: contents, 216 + }) 225 217 } 226 218 227 219 trigger := tangled.Pipeline_PushTriggerData{ ··· 242 234 }, 243 235 } 244 236 245 - cp := compiler.Compile(pipeline) 237 + cp := compiler.Compile(compiler.Parse(pipeline)) 246 238 eventJson, err := json.Marshal(cp) 247 239 if err != nil { 248 240 return err 249 241 } 250 242 243 + for _, e := range compiler.Diagnostics.Errors { 244 + *clientMsgs = append(*clientMsgs, e.String()) 245 + } 246 + 251 247 if pushOptions.verboseCi { 252 - hasDiagnostics := false 253 - if len(pipelineParseErrors) > 0 { 254 - hasDiagnostics = true 255 - *clientMsgs = append(*clientMsgs, "error: failed to parse workflow(s):") 256 - for _, error := range pipelineParseErrors { 257 - *clientMsgs = append(*clientMsgs, error) 258 - } 248 + if compiler.Diagnostics.IsEmpty() { 249 + *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 259 250 } 260 - if len(compiler.Diagnostics.Errors) > 0 { 261 - hasDiagnostics = true 262 - *clientMsgs = append(*clientMsgs, "error(s) on pipeline:") 263 - for _, error := range compiler.Diagnostics.Errors { 264 - *clientMsgs = append(*clientMsgs, fmt.Sprintf("- %s:", error)) 265 - } 266 - } 267 - if len(compiler.Diagnostics.Warnings) > 0 { 268 - hasDiagnostics = true 269 - *clientMsgs = append(*clientMsgs, "warning(s) on pipeline:") 270 - for _, warning := range compiler.Diagnostics.Warnings { 271 - *clientMsgs = append(*clientMsgs, fmt.Sprintf("- at %s: %s: %s", warning.Path, warning.Type, warning.Reason)) 272 - } 273 - } 274 - if !hasDiagnostics { 275 - *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 251 + 252 + for _, w := range compiler.Diagnostics.Warnings { 253 + *clientMsgs = append(*clientMsgs, w.String()) 276 254 } 277 255 } 278 256
-53
knotserver/middleware.go
··· 1 - package knotserver 2 - 3 - import ( 4 - "crypto/hmac" 5 - "crypto/sha256" 6 - "encoding/hex" 7 - "net/http" 8 - "time" 9 - ) 10 - 11 - func (h *Handle) VerifySignature(next http.Handler) http.Handler { 12 - if h.c.Server.Dev { 13 - return next 14 - } 15 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 - signature := r.Header.Get("X-Signature") 17 - if signature == "" || !h.verifyHMAC(signature, r) { 18 - writeError(w, "signature verification failed", http.StatusForbidden) 19 - return 20 - } 21 - next.ServeHTTP(w, r) 22 - }) 23 - } 24 - 25 - func (h *Handle) verifyHMAC(signature string, r *http.Request) bool { 26 - secret := h.c.Server.Secret 27 - timestamp := r.Header.Get("X-Timestamp") 28 - if timestamp == "" { 29 - return false 30 - } 31 - 32 - // Verify that the timestamp is not older than a minute 33 - reqTime, err := time.Parse(time.RFC3339, timestamp) 34 - if err != nil { 35 - return false 36 - } 37 - if time.Since(reqTime) > time.Minute { 38 - return false 39 - } 40 - 41 - message := r.Method + r.URL.Path + timestamp 42 - 43 - mac := hmac.New(sha256.New, []byte(secret)) 44 - mac.Write([]byte(message)) 45 - expectedMAC := mac.Sum(nil) 46 - 47 - signatureBytes, err := hex.DecodeString(signature) 48 - if err != nil { 49 - return false 50 - } 51 - 52 - return hmac.Equal(signatureBytes, expectedMAC) 53 - }
+138 -1279
knotserver/routes.go
··· 1 1 package knotserver 2 2 3 3 import ( 4 - "compress/gzip" 5 4 "context" 6 - "crypto/hmac" 7 - "crypto/sha256" 8 - "encoding/hex" 9 - "encoding/json" 10 - "errors" 11 5 "fmt" 12 - "log" 6 + "log/slog" 13 7 "net/http" 14 - "net/url" 15 - "os" 16 - "path/filepath" 17 - "strconv" 18 - "strings" 19 - "sync" 20 - "time" 8 + "runtime/debug" 21 9 22 - securejoin "github.com/cyphar/filepath-securejoin" 23 - "github.com/gliderlabs/ssh" 24 10 "github.com/go-chi/chi/v5" 25 - gogit "github.com/go-git/go-git/v5" 26 - "github.com/go-git/go-git/v5/plumbing" 27 - "github.com/go-git/go-git/v5/plumbing/object" 28 - "tangled.sh/tangled.sh/core/hook" 11 + "tangled.sh/tangled.sh/core/idresolver" 12 + "tangled.sh/tangled.sh/core/jetstream" 13 + "tangled.sh/tangled.sh/core/knotserver/config" 29 14 "tangled.sh/tangled.sh/core/knotserver/db" 30 - "tangled.sh/tangled.sh/core/knotserver/git" 31 - "tangled.sh/tangled.sh/core/patchutil" 15 + "tangled.sh/tangled.sh/core/knotserver/xrpc" 16 + tlog "tangled.sh/tangled.sh/core/log" 17 + "tangled.sh/tangled.sh/core/notifier" 32 18 "tangled.sh/tangled.sh/core/rbac" 33 - "tangled.sh/tangled.sh/core/types" 19 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 34 20 ) 35 21 36 - func (h *Handle) Index(w http.ResponseWriter, r *http.Request) { 37 - w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 22 + type Handle struct { 23 + c *config.Config 24 + db *db.DB 25 + jc *jetstream.JetstreamClient 26 + e *rbac.Enforcer 27 + l *slog.Logger 28 + n *notifier.Notifier 29 + resolver *idresolver.Resolver 38 30 } 39 31 40 - func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) { 41 - w.Header().Set("Content-Type", "application/json") 32 + func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) { 33 + r := chi.NewRouter() 42 34 43 - capabilities := map[string]any{ 44 - "pull_requests": map[string]any{ 45 - "format_patch": true, 46 - "patch_submissions": true, 47 - "branch_submissions": true, 48 - "fork_submissions": true, 49 - }, 35 + h := Handle{ 36 + c: c, 37 + db: db, 38 + e: e, 39 + l: l, 40 + jc: jc, 41 + n: n, 42 + resolver: idresolver.DefaultResolver(), 50 43 } 51 44 52 - jsonData, err := json.Marshal(capabilities) 45 + err := e.AddKnot(rbac.ThisServer) 53 46 if err != nil { 54 - http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError) 55 - return 47 + return nil, fmt.Errorf("failed to setup enforcer: %w", err) 56 48 } 57 49 58 - w.Write(jsonData) 59 - } 60 - 61 - func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) { 62 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 63 - l := h.l.With("path", path, "handler", "RepoIndex") 64 - ref := chi.URLParam(r, "ref") 65 - ref, _ = url.PathUnescape(ref) 66 - 67 - gr, err := git.Open(path, ref) 68 - if err != nil { 69 - plain, err2 := git.PlainOpen(path) 70 - if err2 != nil { 71 - l.Error("opening repo", "error", err2.Error()) 72 - notFound(w) 73 - return 74 - } 75 - branches, _ := plain.Branches() 76 - 77 - log.Println(err) 78 - 79 - if errors.Is(err, plumbing.ErrReferenceNotFound) { 80 - resp := types.RepoIndexResponse{ 81 - IsEmpty: true, 82 - Branches: branches, 83 - } 84 - writeJSON(w, resp) 85 - return 86 - } else { 87 - l.Error("opening repo", "error", err.Error()) 88 - notFound(w) 89 - return 90 - } 50 + // configure owner 51 + if err = h.configureOwner(); err != nil { 52 + return nil, err 91 53 } 54 + h.l.Info("owner set", "did", h.c.Server.Owner) 55 + h.jc.AddDid(h.c.Server.Owner) 92 56 93 - var ( 94 - commits []*object.Commit 95 - total int 96 - branches []types.Branch 97 - files []types.NiceTree 98 - tags []object.Tag 99 - ) 100 - 101 - var wg sync.WaitGroup 102 - errorsCh := make(chan error, 5) 103 - 104 - wg.Add(1) 105 - go func() { 106 - defer wg.Done() 107 - cs, err := gr.Commits(0, 60) 108 - if err != nil { 109 - errorsCh <- fmt.Errorf("commits: %w", err) 110 - return 111 - } 112 - commits = cs 113 - }() 114 - 115 - wg.Add(1) 116 - go func() { 117 - defer wg.Done() 118 - t, err := gr.TotalCommits() 119 - if err != nil { 120 - errorsCh <- fmt.Errorf("calculating total: %w", err) 121 - return 122 - } 123 - total = t 124 - }() 125 - 126 - wg.Add(1) 127 - go func() { 128 - defer wg.Done() 129 - bs, err := gr.Branches() 130 - if err != nil { 131 - errorsCh <- fmt.Errorf("fetching branches: %w", err) 132 - return 133 - } 134 - branches = bs 135 - }() 136 - 137 - wg.Add(1) 138 - go func() { 139 - defer wg.Done() 140 - ts, err := gr.Tags() 141 - if err != nil { 142 - errorsCh <- fmt.Errorf("fetching tags: %w", err) 143 - return 144 - } 145 - tags = ts 146 - }() 147 - 148 - wg.Add(1) 149 - go func() { 150 - defer wg.Done() 151 - fs, err := gr.FileTree(r.Context(), "") 152 - if err != nil { 153 - errorsCh <- fmt.Errorf("fetching filetree: %w", err) 154 - return 155 - } 156 - files = fs 157 - }() 158 - 159 - wg.Wait() 160 - close(errorsCh) 161 - 162 - // show any errors 163 - for err := range errorsCh { 164 - l.Error("loading repo", "error", err.Error()) 165 - writeError(w, err.Error(), http.StatusInternalServerError) 166 - return 167 - } 168 - 169 - rtags := []*types.TagReference{} 170 - for _, tag := range tags { 171 - var target *object.Tag 172 - if tag.Target != plumbing.ZeroHash { 173 - target = &tag 174 - } 175 - tr := types.TagReference{ 176 - Tag: target, 177 - } 178 - 179 - tr.Reference = types.Reference{ 180 - Name: tag.Name, 181 - Hash: tag.Hash.String(), 182 - } 183 - 184 - if tag.Message != "" { 185 - tr.Message = tag.Message 186 - } 187 - 188 - rtags = append(rtags, &tr) 189 - } 190 - 191 - var readmeContent string 192 - var readmeFile string 193 - for _, readme := range h.c.Repo.Readme { 194 - content, _ := gr.FileContent(readme) 195 - if len(content) > 0 { 196 - readmeContent = string(content) 197 - readmeFile = readme 198 - } 199 - } 200 - 201 - if ref == "" { 202 - mainBranch, err := gr.FindMainBranch() 203 - if err != nil { 204 - writeError(w, err.Error(), http.StatusInternalServerError) 205 - l.Error("finding main branch", "error", err.Error()) 206 - return 207 - } 208 - ref = mainBranch 209 - } 210 - 211 - resp := types.RepoIndexResponse{ 212 - IsEmpty: false, 213 - Ref: ref, 214 - Commits: commits, 215 - Description: getDescription(path), 216 - Readme: readmeContent, 217 - ReadmeFileName: readmeFile, 218 - Files: files, 219 - Branches: branches, 220 - Tags: rtags, 221 - TotalCommits: total, 222 - } 223 - 224 - writeJSON(w, resp) 225 - return 226 - } 227 - 228 - func (h *Handle) RepoTree(w http.ResponseWriter, r *http.Request) { 229 - treePath := chi.URLParam(r, "*") 230 - ref := chi.URLParam(r, "ref") 231 - ref, _ = url.PathUnescape(ref) 232 - 233 - l := h.l.With("handler", "RepoTree", "ref", ref, "treePath", treePath) 234 - 235 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 236 - gr, err := git.Open(path, ref) 57 + // configure known-dids in jetstream consumer 58 + dids, err := h.db.GetAllDids() 237 59 if err != nil { 238 - notFound(w) 239 - return 60 + return nil, fmt.Errorf("failed to get all dids: %w", err) 240 61 } 241 - 242 - files, err := gr.FileTree(r.Context(), treePath) 243 - if err != nil { 244 - writeError(w, err.Error(), http.StatusInternalServerError) 245 - l.Error("file tree", "error", err.Error()) 246 - return 62 + for _, d := range dids { 63 + jc.AddDid(d) 247 64 } 248 65 249 - resp := types.RepoTreeResponse{ 250 - Ref: ref, 251 - Parent: treePath, 252 - Description: getDescription(path), 253 - DotDot: filepath.Dir(treePath), 254 - Files: files, 255 - } 256 - 257 - writeJSON(w, resp) 258 - return 259 - } 260 - 261 - func (h *Handle) BlobRaw(w http.ResponseWriter, r *http.Request) { 262 - treePath := chi.URLParam(r, "*") 263 - ref := chi.URLParam(r, "ref") 264 - ref, _ = url.PathUnescape(ref) 265 - 266 - l := h.l.With("handler", "BlobRaw", "ref", ref, "treePath", treePath) 267 - 268 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 269 - gr, err := git.Open(path, ref) 66 + err = h.jc.StartJetstream(ctx, h.processMessages) 270 67 if err != nil { 271 - notFound(w) 272 - return 68 + return nil, fmt.Errorf("failed to start jetstream: %w", err) 273 69 } 274 70 275 - contents, err := gr.RawContent(treePath) 276 - if err != nil { 277 - writeError(w, err.Error(), http.StatusBadRequest) 278 - l.Error("file content", "error", err.Error()) 279 - return 280 - } 71 + r.Get("/", h.Index) 72 + r.Get("/capabilities", h.Capabilities) 73 + r.Get("/version", h.Version) 74 + r.Get("/owner", func(w http.ResponseWriter, r *http.Request) { 75 + w.Write([]byte(h.c.Server.Owner)) 76 + }) 77 + r.Route("/{did}", func(r chi.Router) { 78 + // Repo routes 79 + r.Route("/{name}", func(r chi.Router) { 281 80 282 - mimeType := http.DetectContentType(contents) 81 + r.Route("/languages", func(r chi.Router) { 82 + r.Get("/", h.RepoLanguages) 83 + r.Get("/{ref}", h.RepoLanguages) 84 + }) 283 85 284 - // exception for svg 285 - if filepath.Ext(treePath) == ".svg" { 286 - mimeType = "image/svg+xml" 287 - } 86 + r.Get("/", h.RepoIndex) 87 + r.Get("/info/refs", h.InfoRefs) 88 + r.Post("/git-upload-pack", h.UploadPack) 89 + r.Post("/git-receive-pack", h.ReceivePack) 90 + r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects 288 91 289 - // allow image, video, and text/plain files to be served directly 290 - switch { 291 - case strings.HasPrefix(mimeType, "image/"): 292 - // allowed 293 - case strings.HasPrefix(mimeType, "video/"): 294 - // allowed 295 - case strings.HasPrefix(mimeType, "text/plain"): 296 - // allowed 297 - default: 298 - l.Error("attempted to serve disallowed file type", "mimetype", mimeType) 299 - writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden) 300 - return 301 - } 92 + r.Route("/tree/{ref}", func(r chi.Router) { 93 + r.Get("/", h.RepoIndex) 94 + r.Get("/*", h.RepoTree) 95 + }) 302 96 303 - w.Header().Set("Cache-Control", "public, max-age=86400") // cache for 24 hours 304 - w.Header().Set("ETag", fmt.Sprintf("%x", sha256.Sum256(contents))) 305 - w.Header().Set("Content-Type", mimeType) 306 - w.Write(contents) 307 - } 97 + r.Route("/blob/{ref}", func(r chi.Router) { 98 + r.Get("/*", h.Blob) 99 + }) 308 100 309 - func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) { 310 - treePath := chi.URLParam(r, "*") 311 - ref := chi.URLParam(r, "ref") 312 - ref, _ = url.PathUnescape(ref) 101 + r.Route("/raw/{ref}", func(r chi.Router) { 102 + r.Get("/*", h.BlobRaw) 103 + }) 313 104 314 - l := h.l.With("handler", "Blob", "ref", ref, "treePath", treePath) 105 + r.Get("/log/{ref}", h.Log) 106 + r.Get("/archive/{file}", h.Archive) 107 + r.Get("/commit/{ref}", h.Diff) 108 + r.Get("/tags", h.Tags) 109 + r.Route("/branches", func(r chi.Router) { 110 + r.Get("/", h.Branches) 111 + r.Get("/{branch}", h.Branch) 112 + r.Get("/default", h.DefaultBranch) 113 + }) 114 + }) 115 + }) 315 116 316 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 317 - gr, err := git.Open(path, ref) 318 - if err != nil { 319 - notFound(w) 320 - return 321 - } 117 + // xrpc apis 118 + r.Mount("/xrpc", h.XrpcRouter()) 322 119 323 - var isBinaryFile bool = false 324 - contents, err := gr.FileContent(treePath) 325 - if errors.Is(err, git.ErrBinaryFile) { 326 - isBinaryFile = true 327 - } else if errors.Is(err, object.ErrFileNotFound) { 328 - notFound(w) 329 - return 330 - } else if err != nil { 331 - writeError(w, err.Error(), http.StatusInternalServerError) 332 - return 333 - } 120 + // Socket that streams git oplogs 121 + r.Get("/events", h.Events) 334 122 335 - bytes := []byte(contents) 336 - // safe := string(sanitize(bytes)) 337 - sizeHint := len(bytes) 338 - 339 - resp := types.RepoBlobResponse{ 340 - Ref: ref, 341 - Contents: string(bytes), 342 - Path: treePath, 343 - IsBinary: isBinaryFile, 344 - SizeHint: uint64(sizeHint), 345 - } 123 + // All public keys on the knot. 124 + r.Get("/keys", h.Keys) 346 125 347 - h.showFile(resp, w, l) 126 + return r, nil 348 127 } 349 128 350 - func (h *Handle) Archive(w http.ResponseWriter, r *http.Request) { 351 - name := chi.URLParam(r, "name") 352 - file := chi.URLParam(r, "file") 129 + func (h *Handle) XrpcRouter() http.Handler { 130 + logger := tlog.New("knots") 353 131 354 - l := h.l.With("handler", "Archive", "name", name, "file", file) 355 - 356 - // TODO: extend this to add more files compression (e.g.: xz) 357 - if !strings.HasSuffix(file, ".tar.gz") { 358 - notFound(w) 359 - return 360 - } 361 - 362 - ref := strings.TrimSuffix(file, ".tar.gz") 363 - 364 - // This allows the browser to use a proper name for the file when 365 - // downloading 366 - filename := fmt.Sprintf("%s-%s.tar.gz", name, ref) 367 - setContentDisposition(w, filename) 368 - setGZipMIME(w) 369 - 370 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 371 - gr, err := git.Open(path, ref) 372 - if err != nil { 373 - notFound(w) 374 - return 375 - } 376 - 377 - gw := gzip.NewWriter(w) 378 - defer gw.Close() 379 - 380 - prefix := fmt.Sprintf("%s-%s", name, ref) 381 - err = gr.WriteTar(gw, prefix) 382 - if err != nil { 383 - // once we start writing to the body we can't report error anymore 384 - // so we are only left with printing the error. 385 - l.Error("writing tar file", "error", err.Error()) 386 - return 387 - } 132 + serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 388 133 389 - err = gw.Flush() 390 - if err != nil { 391 - // once we start writing to the body we can't report error anymore 392 - // so we are only left with printing the error. 393 - l.Error("flushing?", "error", err.Error()) 394 - return 395 - } 396 - } 397 - 398 - func (h *Handle) Log(w http.ResponseWriter, r *http.Request) { 399 - ref := chi.URLParam(r, "ref") 400 - ref, _ = url.PathUnescape(ref) 401 - 402 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 403 - 404 - l := h.l.With("handler", "Log", "ref", ref, "path", path) 405 - 406 - gr, err := git.Open(path, ref) 407 - if err != nil { 408 - notFound(w) 409 - return 410 - } 411 - 412 - // Get page parameters 413 - page := 1 414 - pageSize := 30 415 - 416 - if pageParam := r.URL.Query().Get("page"); pageParam != "" { 417 - if p, err := strconv.Atoi(pageParam); err == nil && p > 0 { 418 - page = p 419 - } 420 - } 421 - 422 - if pageSizeParam := r.URL.Query().Get("per_page"); pageSizeParam != "" { 423 - if ps, err := strconv.Atoi(pageSizeParam); err == nil && ps > 0 { 424 - pageSize = ps 425 - } 426 - } 427 - 428 - // convert to offset/limit 429 - offset := (page - 1) * pageSize 430 - limit := pageSize 431 - 432 - commits, err := gr.Commits(offset, limit) 433 - if err != nil { 434 - writeError(w, err.Error(), http.StatusInternalServerError) 435 - l.Error("fetching commits", "error", err.Error()) 436 - return 437 - } 438 - 439 - total := len(commits) 440 - 441 - resp := types.RepoLogResponse{ 442 - Commits: commits, 443 - Ref: ref, 444 - Description: getDescription(path), 445 - Log: true, 446 - Total: total, 447 - Page: page, 448 - PerPage: pageSize, 449 - } 450 - 451 - writeJSON(w, resp) 452 - return 453 - } 454 - 455 - func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) { 456 - ref := chi.URLParam(r, "ref") 457 - ref, _ = url.PathUnescape(ref) 458 - 459 - l := h.l.With("handler", "Diff", "ref", ref) 460 - 461 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 462 - gr, err := git.Open(path, ref) 463 - if err != nil { 464 - notFound(w) 465 - return 466 - } 467 - 468 - diff, err := gr.Diff() 469 - if err != nil { 470 - writeError(w, err.Error(), http.StatusInternalServerError) 471 - l.Error("getting diff", "error", err.Error()) 472 - return 473 - } 474 - 475 - resp := types.RepoCommitResponse{ 476 - Ref: ref, 477 - Diff: diff, 134 + xrpc := &xrpc.Xrpc{ 135 + Config: h.c, 136 + Db: h.db, 137 + Ingester: h.jc, 138 + Enforcer: h.e, 139 + Logger: logger, 140 + Notifier: h.n, 141 + Resolver: h.resolver, 142 + ServiceAuth: serviceAuth, 478 143 } 479 - 480 - writeJSON(w, resp) 481 - return 144 + return xrpc.Router() 482 145 } 483 146 484 - func (h *Handle) Tags(w http.ResponseWriter, r *http.Request) { 485 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 486 - l := h.l.With("handler", "Refs") 147 + // version is set during build time. 148 + var version string 487 149 488 - gr, err := git.Open(path, "") 489 - if err != nil { 490 - notFound(w) 491 - return 492 - } 493 - 494 - tags, err := gr.Tags() 495 - if err != nil { 496 - // Non-fatal, we *should* have at least one branch to show. 497 - l.Warn("getting tags", "error", err.Error()) 498 - } 499 - 500 - rtags := []*types.TagReference{} 501 - for _, tag := range tags { 502 - var target *object.Tag 503 - if tag.Target != plumbing.ZeroHash { 504 - target = &tag 505 - } 506 - tr := types.TagReference{ 507 - Tag: target, 150 + func (h *Handle) Version(w http.ResponseWriter, r *http.Request) { 151 + if version == "" { 152 + info, ok := debug.ReadBuildInfo() 153 + if !ok { 154 + http.Error(w, "failed to read build info", http.StatusInternalServerError) 155 + return 508 156 } 509 157 510 - tr.Reference = types.Reference{ 511 - Name: tag.Name, 512 - Hash: tag.Hash.String(), 158 + var modVer string 159 + for _, mod := range info.Deps { 160 + if mod.Path == "tangled.sh/tangled.sh/knotserver" { 161 + version = mod.Version 162 + break 163 + } 513 164 } 514 165 515 - if tag.Message != "" { 516 - tr.Message = tag.Message 166 + if modVer == "" { 167 + version = "unknown" 517 168 } 518 - 519 - rtags = append(rtags, &tr) 520 169 } 521 170 522 - resp := types.RepoTagsResponse{ 523 - Tags: rtags, 524 - } 525 - 526 - writeJSON(w, resp) 527 - return 528 - } 529 - 530 - func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) { 531 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 532 - 533 - gr, err := git.PlainOpen(path) 534 - if err != nil { 535 - notFound(w) 536 - return 537 - } 538 - 539 - branches, _ := gr.Branches() 540 - 541 - resp := types.RepoBranchesResponse{ 542 - Branches: branches, 543 - } 544 - 545 - writeJSON(w, resp) 546 - return 171 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 172 + fmt.Fprintf(w, "knotserver/%s", version) 547 173 } 548 174 549 - func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) { 550 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 551 - branchName := chi.URLParam(r, "branch") 552 - branchName, _ = url.PathUnescape(branchName) 175 + func (h *Handle) configureOwner() error { 176 + cfgOwner := h.c.Server.Owner 553 177 554 - l := h.l.With("handler", "Branch") 178 + rbacDomain := "thisserver" 555 179 556 - gr, err := git.PlainOpen(path) 180 + existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain) 557 181 if err != nil { 558 - notFound(w) 559 - return 182 + return err 560 183 } 561 184 562 - ref, err := gr.Branch(branchName) 563 - if err != nil { 564 - l.Error("getting branch", "error", err.Error()) 565 - writeError(w, err.Error(), http.StatusInternalServerError) 566 - return 567 - } 568 - 569 - commit, err := gr.Commit(ref.Hash()) 570 - if err != nil { 571 - l.Error("getting commit object", "error", err.Error()) 572 - writeError(w, err.Error(), http.StatusInternalServerError) 573 - return 574 - } 575 - 576 - defaultBranch, err := gr.FindMainBranch() 577 - isDefault := false 578 - if err != nil { 579 - l.Error("getting default branch", "error", err.Error()) 580 - // do not quit though 581 - } else if defaultBranch == branchName { 582 - isDefault = true 583 - } 584 - 585 - resp := types.RepoBranchResponse{ 586 - Branch: types.Branch{ 587 - Reference: types.Reference{ 588 - Name: ref.Name().Short(), 589 - Hash: ref.Hash().String(), 590 - }, 591 - Commit: commit, 592 - IsDefault: isDefault, 593 - }, 594 - } 595 - 596 - writeJSON(w, resp) 597 - return 598 - } 599 - 600 - func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) { 601 - l := h.l.With("handler", "Keys") 602 - 603 - switch r.Method { 604 - case http.MethodGet: 605 - keys, err := h.db.GetAllPublicKeys() 606 - if err != nil { 607 - writeError(w, err.Error(), http.StatusInternalServerError) 608 - l.Error("getting public keys", "error", err.Error()) 609 - return 610 - } 611 - 612 - data := make([]map[string]any, 0) 613 - for _, key := range keys { 614 - j := key.JSON() 615 - data = append(data, j) 616 - } 617 - writeJSON(w, data) 618 - return 185 + switch len(existing) { 186 + case 0: 187 + // no owner configured, continue 188 + case 1: 189 + // find existing owner 190 + existingOwner := existing[0] 619 191 620 - case http.MethodPut: 621 - pk := db.PublicKey{} 622 - if err := json.NewDecoder(r.Body).Decode(&pk); err != nil { 623 - writeError(w, "invalid request body", http.StatusBadRequest) 624 - return 192 + // no ownership change, this is okay 193 + if existingOwner == h.c.Server.Owner { 194 + break 625 195 } 626 196 627 - _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key)) 197 + // remove existing owner 198 + err = h.e.RemoveKnotOwner(rbacDomain, existingOwner) 628 199 if err != nil { 629 - writeError(w, "invalid pubkey", http.StatusBadRequest) 200 + return nil 630 201 } 631 - 632 - if err := h.db.AddPublicKey(pk); err != nil { 633 - writeError(w, err.Error(), http.StatusInternalServerError) 634 - l.Error("adding public key", "error", err.Error()) 635 - return 636 - } 637 - 638 - w.WriteHeader(http.StatusNoContent) 639 - return 640 - } 641 - } 642 - 643 - func (h *Handle) NewRepo(w http.ResponseWriter, r *http.Request) { 644 - l := h.l.With("handler", "NewRepo") 645 - 646 - data := struct { 647 - Did string `json:"did"` 648 - Name string `json:"name"` 649 - DefaultBranch string `json:"default_branch,omitempty"` 650 - }{} 651 - 652 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 653 - writeError(w, "invalid request body", http.StatusBadRequest) 654 - return 202 + default: 203 + return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", h.c.Server.DBPath) 655 204 } 656 205 657 - if data.DefaultBranch == "" { 658 - data.DefaultBranch = h.c.Repo.MainBranch 659 - } 660 - 661 - did := data.Did 662 - name := data.Name 663 - defaultBranch := data.DefaultBranch 664 - 665 - if err := validateRepoName(name); err != nil { 666 - l.Error("creating repo", "error", err.Error()) 667 - writeError(w, err.Error(), http.StatusBadRequest) 668 - return 669 - } 670 - 671 - relativeRepoPath := filepath.Join(did, name) 672 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 673 - err := git.InitBare(repoPath, defaultBranch) 674 - if err != nil { 675 - l.Error("initializing bare repo", "error", err.Error()) 676 - if errors.Is(err, gogit.ErrRepositoryAlreadyExists) { 677 - writeError(w, "That repo already exists!", http.StatusConflict) 678 - return 679 - } else { 680 - writeError(w, err.Error(), http.StatusInternalServerError) 681 - return 682 - } 683 - } 684 - 685 - // add perms for this user to access the repo 686 - err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 687 - if err != nil { 688 - l.Error("adding repo permissions", "error", err.Error()) 689 - writeError(w, err.Error(), http.StatusInternalServerError) 690 - return 691 - } 692 - 693 - hook.SetupRepo( 694 - hook.Config( 695 - hook.WithScanPath(h.c.Repo.ScanPath), 696 - hook.WithInternalApi(h.c.Server.InternalListenAddr), 697 - ), 698 - repoPath, 699 - ) 700 - 701 - w.WriteHeader(http.StatusNoContent) 702 - } 703 - 704 - func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) { 705 - l := h.l.With("handler", "RepoForkSync") 706 - 707 - data := struct { 708 - Did string `json:"did"` 709 - Source string `json:"source"` 710 - Name string `json:"name,omitempty"` 711 - HiddenRef string `json:"hiddenref"` 712 - }{} 713 - 714 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 715 - writeError(w, "invalid request body", http.StatusBadRequest) 716 - return 717 - } 718 - 719 - did := data.Did 720 - source := data.Source 721 - 722 - if did == "" || source == "" { 723 - l.Error("invalid request body, empty did or name") 724 - w.WriteHeader(http.StatusBadRequest) 725 - return 726 - } 727 - 728 - var name string 729 - if data.Name != "" { 730 - name = data.Name 731 - } else { 732 - name = filepath.Base(source) 733 - } 734 - 735 - branch := chi.URLParam(r, "branch") 736 - branch, _ = url.PathUnescape(branch) 737 - 738 - relativeRepoPath := filepath.Join(did, name) 739 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 740 - 741 - gr, err := git.PlainOpen(repoPath) 742 - if err != nil { 743 - log.Println(err) 744 - notFound(w) 745 - return 746 - } 747 - 748 - forkCommit, err := gr.ResolveRevision(branch) 749 - if err != nil { 750 - l.Error("error resolving ref revision", "msg", err.Error()) 751 - writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest) 752 - return 753 - } 754 - 755 - sourceCommit, err := gr.ResolveRevision(data.HiddenRef) 756 - if err != nil { 757 - l.Error("error resolving hidden ref revision", "msg", err.Error()) 758 - writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest) 759 - return 760 - } 761 - 762 - status := types.UpToDate 763 - if forkCommit.Hash.String() != sourceCommit.Hash.String() { 764 - isAncestor, err := forkCommit.IsAncestor(sourceCommit) 765 - if err != nil { 766 - log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err) 767 - return 768 - } 769 - 770 - if isAncestor { 771 - status = types.FastForwardable 772 - } else { 773 - status = types.Conflict 774 - } 775 - } 776 - 777 - w.Header().Set("Content-Type", "application/json") 778 - json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status}) 779 - } 780 - 781 - func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) { 782 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 783 - ref := chi.URLParam(r, "ref") 784 - ref, _ = url.PathUnescape(ref) 785 - 786 - l := h.l.With("handler", "RepoLanguages") 787 - 788 - gr, err := git.Open(repoPath, ref) 789 - if err != nil { 790 - l.Error("opening repo", "error", err.Error()) 791 - notFound(w) 792 - return 793 - } 794 - 795 - ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 796 - defer cancel() 797 - 798 - sizes, err := gr.AnalyzeLanguages(ctx) 799 - if err != nil { 800 - l.Error("failed to analyze languages", "error", err.Error()) 801 - writeError(w, err.Error(), http.StatusNoContent) 802 - return 803 - } 804 - 805 - resp := types.RepoLanguageResponse{Languages: sizes} 806 - 807 - writeJSON(w, resp) 808 - } 809 - 810 - func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) { 811 - l := h.l.With("handler", "RepoForkSync") 812 - 813 - data := struct { 814 - Did string `json:"did"` 815 - Source string `json:"source"` 816 - Name string `json:"name,omitempty"` 817 - }{} 818 - 819 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 820 - writeError(w, "invalid request body", http.StatusBadRequest) 821 - return 822 - } 823 - 824 - did := data.Did 825 - source := data.Source 826 - 827 - if did == "" || source == "" { 828 - l.Error("invalid request body, empty did or name") 829 - w.WriteHeader(http.StatusBadRequest) 830 - return 831 - } 832 - 833 - var name string 834 - if data.Name != "" { 835 - name = data.Name 836 - } else { 837 - name = filepath.Base(source) 838 - } 839 - 840 - branch := chi.URLParam(r, "branch") 841 - branch, _ = url.PathUnescape(branch) 842 - 843 - relativeRepoPath := filepath.Join(did, name) 844 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 845 - 846 - gr, err := git.PlainOpen(repoPath) 847 - if err != nil { 848 - log.Println(err) 849 - notFound(w) 850 - return 851 - } 852 - 853 - err = gr.Sync(branch) 854 - if err != nil { 855 - l.Error("error syncing repo fork", "error", err.Error()) 856 - writeError(w, err.Error(), http.StatusInternalServerError) 857 - return 858 - } 859 - 860 - w.WriteHeader(http.StatusNoContent) 861 - } 862 - 863 - func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) { 864 - l := h.l.With("handler", "RepoFork") 865 - 866 - data := struct { 867 - Did string `json:"did"` 868 - Source string `json:"source"` 869 - Name string `json:"name,omitempty"` 870 - }{} 871 - 872 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 873 - writeError(w, "invalid request body", http.StatusBadRequest) 874 - return 875 - } 876 - 877 - did := data.Did 878 - source := data.Source 879 - 880 - if did == "" || source == "" { 881 - l.Error("invalid request body, empty did or name") 882 - w.WriteHeader(http.StatusBadRequest) 883 - return 884 - } 885 - 886 - var name string 887 - if data.Name != "" { 888 - name = data.Name 889 - } else { 890 - name = filepath.Base(source) 891 - } 892 - 893 - relativeRepoPath := filepath.Join(did, name) 894 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 895 - 896 - err := git.Fork(repoPath, source) 897 - if err != nil { 898 - l.Error("forking repo", "error", err.Error()) 899 - writeError(w, err.Error(), http.StatusInternalServerError) 900 - return 901 - } 902 - 903 - // add perms for this user to access the repo 904 - err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 905 - if err != nil { 906 - l.Error("adding repo permissions", "error", err.Error()) 907 - writeError(w, err.Error(), http.StatusInternalServerError) 908 - return 909 - } 910 - 911 - hook.SetupRepo( 912 - hook.Config( 913 - hook.WithScanPath(h.c.Repo.ScanPath), 914 - hook.WithInternalApi(h.c.Server.InternalListenAddr), 915 - ), 916 - repoPath, 917 - ) 918 - 919 - w.WriteHeader(http.StatusNoContent) 920 - } 921 - 922 - func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) { 923 - l := h.l.With("handler", "RemoveRepo") 924 - 925 - data := struct { 926 - Did string `json:"did"` 927 - Name string `json:"name"` 928 - }{} 929 - 930 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 931 - writeError(w, "invalid request body", http.StatusBadRequest) 932 - return 933 - } 934 - 935 - did := data.Did 936 - name := data.Name 937 - 938 - if did == "" || name == "" { 939 - l.Error("invalid request body, empty did or name") 940 - w.WriteHeader(http.StatusBadRequest) 941 - return 942 - } 943 - 944 - relativeRepoPath := filepath.Join(did, name) 945 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 946 - err := os.RemoveAll(repoPath) 947 - if err != nil { 948 - l.Error("removing repo", "error", err.Error()) 949 - writeError(w, err.Error(), http.StatusInternalServerError) 950 - return 951 - } 952 - 953 - w.WriteHeader(http.StatusNoContent) 954 - 955 - } 956 - func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) { 957 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 958 - 959 - data := types.MergeRequest{} 960 - 961 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 962 - writeError(w, err.Error(), http.StatusBadRequest) 963 - h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err) 964 - return 965 - } 966 - 967 - mo := &git.MergeOptions{ 968 - AuthorName: data.AuthorName, 969 - AuthorEmail: data.AuthorEmail, 970 - CommitBody: data.CommitBody, 971 - CommitMessage: data.CommitMessage, 972 - } 973 - 974 - patch := data.Patch 975 - branch := data.Branch 976 - gr, err := git.Open(path, branch) 977 - if err != nil { 978 - notFound(w) 979 - return 980 - } 981 - 982 - mo.FormatPatch = patchutil.IsFormatPatch(patch) 983 - 984 - if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil { 985 - var mergeErr *git.ErrMerge 986 - if errors.As(err, &mergeErr) { 987 - conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 988 - for i, conflict := range mergeErr.Conflicts { 989 - conflicts[i] = types.ConflictInfo{ 990 - Filename: conflict.Filename, 991 - Reason: conflict.Reason, 992 - } 993 - } 994 - response := types.MergeCheckResponse{ 995 - IsConflicted: true, 996 - Conflicts: conflicts, 997 - Message: mergeErr.Message, 998 - } 999 - writeConflict(w, response) 1000 - h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr) 1001 - } else { 1002 - writeError(w, err.Error(), http.StatusBadRequest) 1003 - h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error()) 1004 - } 1005 - return 1006 - } 1007 - 1008 - w.WriteHeader(http.StatusOK) 1009 - } 1010 - 1011 - func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) { 1012 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1013 - 1014 - var data struct { 1015 - Patch string `json:"patch"` 1016 - Branch string `json:"branch"` 1017 - } 1018 - 1019 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1020 - writeError(w, err.Error(), http.StatusBadRequest) 1021 - h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err) 1022 - return 1023 - } 1024 - 1025 - patch := data.Patch 1026 - branch := data.Branch 1027 - gr, err := git.Open(path, branch) 1028 - if err != nil { 1029 - notFound(w) 1030 - return 1031 - } 1032 - 1033 - err = gr.MergeCheck([]byte(patch), branch) 1034 - if err == nil { 1035 - response := types.MergeCheckResponse{ 1036 - IsConflicted: false, 1037 - } 1038 - writeJSON(w, response) 1039 - return 1040 - } 1041 - 1042 - var mergeErr *git.ErrMerge 1043 - if errors.As(err, &mergeErr) { 1044 - conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 1045 - for i, conflict := range mergeErr.Conflicts { 1046 - conflicts[i] = types.ConflictInfo{ 1047 - Filename: conflict.Filename, 1048 - Reason: conflict.Reason, 1049 - } 1050 - } 1051 - response := types.MergeCheckResponse{ 1052 - IsConflicted: true, 1053 - Conflicts: conflicts, 1054 - Message: mergeErr.Message, 1055 - } 1056 - writeConflict(w, response) 1057 - h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error()) 1058 - return 1059 - } 1060 - writeError(w, err.Error(), http.StatusInternalServerError) 1061 - h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error()) 1062 - } 1063 - 1064 - func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) { 1065 - rev1 := chi.URLParam(r, "rev1") 1066 - rev1, _ = url.PathUnescape(rev1) 1067 - 1068 - rev2 := chi.URLParam(r, "rev2") 1069 - rev2, _ = url.PathUnescape(rev2) 1070 - 1071 - l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2) 1072 - 1073 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1074 - gr, err := git.PlainOpen(path) 1075 - if err != nil { 1076 - notFound(w) 1077 - return 1078 - } 1079 - 1080 - commit1, err := gr.ResolveRevision(rev1) 1081 - if err != nil { 1082 - l.Error("error resolving revision 1", "msg", err.Error()) 1083 - writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest) 1084 - return 1085 - } 1086 - 1087 - commit2, err := gr.ResolveRevision(rev2) 1088 - if err != nil { 1089 - l.Error("error resolving revision 2", "msg", err.Error()) 1090 - writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest) 1091 - return 1092 - } 1093 - 1094 - rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 1095 - if err != nil { 1096 - l.Error("error comparing revisions", "msg", err.Error()) 1097 - writeError(w, "error comparing revisions", http.StatusBadRequest) 1098 - return 1099 - } 1100 - 1101 - writeJSON(w, types.RepoFormatPatchResponse{ 1102 - Rev1: commit1.Hash.String(), 1103 - Rev2: commit2.Hash.String(), 1104 - FormatPatch: formatPatch, 1105 - Patch: rawPatch, 1106 - }) 1107 - return 1108 - } 1109 - 1110 - func (h *Handle) NewHiddenRef(w http.ResponseWriter, r *http.Request) { 1111 - l := h.l.With("handler", "NewHiddenRef") 1112 - 1113 - forkRef := chi.URLParam(r, "forkRef") 1114 - forkRef, _ = url.PathUnescape(forkRef) 1115 - 1116 - remoteRef := chi.URLParam(r, "remoteRef") 1117 - remoteRef, _ = url.PathUnescape(remoteRef) 1118 - 1119 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1120 - gr, err := git.PlainOpen(path) 1121 - if err != nil { 1122 - notFound(w) 1123 - return 1124 - } 1125 - 1126 - err = gr.TrackHiddenRemoteRef(forkRef, remoteRef) 1127 - if err != nil { 1128 - l.Error("error tracking hidden remote ref", "msg", err.Error()) 1129 - writeError(w, "error tracking hidden remote ref", http.StatusBadRequest) 1130 - return 1131 - } 1132 - 1133 - w.WriteHeader(http.StatusNoContent) 1134 - return 1135 - } 1136 - 1137 - func (h *Handle) AddMember(w http.ResponseWriter, r *http.Request) { 1138 - l := h.l.With("handler", "AddMember") 1139 - 1140 - data := struct { 1141 - Did string `json:"did"` 1142 - }{} 1143 - 1144 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1145 - writeError(w, "invalid request body", http.StatusBadRequest) 1146 - return 1147 - } 1148 - 1149 - did := data.Did 1150 - 1151 - if err := h.db.AddDid(did); err != nil { 1152 - l.Error("adding did", "error", err.Error()) 1153 - writeError(w, err.Error(), http.StatusInternalServerError) 1154 - return 1155 - } 1156 - h.jc.AddDid(did) 1157 - 1158 - if err := h.e.AddKnotMember(rbac.ThisServer, did); err != nil { 1159 - l.Error("adding member", "error", err.Error()) 1160 - writeError(w, err.Error(), http.StatusInternalServerError) 1161 - return 1162 - } 1163 - 1164 - if err := h.fetchAndAddKeys(r.Context(), did); err != nil { 1165 - l.Error("fetching and adding keys", "error", err.Error()) 1166 - writeError(w, err.Error(), http.StatusInternalServerError) 1167 - return 1168 - } 1169 - 1170 - w.WriteHeader(http.StatusNoContent) 1171 - } 1172 - 1173 - func (h *Handle) AddRepoCollaborator(w http.ResponseWriter, r *http.Request) { 1174 - l := h.l.With("handler", "AddRepoCollaborator") 1175 - 1176 - data := struct { 1177 - Did string `json:"did"` 1178 - }{} 1179 - 1180 - ownerDid := chi.URLParam(r, "did") 1181 - repo := chi.URLParam(r, "name") 1182 - 1183 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1184 - writeError(w, "invalid request body", http.StatusBadRequest) 1185 - return 1186 - } 1187 - 1188 - if err := h.db.AddDid(data.Did); err != nil { 1189 - l.Error("adding did", "error", err.Error()) 1190 - writeError(w, err.Error(), http.StatusInternalServerError) 1191 - return 1192 - } 1193 - h.jc.AddDid(data.Did) 1194 - 1195 - repoName, _ := securejoin.SecureJoin(ownerDid, repo) 1196 - if err := h.e.AddCollaborator(data.Did, rbac.ThisServer, repoName); err != nil { 1197 - l.Error("adding repo collaborator", "error", err.Error()) 1198 - writeError(w, err.Error(), http.StatusInternalServerError) 1199 - return 1200 - } 1201 - 1202 - if err := h.fetchAndAddKeys(r.Context(), data.Did); err != nil { 1203 - l.Error("fetching and adding keys", "error", err.Error()) 1204 - writeError(w, err.Error(), http.StatusInternalServerError) 1205 - return 1206 - } 1207 - 1208 - w.WriteHeader(http.StatusNoContent) 1209 - } 1210 - 1211 - func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) { 1212 - l := h.l.With("handler", "DefaultBranch") 1213 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1214 - 1215 - gr, err := git.Open(path, "") 1216 - if err != nil { 1217 - notFound(w) 1218 - return 1219 - } 1220 - 1221 - branch, err := gr.FindMainBranch() 1222 - if err != nil { 1223 - writeError(w, err.Error(), http.StatusInternalServerError) 1224 - l.Error("getting default branch", "error", err.Error()) 1225 - return 1226 - } 1227 - 1228 - writeJSON(w, types.RepoDefaultBranchResponse{ 1229 - Branch: branch, 1230 - }) 1231 - } 1232 - 1233 - func (h *Handle) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1234 - l := h.l.With("handler", "SetDefaultBranch") 1235 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1236 - 1237 - data := struct { 1238 - Branch string `json:"branch"` 1239 - }{} 1240 - 1241 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1242 - writeError(w, err.Error(), http.StatusBadRequest) 1243 - return 1244 - } 1245 - 1246 - gr, err := git.PlainOpen(path) 1247 - if err != nil { 1248 - notFound(w) 1249 - return 1250 - } 1251 - 1252 - err = gr.SetDefaultBranch(data.Branch) 1253 - if err != nil { 1254 - writeError(w, err.Error(), http.StatusInternalServerError) 1255 - l.Error("setting default branch", "error", err.Error()) 1256 - return 1257 - } 1258 - 1259 - w.WriteHeader(http.StatusNoContent) 1260 - } 1261 - 1262 - func (h *Handle) Init(w http.ResponseWriter, r *http.Request) { 1263 - l := h.l.With("handler", "Init") 1264 - 1265 - if h.knotInitialized { 1266 - writeError(w, "knot already initialized", http.StatusConflict) 1267 - return 1268 - } 1269 - 1270 - data := struct { 1271 - Did string `json:"did"` 1272 - }{} 1273 - 1274 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1275 - l.Error("failed to decode request body", "error", err.Error()) 1276 - writeError(w, "invalid request body", http.StatusBadRequest) 1277 - return 1278 - } 1279 - 1280 - if data.Did == "" { 1281 - l.Error("empty DID in request", "did", data.Did) 1282 - writeError(w, "did is empty", http.StatusBadRequest) 1283 - return 1284 - } 1285 - 1286 - if err := h.db.AddDid(data.Did); err != nil { 1287 - l.Error("failed to add DID", "error", err.Error()) 1288 - writeError(w, err.Error(), http.StatusInternalServerError) 1289 - return 1290 - } 1291 - h.jc.AddDid(data.Did) 1292 - 1293 - if err := h.e.AddKnotOwner(rbac.ThisServer, data.Did); err != nil { 1294 - l.Error("adding owner", "error", err.Error()) 1295 - writeError(w, err.Error(), http.StatusInternalServerError) 1296 - return 1297 - } 1298 - 1299 - if err := h.fetchAndAddKeys(r.Context(), data.Did); err != nil { 1300 - l.Error("fetching and adding keys", "error", err.Error()) 1301 - writeError(w, err.Error(), http.StatusInternalServerError) 1302 - return 1303 - } 1304 - 1305 - close(h.init) 1306 - 1307 - mac := hmac.New(sha256.New, []byte(h.c.Server.Secret)) 1308 - mac.Write([]byte("ok")) 1309 - w.Header().Add("X-Signature", hex.EncodeToString(mac.Sum(nil))) 1310 - 1311 - w.WriteHeader(http.StatusNoContent) 1312 - } 1313 - 1314 - func (h *Handle) Health(w http.ResponseWriter, r *http.Request) { 1315 - w.Write([]byte("ok")) 1316 - } 1317 - 1318 - func validateRepoName(name string) error { 1319 - // check for path traversal attempts 1320 - if name == "." || name == ".." || 1321 - strings.Contains(name, "/") || strings.Contains(name, "\\") { 1322 - return fmt.Errorf("Repository name contains invalid path characters") 1323 - } 1324 - 1325 - // check for sequences that could be used for traversal when normalized 1326 - if strings.Contains(name, "./") || strings.Contains(name, "../") || 1327 - strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { 1328 - return fmt.Errorf("Repository name contains invalid path sequence") 1329 - } 1330 - 1331 - // then continue with character validation 1332 - for _, char := range name { 1333 - if !((char >= 'a' && char <= 'z') || 1334 - (char >= 'A' && char <= 'Z') || 1335 - (char >= '0' && char <= '9') || 1336 - char == '-' || char == '_' || char == '.') { 1337 - return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") 1338 - } 1339 - } 1340 - 1341 - // additional check to prevent multiple sequential dots 1342 - if strings.Contains(name, "..") { 1343 - return fmt.Errorf("Repository name cannot contain sequential dots") 1344 - } 1345 - 1346 - // if all checks pass 1347 - return nil 206 + return h.e.AddKnotOwner(rbacDomain, cfgOwner) 1348 207 }
+156
knotserver/xrpc/create_repo.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "net/http" 8 + "path/filepath" 9 + "strings" 10 + 11 + comatproto "github.com/bluesky-social/indigo/api/atproto" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + "github.com/bluesky-social/indigo/xrpc" 14 + securejoin "github.com/cyphar/filepath-securejoin" 15 + gogit "github.com/go-git/go-git/v5" 16 + "tangled.sh/tangled.sh/core/api/tangled" 17 + "tangled.sh/tangled.sh/core/hook" 18 + "tangled.sh/tangled.sh/core/knotserver/git" 19 + "tangled.sh/tangled.sh/core/rbac" 20 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 21 + ) 22 + 23 + func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) { 24 + l := h.Logger.With("handler", "NewRepo") 25 + fail := func(e xrpcerr.XrpcError) { 26 + l.Error("failed", "kind", e.Tag, "error", e.Message) 27 + writeError(w, e, http.StatusBadRequest) 28 + } 29 + 30 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 31 + if !ok { 32 + fail(xrpcerr.MissingActorDidError) 33 + return 34 + } 35 + 36 + isMember, err := h.Enforcer.IsRepoCreateAllowed(actorDid.String(), rbac.ThisServer) 37 + if err != nil { 38 + fail(xrpcerr.GenericError(err)) 39 + return 40 + } 41 + if !isMember { 42 + fail(xrpcerr.AccessControlError(actorDid.String())) 43 + return 44 + } 45 + 46 + var data tangled.RepoCreate_Input 47 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 48 + fail(xrpcerr.GenericError(err)) 49 + return 50 + } 51 + 52 + rkey := data.Rkey 53 + 54 + ident, err := h.Resolver.ResolveIdent(r.Context(), actorDid.String()) 55 + if err != nil || ident.Handle.IsInvalidHandle() { 56 + fail(xrpcerr.GenericError(err)) 57 + return 58 + } 59 + 60 + xrpcc := xrpc.Client{ 61 + Host: ident.PDSEndpoint(), 62 + } 63 + 64 + resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, actorDid.String(), rkey) 65 + if err != nil { 66 + fail(xrpcerr.GenericError(err)) 67 + return 68 + } 69 + 70 + repo := resp.Value.Val.(*tangled.Repo) 71 + 72 + defaultBranch := h.Config.Repo.MainBranch 73 + if data.DefaultBranch != nil && *data.DefaultBranch != "" { 74 + defaultBranch = *data.DefaultBranch 75 + } 76 + 77 + if err := validateRepoName(repo.Name); err != nil { 78 + l.Error("creating repo", "error", err.Error()) 79 + fail(xrpcerr.GenericError(err)) 80 + return 81 + } 82 + 83 + relativeRepoPath := filepath.Join(actorDid.String(), repo.Name) 84 + repoPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, relativeRepoPath) 85 + 86 + if data.Source != nil && *data.Source != "" { 87 + err = git.Fork(repoPath, *data.Source) 88 + if err != nil { 89 + l.Error("forking repo", "error", err.Error()) 90 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 91 + return 92 + } 93 + } else { 94 + err = git.InitBare(repoPath, defaultBranch) 95 + if err != nil { 96 + l.Error("initializing bare repo", "error", err.Error()) 97 + if errors.Is(err, gogit.ErrRepositoryAlreadyExists) { 98 + fail(xrpcerr.RepoExistsError("repository already exists")) 99 + return 100 + } else { 101 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 102 + return 103 + } 104 + } 105 + } 106 + 107 + // add perms for this user to access the repo 108 + err = h.Enforcer.AddRepo(actorDid.String(), rbac.ThisServer, relativeRepoPath) 109 + if err != nil { 110 + l.Error("adding repo permissions", "error", err.Error()) 111 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 112 + return 113 + } 114 + 115 + hook.SetupRepo( 116 + hook.Config( 117 + hook.WithScanPath(h.Config.Repo.ScanPath), 118 + hook.WithInternalApi(h.Config.Server.InternalListenAddr), 119 + ), 120 + repoPath, 121 + ) 122 + 123 + w.WriteHeader(http.StatusOK) 124 + } 125 + 126 + func validateRepoName(name string) error { 127 + // check for path traversal attempts 128 + if name == "." || name == ".." || 129 + strings.Contains(name, "/") || strings.Contains(name, "\\") { 130 + return fmt.Errorf("Repository name contains invalid path characters") 131 + } 132 + 133 + // check for sequences that could be used for traversal when normalized 134 + if strings.Contains(name, "./") || strings.Contains(name, "../") || 135 + strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { 136 + return fmt.Errorf("Repository name contains invalid path sequence") 137 + } 138 + 139 + // then continue with character validation 140 + for _, char := range name { 141 + if !((char >= 'a' && char <= 'z') || 142 + (char >= 'A' && char <= 'Z') || 143 + (char >= '0' && char <= '9') || 144 + char == '-' || char == '_' || char == '.') { 145 + return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") 146 + } 147 + } 148 + 149 + // additional check to prevent multiple sequential dots 150 + if strings.Contains(name, "..") { 151 + return fmt.Errorf("Repository name cannot contain sequential dots") 152 + } 153 + 154 + // if all checks pass 155 + return nil 156 + }
+96
knotserver/xrpc/delete_repo.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "os" 8 + "path/filepath" 9 + 10 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + "github.com/bluesky-social/indigo/xrpc" 13 + securejoin "github.com/cyphar/filepath-securejoin" 14 + "tangled.sh/tangled.sh/core/api/tangled" 15 + "tangled.sh/tangled.sh/core/rbac" 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 + ) 18 + 19 + func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) { 20 + l := x.Logger.With("handler", "DeleteRepo") 21 + fail := func(e xrpcerr.XrpcError) { 22 + l.Error("failed", "kind", e.Tag, "error", e.Message) 23 + writeError(w, e, http.StatusBadRequest) 24 + } 25 + 26 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 + if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 + return 30 + } 31 + 32 + var data tangled.RepoDelete_Input 33 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 + fail(xrpcerr.GenericError(err)) 35 + return 36 + } 37 + 38 + did := data.Did 39 + name := data.Name 40 + rkey := data.Rkey 41 + 42 + if did == "" || name == "" { 43 + fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) 44 + return 45 + } 46 + 47 + ident, err := x.Resolver.ResolveIdent(r.Context(), actorDid.String()) 48 + if err != nil || ident.Handle.IsInvalidHandle() { 49 + fail(xrpcerr.GenericError(err)) 50 + return 51 + } 52 + 53 + xrpcc := xrpc.Client{ 54 + Host: ident.PDSEndpoint(), 55 + } 56 + 57 + // ensure that the record does not exists 58 + _, err = comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, actorDid.String(), rkey) 59 + if err == nil { 60 + fail(xrpcerr.RecordExistsError(rkey)) 61 + return 62 + } 63 + 64 + relativeRepoPath := filepath.Join(did, name) 65 + isDeleteAllowed, err := x.Enforcer.IsRepoDeleteAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath) 66 + if err != nil { 67 + fail(xrpcerr.GenericError(err)) 68 + return 69 + } 70 + if !isDeleteAllowed { 71 + fail(xrpcerr.AccessControlError(actorDid.String())) 72 + return 73 + } 74 + 75 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 76 + if err != nil { 77 + fail(xrpcerr.GenericError(err)) 78 + return 79 + } 80 + 81 + err = os.RemoveAll(repoPath) 82 + if err != nil { 83 + l.Error("deleting repo", "error", err.Error()) 84 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 85 + return 86 + } 87 + 88 + err = x.Enforcer.RemoveRepo(did, rbac.ThisServer, relativeRepoPath) 89 + if err != nil { 90 + l.Error("failed to delete repo from enforcer", "error", err.Error()) 91 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 92 + return 93 + } 94 + 95 + w.WriteHeader(http.StatusOK) 96 + }
+111
knotserver/xrpc/fork_status.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "path/filepath" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + securejoin "github.com/cyphar/filepath-securejoin" 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/knotserver/git" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + "tangled.sh/tangled.sh/core/types" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + ) 17 + 18 + func (x *Xrpc) ForkStatus(w http.ResponseWriter, r *http.Request) { 19 + l := x.Logger.With("handler", "ForkStatus") 20 + fail := func(e xrpcerr.XrpcError) { 21 + l.Error("failed", "kind", e.Tag, "error", e.Message) 22 + writeError(w, e, http.StatusBadRequest) 23 + } 24 + 25 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 + if !ok { 27 + fail(xrpcerr.MissingActorDidError) 28 + return 29 + } 30 + 31 + var data tangled.RepoForkStatus_Input 32 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 + fail(xrpcerr.GenericError(err)) 34 + return 35 + } 36 + 37 + did := data.Did 38 + source := data.Source 39 + branch := data.Branch 40 + hiddenRef := data.HiddenRef 41 + 42 + if did == "" || source == "" || branch == "" || hiddenRef == "" { 43 + fail(xrpcerr.GenericError(fmt.Errorf("did, source, branch, and hiddenRef are required"))) 44 + return 45 + } 46 + 47 + var name string 48 + if data.Name != "" { 49 + name = data.Name 50 + } else { 51 + name = filepath.Base(source) 52 + } 53 + 54 + relativeRepoPath := filepath.Join(did, name) 55 + 56 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { 57 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) 58 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 59 + return 60 + } 61 + 62 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 63 + if err != nil { 64 + fail(xrpcerr.GenericError(err)) 65 + return 66 + } 67 + 68 + gr, err := git.PlainOpen(repoPath) 69 + if err != nil { 70 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 71 + return 72 + } 73 + 74 + forkCommit, err := gr.ResolveRevision(branch) 75 + if err != nil { 76 + l.Error("error resolving ref revision", "msg", err.Error()) 77 + fail(xrpcerr.GenericError(fmt.Errorf("error resolving revision %s: %w", branch, err))) 78 + return 79 + } 80 + 81 + sourceCommit, err := gr.ResolveRevision(hiddenRef) 82 + if err != nil { 83 + l.Error("error resolving hidden ref revision", "msg", err.Error()) 84 + fail(xrpcerr.GenericError(fmt.Errorf("error resolving revision %s: %w", hiddenRef, err))) 85 + return 86 + } 87 + 88 + status := types.UpToDate 89 + if forkCommit.Hash.String() != sourceCommit.Hash.String() { 90 + isAncestor, err := forkCommit.IsAncestor(sourceCommit) 91 + if err != nil { 92 + l.Error("error checking ancestor relationship", "error", err.Error()) 93 + fail(xrpcerr.GenericError(fmt.Errorf("error resolving whether %s is ancestor of %s: %w", branch, hiddenRef, err))) 94 + return 95 + } 96 + 97 + if isAncestor { 98 + status = types.FastForwardable 99 + } else { 100 + status = types.Conflict 101 + } 102 + } 103 + 104 + response := tangled.RepoForkStatus_Output{ 105 + Status: int64(status), 106 + } 107 + 108 + w.Header().Set("Content-Type", "application/json") 109 + w.WriteHeader(http.StatusOK) 110 + json.NewEncoder(w).Encode(response) 111 + }
+73
knotserver/xrpc/fork_sync.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "path/filepath" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + securejoin "github.com/cyphar/filepath-securejoin" 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/knotserver/git" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 15 + ) 16 + 17 + func (x *Xrpc) ForkSync(w http.ResponseWriter, r *http.Request) { 18 + l := x.Logger.With("handler", "ForkSync") 19 + fail := func(e xrpcerr.XrpcError) { 20 + l.Error("failed", "kind", e.Tag, "error", e.Message) 21 + writeError(w, e, http.StatusBadRequest) 22 + } 23 + 24 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 25 + if !ok { 26 + fail(xrpcerr.MissingActorDidError) 27 + return 28 + } 29 + 30 + var data tangled.RepoForkSync_Input 31 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 32 + fail(xrpcerr.GenericError(err)) 33 + return 34 + } 35 + 36 + did := data.Did 37 + name := data.Name 38 + branch := data.Branch 39 + 40 + if did == "" || name == "" { 41 + fail(xrpcerr.GenericError(fmt.Errorf("did, name are required"))) 42 + return 43 + } 44 + 45 + relativeRepoPath := filepath.Join(did, name) 46 + 47 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { 48 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) 49 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 50 + return 51 + } 52 + 53 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 54 + if err != nil { 55 + fail(xrpcerr.GenericError(err)) 56 + return 57 + } 58 + 59 + gr, err := git.Open(repoPath, branch) 60 + if err != nil { 61 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 62 + return 63 + } 64 + 65 + err = gr.Sync() 66 + if err != nil { 67 + l.Error("error syncing repo fork", "error", err.Error()) 68 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 69 + return 70 + } 71 + 72 + w.WriteHeader(http.StatusOK) 73 + }
+104
knotserver/xrpc/hidden_ref.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/bluesky-social/indigo/xrpc" 11 + securejoin "github.com/cyphar/filepath-securejoin" 12 + "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.sh/tangled.sh/core/knotserver/git" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + ) 17 + 18 + func (x *Xrpc) HiddenRef(w http.ResponseWriter, r *http.Request) { 19 + l := x.Logger.With("handler", "HiddenRef") 20 + fail := func(e xrpcerr.XrpcError) { 21 + l.Error("failed", "kind", e.Tag, "error", e.Message) 22 + writeError(w, e, http.StatusBadRequest) 23 + } 24 + 25 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 + if !ok { 27 + fail(xrpcerr.MissingActorDidError) 28 + return 29 + } 30 + 31 + var data tangled.RepoHiddenRef_Input 32 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 + fail(xrpcerr.GenericError(err)) 34 + return 35 + } 36 + 37 + forkRef := data.ForkRef 38 + remoteRef := data.RemoteRef 39 + repoAtUri := data.Repo 40 + 41 + if forkRef == "" || remoteRef == "" || repoAtUri == "" { 42 + fail(xrpcerr.GenericError(fmt.Errorf("forkRef, remoteRef, and repo are required"))) 43 + return 44 + } 45 + 46 + repoAt, err := syntax.ParseATURI(repoAtUri) 47 + if err != nil { 48 + fail(xrpcerr.InvalidRepoError(repoAtUri)) 49 + return 50 + } 51 + 52 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 53 + if err != nil || ident.Handle.IsInvalidHandle() { 54 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 55 + return 56 + } 57 + 58 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 59 + resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 60 + if err != nil { 61 + fail(xrpcerr.GenericError(err)) 62 + return 63 + } 64 + 65 + repo := resp.Value.Val.(*tangled.Repo) 66 + didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name) 67 + if err != nil { 68 + fail(xrpcerr.GenericError(err)) 69 + return 70 + } 71 + 72 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 73 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", didPath) 74 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 75 + return 76 + } 77 + 78 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) 79 + if err != nil { 80 + fail(xrpcerr.GenericError(err)) 81 + return 82 + } 83 + 84 + gr, err := git.PlainOpen(repoPath) 85 + if err != nil { 86 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 87 + return 88 + } 89 + 90 + err = gr.TrackHiddenRemoteRef(forkRef, remoteRef) 91 + if err != nil { 92 + l.Error("error tracking hidden remote ref", "error", err.Error()) 93 + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 94 + return 95 + } 96 + 97 + response := tangled.RepoHiddenRef_Output{ 98 + Success: true, 99 + } 100 + 101 + w.Header().Set("Content-Type", "application/json") 102 + w.WriteHeader(http.StatusOK) 103 + json.NewEncoder(w).Encode(response) 104 + }
+112
knotserver/xrpc/merge.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "net/http" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + securejoin "github.com/cyphar/filepath-securejoin" 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/knotserver/git" 13 + "tangled.sh/tangled.sh/core/patchutil" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + "tangled.sh/tangled.sh/core/types" 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 + ) 18 + 19 + func (x *Xrpc) Merge(w http.ResponseWriter, r *http.Request) { 20 + l := x.Logger.With("handler", "Merge") 21 + fail := func(e xrpcerr.XrpcError) { 22 + l.Error("failed", "kind", e.Tag, "error", e.Message) 23 + writeError(w, e, http.StatusBadRequest) 24 + } 25 + 26 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 + if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 + return 30 + } 31 + 32 + var data tangled.RepoMerge_Input 33 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 + fail(xrpcerr.GenericError(err)) 35 + return 36 + } 37 + 38 + did := data.Did 39 + name := data.Name 40 + 41 + if did == "" || name == "" { 42 + fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) 43 + return 44 + } 45 + 46 + relativeRepoPath, err := securejoin.SecureJoin(did, name) 47 + if err != nil { 48 + fail(xrpcerr.GenericError(err)) 49 + return 50 + } 51 + 52 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { 53 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) 54 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 55 + return 56 + } 57 + 58 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 59 + if err != nil { 60 + fail(xrpcerr.GenericError(err)) 61 + return 62 + } 63 + 64 + gr, err := git.Open(repoPath, data.Branch) 65 + if err != nil { 66 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 67 + return 68 + } 69 + 70 + mo := &git.MergeOptions{} 71 + if data.AuthorName != nil { 72 + mo.AuthorName = *data.AuthorName 73 + } 74 + if data.AuthorEmail != nil { 75 + mo.AuthorEmail = *data.AuthorEmail 76 + } 77 + if data.CommitBody != nil { 78 + mo.CommitBody = *data.CommitBody 79 + } 80 + if data.CommitMessage != nil { 81 + mo.CommitMessage = *data.CommitMessage 82 + } 83 + 84 + mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) 85 + 86 + err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo) 87 + if err != nil { 88 + var mergeErr *git.ErrMerge 89 + if errors.As(err, &mergeErr) { 90 + conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 91 + for i, conflict := range mergeErr.Conflicts { 92 + conflicts[i] = types.ConflictInfo{ 93 + Filename: conflict.Filename, 94 + Reason: conflict.Reason, 95 + } 96 + } 97 + 98 + conflictErr := xrpcerr.NewXrpcError( 99 + xrpcerr.WithTag("MergeConflict"), 100 + xrpcerr.WithMessage(fmt.Sprintf("Merge failed due to conflicts: %s", mergeErr.Message)), 101 + ) 102 + writeError(w, conflictErr, http.StatusConflict) 103 + return 104 + } else { 105 + l.Error("failed to merge", "error", err.Error()) 106 + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 107 + return 108 + } 109 + } 110 + 111 + w.WriteHeader(http.StatusOK) 112 + }
+87
knotserver/xrpc/merge_check.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "net/http" 8 + 9 + securejoin "github.com/cyphar/filepath-securejoin" 10 + "tangled.sh/tangled.sh/core/api/tangled" 11 + "tangled.sh/tangled.sh/core/knotserver/git" 12 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 + ) 14 + 15 + func (x *Xrpc) MergeCheck(w http.ResponseWriter, r *http.Request) { 16 + l := x.Logger.With("handler", "MergeCheck") 17 + fail := func(e xrpcerr.XrpcError) { 18 + l.Error("failed", "kind", e.Tag, "error", e.Message) 19 + writeError(w, e, http.StatusBadRequest) 20 + } 21 + 22 + var data tangled.RepoMergeCheck_Input 23 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 24 + fail(xrpcerr.GenericError(err)) 25 + return 26 + } 27 + 28 + did := data.Did 29 + name := data.Name 30 + 31 + if did == "" || name == "" { 32 + fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) 33 + return 34 + } 35 + 36 + relativeRepoPath, err := securejoin.SecureJoin(did, name) 37 + if err != nil { 38 + fail(xrpcerr.GenericError(err)) 39 + return 40 + } 41 + 42 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 43 + if err != nil { 44 + fail(xrpcerr.GenericError(err)) 45 + return 46 + } 47 + 48 + gr, err := git.Open(repoPath, data.Branch) 49 + if err != nil { 50 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 51 + return 52 + } 53 + 54 + err = gr.MergeCheck([]byte(data.Patch), data.Branch) 55 + 56 + response := tangled.RepoMergeCheck_Output{ 57 + Is_conflicted: false, 58 + } 59 + 60 + if err != nil { 61 + var mergeErr *git.ErrMerge 62 + if errors.As(err, &mergeErr) { 63 + response.Is_conflicted = true 64 + 65 + conflicts := make([]*tangled.RepoMergeCheck_ConflictInfo, len(mergeErr.Conflicts)) 66 + for i, conflict := range mergeErr.Conflicts { 67 + conflicts[i] = &tangled.RepoMergeCheck_ConflictInfo{ 68 + Filename: conflict.Filename, 69 + Reason: conflict.Reason, 70 + } 71 + } 72 + response.Conflicts = conflicts 73 + 74 + if mergeErr.Message != "" { 75 + response.Message = &mergeErr.Message 76 + } 77 + } else { 78 + response.Is_conflicted = true 79 + errMsg := err.Error() 80 + response.Error = &errMsg 81 + } 82 + } 83 + 84 + w.Header().Set("Content-Type", "application/json") 85 + w.WriteHeader(http.StatusOK) 86 + json.NewEncoder(w).Encode(response) 87 + }
-149
knotserver/xrpc/router.go
··· 1 - package xrpc 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - "log/slog" 8 - "net/http" 9 - "strings" 10 - 11 - "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/idresolver" 13 - "tangled.sh/tangled.sh/core/jetstream" 14 - "tangled.sh/tangled.sh/core/knotserver/config" 15 - "tangled.sh/tangled.sh/core/knotserver/db" 16 - "tangled.sh/tangled.sh/core/notifier" 17 - "tangled.sh/tangled.sh/core/rbac" 18 - 19 - "github.com/bluesky-social/indigo/atproto/auth" 20 - "github.com/go-chi/chi/v5" 21 - ) 22 - 23 - type Xrpc struct { 24 - Config *config.Config 25 - Db *db.DB 26 - Ingester *jetstream.JetstreamClient 27 - Enforcer *rbac.Enforcer 28 - Logger *slog.Logger 29 - Notifier *notifier.Notifier 30 - Resolver *idresolver.Resolver 31 - } 32 - 33 - func (x *Xrpc) Router() http.Handler { 34 - r := chi.NewRouter() 35 - 36 - r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 37 - 38 - return r 39 - } 40 - 41 - func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler { 42 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 43 - l := x.Logger.With("url", r.URL) 44 - 45 - token := r.Header.Get("Authorization") 46 - token = strings.TrimPrefix(token, "Bearer ") 47 - 48 - s := auth.ServiceAuthValidator{ 49 - Audience: x.Config.Server.Did().String(), 50 - Dir: x.Resolver.Directory(), 51 - } 52 - 53 - did, err := s.Validate(r.Context(), token, nil) 54 - if err != nil { 55 - l.Error("signature verification failed", "err", err) 56 - writeError(w, AuthError(err), http.StatusForbidden) 57 - return 58 - } 59 - 60 - r = r.WithContext( 61 - context.WithValue(r.Context(), ActorDid, did), 62 - ) 63 - 64 - next.ServeHTTP(w, r) 65 - }) 66 - } 67 - 68 - type XrpcError struct { 69 - Tag string `json:"error"` 70 - Message string `json:"message"` 71 - } 72 - 73 - func NewXrpcError(opts ...ErrOpt) XrpcError { 74 - x := XrpcError{} 75 - for _, o := range opts { 76 - o(&x) 77 - } 78 - 79 - return x 80 - } 81 - 82 - type ErrOpt = func(xerr *XrpcError) 83 - 84 - func WithTag(tag string) ErrOpt { 85 - return func(xerr *XrpcError) { 86 - xerr.Tag = tag 87 - } 88 - } 89 - 90 - func WithMessage[S ~string](s S) ErrOpt { 91 - return func(xerr *XrpcError) { 92 - xerr.Message = string(s) 93 - } 94 - } 95 - 96 - func WithError(e error) ErrOpt { 97 - return func(xerr *XrpcError) { 98 - xerr.Message = e.Error() 99 - } 100 - } 101 - 102 - var MissingActorDidError = NewXrpcError( 103 - WithTag("MissingActorDid"), 104 - WithMessage("actor DID not supplied"), 105 - ) 106 - 107 - var AuthError = func(err error) XrpcError { 108 - return NewXrpcError( 109 - WithTag("Auth"), 110 - WithError(fmt.Errorf("signature verification failed: %w", err)), 111 - ) 112 - } 113 - 114 - var InvalidRepoError = func(r string) XrpcError { 115 - return NewXrpcError( 116 - WithTag("InvalidRepo"), 117 - WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)), 118 - ) 119 - } 120 - 121 - var AccessControlError = func(d string) XrpcError { 122 - return NewXrpcError( 123 - WithTag("AccessControl"), 124 - WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)), 125 - ) 126 - } 127 - 128 - var GitError = func(e error) XrpcError { 129 - return NewXrpcError( 130 - WithTag("Git"), 131 - WithError(fmt.Errorf("git error: %w", e)), 132 - ) 133 - } 134 - 135 - func GenericError(err error) XrpcError { 136 - return NewXrpcError( 137 - WithTag("Generic"), 138 - WithError(err), 139 - ) 140 - } 141 - 142 - // this is slightly different from http_util::write_error to follow the spec: 143 - // 144 - // the json object returned must include an "error" and a "message" 145 - func writeError(w http.ResponseWriter, e XrpcError, status int) { 146 - w.Header().Set("Content-Type", "application/json") 147 - w.WriteHeader(status) 148 - json.NewEncoder(w).Encode(e) 149 - }
+12 -10
knotserver/xrpc/set_default_branch.go
··· 12 12 "tangled.sh/tangled.sh/core/api/tangled" 13 13 "tangled.sh/tangled.sh/core/knotserver/git" 14 14 "tangled.sh/tangled.sh/core/rbac" 15 + 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 15 17 ) 16 18 17 19 const ActorDid string = "ActorDid" 18 20 19 21 func (x *Xrpc) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 20 22 l := x.Logger 21 - fail := func(e XrpcError) { 23 + fail := func(e xrpcerr.XrpcError) { 22 24 l.Error("failed", "kind", e.Tag, "error", e.Message) 23 25 writeError(w, e, http.StatusBadRequest) 24 26 } 25 27 26 28 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 29 if !ok { 28 - fail(MissingActorDidError) 30 + fail(xrpcerr.MissingActorDidError) 29 31 return 30 32 } 31 33 32 34 var data tangled.RepoSetDefaultBranch_Input 33 35 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 - fail(GenericError(err)) 36 + fail(xrpcerr.GenericError(err)) 35 37 return 36 38 } 37 39 38 40 // unfortunately we have to resolve repo-at here 39 41 repoAt, err := syntax.ParseATURI(data.Repo) 40 42 if err != nil { 41 - fail(InvalidRepoError(data.Repo)) 43 + fail(xrpcerr.InvalidRepoError(data.Repo)) 42 44 return 43 45 } 44 46 45 47 // resolve this aturi to extract the repo record 46 48 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 47 49 if err != nil || ident.Handle.IsInvalidHandle() { 48 - fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 50 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 49 51 return 50 52 } 51 53 52 54 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 53 55 resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 54 56 if err != nil { 55 - fail(GenericError(err)) 57 + fail(xrpcerr.GenericError(err)) 56 58 return 57 59 } 58 60 59 61 repo := resp.Value.Val.(*tangled.Repo) 60 62 didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name) 61 63 if err != nil { 62 - fail(GenericError(err)) 64 + fail(xrpcerr.GenericError(err)) 63 65 return 64 66 } 65 67 66 68 if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 67 69 l.Error("insufficent permissions", "did", actorDid.String()) 68 - writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 70 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 69 71 return 70 72 } 71 73 72 74 path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) 73 75 gr, err := git.PlainOpen(path) 74 76 if err != nil { 75 - fail(InvalidRepoError(data.Repo)) 77 + fail(xrpcerr.GenericError(err)) 76 78 return 77 79 } 78 80 79 81 err = gr.SetDefaultBranch(data.DefaultBranch) 80 82 if err != nil { 81 83 l.Error("setting default branch", "error", err.Error()) 82 - writeError(w, GitError(err), http.StatusInternalServerError) 84 + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 83 85 return 84 86 } 85 87
+60
knotserver/xrpc/xrpc.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "log/slog" 6 + "net/http" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + "tangled.sh/tangled.sh/core/idresolver" 10 + "tangled.sh/tangled.sh/core/jetstream" 11 + "tangled.sh/tangled.sh/core/knotserver/config" 12 + "tangled.sh/tangled.sh/core/knotserver/db" 13 + "tangled.sh/tangled.sh/core/notifier" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 17 + 18 + "github.com/go-chi/chi/v5" 19 + ) 20 + 21 + type Xrpc struct { 22 + Config *config.Config 23 + Db *db.DB 24 + Ingester *jetstream.JetstreamClient 25 + Enforcer *rbac.Enforcer 26 + Logger *slog.Logger 27 + Notifier *notifier.Notifier 28 + Resolver *idresolver.Resolver 29 + ServiceAuth *serviceauth.ServiceAuth 30 + } 31 + 32 + func (x *Xrpc) Router() http.Handler { 33 + r := chi.NewRouter() 34 + 35 + r.Group(func(r chi.Router) { 36 + r.Use(x.ServiceAuth.VerifyServiceAuth) 37 + 38 + r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 39 + r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) 40 + r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo) 41 + r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus) 42 + r.Post("/"+tangled.RepoForkSyncNSID, x.ForkSync) 43 + r.Post("/"+tangled.RepoHiddenRefNSID, x.HiddenRef) 44 + r.Post("/"+tangled.RepoMergeNSID, x.Merge) 45 + }) 46 + 47 + // merge check is an open endpoint 48 + // 49 + // TODO: should we constrain this more? 50 + // - we can calculate on PR submit/resubmit/gitRefUpdate etc. 51 + // - use ETags on clients to keep requests to a minimum 52 + r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck) 53 + return r 54 + } 55 + 56 + func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 57 + w.Header().Set("Content-Type", "application/json") 58 + w.WriteHeader(status) 59 + json.NewEncoder(w).Encode(e) 60 + }
+24
lexicons/knot/knot.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.knot", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "any", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "createdAt" 14 + ], 15 + "properties": { 16 + "createdAt": { 17 + "type": "string", 18 + "format": "datetime" 19 + } 20 + } 21 + } 22 + } 23 + } 24 + }
+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 },
+33
lexicons/repo/create.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.create", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create a new repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "rkey" 14 + ], 15 + "properties": { 16 + "rkey": { 17 + "type": "string", 18 + "description": "Rkey of the repository record" 19 + }, 20 + "defaultBranch": { 21 + "type": "string", 22 + "description": "Default branch to push to" 23 + }, 24 + "source": { 25 + "type": "string", 26 + "description": "A source URL to clone from, populate this when forking or importing a repository." 27 + } 28 + } 29 + } 30 + } 31 + } 32 + } 33 + }
+32
lexicons/repo/delete.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.delete", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Delete a repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["did", "name", "rkey"], 13 + "properties": { 14 + "did": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the repository owner" 18 + }, 19 + "name": { 20 + "type": "string", 21 + "description": "Name of the repository to delete" 22 + }, 23 + "rkey": { 24 + "type": "string", 25 + "description": "Rkey of the repository record" 26 + } 27 + } 28 + } 29 + } 30 + } 31 + } 32 + }
+53
lexicons/repo/forkStatus.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.forkStatus", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Check fork status relative to upstream source", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["did", "name", "source", "branch", "hiddenRef"], 13 + "properties": { 14 + "did": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the fork owner" 18 + }, 19 + "name": { 20 + "type": "string", 21 + "description": "Name of the forked repository" 22 + }, 23 + "source": { 24 + "type": "string", 25 + "description": "Source repository URL" 26 + }, 27 + "branch": { 28 + "type": "string", 29 + "description": "Branch to check status for" 30 + }, 31 + "hiddenRef": { 32 + "type": "string", 33 + "description": "Hidden ref to use for comparison" 34 + } 35 + } 36 + } 37 + }, 38 + "output": { 39 + "encoding": "application/json", 40 + "schema": { 41 + "type": "object", 42 + "required": ["status"], 43 + "properties": { 44 + "status": { 45 + "type": "integer", 46 + "description": "Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch" 47 + } 48 + } 49 + } 50 + } 51 + } 52 + } 53 + }
+42
lexicons/repo/forkSync.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.forkSync", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Sync a forked repository with its upstream source", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "did", 14 + "source", 15 + "name", 16 + "branch" 17 + ], 18 + "properties": { 19 + "did": { 20 + "type": "string", 21 + "format": "did", 22 + "description": "DID of the fork owner" 23 + }, 24 + "source": { 25 + "type": "string", 26 + "format": "at-uri", 27 + "description": "AT-URI of the source repository" 28 + }, 29 + "name": { 30 + "type": "string", 31 + "description": "Name of the forked repository" 32 + }, 33 + "branch": { 34 + "type": "string", 35 + "description": "Branch to sync" 36 + } 37 + } 38 + } 39 + } 40 + } 41 + } 42 + }
+59
lexicons/repo/hiddenRef.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.hiddenRef", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create a hidden ref in a repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "forkRef", 15 + "remoteRef" 16 + ], 17 + "properties": { 18 + "repo": { 19 + "type": "string", 20 + "format": "at-uri", 21 + "description": "AT-URI of the repository" 22 + }, 23 + "forkRef": { 24 + "type": "string", 25 + "description": "Fork reference name" 26 + }, 27 + "remoteRef": { 28 + "type": "string", 29 + "description": "Remote reference name" 30 + } 31 + } 32 + } 33 + }, 34 + "output": { 35 + "encoding": "application/json", 36 + "schema": { 37 + "type": "object", 38 + "required": [ 39 + "success" 40 + ], 41 + "properties": { 42 + "success": { 43 + "type": "boolean", 44 + "description": "Whether the hidden ref was created successfully" 45 + }, 46 + "ref": { 47 + "type": "string", 48 + "description": "The created hidden ref name" 49 + }, 50 + "error": { 51 + "type": "string", 52 + "description": "Error message if creation failed" 53 + } 54 + } 55 + } 56 + } 57 + } 58 + } 59 + }
+52
lexicons/repo/merge.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.merge", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Merge a patch into a repository branch", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["did", "name", "patch", "branch"], 13 + "properties": { 14 + "did": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the repository owner" 18 + }, 19 + "name": { 20 + "type": "string", 21 + "description": "Name of the repository" 22 + }, 23 + "patch": { 24 + "type": "string", 25 + "description": "Patch content to merge" 26 + }, 27 + "branch": { 28 + "type": "string", 29 + "description": "Target branch to merge into" 30 + }, 31 + "authorName": { 32 + "type": "string", 33 + "description": "Author name for the merge commit" 34 + }, 35 + "authorEmail": { 36 + "type": "string", 37 + "description": "Author email for the merge commit" 38 + }, 39 + "commitBody": { 40 + "type": "string", 41 + "description": "Additional commit message body" 42 + }, 43 + "commitMessage": { 44 + "type": "string", 45 + "description": "Merge commit message" 46 + } 47 + } 48 + } 49 + } 50 + } 51 + } 52 + }
+79
lexicons/repo/mergeCheck.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.mergeCheck", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Check if a merge is possible between two branches", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["did", "name", "patch", "branch"], 13 + "properties": { 14 + "did": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the repository owner" 18 + }, 19 + "name": { 20 + "type": "string", 21 + "description": "Name of the repository" 22 + }, 23 + "patch": { 24 + "type": "string", 25 + "description": "Patch or pull request to check for merge conflicts" 26 + }, 27 + "branch": { 28 + "type": "string", 29 + "description": "Target branch to merge into" 30 + } 31 + } 32 + } 33 + }, 34 + "output": { 35 + "encoding": "application/json", 36 + "schema": { 37 + "type": "object", 38 + "required": ["is_conflicted"], 39 + "properties": { 40 + "is_conflicted": { 41 + "type": "boolean", 42 + "description": "Whether the merge has conflicts" 43 + }, 44 + "conflicts": { 45 + "type": "array", 46 + "description": "List of files with merge conflicts", 47 + "items": { 48 + "type": "ref", 49 + "ref": "#conflictInfo" 50 + } 51 + }, 52 + "message": { 53 + "type": "string", 54 + "description": "Additional message about the merge check" 55 + }, 56 + "error": { 57 + "type": "string", 58 + "description": "Error message if check failed" 59 + } 60 + } 61 + } 62 + } 63 + }, 64 + "conflictInfo": { 65 + "type": "object", 66 + "required": ["filename", "reason"], 67 + "properties": { 68 + "filename": { 69 + "type": "string", 70 + "description": "Name of the conflicted file" 71 + }, 72 + "reason": { 73 + "type": "string", 74 + "description": "Reason for the conflict" 75 + } 76 + } 77 + } 78 + } 79 + }
+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)})
+8 -2
nix/gomod2nix.toml
··· 181 181 [mod."github.com/gorilla/css"] 182 182 version = "v1.0.1" 183 183 hash = "sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A=" 184 + [mod."github.com/gorilla/feeds"] 185 + version = "v1.2.0" 186 + hash = "sha256-ptczizo27t6Bsq6rHJ4WiHmBRP54UC5yNfHghAqOBQk=" 184 187 [mod."github.com/gorilla/securecookie"] 185 188 version = "v1.1.2" 186 189 hash = "sha256-KeMHNM9emxX+N0WYiZsTii7n8sNsmjWwbnQ9SaJfTKE=" ··· 423 426 version = "v0.3.1" 424 427 hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc=" 425 428 [mod."github.com/yuin/goldmark"] 426 - version = "v1.4.13" 427 - 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=" 428 434 [mod."gitlab.com/yawning/secp256k1-voi"] 429 435 version = "v0.0.0-20230925100816-f2616030848b" 430 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 = {
+32 -29
nix/modules/knot.nix
··· 93 93 description = "Internal address for inter-service communication"; 94 94 }; 95 95 96 - secretFile = mkOption { 97 - type = lib.types.path; 98 - example = "KNOT_SERVER_SECRET=<hash>"; 99 - description = "File containing secret key provided by appview (required)"; 96 + owner = mkOption { 97 + type = types.str; 98 + example = "did:plc:qfpnj4og54vl56wngdriaxug"; 99 + description = "DID of owner (required)"; 100 100 }; 101 101 102 102 dbPath = mkOption { ··· 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}" ··· 196 199 "KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 197 200 "KNOT_SERVER_DB_PATH=${cfg.server.dbPath}" 198 201 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 202 + "KNOT_SERVER_OWNER=${cfg.server.owner}" 199 203 ]; 200 - EnvironmentFile = cfg.server.secretFile; 201 204 ExecStart = "${cfg.package}/bin/knot server"; 202 205 Restart = "always"; 203 206 };
+2 -2
nix/modules/spindle.nix
··· 111 111 "SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}" 112 112 "SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}" 113 113 "SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}" 114 - "SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 115 - "SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 114 + "SPINDLE_NIXERY_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 115 + "SPINDLE_NIXERY_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 116 116 ]; 117 117 ExecStart = "${cfg.package}/bin/spindle"; 118 118 Restart = "always";
+8 -2
nix/pkgs/appview-static-files.nix
··· 9 9 tailwindcss, 10 10 src, 11 11 }: 12 - runCommandLocal "appview-static-files" {} '' 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 + } '' 13 19 mkdir -p $out/{fonts,icons} && cd $out 14 20 cp -f ${htmx-src} htmx.min.js 15 21 cp -f ${htmx-ws-src} htmx-ext-ws.min.js 16 22 cp -rf ${lucide-src}/*.svg icons/ 17 23 cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/ 18 24 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 19 - cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 fonts/ 25 + cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 20 26 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 21 27 # for whatever reason (produces broken css), so we are doing this instead 22 28 cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
+8 -3
nix/pkgs/genjwks.nix
··· 1 1 { 2 - src, 3 2 buildGoApplication, 4 3 modules, 5 4 }: 6 5 buildGoApplication { 7 6 pname = "genjwks"; 8 7 version = "0.1.0"; 9 - inherit src modules; 10 - subPackages = ["cmd/genjwks"]; 8 + src = ../../cmd/genjwks; 9 + postPatch = '' 10 + ln -s ${../../go.mod} ./go.mod 11 + ''; 12 + postInstall = '' 13 + mv $out/bin/core $out/bin/genjwks 14 + ''; 15 + inherit modules; 11 16 doCheck = false; 12 17 CGO_ENABLED = 0; 13 18 }
+55 -16
nix/vm.nix
··· 1 1 { 2 2 nixpkgs, 3 3 system, 4 + hostSystem, 4 5 self, 5 6 }: let 6 7 envVar = name: let ··· 16 17 self.nixosModules.knot 17 18 self.nixosModules.spindle 18 19 ({ 20 + lib, 19 21 config, 20 22 pkgs, 21 23 ... 22 24 }: { 23 - nixos-shell = { 24 - inheritPath = false; 25 - mounts = { 26 - mountHome = false; 27 - mountNixProfile = false; 28 - }; 29 - }; 30 - virtualisation = { 25 + virtualisation.vmVariant.virtualisation = { 26 + host.pkgs = import nixpkgs {system = hostSystem;}; 27 + 28 + graphics = false; 31 29 memorySize = 2048; 32 30 diskSize = 10 * 1024; 33 31 cores = 2; ··· 51 49 guest.port = 6555; 52 50 } 53 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 + }; 54 70 }; 71 + # This is fine because any and all ports that are forwarded to host are explicitly marked above, we don't need a separate guest firewall 72 + networking.firewall.enable = false; 73 + time.timeZone = "Europe/London"; 55 74 services.getty.autologinUser = "root"; 56 75 environment.systemPackages = with pkgs; [curl vim git sqlite litecli]; 57 - systemd.tmpfiles.rules = let 58 - u = config.services.tangled-knot.gitUser; 59 - g = config.services.tangled-knot.gitUser; 60 - in [ 61 - "d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first 62 - "f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=${envVar "TANGLED_VM_KNOT_SECRET"}" 63 - ]; 64 76 services.tangled-knot = { 65 77 enable = true; 66 78 motd = "Welcome to the development knot!\n"; 67 79 server = { 68 - secretFile = "/var/lib/knot/secret"; 80 + owner = envVar "TANGLED_VM_KNOT_OWNER"; 69 81 hostname = "localhost:6000"; 70 82 listenAddr = "0.0.0.0:6000"; 71 83 }; ··· 81 93 provider = "sqlite"; 82 94 }; 83 95 }; 96 + }; 97 + users = { 98 + # So we don't have to deal with permission clashing between 99 + # blank disk VMs and existing state 100 + users.${config.services.tangled-knot.gitUser}.uid = 666; 101 + groups.${config.services.tangled-knot.gitUser}.gid = 666; 102 + 103 + # TODO: separate spindle user 104 + }; 105 + systemd.services = let 106 + mkDataSyncScripts = source: target: { 107 + enableStrictShellChecks = true; 108 + 109 + preStart = lib.mkBefore '' 110 + mkdir -p ${target} 111 + ${lib.getExe pkgs.rsync} -a ${source}/ ${target} 112 + ''; 113 + 114 + postStop = lib.mkAfter '' 115 + ${lib.getExe pkgs.rsync} -a ${target}/ ${source} 116 + ''; 117 + 118 + serviceConfig.PermissionsStartOnly = true; 119 + }; 120 + in { 121 + knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled-knot.stateDir; 122 + spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled-spindle.server.dbPath); 84 123 }; 85 124 }) 86 125 ];
+14 -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 } ··· 97 97 func (e *Enforcer) RemoveSpindle(spindle string) error { 98 98 spindle = intoSpindle(spindle) 99 99 _, err := e.E.DeleteDomains(spindle) 100 + return err 101 + } 102 + 103 + func (e *Enforcer) RemoveKnot(knot string) error { 104 + _, err := e.E.DeleteDomains(knot) 100 105 return err 101 106 } 102 107 ··· 270 275 271 276 func (e *Enforcer) IsSpindleInviteAllowed(user, domain string) (bool, error) { 272 277 return e.isInviteAllowed(user, intoSpindle(domain)) 278 + } 279 + 280 + func (e *Enforcer) IsRepoCreateAllowed(user, domain string) (bool, error) { 281 + return e.E.Enforce(user, domain, domain, "repo:create") 282 + } 283 + 284 + func (e *Enforcer) IsRepoDeleteAllowed(user, domain, repo string) (bool, error) { 285 + return e.E.Enforce(user, domain, repo, "repo:delete") 273 286 } 274 287 275 288 func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) {
+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) {
+14 -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 } 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 _jetstream ( 30 34 id integer primary key autoincrement, 31 35 last_time_us integer not null
-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 + }
+421
spindle/engines/nixery/engine.go
··· 1 + package nixery 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "log/slog" 9 + "os" 10 + "path" 11 + "runtime" 12 + "sync" 13 + "time" 14 + 15 + "github.com/docker/docker/api/types/container" 16 + "github.com/docker/docker/api/types/image" 17 + "github.com/docker/docker/api/types/mount" 18 + "github.com/docker/docker/api/types/network" 19 + "github.com/docker/docker/client" 20 + "github.com/docker/docker/pkg/stdcopy" 21 + "gopkg.in/yaml.v3" 22 + "tangled.sh/tangled.sh/core/api/tangled" 23 + "tangled.sh/tangled.sh/core/log" 24 + "tangled.sh/tangled.sh/core/spindle/config" 25 + "tangled.sh/tangled.sh/core/spindle/engine" 26 + "tangled.sh/tangled.sh/core/spindle/models" 27 + "tangled.sh/tangled.sh/core/spindle/secrets" 28 + ) 29 + 30 + const ( 31 + workspaceDir = "/tangled/workspace" 32 + homeDir = "/tangled/home" 33 + ) 34 + 35 + type cleanupFunc func(context.Context) error 36 + 37 + type Engine struct { 38 + docker client.APIClient 39 + l *slog.Logger 40 + cfg *config.Config 41 + 42 + cleanupMu sync.Mutex 43 + cleanup map[string][]cleanupFunc 44 + } 45 + 46 + type Step struct { 47 + name string 48 + kind models.StepKind 49 + command string 50 + environment map[string]string 51 + } 52 + 53 + func (s Step) Name() string { 54 + return s.name 55 + } 56 + 57 + func (s Step) Command() string { 58 + return s.command 59 + } 60 + 61 + func (s Step) Kind() models.StepKind { 62 + return s.kind 63 + } 64 + 65 + // setupSteps get added to start of Steps 66 + type setupSteps []models.Step 67 + 68 + // addStep adds a step to the beginning of the workflow's steps. 69 + func (ss *setupSteps) addStep(step models.Step) { 70 + *ss = append(*ss, step) 71 + } 72 + 73 + type addlFields struct { 74 + image string 75 + container string 76 + env map[string]string 77 + } 78 + 79 + func (e *Engine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) { 80 + swf := &models.Workflow{} 81 + addl := addlFields{} 82 + 83 + dwf := &struct { 84 + Steps []struct { 85 + Command string `yaml:"command"` 86 + Name string `yaml:"name"` 87 + Environment map[string]string `yaml:"environment"` 88 + } `yaml:"steps"` 89 + Dependencies map[string][]string `yaml:"dependencies"` 90 + Environment map[string]string `yaml:"environment"` 91 + }{} 92 + err := yaml.Unmarshal([]byte(twf.Raw), &dwf) 93 + if err != nil { 94 + return nil, err 95 + } 96 + 97 + for _, dstep := range dwf.Steps { 98 + sstep := Step{} 99 + sstep.environment = dstep.Environment 100 + sstep.command = dstep.Command 101 + sstep.name = dstep.Name 102 + sstep.kind = models.StepKindUser 103 + swf.Steps = append(swf.Steps, sstep) 104 + } 105 + swf.Name = twf.Name 106 + addl.env = dwf.Environment 107 + addl.image = workflowImage(dwf.Dependencies, e.cfg.NixeryPipelines.Nixery) 108 + 109 + setup := &setupSteps{} 110 + 111 + setup.addStep(nixConfStep()) 112 + setup.addStep(cloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev)) 113 + // this step could be empty 114 + if s := dependencyStep(dwf.Dependencies); s != nil { 115 + setup.addStep(*s) 116 + } 117 + 118 + // append setup steps in order to the start of workflow steps 119 + swf.Steps = append(*setup, swf.Steps...) 120 + swf.Data = addl 121 + 122 + return swf, nil 123 + } 124 + 125 + func (e *Engine) WorkflowTimeout() time.Duration { 126 + workflowTimeoutStr := e.cfg.NixeryPipelines.WorkflowTimeout 127 + workflowTimeout, err := time.ParseDuration(workflowTimeoutStr) 128 + if err != nil { 129 + e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr) 130 + workflowTimeout = 5 * time.Minute 131 + } 132 + 133 + return workflowTimeout 134 + } 135 + 136 + func workflowImage(deps map[string][]string, nixery string) string { 137 + var dependencies string 138 + for reg, ds := range deps { 139 + if reg == "nixpkgs" { 140 + dependencies = path.Join(ds...) 141 + } 142 + } 143 + 144 + // load defaults from somewhere else 145 + dependencies = path.Join(dependencies, "bash", "git", "coreutils", "nix") 146 + 147 + if runtime.GOARCH == "arm64" { 148 + dependencies = path.Join("arm64", dependencies) 149 + } 150 + 151 + return path.Join(nixery, dependencies) 152 + } 153 + 154 + func New(ctx context.Context, cfg *config.Config) (*Engine, error) { 155 + dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 156 + if err != nil { 157 + return nil, err 158 + } 159 + 160 + l := log.FromContext(ctx).With("component", "spindle") 161 + 162 + e := &Engine{ 163 + docker: dcli, 164 + l: l, 165 + cfg: cfg, 166 + } 167 + 168 + e.cleanup = make(map[string][]cleanupFunc) 169 + 170 + return e, nil 171 + } 172 + 173 + func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId, wf *models.Workflow) error { 174 + e.l.Info("setting up workflow", "workflow", wid) 175 + 176 + _, err := e.docker.NetworkCreate(ctx, networkName(wid), network.CreateOptions{ 177 + Driver: "bridge", 178 + }) 179 + if err != nil { 180 + return err 181 + } 182 + e.registerCleanup(wid, func(ctx context.Context) error { 183 + return e.docker.NetworkRemove(ctx, networkName(wid)) 184 + }) 185 + 186 + addl := wf.Data.(addlFields) 187 + 188 + reader, err := e.docker.ImagePull(ctx, addl.image, image.PullOptions{}) 189 + if err != nil { 190 + e.l.Error("pipeline image pull failed!", "image", addl.image, "workflowId", wid, "error", err.Error()) 191 + 192 + return fmt.Errorf("pulling image: %w", err) 193 + } 194 + defer reader.Close() 195 + io.Copy(os.Stdout, reader) 196 + 197 + resp, err := e.docker.ContainerCreate(ctx, &container.Config{ 198 + Image: addl.image, 199 + Cmd: []string{"cat"}, 200 + OpenStdin: true, // so cat stays alive :3 201 + Tty: false, 202 + Hostname: "spindle", 203 + WorkingDir: workspaceDir, 204 + Labels: map[string]string{ 205 + "sh.tangled.pipeline/workflow_id": wid.String(), 206 + }, 207 + // TODO(winter): investigate whether environment variables passed here 208 + // get propagated to ContainerExec processes 209 + }, &container.HostConfig{ 210 + Mounts: []mount.Mount{ 211 + { 212 + Type: mount.TypeTmpfs, 213 + Target: "/tmp", 214 + ReadOnly: false, 215 + TmpfsOptions: &mount.TmpfsOptions{ 216 + Mode: 0o1777, // world-writeable sticky bit 217 + Options: [][]string{ 218 + {"exec"}, 219 + }, 220 + }, 221 + }, 222 + }, 223 + ReadonlyRootfs: false, 224 + CapDrop: []string{"ALL"}, 225 + CapAdd: []string{"CAP_DAC_OVERRIDE"}, 226 + SecurityOpt: []string{"no-new-privileges"}, 227 + ExtraHosts: []string{"host.docker.internal:host-gateway"}, 228 + }, nil, nil, "") 229 + if err != nil { 230 + return fmt.Errorf("creating container: %w", err) 231 + } 232 + e.registerCleanup(wid, func(ctx context.Context) error { 233 + err = e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{}) 234 + if err != nil { 235 + return err 236 + } 237 + 238 + return e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{ 239 + RemoveVolumes: true, 240 + RemoveLinks: false, 241 + Force: false, 242 + }) 243 + }) 244 + 245 + err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}) 246 + if err != nil { 247 + return fmt.Errorf("starting container: %w", err) 248 + } 249 + 250 + mkExecResp, err := e.docker.ContainerExecCreate(ctx, resp.ID, container.ExecOptions{ 251 + Cmd: []string{"mkdir", "-p", workspaceDir, homeDir}, 252 + AttachStdout: true, // NOTE(winter): pretty sure this will make it so that when stdout read is done below, mkdir is done. maybe?? 253 + AttachStderr: true, // for good measure, backed up by docker/cli ("If -d is not set, attach to everything by default") 254 + }) 255 + if err != nil { 256 + return err 257 + } 258 + 259 + // This actually *starts* the command. Thanks, Docker! 260 + execResp, err := e.docker.ContainerExecAttach(ctx, mkExecResp.ID, container.ExecAttachOptions{}) 261 + if err != nil { 262 + return err 263 + } 264 + defer execResp.Close() 265 + 266 + // This is apparently best way to wait for the command to complete. 267 + _, err = io.ReadAll(execResp.Reader) 268 + if err != nil { 269 + return err 270 + } 271 + 272 + execInspectResp, err := e.docker.ContainerExecInspect(ctx, mkExecResp.ID) 273 + if err != nil { 274 + return err 275 + } 276 + 277 + if execInspectResp.ExitCode != 0 { 278 + return fmt.Errorf("mkdir exited with exit code %d", execInspectResp.ExitCode) 279 + } else if execInspectResp.Running { 280 + return errors.New("mkdir is somehow still running??") 281 + } 282 + 283 + addl.container = resp.ID 284 + wf.Data = addl 285 + 286 + return nil 287 + } 288 + 289 + func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *models.WorkflowLogger) error { 290 + addl := w.Data.(addlFields) 291 + workflowEnvs := ConstructEnvs(addl.env) 292 + // TODO(winter): should SetupWorkflow also have secret access? 293 + // IMO yes, but probably worth thinking on. 294 + for _, s := range secrets { 295 + workflowEnvs.AddEnv(s.Key, s.Value) 296 + } 297 + 298 + step := w.Steps[idx].(Step) 299 + 300 + select { 301 + case <-ctx.Done(): 302 + return ctx.Err() 303 + default: 304 + } 305 + 306 + envs := append(EnvVars(nil), workflowEnvs...) 307 + for k, v := range step.environment { 308 + envs.AddEnv(k, v) 309 + } 310 + envs.AddEnv("HOME", homeDir) 311 + 312 + mkExecResp, err := e.docker.ContainerExecCreate(ctx, addl.container, container.ExecOptions{ 313 + Cmd: []string{"bash", "-c", step.command}, 314 + AttachStdout: true, 315 + AttachStderr: true, 316 + Env: envs, 317 + }) 318 + if err != nil { 319 + return fmt.Errorf("creating exec: %w", err) 320 + } 321 + 322 + // start tailing logs in background 323 + tailDone := make(chan error, 1) 324 + go func() { 325 + tailDone <- e.tailStep(ctx, wfLogger, mkExecResp.ID, wid, idx, step) 326 + }() 327 + 328 + select { 329 + case <-tailDone: 330 + 331 + case <-ctx.Done(): 332 + // cleanup will be handled by DestroyWorkflow, since 333 + // Docker doesn't provide an API to kill an exec run 334 + // (sure, we could grab the PID and kill it ourselves, 335 + // but that's wasted effort) 336 + e.l.Warn("step timed out", "step", step.Name) 337 + 338 + <-tailDone 339 + 340 + return engine.ErrTimedOut 341 + } 342 + 343 + select { 344 + case <-ctx.Done(): 345 + return ctx.Err() 346 + default: 347 + } 348 + 349 + execInspectResp, err := e.docker.ContainerExecInspect(ctx, mkExecResp.ID) 350 + if err != nil { 351 + return err 352 + } 353 + 354 + if execInspectResp.ExitCode != 0 { 355 + inspectResp, err := e.docker.ContainerInspect(ctx, addl.container) 356 + if err != nil { 357 + return err 358 + } 359 + 360 + e.l.Error("workflow failed!", "workflow_id", wid.String(), "exit_code", execInspectResp.ExitCode, "oom_killed", inspectResp.State.OOMKilled) 361 + 362 + if inspectResp.State.OOMKilled { 363 + return ErrOOMKilled 364 + } 365 + return engine.ErrWorkflowFailed 366 + } 367 + 368 + return nil 369 + } 370 + 371 + func (e *Engine) tailStep(ctx context.Context, wfLogger *models.WorkflowLogger, execID string, wid models.WorkflowId, stepIdx int, step models.Step) error { 372 + if wfLogger == nil { 373 + return nil 374 + } 375 + 376 + // This actually *starts* the command. Thanks, Docker! 377 + logs, err := e.docker.ContainerExecAttach(ctx, execID, container.ExecAttachOptions{}) 378 + if err != nil { 379 + return err 380 + } 381 + defer logs.Close() 382 + 383 + _, err = stdcopy.StdCopy( 384 + wfLogger.DataWriter("stdout"), 385 + wfLogger.DataWriter("stderr"), 386 + logs.Reader, 387 + ) 388 + if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) { 389 + return fmt.Errorf("failed to copy logs: %w", err) 390 + } 391 + 392 + return nil 393 + } 394 + 395 + func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error { 396 + e.cleanupMu.Lock() 397 + key := wid.String() 398 + 399 + fns := e.cleanup[key] 400 + delete(e.cleanup, key) 401 + e.cleanupMu.Unlock() 402 + 403 + for _, fn := range fns { 404 + if err := fn(ctx); err != nil { 405 + e.l.Error("failed to cleanup workflow resource", "workflowId", wid, "error", err) 406 + } 407 + } 408 + return nil 409 + } 410 + 411 + func (e *Engine) registerCleanup(wid models.WorkflowId, fn cleanupFunc) { 412 + e.cleanupMu.Lock() 413 + defer e.cleanupMu.Unlock() 414 + 415 + key := wid.String() 416 + e.cleanup[key] = append(e.cleanup[key], fn) 417 + } 418 + 419 + func networkName(wid models.WorkflowId) string { 420 + return fmt.Sprintf("workflow-network-%s", wid) 421 + }
+28
spindle/engines/nixery/envs.go
··· 1 + package nixery 2 + 3 + import ( 4 + "fmt" 5 + ) 6 + 7 + type EnvVars []string 8 + 9 + // ConstructEnvs converts a tangled.Pipeline_Step_Environment_Elem.{Key,Value} 10 + // representation into a docker-friendly []string{"KEY=value", ...} slice. 11 + func ConstructEnvs(envs map[string]string) EnvVars { 12 + var dockerEnvs EnvVars 13 + for k, v := range envs { 14 + ev := fmt.Sprintf("%s=%s", k, v) 15 + dockerEnvs = append(dockerEnvs, ev) 16 + } 17 + return dockerEnvs 18 + } 19 + 20 + // Slice returns the EnvVar as a []string slice. 21 + func (ev EnvVars) Slice() []string { 22 + return ev 23 + } 24 + 25 + // AddEnv adds a key=value string to the EnvVar. 26 + func (ev *EnvVars) AddEnv(key, value string) { 27 + *ev = append(*ev, fmt.Sprintf("%s=%s", key, value)) 28 + }
+48
spindle/engines/nixery/envs_test.go
··· 1 + package nixery 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + ) 8 + 9 + func TestConstructEnvs(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + in map[string]string 13 + want EnvVars 14 + }{ 15 + { 16 + name: "empty input", 17 + in: make(map[string]string), 18 + want: EnvVars{}, 19 + }, 20 + { 21 + name: "single env var", 22 + in: map[string]string{"FOO": "bar"}, 23 + want: EnvVars{"FOO=bar"}, 24 + }, 25 + { 26 + name: "multiple env vars", 27 + in: map[string]string{"FOO": "bar", "BAZ": "qux"}, 28 + want: EnvVars{"FOO=bar", "BAZ=qux"}, 29 + }, 30 + } 31 + for _, tt := range tests { 32 + t.Run(tt.name, func(t *testing.T) { 33 + got := ConstructEnvs(tt.in) 34 + if got == nil { 35 + got = EnvVars{} 36 + } 37 + assert.ElementsMatch(t, tt.want, got) 38 + }) 39 + } 40 + } 41 + 42 + func TestAddEnv(t *testing.T) { 43 + ev := EnvVars{} 44 + ev.AddEnv("FOO", "bar") 45 + ev.AddEnv("BAZ", "qux") 46 + want := EnvVars{"FOO=bar", "BAZ=qux"} 47 + assert.ElementsMatch(t, want, ev) 48 + }
+7
spindle/engines/nixery/errors.go
··· 1 + package nixery 2 + 3 + import "errors" 4 + 5 + var ( 6 + ErrOOMKilled = errors.New("oom killed") 7 + )
+126
spindle/engines/nixery/setup_steps.go
··· 1 + package nixery 2 + 3 + import ( 4 + "fmt" 5 + "path" 6 + "strings" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + "tangled.sh/tangled.sh/core/workflow" 10 + ) 11 + 12 + func nixConfStep() Step { 13 + setupCmd := `mkdir -p /etc/nix 14 + echo 'extra-experimental-features = nix-command flakes' >> /etc/nix/nix.conf 15 + echo 'build-users-group = ' >> /etc/nix/nix.conf` 16 + return Step{ 17 + command: setupCmd, 18 + name: "Configure Nix", 19 + } 20 + } 21 + 22 + // cloneOptsAsSteps processes clone options and adds corresponding steps 23 + // to the beginning of the workflow's step list if cloning is not skipped. 24 + // 25 + // the steps to do here are: 26 + // - git init 27 + // - git remote add origin <url> 28 + // - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha> 29 + // - git checkout FETCH_HEAD 30 + func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step { 31 + if twf.Clone.Skip { 32 + return Step{} 33 + } 34 + 35 + var commands []string 36 + 37 + // initialize git repo in workspace 38 + commands = append(commands, "git init") 39 + 40 + // add repo as git remote 41 + scheme := "https://" 42 + if dev { 43 + scheme = "http://" 44 + tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal") 45 + } 46 + url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo) 47 + commands = append(commands, fmt.Sprintf("git remote add origin %s", url)) 48 + 49 + // run git fetch 50 + { 51 + var fetchArgs []string 52 + 53 + // default clone depth is 1 54 + depth := 1 55 + if twf.Clone.Depth > 1 { 56 + depth = int(twf.Clone.Depth) 57 + } 58 + fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth)) 59 + 60 + // optionally recurse submodules 61 + if twf.Clone.Submodules { 62 + fetchArgs = append(fetchArgs, "--recurse-submodules=yes") 63 + } 64 + 65 + // set remote to fetch from 66 + fetchArgs = append(fetchArgs, "origin") 67 + 68 + // set revision to checkout 69 + switch workflow.TriggerKind(tr.Kind) { 70 + case workflow.TriggerKindManual: 71 + // TODO: unimplemented 72 + case workflow.TriggerKindPush: 73 + fetchArgs = append(fetchArgs, tr.Push.NewSha) 74 + case workflow.TriggerKindPullRequest: 75 + fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha) 76 + } 77 + 78 + commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " "))) 79 + } 80 + 81 + // run git checkout 82 + commands = append(commands, "git checkout FETCH_HEAD") 83 + 84 + cloneStep := Step{ 85 + command: strings.Join(commands, "\n"), 86 + name: "Clone repository into workspace", 87 + } 88 + return cloneStep 89 + } 90 + 91 + // dependencyStep processes dependencies defined in the workflow. 92 + // For dependencies using a custom registry (i.e. not nixpkgs), it collects 93 + // all packages and adds a single 'nix profile install' step to the 94 + // beginning of the workflow's step list. 95 + func dependencyStep(deps map[string][]string) *Step { 96 + var customPackages []string 97 + 98 + for registry, packages := range deps { 99 + if registry == "nixpkgs" { 100 + continue 101 + } 102 + 103 + if len(packages) == 0 { 104 + customPackages = append(customPackages, registry) 105 + } 106 + // collect packages from custom registries 107 + for _, pkg := range packages { 108 + customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg)) 109 + } 110 + } 111 + 112 + if len(customPackages) > 0 { 113 + installCmd := "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install" 114 + cmd := fmt.Sprintf("%s %s", installCmd, strings.Join(customPackages, " ")) 115 + installStep := Step{ 116 + command: cmd, 117 + name: "Install custom dependencies", 118 + environment: map[string]string{ 119 + "NIX_NO_COLOR": "1", 120 + "NIX_SHOW_DOWNLOAD_PROGRESS": "0", 121 + }, 122 + } 123 + return &installStep 124 + } 125 + return nil 126 + }
+8 -4
spindle/ingester.go
··· 40 40 41 41 switch e.Commit.Collection { 42 42 case tangled.SpindleMemberNSID: 43 - s.ingestMember(ctx, e) 43 + err = s.ingestMember(ctx, e) 44 44 case tangled.RepoNSID: 45 - s.ingestRepo(ctx, e) 45 + err = s.ingestRepo(ctx, e) 46 46 case tangled.RepoCollaboratorNSID: 47 - s.ingestCollaborator(ctx, e) 47 + err = s.ingestCollaborator(ctx, e) 48 48 } 49 49 50 - 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 51 55 } 52 56 } 53 57
+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/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 -14
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" 26 27 "tangled.sh/tangled.sh/core/spindle/xrpc" 28 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 27 29 ) 28 30 29 31 //go:embed motd ··· 39 41 e *rbac.Enforcer 40 42 l *slog.Logger 41 43 n *notifier.Notifier 42 - eng *engine.Engine 44 + engs map[string]models.Engine 43 45 jq *queue.Queue 44 46 cfg *config.Config 45 47 ks *eventconsumer.Consumer ··· 93 95 return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 94 96 } 95 97 96 - eng, err := engine.New(ctx, cfg, d, &n, vault) 98 + nixeryEng, err := nixery.New(ctx, cfg) 97 99 if err != nil { 98 100 return err 99 101 } ··· 128 130 db: d, 129 131 l: logger, 130 132 n: &n, 131 - eng: eng, 133 + engs: map[string]models.Engine{"nixery": nixeryEng}, 132 134 jq: jq, 133 135 cfg: cfg, 134 136 res: resolver, ··· 212 214 func (s *Spindle) XrpcRouter() http.Handler { 213 215 logger := s.l.With("route", "xrpc") 214 216 217 + serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String()) 218 + 215 219 x := xrpc.Xrpc{ 216 - Logger: logger, 217 - Db: s.db, 218 - Enforcer: s.e, 219 - Engine: s.eng, 220 - Config: s.cfg, 221 - Resolver: s.res, 222 - Vault: s.vault, 220 + Logger: logger, 221 + Db: s.db, 222 + Enforcer: s.e, 223 + Engines: s.engs, 224 + Config: s.cfg, 225 + Resolver: s.res, 226 + Vault: s.vault, 227 + ServiceAuth: serviceAuth, 223 228 } 224 229 225 230 return x.Router() ··· 242 247 return fmt.Errorf("no repo data found") 243 248 } 244 249 250 + if src.Key() != tpl.TriggerMetadata.Repo.Knot { 251 + return fmt.Errorf("repo knot does not match event source: %s != %s", src.Key(), tpl.TriggerMetadata.Repo.Knot) 252 + } 253 + 245 254 // filter by repos 246 255 _, err = s.db.GetRepo( 247 256 tpl.TriggerMetadata.Repo.Knot, ··· 257 266 Rkey: msg.Rkey, 258 267 } 259 268 269 + workflows := make(map[models.Engine][]models.Workflow) 270 + 260 271 for _, w := range tpl.Workflows { 261 272 if w != nil { 262 - err := s.db.StatusPending(models.WorkflowId{ 273 + if _, ok := s.engs[w.Engine]; !ok { 274 + err = s.db.StatusFailed(models.WorkflowId{ 275 + PipelineId: pipelineId, 276 + Name: w.Name, 277 + }, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n) 278 + if err != nil { 279 + return err 280 + } 281 + 282 + continue 283 + } 284 + 285 + eng := s.engs[w.Engine] 286 + 287 + if _, ok := workflows[eng]; !ok { 288 + workflows[eng] = []models.Workflow{} 289 + } 290 + 291 + ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl) 292 + if err != nil { 293 + return err 294 + } 295 + 296 + workflows[eng] = append(workflows[eng], *ewf) 297 + 298 + err = s.db.StatusPending(models.WorkflowId{ 263 299 PipelineId: pipelineId, 264 300 Name: w.Name, 265 301 }, s.n) ··· 269 305 } 270 306 } 271 307 272 - spl := models.ToPipeline(tpl, *s.cfg) 273 - 274 308 ok := s.jq.Enqueue(queue.Job{ 275 309 Run: func() error { 276 - s.eng.StartWorkflows(ctx, spl, pipelineId) 310 + engine.StartWorkflows(s.l, s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 311 + RepoOwner: tpl.TriggerMetadata.Repo.Did, 312 + RepoName: tpl.TriggerMetadata.Repo.Repo, 313 + Workflows: workflows, 314 + }, pipelineId) 277 315 return nil 278 316 }, 279 317 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,
+11 -10
spindle/xrpc/add_secret.go
··· 13 13 "tangled.sh/tangled.sh/core/api/tangled" 14 14 "tangled.sh/tangled.sh/core/rbac" 15 15 "tangled.sh/tangled.sh/core/spindle/secrets" 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 17 ) 17 18 18 19 func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) { 19 20 l := x.Logger 20 - fail := func(e XrpcError) { 21 + fail := func(e xrpcerr.XrpcError) { 21 22 l.Error("failed", "kind", e.Tag, "error", e.Message) 22 23 writeError(w, e, http.StatusBadRequest) 23 24 } 24 25 25 26 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 27 if !ok { 27 - fail(MissingActorDidError) 28 + fail(xrpcerr.MissingActorDidError) 28 29 return 29 30 } 30 31 31 32 var data tangled.RepoAddSecret_Input 32 33 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 - fail(GenericError(err)) 34 + fail(xrpcerr.GenericError(err)) 34 35 return 35 36 } 36 37 37 38 if err := secrets.ValidateKey(data.Key); err != nil { 38 - fail(GenericError(err)) 39 + fail(xrpcerr.GenericError(err)) 39 40 return 40 41 } 41 42 42 43 // unfortunately we have to resolve repo-at here 43 44 repoAt, err := syntax.ParseATURI(data.Repo) 44 45 if err != nil { 45 - fail(InvalidRepoError(data.Repo)) 46 + fail(xrpcerr.InvalidRepoError(data.Repo)) 46 47 return 47 48 } 48 49 49 50 // resolve this aturi to extract the repo record 50 51 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 51 52 if err != nil || ident.Handle.IsInvalidHandle() { 52 - fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 53 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 53 54 return 54 55 } 55 56 56 57 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 57 58 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 58 59 if err != nil { 59 - fail(GenericError(err)) 60 + fail(xrpcerr.GenericError(err)) 60 61 return 61 62 } 62 63 63 64 repo := resp.Value.Val.(*tangled.Repo) 64 65 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 65 66 if err != nil { 66 - fail(GenericError(err)) 67 + fail(xrpcerr.GenericError(err)) 67 68 return 68 69 } 69 70 70 71 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 71 72 l.Error("insufficent permissions", "did", actorDid.String()) 72 - writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 73 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 73 74 return 74 75 } 75 76 ··· 83 84 err = x.Vault.AddSecret(r.Context(), secret) 84 85 if err != nil { 85 86 l.Error("failed to add secret to vault", "did", actorDid.String(), "err", err) 86 - writeError(w, GenericError(err), http.StatusInternalServerError) 87 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 87 88 return 88 89 } 89 90
+10 -9
spindle/xrpc/list_secrets.go
··· 13 13 "tangled.sh/tangled.sh/core/api/tangled" 14 14 "tangled.sh/tangled.sh/core/rbac" 15 15 "tangled.sh/tangled.sh/core/spindle/secrets" 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 17 ) 17 18 18 19 func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) { 19 20 l := x.Logger 20 - fail := func(e XrpcError) { 21 + fail := func(e xrpcerr.XrpcError) { 21 22 l.Error("failed", "kind", e.Tag, "error", e.Message) 22 23 writeError(w, e, http.StatusBadRequest) 23 24 } 24 25 25 26 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 27 if !ok { 27 - fail(MissingActorDidError) 28 + fail(xrpcerr.MissingActorDidError) 28 29 return 29 30 } 30 31 31 32 repoParam := r.URL.Query().Get("repo") 32 33 if repoParam == "" { 33 - fail(GenericError(fmt.Errorf("empty params"))) 34 + fail(xrpcerr.GenericError(fmt.Errorf("empty params"))) 34 35 return 35 36 } 36 37 37 38 // unfortunately we have to resolve repo-at here 38 39 repoAt, err := syntax.ParseATURI(repoParam) 39 40 if err != nil { 40 - fail(InvalidRepoError(repoParam)) 41 + fail(xrpcerr.InvalidRepoError(repoParam)) 41 42 return 42 43 } 43 44 44 45 // resolve this aturi to extract the repo record 45 46 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 46 47 if err != nil || ident.Handle.IsInvalidHandle() { 47 - fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 48 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 48 49 return 49 50 } 50 51 51 52 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 52 53 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 53 54 if err != nil { 54 - fail(GenericError(err)) 55 + fail(xrpcerr.GenericError(err)) 55 56 return 56 57 } 57 58 58 59 repo := resp.Value.Val.(*tangled.Repo) 59 60 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 60 61 if err != nil { 61 - fail(GenericError(err)) 62 + fail(xrpcerr.GenericError(err)) 62 63 return 63 64 } 64 65 65 66 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 66 67 l.Error("insufficent permissions", "did", actorDid.String()) 67 - writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 68 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 68 69 return 69 70 } 70 71 71 72 ls, err := x.Vault.GetSecretsLocked(r.Context(), secrets.DidSlashRepo(didPath)) 72 73 if err != nil { 73 74 l.Error("failed to get secret from vault", "did", actorDid.String(), "err", err) 74 - writeError(w, GenericError(err), http.StatusInternalServerError) 75 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 75 76 return 76 77 } 77 78
+10 -9
spindle/xrpc/remove_secret.go
··· 12 12 "tangled.sh/tangled.sh/core/api/tangled" 13 13 "tangled.sh/tangled.sh/core/rbac" 14 14 "tangled.sh/tangled.sh/core/spindle/secrets" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 15 16 ) 16 17 17 18 func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) { 18 19 l := x.Logger 19 - fail := func(e XrpcError) { 20 + fail := func(e xrpcerr.XrpcError) { 20 21 l.Error("failed", "kind", e.Tag, "error", e.Message) 21 22 writeError(w, e, http.StatusBadRequest) 22 23 } 23 24 24 25 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 25 26 if !ok { 26 - fail(MissingActorDidError) 27 + fail(xrpcerr.MissingActorDidError) 27 28 return 28 29 } 29 30 30 31 var data tangled.RepoRemoveSecret_Input 31 32 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 32 - fail(GenericError(err)) 33 + fail(xrpcerr.GenericError(err)) 33 34 return 34 35 } 35 36 36 37 // unfortunately we have to resolve repo-at here 37 38 repoAt, err := syntax.ParseATURI(data.Repo) 38 39 if err != nil { 39 - fail(InvalidRepoError(data.Repo)) 40 + fail(xrpcerr.InvalidRepoError(data.Repo)) 40 41 return 41 42 } 42 43 43 44 // resolve this aturi to extract the repo record 44 45 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 45 46 if err != nil || ident.Handle.IsInvalidHandle() { 46 - fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 47 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 47 48 return 48 49 } 49 50 50 51 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 51 52 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 52 53 if err != nil { 53 - fail(GenericError(err)) 54 + fail(xrpcerr.GenericError(err)) 54 55 return 55 56 } 56 57 57 58 repo := resp.Value.Val.(*tangled.Repo) 58 59 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 59 60 if err != nil { 60 - fail(GenericError(err)) 61 + fail(xrpcerr.GenericError(err)) 61 62 return 62 63 } 63 64 64 65 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 65 66 l.Error("insufficent permissions", "did", actorDid.String()) 66 - writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 67 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 67 68 return 68 69 } 69 70 ··· 74 75 err = x.Vault.RemoveSecret(r.Context(), secret) 75 76 if err != nil { 76 77 l.Error("failed to remove secret from vault", "did", actorDid.String(), "err", err) 77 - writeError(w, GenericError(err), http.StatusInternalServerError) 78 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 78 79 return 79 80 } 80 81
+15 -110
spindle/xrpc/xrpc.go
··· 1 1 package xrpc 2 2 3 3 import ( 4 - "context" 5 4 _ "embed" 6 5 "encoding/json" 7 - "fmt" 8 6 "log/slog" 9 7 "net/http" 10 - "strings" 11 8 12 - "github.com/bluesky-social/indigo/atproto/auth" 13 9 "github.com/go-chi/chi/v5" 14 10 15 11 "tangled.sh/tangled.sh/core/api/tangled" ··· 17 13 "tangled.sh/tangled.sh/core/rbac" 18 14 "tangled.sh/tangled.sh/core/spindle/config" 19 15 "tangled.sh/tangled.sh/core/spindle/db" 20 - "tangled.sh/tangled.sh/core/spindle/engine" 16 + "tangled.sh/tangled.sh/core/spindle/models" 21 17 "tangled.sh/tangled.sh/core/spindle/secrets" 18 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 19 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 22 20 ) 23 21 24 22 const ActorDid string = "ActorDid" 25 23 26 24 type Xrpc struct { 27 - Logger *slog.Logger 28 - Db *db.DB 29 - Enforcer *rbac.Enforcer 30 - Engine *engine.Engine 31 - Config *config.Config 32 - Resolver *idresolver.Resolver 33 - Vault secrets.Manager 25 + Logger *slog.Logger 26 + Db *db.DB 27 + Enforcer *rbac.Enforcer 28 + Engines map[string]models.Engine 29 + Config *config.Config 30 + Resolver *idresolver.Resolver 31 + Vault secrets.Manager 32 + ServiceAuth *serviceauth.ServiceAuth 34 33 } 35 34 36 35 func (x *Xrpc) Router() http.Handler { 37 36 r := chi.NewRouter() 38 37 39 - r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 40 - r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 41 - r.With(x.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 38 + r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 39 + r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 40 + r.With(x.ServiceAuth.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 42 41 43 42 return r 44 43 } 45 44 46 - func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler { 47 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 48 - l := x.Logger.With("url", r.URL) 49 - 50 - token := r.Header.Get("Authorization") 51 - token = strings.TrimPrefix(token, "Bearer ") 52 - 53 - s := auth.ServiceAuthValidator{ 54 - Audience: x.Config.Server.Did().String(), 55 - Dir: x.Resolver.Directory(), 56 - } 57 - 58 - did, err := s.Validate(r.Context(), token, nil) 59 - if err != nil { 60 - l.Error("signature verification failed", "err", err) 61 - writeError(w, AuthError(err), http.StatusForbidden) 62 - return 63 - } 64 - 65 - r = r.WithContext( 66 - context.WithValue(r.Context(), ActorDid, did), 67 - ) 68 - 69 - next.ServeHTTP(w, r) 70 - }) 71 - } 72 - 73 - type XrpcError struct { 74 - Tag string `json:"error"` 75 - Message string `json:"message"` 76 - } 77 - 78 - func NewXrpcError(opts ...ErrOpt) XrpcError { 79 - x := XrpcError{} 80 - for _, o := range opts { 81 - o(&x) 82 - } 83 - 84 - return x 85 - } 86 - 87 - type ErrOpt = func(xerr *XrpcError) 88 - 89 - func WithTag(tag string) ErrOpt { 90 - return func(xerr *XrpcError) { 91 - xerr.Tag = tag 92 - } 93 - } 94 - 95 - func WithMessage[S ~string](s S) ErrOpt { 96 - return func(xerr *XrpcError) { 97 - xerr.Message = string(s) 98 - } 99 - } 100 - 101 - func WithError(e error) ErrOpt { 102 - return func(xerr *XrpcError) { 103 - xerr.Message = e.Error() 104 - } 105 - } 106 - 107 - var MissingActorDidError = NewXrpcError( 108 - WithTag("MissingActorDid"), 109 - WithMessage("actor DID not supplied"), 110 - ) 111 - 112 - var AuthError = func(err error) XrpcError { 113 - return NewXrpcError( 114 - WithTag("Auth"), 115 - WithError(fmt.Errorf("signature verification failed: %w", err)), 116 - ) 117 - } 118 - 119 - var InvalidRepoError = func(r string) XrpcError { 120 - return NewXrpcError( 121 - WithTag("InvalidRepo"), 122 - WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)), 123 - ) 124 - } 125 - 126 - func GenericError(err error) XrpcError { 127 - return NewXrpcError( 128 - WithTag("Generic"), 129 - WithError(err), 130 - ) 131 - } 132 - 133 - var AccessControlError = func(d string) XrpcError { 134 - return NewXrpcError( 135 - WithTag("AccessControl"), 136 - WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)), 137 - ) 138 - } 139 - 140 45 // this is slightly different from http_util::write_error to follow the spec: 141 46 // 142 47 // the json object returned must include an "error" and a "message" 143 - func writeError(w http.ResponseWriter, e XrpcError, status int) { 48 + func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 144 49 w.Header().Set("Content-Type", "application/json") 145 50 w.WriteHeader(status) 146 51 json.NewEncoder(w).Encode(e)
+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 - }
+110
xrpc/errors/errors.go
··· 1 + package errors 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + ) 7 + 8 + type XrpcError struct { 9 + Tag string `json:"error"` 10 + Message string `json:"message"` 11 + } 12 + 13 + func (x XrpcError) Error() string { 14 + if x.Message != "" { 15 + return fmt.Sprintf("%s: %s", x.Tag, x.Message) 16 + } 17 + return x.Tag 18 + } 19 + 20 + func NewXrpcError(opts ...ErrOpt) XrpcError { 21 + x := XrpcError{} 22 + for _, o := range opts { 23 + o(&x) 24 + } 25 + 26 + return x 27 + } 28 + 29 + type ErrOpt = func(xerr *XrpcError) 30 + 31 + func WithTag(tag string) ErrOpt { 32 + return func(xerr *XrpcError) { 33 + xerr.Tag = tag 34 + } 35 + } 36 + 37 + func WithMessage[S ~string](s S) ErrOpt { 38 + return func(xerr *XrpcError) { 39 + xerr.Message = string(s) 40 + } 41 + } 42 + 43 + func WithError(e error) ErrOpt { 44 + return func(xerr *XrpcError) { 45 + xerr.Message = e.Error() 46 + } 47 + } 48 + 49 + var MissingActorDidError = NewXrpcError( 50 + WithTag("MissingActorDid"), 51 + WithMessage("actor DID not supplied"), 52 + ) 53 + 54 + var AuthError = func(err error) XrpcError { 55 + return NewXrpcError( 56 + WithTag("Auth"), 57 + WithError(fmt.Errorf("signature verification failed: %w", err)), 58 + ) 59 + } 60 + 61 + var InvalidRepoError = func(r string) XrpcError { 62 + return NewXrpcError( 63 + WithTag("InvalidRepo"), 64 + WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)), 65 + ) 66 + } 67 + 68 + var GitError = func(e error) XrpcError { 69 + return NewXrpcError( 70 + WithTag("Git"), 71 + WithError(fmt.Errorf("git error: %w", e)), 72 + ) 73 + } 74 + 75 + var AccessControlError = func(d string) XrpcError { 76 + return NewXrpcError( 77 + WithTag("AccessControl"), 78 + WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)), 79 + ) 80 + } 81 + 82 + var RepoExistsError = func(r string) XrpcError { 83 + return NewXrpcError( 84 + WithTag("RepoExists"), 85 + WithError(fmt.Errorf("repo already exists: %s", r)), 86 + ) 87 + } 88 + 89 + var RecordExistsError = func(r string) XrpcError { 90 + return NewXrpcError( 91 + WithTag("RecordExists"), 92 + WithError(fmt.Errorf("repo already exists: %s", r)), 93 + ) 94 + } 95 + 96 + func GenericError(err error) XrpcError { 97 + return NewXrpcError( 98 + WithTag("Generic"), 99 + WithError(err), 100 + ) 101 + } 102 + 103 + func Unmarshal(errStr string) (XrpcError, error) { 104 + var xerr XrpcError 105 + err := json.Unmarshal([]byte(errStr), &xerr) 106 + if err != nil { 107 + return XrpcError{}, fmt.Errorf("failed to unmarshal XrpcError: %w", err) 108 + } 109 + return xerr, nil 110 + }
+65
xrpc/serviceauth/service_auth.go
··· 1 + package serviceauth 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "log/slog" 7 + "net/http" 8 + "strings" 9 + 10 + "github.com/bluesky-social/indigo/atproto/auth" 11 + "tangled.sh/tangled.sh/core/idresolver" 12 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 + ) 14 + 15 + const ActorDid string = "ActorDid" 16 + 17 + type ServiceAuth struct { 18 + logger *slog.Logger 19 + resolver *idresolver.Resolver 20 + audienceDid string 21 + } 22 + 23 + func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth { 24 + return &ServiceAuth{ 25 + logger: logger, 26 + resolver: resolver, 27 + audienceDid: audienceDid, 28 + } 29 + } 30 + 31 + func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler { 32 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 + l := sa.logger.With("url", r.URL) 34 + 35 + token := r.Header.Get("Authorization") 36 + token = strings.TrimPrefix(token, "Bearer ") 37 + 38 + s := auth.ServiceAuthValidator{ 39 + Audience: sa.audienceDid, 40 + Dir: sa.resolver.Directory(), 41 + } 42 + 43 + did, err := s.Validate(r.Context(), token, nil) 44 + if err != nil { 45 + l.Error("signature verification failed", "err", err) 46 + writeError(w, xrpcerr.AuthError(err), http.StatusForbidden) 47 + return 48 + } 49 + 50 + r = r.WithContext( 51 + context.WithValue(r.Context(), ActorDid, did), 52 + ) 53 + 54 + next.ServeHTTP(w, r) 55 + }) 56 + } 57 + 58 + // this is slightly different from http_util::write_error to follow the spec: 59 + // 60 + // the json object returned must include an "error" and a "message" 61 + func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 62 + w.Header().Set("Content-Type", "application/json") 63 + w.WriteHeader(status) 64 + json.NewEncoder(w).Encode(e) 65 + }