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

Compare changes

Choose any two refs to compare.

Changed files
+16005 -7945
.air
.tangled
.zed
api
appview
cache
session
config
db
dns
issues
knots
middleware
oauth
pages
pulls
repo
reporesolver
serververify
settings
signup
spindles
spindleverify
state
strings
xrpcclient
cmd
genjwks
punchcardPopulate
docs
eventconsumer
cursor
guard
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"]
+4
.gitignore
··· 14 14 .DS_Store 15 15 .env 16 16 *.rdb 17 + .envrc 18 + # Created if following hacking.md 19 + genjwks.out 20 + /nix/vm-data
+12
.prettierrc.json
··· 1 + { 2 + "overrides": [ 3 + { 4 + "files": ["*.html"], 5 + "options": { 6 + "parser": "go-template" 7 + } 8 + } 9 + ], 10 + "bracketSameLine": true, 11 + "htmlWhitespaceSensitivity": "ignore" 12 + }
+2
.tangled/workflows/build.yml
··· 2 2 - event: ["push", "pull_request"] 3 3 branch: ["master"] 4 4 5 + engine: nixery 6 + 5 7 dependencies: 6 8 nixpkgs: 7 9 - go
+3 -12
.tangled/workflows/fmt.yml
··· 2 2 - event: ["push", "pull_request"] 3 3 branch: ["master"] 4 4 5 - dependencies: 6 - nixpkgs: 7 - - go 8 - - alejandra 5 + engine: nixery 9 6 10 7 steps: 11 - - name: "nix fmt" 8 + - name: "Check formatting" 12 9 command: | 13 - alejandra -c nix/**/*.nix flake.nix 14 - 15 - - name: "go fmt" 16 - command: | 17 - unformatted=$(gofmt -l .) 18 - test -z "$unformatted" || (echo "$unformatted" && exit 1) 19 - 10 + nix run .#fmt -- --ci
+2
.tangled/workflows/test.yml
··· 2 2 - event: ["push", "pull_request"] 3 3 branch: ["master"] 4 4 5 + engine: nixery 6 + 5 7 dependencies: 6 8 nixpkgs: 7 9 - go
-16
.zed/settings.json
··· 1 - // Folder-specific settings 2 - // 3 - // For a full list of overridable settings, and general information on folder-specific settings, 4 - // see the documentation: https://zed.dev/docs/configuring-zed#settings-files 5 - { 6 - "languages": { 7 - "HTML": { 8 - "prettier": { 9 - "format_on_save": false, 10 - "allowed": true, 11 - "parser": "go-template", 12 - "plugins": ["prettier-plugin-go-template"] 13 - } 14 - } 15 - } 16 - }
+613 -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 { 4423 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("engine"))); err != nil { 4661 4424 return err 4662 4425 } 4663 - if _, err := cw.WriteString(string("steps")); err != nil { 4426 + if _, err := cw.WriteString(string("engine")); err != nil { 4664 4427 return err 4665 4428 } 4666 4429 4667 - if len(t.Steps) > 8192 { 4668 - return xerrors.Errorf("Slice value in field t.Steps was too long") 4430 + if len(t.Engine) > 1000000 { 4431 + return xerrors.Errorf("Value in field t.Engine was too long") 4669 4432 } 4670 4433 4671 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Steps))); err != nil { 4434 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Engine))); err != nil { 4672 4435 return err 4673 4436 } 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 { 4437 + if _, err := cw.WriteString(string(t.Engine)); err != nil { 4687 4438 return err 4688 4439 } 4689 - if _, err := cw.WriteString(string("environment")); err != nil { 4690 - return err 4691 - } 4692 - 4693 - if len(t.Environment) > 8192 { 4694 - return xerrors.Errorf("Slice value in field t.Environment was too long") 4695 - } 4696 - 4697 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Environment))); err != nil { 4698 - return err 4699 - } 4700 - for _, v := range t.Environment { 4701 - if err := v.MarshalCBOR(cw); err != nil { 4702 - return err 4703 - } 4704 - 4705 - } 4706 - 4707 - // t.Dependencies ([]*tangled.Pipeline_Dependency) (slice) 4708 - if len("dependencies") > 1000000 { 4709 - return xerrors.Errorf("Value in field \"dependencies\" was too long") 4710 - } 4711 - 4712 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("dependencies"))); err != nil { 4713 - return err 4714 - } 4715 - if _, err := cw.WriteString(string("dependencies")); err != nil { 4716 - return err 4717 - } 4718 - 4719 - if len(t.Dependencies) > 8192 { 4720 - return xerrors.Errorf("Slice value in field t.Dependencies was too long") 4721 - } 4722 - 4723 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Dependencies))); err != nil { 4724 - return err 4725 - } 4726 - for _, v := range t.Dependencies { 4727 - if err := v.MarshalCBOR(cw); err != nil { 4728 - return err 4729 - } 4730 - 4731 - } 4732 4440 return nil 4733 4441 } 4734 4442 ··· 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": 4809 - 4810 - maj, extra, err = cr.ReadHeader() 4811 - if err != nil { 4812 - return err 4813 - } 4814 - 4815 - if extra > 8192 { 4816 - return fmt.Errorf("t.Steps: array too large (%d)", extra) 4817 - } 4818 - 4819 - if maj != cbg.MajArray { 4820 - return fmt.Errorf("expected cbor array") 4821 - } 4822 - 4823 - if extra > 0 { 4824 - t.Steps = make([]*Pipeline_Step, extra) 4825 - } 4826 - 4827 - for i := 0; i < int(extra); i++ { 4828 - { 4829 - var maj byte 4830 - var extra uint64 4831 - var err error 4832 - _ = maj 4833 - _ = extra 4834 - _ = err 4835 - 4836 - { 4837 - 4838 - b, err := cr.ReadByte() 4839 - if err != nil { 4840 - return err 4841 - } 4842 - if b != cbg.CborNull[0] { 4843 - if err := cr.UnreadByte(); err != nil { 4844 - return err 4845 - } 4846 - t.Steps[i] = new(Pipeline_Step) 4847 - if err := t.Steps[i].UnmarshalCBOR(cr); err != nil { 4848 - return xerrors.Errorf("unmarshaling t.Steps[i] pointer: %w", err) 4849 - } 4850 - } 4851 - 4852 - } 4853 - 4854 - } 4855 - } 4856 - // t.Environment ([]*tangled.Pipeline_Pair) (slice) 4857 - case "environment": 4858 - 4859 - maj, extra, err = cr.ReadHeader() 4860 - if err != nil { 4861 - return err 4862 - } 4863 - 4864 - if extra > 8192 { 4865 - return fmt.Errorf("t.Environment: array too large (%d)", extra) 4866 - } 4867 - 4868 - if maj != cbg.MajArray { 4869 - return fmt.Errorf("expected cbor array") 4870 - } 4871 - 4872 - if extra > 0 { 4873 - t.Environment = make([]*Pipeline_Pair, extra) 4874 - } 4875 - 4876 - for i := 0; i < int(extra); i++ { 4877 - { 4878 - var maj byte 4879 - var extra uint64 4880 - var err error 4881 - _ = maj 4882 - _ = extra 4883 - _ = err 4884 - 4885 - { 4886 - 4887 - b, err := cr.ReadByte() 4888 - if err != nil { 4889 - return err 4890 - } 4891 - if b != cbg.CborNull[0] { 4892 - if err := cr.UnreadByte(); err != nil { 4893 - return err 4894 - } 4895 - t.Environment[i] = new(Pipeline_Pair) 4896 - if err := t.Environment[i].UnmarshalCBOR(cr); err != nil { 4897 - return xerrors.Errorf("unmarshaling t.Environment[i] pointer: %w", err) 4898 - } 4899 - } 4900 - 4901 - } 4526 + // t.Engine (string) (string) 4527 + case "engine": 4902 4528 4529 + { 4530 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4531 + if err != nil { 4532 + return err 4903 4533 } 4904 - } 4905 - // t.Dependencies ([]*tangled.Pipeline_Dependency) (slice) 4906 - case "dependencies": 4907 4534 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: ··· 5831 5414 } 5832 5415 } 5833 5416 5417 + } 5418 + // t.CreatedAt (string) (string) 5419 + case "createdAt": 5420 + 5421 + { 5422 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5423 + if err != nil { 5424 + return err 5425 + } 5426 + 5427 + t.CreatedAt = string(sval) 5428 + } 5429 + 5430 + default: 5431 + // Field doesn't exist on this type, so ignore it 5432 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 5433 + return err 5434 + } 5435 + } 5436 + } 5437 + 5438 + return nil 5439 + } 5440 + func (t *RepoCollaborator) MarshalCBOR(w io.Writer) error { 5441 + if t == nil { 5442 + _, err := w.Write(cbg.CborNull) 5443 + return err 5444 + } 5445 + 5446 + cw := cbg.NewCborWriter(w) 5447 + 5448 + if _, err := cw.Write([]byte{164}); err != nil { 5449 + return err 5450 + } 5451 + 5452 + // t.Repo (string) (string) 5453 + if len("repo") > 1000000 { 5454 + return xerrors.Errorf("Value in field \"repo\" was too long") 5455 + } 5456 + 5457 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 5458 + return err 5459 + } 5460 + if _, err := cw.WriteString(string("repo")); err != nil { 5461 + return err 5462 + } 5463 + 5464 + if len(t.Repo) > 1000000 { 5465 + return xerrors.Errorf("Value in field t.Repo was too long") 5466 + } 5467 + 5468 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil { 5469 + return err 5470 + } 5471 + if _, err := cw.WriteString(string(t.Repo)); err != nil { 5472 + return err 5473 + } 5474 + 5475 + // t.LexiconTypeID (string) (string) 5476 + if len("$type") > 1000000 { 5477 + return xerrors.Errorf("Value in field \"$type\" was too long") 5478 + } 5479 + 5480 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 5481 + return err 5482 + } 5483 + if _, err := cw.WriteString(string("$type")); err != nil { 5484 + return err 5485 + } 5486 + 5487 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.repo.collaborator"))); err != nil { 5488 + return err 5489 + } 5490 + if _, err := cw.WriteString(string("sh.tangled.repo.collaborator")); err != nil { 5491 + return err 5492 + } 5493 + 5494 + // t.Subject (string) (string) 5495 + if len("subject") > 1000000 { 5496 + return xerrors.Errorf("Value in field \"subject\" was too long") 5497 + } 5498 + 5499 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil { 5500 + return err 5501 + } 5502 + if _, err := cw.WriteString(string("subject")); err != nil { 5503 + return err 5504 + } 5505 + 5506 + if len(t.Subject) > 1000000 { 5507 + return xerrors.Errorf("Value in field t.Subject was too long") 5508 + } 5509 + 5510 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil { 5511 + return err 5512 + } 5513 + if _, err := cw.WriteString(string(t.Subject)); err != nil { 5514 + return err 5515 + } 5516 + 5517 + // t.CreatedAt (string) (string) 5518 + if len("createdAt") > 1000000 { 5519 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 5520 + } 5521 + 5522 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 5523 + return err 5524 + } 5525 + if _, err := cw.WriteString(string("createdAt")); err != nil { 5526 + return err 5527 + } 5528 + 5529 + if len(t.CreatedAt) > 1000000 { 5530 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 5531 + } 5532 + 5533 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 5534 + return err 5535 + } 5536 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 5537 + return err 5538 + } 5539 + return nil 5540 + } 5541 + 5542 + func (t *RepoCollaborator) UnmarshalCBOR(r io.Reader) (err error) { 5543 + *t = RepoCollaborator{} 5544 + 5545 + cr := cbg.NewCborReader(r) 5546 + 5547 + maj, extra, err := cr.ReadHeader() 5548 + if err != nil { 5549 + return err 5550 + } 5551 + defer func() { 5552 + if err == io.EOF { 5553 + err = io.ErrUnexpectedEOF 5554 + } 5555 + }() 5556 + 5557 + if maj != cbg.MajMap { 5558 + return fmt.Errorf("cbor input should be of type map") 5559 + } 5560 + 5561 + if extra > cbg.MaxLength { 5562 + return fmt.Errorf("RepoCollaborator: map struct too large (%d)", extra) 5563 + } 5564 + 5565 + n := extra 5566 + 5567 + nameBuf := make([]byte, 9) 5568 + for i := uint64(0); i < n; i++ { 5569 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 5570 + if err != nil { 5571 + return err 5572 + } 5573 + 5574 + if !ok { 5575 + // Field doesn't exist on this type, so ignore it 5576 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 5577 + return err 5578 + } 5579 + continue 5580 + } 5581 + 5582 + switch string(nameBuf[:nameLen]) { 5583 + // t.Repo (string) (string) 5584 + case "repo": 5585 + 5586 + { 5587 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5588 + if err != nil { 5589 + return err 5590 + } 5591 + 5592 + t.Repo = string(sval) 5593 + } 5594 + // t.LexiconTypeID (string) (string) 5595 + case "$type": 5596 + 5597 + { 5598 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5599 + if err != nil { 5600 + return err 5601 + } 5602 + 5603 + t.LexiconTypeID = string(sval) 5604 + } 5605 + // t.Subject (string) (string) 5606 + case "subject": 5607 + 5608 + { 5609 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5610 + if err != nil { 5611 + return err 5612 + } 5613 + 5614 + t.Subject = string(sval) 5834 5615 } 5835 5616 // t.CreatedAt (string) (string) 5836 5617 case "createdAt": ··· 8225 8006 8226 8007 return nil 8227 8008 } 8009 + func (t *String) MarshalCBOR(w io.Writer) error { 8010 + if t == nil { 8011 + _, err := w.Write(cbg.CborNull) 8012 + return err 8013 + } 8014 + 8015 + cw := cbg.NewCborWriter(w) 8016 + 8017 + if _, err := cw.Write([]byte{165}); err != nil { 8018 + return err 8019 + } 8020 + 8021 + // t.LexiconTypeID (string) (string) 8022 + if len("$type") > 1000000 { 8023 + return xerrors.Errorf("Value in field \"$type\" was too long") 8024 + } 8025 + 8026 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 8027 + return err 8028 + } 8029 + if _, err := cw.WriteString(string("$type")); err != nil { 8030 + return err 8031 + } 8032 + 8033 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.string"))); err != nil { 8034 + return err 8035 + } 8036 + if _, err := cw.WriteString(string("sh.tangled.string")); err != nil { 8037 + return err 8038 + } 8039 + 8040 + // t.Contents (string) (string) 8041 + if len("contents") > 1000000 { 8042 + return xerrors.Errorf("Value in field \"contents\" was too long") 8043 + } 8044 + 8045 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("contents"))); err != nil { 8046 + return err 8047 + } 8048 + if _, err := cw.WriteString(string("contents")); err != nil { 8049 + return err 8050 + } 8051 + 8052 + if len(t.Contents) > 1000000 { 8053 + return xerrors.Errorf("Value in field t.Contents was too long") 8054 + } 8055 + 8056 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Contents))); err != nil { 8057 + return err 8058 + } 8059 + if _, err := cw.WriteString(string(t.Contents)); err != nil { 8060 + return err 8061 + } 8062 + 8063 + // t.Filename (string) (string) 8064 + if len("filename") > 1000000 { 8065 + return xerrors.Errorf("Value in field \"filename\" was too long") 8066 + } 8067 + 8068 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("filename"))); err != nil { 8069 + return err 8070 + } 8071 + if _, err := cw.WriteString(string("filename")); err != nil { 8072 + return err 8073 + } 8074 + 8075 + if len(t.Filename) > 1000000 { 8076 + return xerrors.Errorf("Value in field t.Filename was too long") 8077 + } 8078 + 8079 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Filename))); err != nil { 8080 + return err 8081 + } 8082 + if _, err := cw.WriteString(string(t.Filename)); err != nil { 8083 + return err 8084 + } 8085 + 8086 + // t.CreatedAt (string) (string) 8087 + if len("createdAt") > 1000000 { 8088 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 8089 + } 8090 + 8091 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 8092 + return err 8093 + } 8094 + if _, err := cw.WriteString(string("createdAt")); err != nil { 8095 + return err 8096 + } 8097 + 8098 + if len(t.CreatedAt) > 1000000 { 8099 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 8100 + } 8101 + 8102 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 8103 + return err 8104 + } 8105 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 8106 + return err 8107 + } 8108 + 8109 + // t.Description (string) (string) 8110 + if len("description") > 1000000 { 8111 + return xerrors.Errorf("Value in field \"description\" was too long") 8112 + } 8113 + 8114 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil { 8115 + return err 8116 + } 8117 + if _, err := cw.WriteString(string("description")); err != nil { 8118 + return err 8119 + } 8120 + 8121 + if len(t.Description) > 1000000 { 8122 + return xerrors.Errorf("Value in field t.Description was too long") 8123 + } 8124 + 8125 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Description))); err != nil { 8126 + return err 8127 + } 8128 + if _, err := cw.WriteString(string(t.Description)); err != nil { 8129 + return err 8130 + } 8131 + return nil 8132 + } 8133 + 8134 + func (t *String) UnmarshalCBOR(r io.Reader) (err error) { 8135 + *t = String{} 8136 + 8137 + cr := cbg.NewCborReader(r) 8138 + 8139 + maj, extra, err := cr.ReadHeader() 8140 + if err != nil { 8141 + return err 8142 + } 8143 + defer func() { 8144 + if err == io.EOF { 8145 + err = io.ErrUnexpectedEOF 8146 + } 8147 + }() 8148 + 8149 + if maj != cbg.MajMap { 8150 + return fmt.Errorf("cbor input should be of type map") 8151 + } 8152 + 8153 + if extra > cbg.MaxLength { 8154 + return fmt.Errorf("String: map struct too large (%d)", extra) 8155 + } 8156 + 8157 + n := extra 8158 + 8159 + nameBuf := make([]byte, 11) 8160 + for i := uint64(0); i < n; i++ { 8161 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 8162 + if err != nil { 8163 + return err 8164 + } 8165 + 8166 + if !ok { 8167 + // Field doesn't exist on this type, so ignore it 8168 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 8169 + return err 8170 + } 8171 + continue 8172 + } 8173 + 8174 + switch string(nameBuf[:nameLen]) { 8175 + // t.LexiconTypeID (string) (string) 8176 + case "$type": 8177 + 8178 + { 8179 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8180 + if err != nil { 8181 + return err 8182 + } 8183 + 8184 + t.LexiconTypeID = string(sval) 8185 + } 8186 + // t.Contents (string) (string) 8187 + case "contents": 8188 + 8189 + { 8190 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8191 + if err != nil { 8192 + return err 8193 + } 8194 + 8195 + t.Contents = string(sval) 8196 + } 8197 + // t.Filename (string) (string) 8198 + case "filename": 8199 + 8200 + { 8201 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8202 + if err != nil { 8203 + return err 8204 + } 8205 + 8206 + t.Filename = string(sval) 8207 + } 8208 + // t.CreatedAt (string) (string) 8209 + case "createdAt": 8210 + 8211 + { 8212 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8213 + if err != nil { 8214 + return err 8215 + } 8216 + 8217 + t.CreatedAt = string(sval) 8218 + } 8219 + // t.Description (string) (string) 8220 + case "description": 8221 + 8222 + { 8223 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8224 + if err != nil { 8225 + return err 8226 + } 8227 + 8228 + t.Description = string(sval) 8229 + } 8230 + 8231 + default: 8232 + // Field doesn't exist on this type, so ignore it 8233 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 8234 + return err 8235 + } 8236 + } 8237 + } 8238 + 8239 + return nil 8240 + }
+25
api/tangled/repocollaborator.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.collaborator 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + RepoCollaboratorNSID = "sh.tangled.repo.collaborator" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.repo.collaborator", &RepoCollaborator{}) 17 + } // 18 + // RECORDTYPE: RepoCollaborator 19 + type RepoCollaborator struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.collaborator" cborgen:"$type,const=sh.tangled.repo.collaborator"` 21 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 22 + // repo: repo to add this user to 23 + Repo string `json:"repo" cborgen:"repo"` 24 + Subject string `json:"subject" cborgen:"subject"` 25 + }
+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 }
+25
api/tangled/tangledstring.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.string 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + StringNSID = "sh.tangled.string" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.string", &String{}) 17 + } // 18 + // RECORDTYPE: String 19 + type String struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.string" cborgen:"$type,const=sh.tangled.string"` 21 + Contents string `json:"contents" cborgen:"contents"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Description string `json:"description" cborgen:"description"` 24 + Filename string `json:"filename" cborgen:"filename"` 25 + }
+1
appview/cache/session/store.go
··· 31 31 PkceVerifier string 32 32 DpopAuthserverNonce string 33 33 DpopPrivateJwk string 34 + ReturnUrl string 34 35 } 35 36 36 37 type SessionStore struct {
+21 -5
appview/config/config.go
··· 10 10 ) 11 11 12 12 type CoreConfig struct { 13 - CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"` 14 - DbPath string `env:"DB_PATH, default=appview.db"` 15 - ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"` 16 - AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"` 17 - Dev bool `env:"DEV, default=false"` 13 + CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"` 14 + DbPath string `env:"DB_PATH, default=appview.db"` 15 + ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"` 16 + AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"` 17 + Dev bool `env:"DEV, default=false"` 18 + DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` 19 + 20 + // temporarily, to add users to default knot and spindle 21 + AppPassword string `env:"APP_PASSWORD"` 18 22 } 19 23 20 24 type OAuthConfig struct { ··· 59 63 DB int `env:"DB, default=0"` 60 64 } 61 65 66 + type PdsConfig struct { 67 + Host string `env:"HOST, default=https://tngl.sh"` 68 + AdminSecret string `env:"ADMIN_SECRET"` 69 + } 70 + 71 + type Cloudflare struct { 72 + ApiToken string `env:"API_TOKEN"` 73 + ZoneId string `env:"ZONE_ID"` 74 + } 75 + 62 76 func (cfg RedisConfig) ToURL() string { 63 77 u := &url.URL{ 64 78 Scheme: "redis", ··· 84 98 Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 85 99 OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 86 100 Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` 101 + Pds PdsConfig `env:",prefix=TANGLED_PDS_"` 102 + Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` 87 103 } 88 104 89 105 func LoadConfig(ctx context.Context) (*Config, error) {
+76
appview/db/collaborators.go
··· 1 + package db 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + "time" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + ) 10 + 11 + type Collaborator struct { 12 + // identifiers for the record 13 + Id int64 14 + Did syntax.DID 15 + Rkey string 16 + 17 + // content 18 + SubjectDid syntax.DID 19 + RepoAt syntax.ATURI 20 + 21 + // meta 22 + Created time.Time 23 + } 24 + 25 + func AddCollaborator(e Execer, c Collaborator) error { 26 + _, err := e.Exec( 27 + `insert into collaborators (did, rkey, subject_did, repo_at) values (?, ?, ?, ?);`, 28 + c.Did, c.Rkey, c.SubjectDid, c.RepoAt, 29 + ) 30 + return err 31 + } 32 + 33 + func DeleteCollaborator(e Execer, filters ...filter) error { 34 + var conditions []string 35 + var args []any 36 + for _, filter := range filters { 37 + conditions = append(conditions, filter.Condition()) 38 + args = append(args, filter.Arg()...) 39 + } 40 + 41 + whereClause := "" 42 + if conditions != nil { 43 + whereClause = " where " + strings.Join(conditions, " and ") 44 + } 45 + 46 + query := fmt.Sprintf(`delete from collaborators %s`, whereClause) 47 + 48 + _, err := e.Exec(query, args...) 49 + return err 50 + } 51 + 52 + func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) { 53 + rows, err := e.Query(`select repo_at from collaborators where subject_did = ?`, collaborator) 54 + if err != nil { 55 + return nil, err 56 + } 57 + defer rows.Close() 58 + 59 + var repoAts []string 60 + for rows.Next() { 61 + var aturi string 62 + err := rows.Scan(&aturi) 63 + if err != nil { 64 + return nil, err 65 + } 66 + repoAts = append(repoAts, aturi) 67 + } 68 + if err := rows.Err(); err != nil { 69 + return nil, err 70 + } 71 + if repoAts == nil { 72 + return nil, nil 73 + } 74 + 75 + return GetRepos(e, 0, FilterIn("at_uri", repoAts)) 76 + }
+152 -27
appview/db/db.go
··· 27 27 } 28 28 29 29 func Make(dbPath string) (*DB, error) { 30 - db, err := sql.Open("sqlite3", dbPath) 30 + // https://github.com/mattn/go-sqlite3#connection-string 31 + opts := []string{ 32 + "_foreign_keys=1", 33 + "_journal_mode=WAL", 34 + "_synchronous=NORMAL", 35 + "_auto_vacuum=incremental", 36 + } 37 + 38 + db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 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, ··· 355 363 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 356 364 357 365 -- constraints 358 - foreign key (did, instance) references spindles(owner, instance) on delete cascade, 359 366 unique (did, instance, subject) 360 367 ); 361 368 ··· 437 444 unique(repo_at, ref, language) 438 445 ); 439 446 447 + create table if not exists signups_inflight ( 448 + id integer primary key autoincrement, 449 + email text not null unique, 450 + invite_code text not null, 451 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) 452 + ); 453 + 454 + create table if not exists strings ( 455 + -- identifiers 456 + did text not null, 457 + rkey text not null, 458 + 459 + -- content 460 + filename text not null, 461 + description text, 462 + content text not null, 463 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 464 + edited text, 465 + 466 + primary key (did, rkey) 467 + ); 468 + 440 469 create table if not exists migrations ( 441 470 id integer primary key autoincrement, 442 471 name text unique 443 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); 444 477 `) 445 478 if err != nil { 446 479 return nil, err 447 480 } 448 481 449 482 // run migrations 450 - runMigration(db, "add-description-to-repos", func(tx *sql.Tx) error { 483 + runMigration(conn, "add-description-to-repos", func(tx *sql.Tx) error { 451 484 tx.Exec(` 452 485 alter table repos add column description text check (length(description) <= 200); 453 486 `) 454 487 return nil 455 488 }) 456 489 457 - runMigration(db, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 490 + runMigration(conn, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 458 491 // add unconstrained column 459 492 _, err := tx.Exec(` 460 493 alter table public_keys ··· 477 510 return nil 478 511 }) 479 512 480 - runMigration(db, "add-rkey-to-comments", func(tx *sql.Tx) error { 513 + runMigration(conn, "add-rkey-to-comments", func(tx *sql.Tx) error { 481 514 _, err := tx.Exec(` 482 515 alter table comments drop column comment_at; 483 516 alter table comments add column rkey text; ··· 485 518 return err 486 519 }) 487 520 488 - 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 { 489 522 _, err := tx.Exec(` 490 523 alter table comments add column deleted text; -- timestamp 491 524 alter table comments add column edited text; -- timestamp ··· 493 526 return err 494 527 }) 495 528 496 - 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 { 497 530 _, err := tx.Exec(` 498 531 alter table pulls add column source_branch text; 499 532 alter table pulls add column source_repo_at text; ··· 502 535 return err 503 536 }) 504 537 505 - runMigration(db, "add-source-to-repos", func(tx *sql.Tx) error { 538 + runMigration(conn, "add-source-to-repos", func(tx *sql.Tx) error { 506 539 _, err := tx.Exec(` 507 540 alter table repos add column source text; 508 541 `) ··· 513 546 // NOTE: this cannot be done in a transaction, so it is run outside [0] 514 547 // 515 548 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys 516 - db.Exec("pragma foreign_keys = off;") 517 - 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 { 518 551 _, err := tx.Exec(` 519 552 create table pulls_new ( 520 553 -- identifiers ··· 569 602 `) 570 603 return err 571 604 }) 572 - db.Exec("pragma foreign_keys = on;") 605 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 573 606 574 607 // run migrations 575 - runMigration(db, "add-spindle-to-repos", func(tx *sql.Tx) error { 608 + runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error { 576 609 tx.Exec(` 577 610 alter table repos add column spindle text; 578 611 `) 579 612 return nil 580 613 }) 581 614 615 + // drop all knot secrets, add unique constraint to knots 616 + // 617 + // knots will henceforth use service auth for signed requests 618 + runMigration(conn, "no-more-secrets", func(tx *sql.Tx) error { 619 + _, err := tx.Exec(` 620 + create table registrations_new ( 621 + id integer primary key autoincrement, 622 + domain text not null, 623 + did text not null, 624 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 625 + registered text, 626 + read_only integer not null default 0, 627 + unique(domain, did) 628 + ); 629 + 630 + insert into registrations_new (id, domain, did, created, registered, read_only) 631 + select id, domain, did, created, registered, 1 from registrations 632 + where registered is not null; 633 + 634 + drop table registrations; 635 + alter table registrations_new rename to registrations; 636 + `) 637 + return err 638 + }) 639 + 640 + // recreate and add rkey + created columns with default constraint 641 + runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error { 642 + // create new table 643 + // - repo_at instead of repo integer 644 + // - rkey field 645 + // - created field 646 + _, err := tx.Exec(` 647 + create table collaborators_new ( 648 + -- identifiers for the record 649 + id integer primary key autoincrement, 650 + did text not null, 651 + rkey text, 652 + 653 + -- content 654 + subject_did text not null, 655 + repo_at text not null, 656 + 657 + -- meta 658 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 659 + 660 + -- constraints 661 + foreign key (repo_at) references repos(at_uri) on delete cascade 662 + ) 663 + `) 664 + if err != nil { 665 + return err 666 + } 667 + 668 + // copy data 669 + _, err = tx.Exec(` 670 + insert into collaborators_new (id, did, rkey, subject_did, repo_at) 671 + select 672 + c.id, 673 + r.did, 674 + '', 675 + c.did, 676 + r.at_uri 677 + from collaborators c 678 + join repos r on c.repo = r.id 679 + `) 680 + if err != nil { 681 + return err 682 + } 683 + 684 + // drop old table 685 + _, err = tx.Exec(`drop table collaborators`) 686 + if err != nil { 687 + return err 688 + } 689 + 690 + // rename new table 691 + _, err = tx.Exec(`alter table collaborators_new rename to collaborators`) 692 + return err 693 + }) 694 + 695 + runMigration(conn, "add-rkey-to-issues", func(tx *sql.Tx) error { 696 + _, err := tx.Exec(` 697 + alter table issues add column rkey text not null default ''; 698 + 699 + -- get last url section from issue_at and save to rkey column 700 + update issues 701 + set rkey = replace(issue_at, rtrim(issue_at, replace(issue_at, '/', '')), ''); 702 + `) 703 + return err 704 + }) 705 + 582 706 return &DB{db}, nil 583 707 } 584 708 585 709 type migrationFn = func(*sql.Tx) error 586 710 587 - func runMigration(d *sql.DB, name string, migrationFn migrationFn) error { 588 - tx, err := d.Begin() 711 + func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error { 712 + tx, err := c.BeginTx(context.Background(), nil) 589 713 if err != nil { 590 714 return err 591 715 } ··· 652 776 kind := rv.Kind() 653 777 654 778 // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)` 655 - if kind == reflect.Slice || kind == reflect.Array { 779 + if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 656 780 if rv.Len() == 0 { 657 - panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key)) 781 + // always false 782 + return "1 = 0" 658 783 } 659 784 660 785 placeholders := make([]string, rv.Len()) ··· 671 796 func (f filter) Arg() []any { 672 797 rv := reflect.ValueOf(f.arg) 673 798 kind := rv.Kind() 674 - if kind == reflect.Slice || kind == reflect.Array { 799 + if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 675 800 if rv.Len() == 0 { 676 - panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key)) 801 + return nil 677 802 } 678 803 679 804 out := make([]any, rv.Len())
+16 -2
appview/db/email.go
··· 103 103 query := ` 104 104 select email, did 105 105 from emails 106 - where 107 - verified = ? 106 + where 107 + verified = ? 108 108 and email in (` + strings.Join(placeholders, ",") + `) 109 109 ` 110 110 ··· 153 153 ` 154 154 var count int 155 155 err := e.QueryRow(query, did, email).Scan(&count) 156 + if err != nil { 157 + return false, err 158 + } 159 + return count > 0, nil 160 + } 161 + 162 + func CheckEmailExistsAtAll(e Execer, email string) (bool, error) { 163 + query := ` 164 + select count(*) 165 + from emails 166 + where email = ? 167 + ` 168 + var count int 169 + err := e.QueryRow(query, email).Scan(&count) 156 170 if err != nil { 157 171 return false, err 158 172 }
+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 -45
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 } ··· 550 549 return &repo, nil 551 550 } 552 551 553 - func AddCollaborator(e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error { 554 - _, err := e.Exec( 555 - `insert into collaborators (did, repo) 556 - values (?, (select id from repos where did = ? and name = ? and knot = ?));`, 557 - collaborator, repoOwnerDid, repoName, repoKnot) 558 - return err 559 - } 560 - 561 552 func UpdateDescription(e Execer, repoAt, newDescription string) error { 562 553 _, err := e.Exec( 563 554 `update repos set description = ? where at_uri = ?`, newDescription, repoAt) 564 555 return err 565 556 } 566 557 567 - func UpdateSpindle(e Execer, repoAt, spindle string) error { 558 + func UpdateSpindle(e Execer, repoAt string, spindle *string) error { 568 559 _, err := e.Exec( 569 560 `update repos set spindle = ? where at_uri = ?`, spindle, repoAt) 570 561 return err 571 - } 572 - 573 - func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) { 574 - rows, err := e.Query(`select repo from collaborators where did = ?`, collaborator) 575 - if err != nil { 576 - return nil, err 577 - } 578 - defer rows.Close() 579 - 580 - var repoIds []int 581 - for rows.Next() { 582 - var id int 583 - err := rows.Scan(&id) 584 - if err != nil { 585 - return nil, err 586 - } 587 - repoIds = append(repoIds, id) 588 - } 589 - if err := rows.Err(); err != nil { 590 - return nil, err 591 - } 592 - if repoIds == nil { 593 - return nil, nil 594 - } 595 - 596 - return GetRepos(e, 0, FilterIn("id", repoIds)) 597 562 } 598 563 599 564 type RepoStats struct {
+29
appview/db/signup.go
··· 1 + package db 2 + 3 + import "time" 4 + 5 + type InflightSignup struct { 6 + Id int64 7 + Email string 8 + InviteCode string 9 + Created time.Time 10 + } 11 + 12 + func AddInflightSignup(e Execer, signup InflightSignup) error { 13 + query := `insert into signups_inflight (email, invite_code) values (?, ?)` 14 + _, err := e.Exec(query, signup.Email, signup.InviteCode) 15 + return err 16 + } 17 + 18 + func DeleteInflightSignup(e Execer, email string) error { 19 + query := `delete from signups_inflight where email = ?` 20 + _, err := e.Exec(query, email) 21 + return err 22 + } 23 + 24 + func GetEmailForCode(e Execer, inviteCode string) (string, error) { 25 + query := `select email from signups_inflight where invite_code = ?` 26 + var email string 27 + err := e.QueryRow(query, inviteCode).Scan(&email) 28 + return email, err 29 + }
+73 -6
appview/db/star.go
··· 47 47 // Get a star record 48 48 func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) { 49 49 query := ` 50 - select starred_by_did, repo_at, created, rkey 50 + select starred_by_did, repo_at, created, rkey 51 51 from stars 52 52 where starred_by_did = ? and repo_at = ?` 53 53 row := e.QueryRow(query, starredByDid, repoAt) ··· 119 119 } 120 120 121 121 repoQuery := fmt.Sprintf( 122 - `select starred_by_did, repo_at, created, rkey 122 + `select starred_by_did, repo_at, created, rkey 123 123 from stars 124 124 %s 125 125 order by created desc ··· 187 187 var stars []Star 188 188 189 189 rows, err := e.Query(` 190 - select 190 + select 191 191 s.starred_by_did, 192 192 s.repo_at, 193 193 s.rkey, ··· 196 196 r.name, 197 197 r.knot, 198 198 r.rkey, 199 - r.created, 200 - r.at_uri 199 + r.created 201 200 from stars s 202 201 join repos r on s.repo_at = r.at_uri 203 202 `) ··· 222 221 &repo.Knot, 223 222 &repo.Rkey, 224 223 &repoCreatedAt, 225 - &repo.AtUri, 226 224 ); err != nil { 227 225 return nil, err 228 226 } ··· 246 244 247 245 return stars, nil 248 246 } 247 + 248 + // GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week 249 + func GetTopStarredReposLastWeek(e Execer) ([]Repo, error) { 250 + // first, get the top repo URIs by star count from the last week 251 + query := ` 252 + with recent_starred_repos as ( 253 + select distinct repo_at 254 + from stars 255 + where created >= datetime('now', '-7 days') 256 + ), 257 + repo_star_counts as ( 258 + select 259 + s.repo_at, 260 + count(*) as star_count 261 + from stars s 262 + join recent_starred_repos rsr on s.repo_at = rsr.repo_at 263 + group by s.repo_at 264 + ) 265 + select rsc.repo_at 266 + from repo_star_counts rsc 267 + order by rsc.star_count desc 268 + limit 8 269 + ` 270 + 271 + rows, err := e.Query(query) 272 + if err != nil { 273 + return nil, err 274 + } 275 + defer rows.Close() 276 + 277 + var repoUris []string 278 + for rows.Next() { 279 + var repoUri string 280 + err := rows.Scan(&repoUri) 281 + if err != nil { 282 + return nil, err 283 + } 284 + repoUris = append(repoUris, repoUri) 285 + } 286 + 287 + if err := rows.Err(); err != nil { 288 + return nil, err 289 + } 290 + 291 + if len(repoUris) == 0 { 292 + return []Repo{}, nil 293 + } 294 + 295 + // get full repo data 296 + repos, err := GetRepos(e, 0, FilterIn("at_uri", repoUris)) 297 + if err != nil { 298 + return nil, err 299 + } 300 + 301 + // sort repos by the original trending order 302 + repoMap := make(map[string]Repo) 303 + for _, repo := range repos { 304 + repoMap[repo.RepoAt().String()] = repo 305 + } 306 + 307 + orderedRepos := make([]Repo, 0, len(repoUris)) 308 + for _, uri := range repoUris { 309 + if repo, exists := repoMap[uri]; exists { 310 + orderedRepos = append(orderedRepos, repo) 311 + } 312 + } 313 + 314 + return orderedRepos, nil 315 + }
+252
appview/db/strings.go
··· 1 + package db 2 + 3 + import ( 4 + "bytes" 5 + "database/sql" 6 + "errors" 7 + "fmt" 8 + "io" 9 + "strings" 10 + "time" 11 + "unicode/utf8" 12 + 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + "tangled.sh/tangled.sh/core/api/tangled" 15 + ) 16 + 17 + type String struct { 18 + Did syntax.DID 19 + Rkey string 20 + 21 + Filename string 22 + Description string 23 + Contents string 24 + Created time.Time 25 + Edited *time.Time 26 + } 27 + 28 + func (s *String) StringAt() syntax.ATURI { 29 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey)) 30 + } 31 + 32 + type StringStats struct { 33 + LineCount uint64 34 + ByteCount uint64 35 + } 36 + 37 + func (s String) Stats() StringStats { 38 + lineCount, err := countLines(strings.NewReader(s.Contents)) 39 + if err != nil { 40 + // non-fatal 41 + // TODO: log this? 42 + } 43 + 44 + return StringStats{ 45 + LineCount: uint64(lineCount), 46 + ByteCount: uint64(len(s.Contents)), 47 + } 48 + } 49 + 50 + func (s String) Validate() error { 51 + var err error 52 + 53 + if utf8.RuneCountInString(s.Filename) > 140 { 54 + err = errors.Join(err, fmt.Errorf("filename too long")) 55 + } 56 + 57 + if utf8.RuneCountInString(s.Description) > 280 { 58 + err = errors.Join(err, fmt.Errorf("description too long")) 59 + } 60 + 61 + if len(s.Contents) == 0 { 62 + err = errors.Join(err, fmt.Errorf("contents is empty")) 63 + } 64 + 65 + return err 66 + } 67 + 68 + func (s *String) AsRecord() tangled.String { 69 + return tangled.String{ 70 + Filename: s.Filename, 71 + Description: s.Description, 72 + Contents: s.Contents, 73 + CreatedAt: s.Created.Format(time.RFC3339), 74 + } 75 + } 76 + 77 + func StringFromRecord(did, rkey string, record tangled.String) String { 78 + created, err := time.Parse(record.CreatedAt, time.RFC3339) 79 + if err != nil { 80 + created = time.Now() 81 + } 82 + return String{ 83 + Did: syntax.DID(did), 84 + Rkey: rkey, 85 + Filename: record.Filename, 86 + Description: record.Description, 87 + Contents: record.Contents, 88 + Created: created, 89 + } 90 + } 91 + 92 + func AddString(e Execer, s String) error { 93 + _, err := e.Exec( 94 + `insert into strings ( 95 + did, 96 + rkey, 97 + filename, 98 + description, 99 + content, 100 + created, 101 + edited 102 + ) 103 + values (?, ?, ?, ?, ?, ?, null) 104 + on conflict(did, rkey) do update set 105 + filename = excluded.filename, 106 + description = excluded.description, 107 + content = excluded.content, 108 + edited = case 109 + when 110 + strings.content != excluded.content 111 + or strings.filename != excluded.filename 112 + or strings.description != excluded.description then ? 113 + else strings.edited 114 + end`, 115 + s.Did, 116 + s.Rkey, 117 + s.Filename, 118 + s.Description, 119 + s.Contents, 120 + s.Created.Format(time.RFC3339), 121 + time.Now().Format(time.RFC3339), 122 + ) 123 + return err 124 + } 125 + 126 + func GetStrings(e Execer, limit int, filters ...filter) ([]String, error) { 127 + var all []String 128 + 129 + var conditions []string 130 + var args []any 131 + for _, filter := range filters { 132 + conditions = append(conditions, filter.Condition()) 133 + args = append(args, filter.Arg()...) 134 + } 135 + 136 + whereClause := "" 137 + if conditions != nil { 138 + whereClause = " where " + strings.Join(conditions, " and ") 139 + } 140 + 141 + limitClause := "" 142 + if limit != 0 { 143 + limitClause = fmt.Sprintf(" limit %d ", limit) 144 + } 145 + 146 + query := fmt.Sprintf(`select 147 + did, 148 + rkey, 149 + filename, 150 + description, 151 + content, 152 + created, 153 + edited 154 + from strings 155 + %s 156 + order by created desc 157 + %s`, 158 + whereClause, 159 + limitClause, 160 + ) 161 + 162 + rows, err := e.Query(query, args...) 163 + 164 + if err != nil { 165 + return nil, err 166 + } 167 + defer rows.Close() 168 + 169 + for rows.Next() { 170 + var s String 171 + var createdAt string 172 + var editedAt sql.NullString 173 + 174 + if err := rows.Scan( 175 + &s.Did, 176 + &s.Rkey, 177 + &s.Filename, 178 + &s.Description, 179 + &s.Contents, 180 + &createdAt, 181 + &editedAt, 182 + ); err != nil { 183 + return nil, err 184 + } 185 + 186 + s.Created, err = time.Parse(time.RFC3339, createdAt) 187 + if err != nil { 188 + s.Created = time.Now() 189 + } 190 + 191 + if editedAt.Valid { 192 + e, err := time.Parse(time.RFC3339, editedAt.String) 193 + if err != nil { 194 + e = time.Now() 195 + } 196 + s.Edited = &e 197 + } 198 + 199 + all = append(all, s) 200 + } 201 + 202 + if err := rows.Err(); err != nil { 203 + return nil, err 204 + } 205 + 206 + return all, nil 207 + } 208 + 209 + func DeleteString(e Execer, filters ...filter) error { 210 + var conditions []string 211 + var args []any 212 + for _, filter := range filters { 213 + conditions = append(conditions, filter.Condition()) 214 + args = append(args, filter.Arg()...) 215 + } 216 + 217 + whereClause := "" 218 + if conditions != nil { 219 + whereClause = " where " + strings.Join(conditions, " and ") 220 + } 221 + 222 + query := fmt.Sprintf(`delete from strings %s`, whereClause) 223 + 224 + _, err := e.Exec(query, args...) 225 + return err 226 + } 227 + 228 + func countLines(r io.Reader) (int, error) { 229 + buf := make([]byte, 32*1024) 230 + bufLen := 0 231 + count := 0 232 + nl := []byte{'\n'} 233 + 234 + for { 235 + c, err := r.Read(buf) 236 + if c > 0 { 237 + bufLen += c 238 + } 239 + count += bytes.Count(buf[:c], nl) 240 + 241 + switch { 242 + case err == io.EOF: 243 + /* handle last line not having a newline at the end */ 244 + if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { 245 + count++ 246 + } 247 + return count, nil 248 + case err != nil: 249 + return 0, err 250 + } 251 + } 252 + }
+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 })
+53
appview/dns/cloudflare.go
··· 1 + package dns 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "github.com/cloudflare/cloudflare-go" 8 + "tangled.sh/tangled.sh/core/appview/config" 9 + ) 10 + 11 + type Record struct { 12 + Type string 13 + Name string 14 + Content string 15 + TTL int 16 + Proxied bool 17 + } 18 + 19 + type Cloudflare struct { 20 + api *cloudflare.API 21 + zone string 22 + } 23 + 24 + func NewCloudflare(c *config.Config) (*Cloudflare, error) { 25 + apiToken := c.Cloudflare.ApiToken 26 + api, err := cloudflare.NewWithAPIToken(apiToken) 27 + if err != nil { 28 + return nil, err 29 + } 30 + return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil 31 + } 32 + 33 + func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) error { 34 + _, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{ 35 + Type: record.Type, 36 + Name: record.Name, 37 + Content: record.Content, 38 + TTL: record.TTL, 39 + Proxied: &record.Proxied, 40 + }) 41 + if err != nil { 42 + return fmt.Errorf("failed to create DNS record: %w", err) 43 + } 44 + return nil 45 + } 46 + 47 + func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error { 48 + err := cf.api.DeleteDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), recordID) 49 + if err != nil { 50 + return fmt.Errorf("failed to delete DNS record: %w", err) 51 + } 52 + return nil 53 + }
+235 -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) 71 + case tangled.StringNSID: 72 + err = i.ingestString(e) 67 73 } 68 74 l = i.Logger.With("nsid", e.Commit.Collection) 69 75 } 70 76 71 77 if err != nil { 72 - l.Error("error ingesting record", "err", err) 78 + l.Debug("error ingesting record", "err", err) 73 79 } 74 80 75 - return err 81 + return nil 76 82 } 77 83 } 78 84 ··· 385 391 if err != nil { 386 392 return fmt.Errorf("failed to update ACLs: %w", err) 387 393 } 394 + 395 + l.Info("added spindle member") 388 396 case models.CommitOperationDelete: 389 397 rkey := e.Commit.RKey 390 398 ··· 431 439 if err = i.Enforcer.E.SavePolicy(); err != nil { 432 440 return fmt.Errorf("failed to save ACLs: %w", err) 433 441 } 442 + 443 + l.Info("removed spindle member") 434 444 } 435 445 436 446 return nil ··· 469 479 return err 470 480 } 471 481 472 - err = spindleverify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev) 482 + err = serververify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev) 473 483 if err != nil { 474 484 l.Error("failed to add spindle to db", "err", err, "instance", instance) 475 485 return err 476 486 } 477 487 478 - _, err = spindleverify.MarkVerified(ddb, i.Enforcer, instance, did) 488 + _, err = serververify.MarkSpindleVerified(ddb, i.Enforcer, instance, did) 479 489 if err != nil { 480 490 return fmt.Errorf("failed to mark verified: %w", err) 481 491 } ··· 510 520 i.Enforcer.E.LoadPolicy() 511 521 }() 512 522 523 + // remove spindle members first 524 + err = db.RemoveSpindleMember( 525 + tx, 526 + db.FilterEq("owner", did), 527 + db.FilterEq("instance", instance), 528 + ) 529 + if err != nil { 530 + return err 531 + } 532 + 513 533 err = db.DeleteSpindle( 514 534 tx, 515 535 db.FilterEq("owner", did), ··· 539 559 540 560 return nil 541 561 } 562 + 563 + func (i *Ingester) ingestString(e *models.Event) error { 564 + did := e.Did 565 + rkey := e.Commit.RKey 566 + 567 + var err error 568 + 569 + l := i.Logger.With("handler", "ingestString", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 570 + l.Info("ingesting record") 571 + 572 + ddb, ok := i.Db.Execer.(*db.DB) 573 + if !ok { 574 + return fmt.Errorf("failed to index string record, invalid db cast") 575 + } 576 + 577 + switch e.Commit.Operation { 578 + case models.CommitOperationCreate, models.CommitOperationUpdate: 579 + raw := json.RawMessage(e.Commit.Record) 580 + record := tangled.String{} 581 + err = json.Unmarshal(raw, &record) 582 + if err != nil { 583 + l.Error("invalid record", "err", err) 584 + return err 585 + } 586 + 587 + string := db.StringFromRecord(did, rkey, record) 588 + 589 + if err = string.Validate(); err != nil { 590 + l.Error("invalid record", "err", err) 591 + return err 592 + } 593 + 594 + if err = db.AddString(ddb, string); err != nil { 595 + l.Error("failed to add string", "err", err) 596 + return err 597 + } 598 + 599 + return nil 600 + 601 + case models.CommitOperationDelete: 602 + if err := db.DeleteString( 603 + ddb, 604 + db.FilterEq("did", did), 605 + db.FilterEq("rkey", rkey), 606 + ); err != nil { 607 + l.Error("failed to delete", "err", err) 608 + return fmt.Errorf("failed to delete string record: %w", err) 609 + } 610 + 611 + return nil 612 + } 613 + 614 + return nil 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 }
+19 -24
appview/middleware/middleware.go
··· 5 5 "fmt" 6 6 "log" 7 7 "net/http" 8 + "net/url" 8 9 "slices" 9 10 "strconv" 10 11 "strings" 11 - "time" 12 12 13 13 "github.com/bluesky-social/indigo/atproto/identity" 14 14 "github.com/go-chi/chi/v5" ··· 46 46 func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 47 47 return func(next http.Handler) http.Handler { 48 48 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 + returnURL := "/" 50 + if u, err := url.Parse(r.Header.Get("Referer")); err == nil { 51 + returnURL = u.RequestURI() 52 + } 53 + 54 + loginURL := fmt.Sprintf("/login?return_url=%s", url.QueryEscape(returnURL)) 55 + 49 56 redirectFunc := func(w http.ResponseWriter, r *http.Request) { 50 - http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) 57 + http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect) 51 58 } 52 59 if r.Header.Get("HX-Request") == "true" { 53 60 redirectFunc = func(w http.ResponseWriter, _ *http.Request) { 54 - w.Header().Set("HX-Redirect", "/login") 61 + w.Header().Set("HX-Redirect", loginURL) 55 62 w.WriteHeader(http.StatusOK) 56 63 } 57 64 } ··· 167 174 } 168 175 } 169 176 170 - func StripLeadingAt(next http.Handler) http.Handler { 171 - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 172 - path := req.URL.EscapedPath() 173 - if strings.HasPrefix(path, "/@") { 174 - req.URL.RawPath = "/" + strings.TrimPrefix(path, "/@") 175 - } 176 - next.ServeHTTP(w, req) 177 - }) 178 - } 179 - 180 177 func (mw Middleware) ResolveIdent() middlewareFunc { 181 178 excluded := []string{"favicon.ico"} 182 179 ··· 188 185 return 189 186 } 190 187 188 + didOrHandle = strings.TrimPrefix(didOrHandle, "@") 189 + 191 190 id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) 192 191 if err != nil { 193 192 // invalid did or handle 194 - log.Println("failed to resolve did/handle:", err) 193 + log.Printf("failed to resolve did/handle '%s': %s\n", didOrHandle, err) 195 194 mw.pages.Error404(w) 196 195 return 197 196 } ··· 218 217 if err != nil { 219 218 // invalid did or handle 220 219 log.Println("failed to resolve repo") 221 - mw.pages.Error404(w) 220 + mw.pages.ErrorKnot404(w) 222 221 return 223 222 } 224 223 225 - ctx := context.WithValue(req.Context(), "knot", repo.Knot) 226 - ctx = context.WithValue(ctx, "repoAt", repo.AtUri) 227 - ctx = context.WithValue(ctx, "repoDescription", repo.Description) 228 - ctx = context.WithValue(ctx, "repoSpindle", repo.Spindle) 229 - ctx = context.WithValue(ctx, "repoAddedAt", repo.Created.Format(time.RFC3339)) 224 + ctx := context.WithValue(req.Context(), "repo", repo) 230 225 next.ServeHTTP(w, req.WithContext(ctx)) 231 226 }) 232 227 } ··· 239 234 f, err := mw.repoResolver.Resolve(r) 240 235 if err != nil { 241 236 log.Println("failed to fully resolve repo", err) 242 - http.Error(w, "invalid repo url", http.StatusNotFound) 237 + mw.pages.ErrorKnot404(w) 243 238 return 244 239 } 245 240 ··· 251 246 return 252 247 } 253 248 254 - pr, err := db.GetPull(mw.db, f.RepoAt, prIdInt) 249 + pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt) 255 250 if err != nil { 256 251 log.Println("failed to get pull and comments", err) 257 252 return ··· 288 283 f, err := mw.repoResolver.Resolve(r) 289 284 if err != nil { 290 285 log.Println("failed to fully resolve repo", err) 291 - http.Error(w, "invalid repo url", http.StatusNotFound) 286 + mw.pages.ErrorKnot404(w) 292 287 return 293 288 } 294 289 295 - fullName := f.OwnerHandle() + "/" + f.RepoName 290 + fullName := f.OwnerHandle() + "/" + f.Name 296 291 297 292 if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 298 293 if r.URL.Query().Get("go-get") == "1" {
+189 -17
appview/oauth/handler/handler.go
··· 1 1 package oauth 2 2 3 3 import ( 4 + "bytes" 5 + "context" 4 6 "encoding/json" 5 7 "fmt" 6 8 "log" 7 9 "net/http" 8 10 "net/url" 11 + "slices" 9 12 "strings" 13 + "time" 10 14 11 15 "github.com/go-chi/chi/v5" 12 16 "github.com/gorilla/sessions" 13 17 "github.com/lestrrat-go/jwx/v2/jwk" 14 18 "github.com/posthog/posthog-go" 15 19 "tangled.sh/icyphox.sh/atproto-oauth/helpers" 20 + tangled "tangled.sh/tangled.sh/core/api/tangled" 16 21 sessioncache "tangled.sh/tangled.sh/core/appview/cache/session" 17 22 "tangled.sh/tangled.sh/core/appview/config" 18 23 "tangled.sh/tangled.sh/core/appview/db" ··· 21 26 "tangled.sh/tangled.sh/core/appview/oauth/client" 22 27 "tangled.sh/tangled.sh/core/appview/pages" 23 28 "tangled.sh/tangled.sh/core/idresolver" 24 - "tangled.sh/tangled.sh/core/knotclient" 25 29 "tangled.sh/tangled.sh/core/rbac" 30 + "tangled.sh/tangled.sh/core/tid" 26 31 ) 27 32 28 33 const ( ··· 104 109 func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) { 105 110 switch r.Method { 106 111 case http.MethodGet: 107 - o.pages.Login(w, pages.LoginParams{}) 112 + returnURL := r.URL.Query().Get("return_url") 113 + o.pages.Login(w, pages.LoginParams{ 114 + ReturnUrl: returnURL, 115 + }) 108 116 case http.MethodPost: 109 117 handle := r.FormValue("handle") 110 118 ··· 189 197 DpopAuthserverNonce: parResp.DpopAuthserverNonce, 190 198 DpopPrivateJwk: string(dpopKeyJson), 191 199 State: parResp.State, 200 + ReturnUrl: r.FormValue("return_url"), 192 201 }) 193 202 if err != nil { 194 203 log.Println("failed to save oauth request:", err) ··· 244 253 return 245 254 } 246 255 256 + if iss != oauthRequest.AuthserverIss { 257 + log.Println("mismatched iss:", iss, "!=", oauthRequest.AuthserverIss, "for state:", state) 258 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 259 + return 260 + } 261 + 247 262 self := o.oauth.ClientMetadata() 248 263 249 264 oauthClient, err := client.NewClient( ··· 294 309 295 310 log.Println("session saved successfully") 296 311 go o.addToDefaultKnot(oauthRequest.Did) 312 + go o.addToDefaultSpindle(oauthRequest.Did) 297 313 298 314 if !o.config.Core.Dev { 299 315 err = o.posthog.Enqueue(posthog.Capture{ ··· 305 321 } 306 322 } 307 323 308 - http.Redirect(w, r, "/", http.StatusFound) 324 + returnUrl := oauthRequest.ReturnUrl 325 + if returnUrl == "" { 326 + returnUrl = "/" 327 + } 328 + 329 + http.Redirect(w, r, returnUrl, http.StatusFound) 309 330 } 310 331 311 332 func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) { ··· 332 353 return pubKey, nil 333 354 } 334 355 335 - func (o *OAuthHandler) addToDefaultKnot(did string) { 336 - defaultKnot := "knot1.tangled.sh" 356 + var ( 357 + tangledHandle = "tangled.sh" 358 + tangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli" 359 + defaultSpindle = "spindle.tangled.sh" 360 + defaultKnot = "knot1.tangled.sh" 361 + ) 337 362 338 - log.Printf("adding %s to default knot", did) 339 - err := o.enforcer.AddKnotMember(defaultKnot, did) 363 + func (o *OAuthHandler) addToDefaultSpindle(did string) { 364 + // use the tangled.sh app password to get an accessJwt 365 + // and create an sh.tangled.spindle.member record with that 366 + spindleMembers, err := db.GetSpindleMembers( 367 + o.db, 368 + db.FilterEq("instance", "spindle.tangled.sh"), 369 + db.FilterEq("subject", did), 370 + ) 340 371 if err != nil { 341 - log.Println("failed to add user to knot1.tangled.sh: ", err) 372 + log.Printf("failed to get spindle members for did %s: %v", did, err) 373 + return 374 + } 375 + 376 + if len(spindleMembers) != 0 { 377 + log.Printf("did %s is already a member of the default spindle", did) 342 378 return 343 379 } 344 - err = o.enforcer.E.SavePolicy() 380 + 381 + log.Printf("adding %s to default spindle", did) 382 + session, err := o.createAppPasswordSession() 345 383 if err != nil { 346 - log.Println("failed to add user to knot1.tangled.sh: ", err) 384 + log.Printf("failed to create session: %s", err) 347 385 return 348 386 } 349 387 350 - secret, err := db.GetRegistrationKey(o.db, defaultKnot) 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) 397 + return 398 + } 399 + 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) 351 408 if err != nil { 352 - log.Println("failed to get registration key for knot1.tangled.sh") 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) 353 415 return 354 416 } 355 - signedClient, err := knotclient.NewSignedClient(defaultKnot, secret, o.config.Core.Dev) 356 - resp, err := signedClient.AddMember(did) 417 + 418 + log.Printf("adding %s to default knot", did) 419 + session, err := o.createAppPasswordSession() 357 420 if err != nil { 358 - log.Println("failed to add user to knot1.tangled.sh: ", err) 421 + log.Printf("failed to create session: %s", err) 359 422 return 360 423 } 361 424 362 - if resp.StatusCode != http.StatusNoContent { 363 - log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode) 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) 364 434 return 365 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 + } 451 + 452 + resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid) 453 + if err != nil { 454 + return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", tangledDid, err) 455 + } 456 + 457 + pdsEndpoint := resolved.PDSEndpoint() 458 + if pdsEndpoint == "" { 459 + return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", tangledDid) 460 + } 461 + 462 + sessionPayload := map[string]string{ 463 + "identifier": tangledHandle, 464 + "password": appPassword, 465 + } 466 + sessionBytes, err := json.Marshal(sessionPayload) 467 + if err != nil { 468 + return nil, fmt.Errorf("failed to marshal session payload: %v", err) 469 + } 470 + 471 + sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 472 + sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 473 + if err != nil { 474 + return nil, fmt.Errorf("failed to create session request: %v", err) 475 + } 476 + sessionReq.Header.Set("Content-Type", "application/json") 477 + 478 + client := &http.Client{Timeout: 30 * time.Second} 479 + sessionResp, err := client.Do(sessionReq) 480 + if err != nil { 481 + return nil, fmt.Errorf("failed to create session: %v", err) 482 + } 483 + defer sessionResp.Body.Close() 484 + 485 + if sessionResp.StatusCode != http.StatusOK { 486 + return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 487 + } 488 + 489 + var session session 490 + if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 491 + return nil, fmt.Errorf("failed to decode session response: %v", err) 492 + } 493 + 494 + session.PdsEndpoint = pdsEndpoint 495 + 496 + return &session, nil 497 + } 498 + 499 + func (s *session) putRecord(record any) error { 500 + recordBytes, err := json.Marshal(record) 501 + if err != nil { 502 + return fmt.Errorf("failed to marshal knot member record: %w", err) 503 + } 504 + 505 + payload := map[string]any{ 506 + "repo": tangledDid, 507 + "collection": tangled.KnotMemberNSID, 508 + "rkey": tid.TID(), 509 + "record": json.RawMessage(recordBytes), 510 + } 511 + 512 + payloadBytes, err := json.Marshal(payload) 513 + if err != nil { 514 + return fmt.Errorf("failed to marshal request payload: %w", err) 515 + } 516 + 517 + url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 518 + req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 519 + if err != nil { 520 + return fmt.Errorf("failed to create HTTP request: %w", err) 521 + } 522 + 523 + req.Header.Set("Content-Type", "application/json") 524 + req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 525 + 526 + client := &http.Client{Timeout: 30 * time.Second} 527 + resp, err := client.Do(req) 528 + if err != nil { 529 + return fmt.Errorf("failed to add user to default Knot: %w", err) 530 + } 531 + defer resp.Body.Close() 532 + 533 + if resp.StatusCode != http.StatusOK { 534 + return fmt.Errorf("failed to add user to default Knot: HTTP %d", resp.StatusCode) 535 + } 536 + 537 + return nil 366 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 }
+2 -2
appview/pages/markup/camo.go
··· 9 9 "github.com/yuin/goldmark/ast" 10 10 ) 11 11 12 - func generateCamoURL(baseURL, secret, imageURL string) string { 12 + func GenerateCamoURL(baseURL, secret, imageURL string) string { 13 13 h := hmac.New(sha256.New, []byte(secret)) 14 14 h.Write([]byte(imageURL)) 15 15 signature := hex.EncodeToString(h.Sum(nil)) ··· 24 24 } 25 25 26 26 if rctx.CamoUrl != "" && rctx.CamoSecret != "" { 27 - return generateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst) 27 + return GenerateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst) 28 28 } 29 29 30 30 return dst
+61 -31
appview/pages/markup/markdown.go
··· 9 9 "path" 10 10 "strings" 11 11 12 - "github.com/microcosm-cc/bluemonday" 12 + chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 13 + "github.com/alecthomas/chroma/v2/styles" 13 14 "github.com/yuin/goldmark" 15 + highlighting "github.com/yuin/goldmark-highlighting/v2" 14 16 "github.com/yuin/goldmark/ast" 15 17 "github.com/yuin/goldmark/extension" 16 18 "github.com/yuin/goldmark/parser" ··· 40 42 repoinfo.RepoInfo 41 43 IsDev bool 42 44 RendererType RendererType 45 + Sanitizer Sanitizer 43 46 } 44 47 45 48 func (rctx *RenderContext) RenderMarkdown(source string) string { 46 49 md := goldmark.New( 47 - goldmark.WithExtensions(extension.GFM), 50 + goldmark.WithExtensions( 51 + extension.GFM, 52 + highlighting.NewHighlighting( 53 + highlighting.WithFormatOptions( 54 + chromahtml.Standalone(false), 55 + chromahtml.WithClasses(true), 56 + ), 57 + highlighting.WithCustomStyle(styles.Get("catppuccin-latte")), 58 + ), 59 + extension.NewFootnote( 60 + extension.WithFootnoteIDPrefix([]byte("footnote")), 61 + ), 62 + ), 48 63 goldmark.WithParserOptions( 49 64 parser.WithAutoHeadingID(), 50 65 ), ··· 145 160 } 146 161 } 147 162 148 - func (rctx *RenderContext) Sanitize(html string) string { 149 - policy := bluemonday.UGCPolicy() 150 - 151 - // video 152 - policy.AllowElements("video") 153 - policy.AllowAttrs("controls").OnElements("video") 154 - policy.AllowElements("source") 155 - policy.AllowAttrs("src", "type").OnElements("source") 156 - 157 - // centering content 158 - policy.AllowElements("center") 163 + func (rctx *RenderContext) SanitizeDefault(html string) string { 164 + return rctx.Sanitizer.SanitizeDefault(html) 165 + } 159 166 160 - policy.AllowAttrs("align", "style", "width", "height").Globally() 161 - policy.AllowStyles( 162 - "margin", 163 - "padding", 164 - "text-align", 165 - "font-weight", 166 - "text-decoration", 167 - "padding-left", 168 - "padding-right", 169 - "padding-top", 170 - "padding-bottom", 171 - "margin-left", 172 - "margin-right", 173 - "margin-top", 174 - "margin-bottom", 175 - ) 176 - return policy.Sanitize(html) 167 + func (rctx *RenderContext) SanitizeDescription(html string) string { 168 + return rctx.Sanitizer.SanitizeDescription(html) 177 169 } 178 170 179 171 type MarkdownTransformer struct { ··· 189 181 switch a.rctx.RendererType { 190 182 case RendererTypeRepoMarkdown: 191 183 switch n := n.(type) { 184 + case *ast.Heading: 185 + a.rctx.anchorHeadingTransformer(n) 192 186 case *ast.Link: 193 187 a.rctx.relativeLinkTransformer(n) 194 188 case *ast.Image: ··· 197 191 } 198 192 case RendererTypeDefault: 199 193 switch n := n.(type) { 194 + case *ast.Heading: 195 + a.rctx.anchorHeadingTransformer(n) 200 196 case *ast.Image: 201 197 a.rctx.imageFromKnotAstTransformer(n) 202 198 a.rctx.camoImageLinkAstTransformer(n) ··· 211 207 212 208 dst := string(link.Destination) 213 209 214 - if isAbsoluteUrl(dst) { 210 + if isAbsoluteUrl(dst) || isFragment(dst) || isMail(dst) { 215 211 return 216 212 } 217 213 ··· 252 248 img.Destination = []byte(rctx.imageFromKnotTransformer(dst)) 253 249 } 254 250 251 + func (rctx *RenderContext) anchorHeadingTransformer(h *ast.Heading) { 252 + idGeneric, exists := h.AttributeString("id") 253 + if !exists { 254 + return // no id, nothing to do 255 + } 256 + id, ok := idGeneric.([]byte) 257 + if !ok { 258 + return 259 + } 260 + 261 + // create anchor link 262 + anchor := ast.NewLink() 263 + anchor.Destination = fmt.Appendf(nil, "#%s", string(id)) 264 + anchor.SetAttribute([]byte("class"), []byte("anchor")) 265 + 266 + // create icon text 267 + iconText := ast.NewString([]byte("#")) 268 + anchor.AppendChild(anchor, iconText) 269 + 270 + // set class on heading 271 + h.SetAttribute([]byte("class"), []byte("heading")) 272 + 273 + // append anchor to heading 274 + h.AppendChild(h, anchor) 275 + } 276 + 255 277 // actualPath decides when to join the file path with the 256 278 // current repository directory (essentially only when the link 257 279 // destination is relative. if it's absolute then we assume the ··· 271 293 } 272 294 return parsed.IsAbs() 273 295 } 296 + 297 + func isFragment(link string) bool { 298 + return strings.HasPrefix(link, "#") 299 + } 300 + 301 + func isMail(link string) bool { 302 + return strings.HasPrefix(link, "mailto:") 303 + }
+117
appview/pages/markup/sanitizer.go
··· 1 + package markup 2 + 3 + import ( 4 + "maps" 5 + "regexp" 6 + "slices" 7 + "strings" 8 + 9 + "github.com/alecthomas/chroma/v2" 10 + "github.com/microcosm-cc/bluemonday" 11 + ) 12 + 13 + type Sanitizer struct { 14 + defaultPolicy *bluemonday.Policy 15 + descriptionPolicy *bluemonday.Policy 16 + } 17 + 18 + func NewSanitizer() Sanitizer { 19 + return Sanitizer{ 20 + defaultPolicy: defaultPolicy(), 21 + descriptionPolicy: descriptionPolicy(), 22 + } 23 + } 24 + 25 + func (s *Sanitizer) SanitizeDefault(html string) string { 26 + return s.defaultPolicy.Sanitize(html) 27 + } 28 + func (s *Sanitizer) SanitizeDescription(html string) string { 29 + return s.descriptionPolicy.Sanitize(html) 30 + } 31 + 32 + func defaultPolicy() *bluemonday.Policy { 33 + policy := bluemonday.UGCPolicy() 34 + 35 + // Allow generally safe attributes 36 + generalSafeAttrs := []string{ 37 + "abbr", "accept", "accept-charset", 38 + "accesskey", "action", "align", "alt", 39 + "aria-describedby", "aria-hidden", "aria-label", "aria-labelledby", 40 + "axis", "border", "cellpadding", "cellspacing", "char", 41 + "charoff", "charset", "checked", 42 + "clear", "cols", "colspan", "color", 43 + "compact", "coords", "datetime", "dir", 44 + "disabled", "enctype", "for", "frame", 45 + "headers", "height", "hreflang", 46 + "hspace", "ismap", "label", "lang", 47 + "maxlength", "media", "method", 48 + "multiple", "name", "nohref", "noshade", 49 + "nowrap", "open", "prompt", "readonly", "rel", "rev", 50 + "rows", "rowspan", "rules", "scope", 51 + "selected", "shape", "size", "span", 52 + "start", "summary", "tabindex", "target", 53 + "title", "type", "usemap", "valign", "value", 54 + "vspace", "width", "itemprop", 55 + } 56 + 57 + generalSafeElements := []string{ 58 + "h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "br", "b", "i", "strong", "em", "a", "pre", "code", "img", "tt", 59 + "div", "ins", "del", "sup", "sub", "p", "ol", "ul", "table", "thead", "tbody", "tfoot", "blockquote", "label", 60 + "dl", "dt", "dd", "kbd", "q", "samp", "var", "hr", "ruby", "rt", "rp", "li", "tr", "td", "th", "s", "strike", "summary", 61 + "details", "caption", "figure", "figcaption", 62 + "abbr", "bdo", "cite", "dfn", "mark", "small", "span", "time", "video", "wbr", 63 + } 64 + 65 + policy.AllowAttrs(generalSafeAttrs...).OnElements(generalSafeElements...) 66 + 67 + // video 68 + policy.AllowAttrs("src", "autoplay", "controls").OnElements("video") 69 + 70 + // checkboxes 71 + policy.AllowAttrs("type").Matching(regexp.MustCompile(`^checkbox$`)).OnElements("input") 72 + policy.AllowAttrs("checked", "disabled", "data-source-position").OnElements("input") 73 + 74 + // for code blocks 75 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`chroma`)).OnElements("pre") 76 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`anchor|footnote-ref|footnote-backref`)).OnElements("a") 77 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8") 78 + policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span") 79 + 80 + // centering content 81 + policy.AllowElements("center") 82 + 83 + policy.AllowAttrs("align", "style", "width", "height").Globally() 84 + policy.AllowStyles( 85 + "margin", 86 + "padding", 87 + "text-align", 88 + "font-weight", 89 + "text-decoration", 90 + "padding-left", 91 + "padding-right", 92 + "padding-top", 93 + "padding-bottom", 94 + "margin-left", 95 + "margin-right", 96 + "margin-top", 97 + "margin-bottom", 98 + ) 99 + 100 + return policy 101 + } 102 + 103 + func descriptionPolicy() *bluemonday.Policy { 104 + policy := bluemonday.NewPolicy() 105 + policy.AllowStandardURLs() 106 + 107 + // allow italics and bold. 108 + policy.AllowElements("i", "b", "em", "strong") 109 + 110 + // allow code. 111 + policy.AllowElements("code") 112 + 113 + // allow links 114 + policy.AllowAttrs("href", "target", "rel").OnElements("a") 115 + 116 + return policy 117 + }
+281 -83
appview/pages/pages.go
··· 16 16 "strings" 17 17 "sync" 18 18 19 + "tangled.sh/tangled.sh/core/api/tangled" 19 20 "tangled.sh/tangled.sh/core/appview/commitverify" 20 21 "tangled.sh/tangled.sh/core/appview/config" 21 22 "tangled.sh/tangled.sh/core/appview/db" ··· 23 24 "tangled.sh/tangled.sh/core/appview/pages/markup" 24 25 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 25 26 "tangled.sh/tangled.sh/core/appview/pagination" 27 + "tangled.sh/tangled.sh/core/idresolver" 26 28 "tangled.sh/tangled.sh/core/patchutil" 27 29 "tangled.sh/tangled.sh/core/types" 28 30 ··· 30 32 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 31 33 "github.com/alecthomas/chroma/v2/lexers" 32 34 "github.com/alecthomas/chroma/v2/styles" 35 + "github.com/bluesky-social/indigo/atproto/identity" 33 36 "github.com/bluesky-social/indigo/atproto/syntax" 34 37 "github.com/go-git/go-git/v5/plumbing" 35 38 "github.com/go-git/go-git/v5/plumbing/object" ··· 43 46 t map[string]*template.Template 44 47 45 48 avatar config.AvatarConfig 49 + resolver *idresolver.Resolver 46 50 dev bool 47 51 embedFS embed.FS 48 52 templateDir string // Path to templates on disk for dev mode 49 53 rctx *markup.RenderContext 50 54 } 51 55 52 - func NewPages(config *config.Config) *Pages { 56 + func NewPages(config *config.Config, res *idresolver.Resolver) *Pages { 53 57 // initialized with safe defaults, can be overriden per use 54 58 rctx := &markup.RenderContext{ 55 59 IsDev: config.Core.Dev, 56 60 CamoUrl: config.Camo.Host, 57 61 CamoSecret: config.Camo.SharedSecret, 62 + Sanitizer: markup.NewSanitizer(), 58 63 } 59 64 60 65 p := &Pages{ ··· 64 69 avatar: config.Avatar, 65 70 embedFS: Files, 66 71 rctx: rctx, 72 + resolver: res, 67 73 templateDir: "appview/pages", 68 74 } 69 75 ··· 254 260 return p.executeOrReload(name, w, "layouts/repobase", params) 255 261 } 256 262 263 + func (p *Pages) Favicon(w io.Writer) error { 264 + return p.executePlain("favicon", w, nil) 265 + } 266 + 257 267 type LoginParams struct { 268 + ReturnUrl string 258 269 } 259 270 260 271 func (p *Pages) Login(w io.Writer, params LoginParams) error { 261 272 return p.executePlain("user/login", w, params) 262 273 } 263 274 275 + func (p *Pages) Signup(w io.Writer) error { 276 + return p.executePlain("user/signup", w, nil) 277 + } 278 + 279 + func (p *Pages) CompleteSignup(w io.Writer) error { 280 + return p.executePlain("user/completeSignup", w, nil) 281 + } 282 + 283 + type TermsOfServiceParams struct { 284 + LoggedInUser *oauth.User 285 + } 286 + 287 + func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 288 + return p.execute("legal/terms", w, params) 289 + } 290 + 291 + type PrivacyPolicyParams struct { 292 + LoggedInUser *oauth.User 293 + } 294 + 295 + func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 296 + return p.execute("legal/privacy", w, params) 297 + } 298 + 264 299 type TimelineParams struct { 265 300 LoggedInUser *oauth.User 266 301 Timeline []db.TimelineEvent 267 - DidHandleMap map[string]string 302 + Repos []db.Repo 268 303 } 269 304 270 305 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 271 - return p.execute("timeline", w, params) 306 + return p.execute("timeline/timeline", w, params) 307 + } 308 + 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) 272 317 } 273 318 274 - type SettingsParams struct { 319 + type UserKeysSettingsParams struct { 275 320 LoggedInUser *oauth.User 276 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 277 332 Emails []db.Email 333 + Tabs []map[string]any 334 + Tab string 278 335 } 279 336 280 - func (p *Pages) Settings(w io.Writer, params SettingsParams) error { 281 - 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) 282 347 } 283 348 284 349 type KnotsParams struct { ··· 292 357 293 358 type KnotParams struct { 294 359 LoggedInUser *oauth.User 295 - DidHandleMap map[string]string 296 360 Registration *db.Registration 297 361 Members []string 298 362 Repos map[string][]db.Repo ··· 304 368 } 305 369 306 370 type KnotListingParams struct { 307 - db.Registration 371 + *db.Registration 308 372 } 309 373 310 374 func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error { 311 375 return p.executePlain("knots/fragments/knotListing", w, params) 312 376 } 313 377 314 - type KnotListingFullParams struct { 315 - Registrations []db.Registration 316 - } 317 - 318 - func (p *Pages) KnotListingFull(w io.Writer, params KnotListingFullParams) error { 319 - return p.executePlain("knots/fragments/knotListingFull", w, params) 320 - } 321 - 322 - type KnotSecretParams struct { 323 - Secret string 324 - } 325 - 326 - func (p *Pages) KnotSecret(w io.Writer, params KnotSecretParams) error { 327 - return p.executePlain("knots/fragments/secret", w, params) 328 - } 329 - 330 378 type SpindlesParams struct { 331 379 LoggedInUser *oauth.User 332 380 Spindles []db.Spindle ··· 349 397 Spindle db.Spindle 350 398 Members []string 351 399 Repos map[string][]db.Repo 352 - DidHandleMap map[string]string 353 400 } 354 401 355 402 func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { ··· 375 422 return p.execute("repo/fork", w, params) 376 423 } 377 424 378 - type ProfilePageParams struct { 425 + type ProfileHomePageParams struct { 379 426 LoggedInUser *oauth.User 380 427 Repos []db.Repo 381 428 CollaboratingRepos []db.Repo 382 429 ProfileTimeline *db.ProfileTimeline 383 430 Card ProfileCard 384 431 Punchcard db.Punchcard 385 - 386 - DidHandleMap map[string]string 387 432 } 388 433 389 434 type ProfileCard struct { 390 - UserDid string 391 - UserHandle string 392 - FollowStatus db.FollowStatus 393 - AvatarUri string 394 - Followers int 395 - Following int 435 + UserDid string 436 + UserHandle string 437 + FollowStatus db.FollowStatus 438 + FollowersCount int 439 + FollowingCount int 396 440 397 441 Profile *db.Profile 398 442 } 399 443 400 - func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error { 444 + func (p *Pages) ProfileHomePage(w io.Writer, params ProfileHomePageParams) error { 401 445 return p.execute("user/profile", w, params) 402 446 } 403 447 ··· 405 449 LoggedInUser *oauth.User 406 450 Repos []db.Repo 407 451 Card ProfileCard 408 - 409 - DidHandleMap map[string]string 410 452 } 411 453 412 454 func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error { 413 455 return p.execute("user/repos", w, params) 414 456 } 415 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 + 416 486 type FollowFragmentParams struct { 417 487 UserDid string 418 488 FollowStatus db.FollowStatus ··· 435 505 LoggedInUser *oauth.User 436 506 Profile *db.Profile 437 507 AllRepos []PinnedRepo 438 - DidHandleMap map[string]string 439 508 } 440 509 441 510 type PinnedRepo struct { ··· 470 539 } 471 540 472 541 type RepoIndexParams struct { 473 - LoggedInUser *oauth.User 474 - RepoInfo repoinfo.RepoInfo 475 - Active string 476 - TagMap map[string][]string 477 - CommitsTrunc []*object.Commit 478 - TagsTrunc []*types.TagReference 479 - BranchesTrunc []types.Branch 480 - 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 481 550 HTMLReadme template.HTML 482 551 Raw bool 483 552 EmailToDidOrHandle map[string]string ··· 494 563 } 495 564 496 565 p.rctx.RepoInfo = params.RepoInfo 566 + p.rctx.RepoInfo.Ref = params.Ref 497 567 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 498 568 499 569 if params.ReadmeFileName != "" { 500 - var htmlString string 501 570 ext := filepath.Ext(params.ReadmeFileName) 502 571 switch ext { 503 572 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 504 - htmlString = p.rctx.Sanitize(htmlString) 505 - htmlString = p.rctx.RenderMarkdown(params.Readme) 506 573 params.Raw = false 507 - params.HTMLReadme = template.HTML(htmlString) 574 + htmlString := p.rctx.RenderMarkdown(params.Readme) 575 + sanitized := p.rctx.SanitizeDefault(htmlString) 576 + params.HTMLReadme = template.HTML(sanitized) 508 577 default: 509 578 params.Raw = true 510 579 } ··· 623 692 LoggedInUser *oauth.User 624 693 RepoInfo repoinfo.RepoInfo 625 694 Active string 695 + Unsupported bool 696 + IsImage bool 697 + IsVideo bool 698 + ContentSrc string 626 699 BreadCrumbs [][]string 627 700 ShowRendered bool 628 701 RenderToggle bool ··· 639 712 p.rctx.RepoInfo = params.RepoInfo 640 713 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 641 714 htmlString := p.rctx.RenderMarkdown(params.Contents) 642 - params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString)) 715 + sanitized := p.rctx.SanitizeDefault(htmlString) 716 + params.RenderedContents = template.HTML(sanitized) 643 717 } 644 718 } 645 719 646 - if params.Lines < 5000 { 647 - c := params.Contents 648 - formatter := chromahtml.New( 649 - chromahtml.InlineCode(false), 650 - chromahtml.WithLineNumbers(true), 651 - chromahtml.WithLinkableLineNumbers(true, "L"), 652 - chromahtml.Standalone(false), 653 - chromahtml.WithClasses(true), 654 - ) 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 + ) 655 728 656 - lexer := lexers.Get(filepath.Base(params.Path)) 657 - if lexer == nil { 658 - lexer = lexers.Fallback 659 - } 660 - 661 - iterator, err := lexer.Tokenise(nil, c) 662 - if err != nil { 663 - return fmt.Errorf("chroma tokenize: %w", err) 664 - } 729 + lexer := lexers.Get(filepath.Base(params.Path)) 730 + if lexer == nil { 731 + lexer = lexers.Fallback 732 + } 665 733 666 - var code bytes.Buffer 667 - err = formatter.Format(&code, style, iterator) 668 - if err != nil { 669 - return fmt.Errorf("chroma format: %w", err) 670 - } 734 + iterator, err := lexer.Tokenise(nil, c) 735 + if err != nil { 736 + return fmt.Errorf("chroma tokenize: %w", err) 737 + } 671 738 672 - 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) 673 743 } 674 744 745 + params.Contents = code.String() 675 746 params.Active = "overview" 676 747 return p.executeRepo("repo/blob", w, params) 677 748 } ··· 690 761 Branches []types.Branch 691 762 Spindles []string 692 763 CurrentSpindle string 764 + Secrets []*tangled.RepoListSecrets_Secret 765 + 693 766 // TODO: use repoinfo.roles 694 767 IsCollaboratorInviteAllowed bool 695 768 } ··· 699 772 return p.executeRepo("repo/settings", w, params) 700 773 } 701 774 775 + type RepoGeneralSettingsParams struct { 776 + LoggedInUser *oauth.User 777 + RepoInfo repoinfo.RepoInfo 778 + Active string 779 + Tabs []map[string]any 780 + Tab string 781 + Branches []types.Branch 782 + } 783 + 784 + func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error { 785 + params.Active = "settings" 786 + return p.executeRepo("repo/settings/general", w, params) 787 + } 788 + 789 + type RepoAccessSettingsParams struct { 790 + LoggedInUser *oauth.User 791 + RepoInfo repoinfo.RepoInfo 792 + Active string 793 + Tabs []map[string]any 794 + Tab string 795 + Collaborators []Collaborator 796 + } 797 + 798 + func (p *Pages) RepoAccessSettings(w io.Writer, params RepoAccessSettingsParams) error { 799 + params.Active = "settings" 800 + return p.executeRepo("repo/settings/access", w, params) 801 + } 802 + 803 + type RepoPipelineSettingsParams struct { 804 + LoggedInUser *oauth.User 805 + RepoInfo repoinfo.RepoInfo 806 + Active string 807 + Tabs []map[string]any 808 + Tab string 809 + Spindles []string 810 + CurrentSpindle string 811 + Secrets []map[string]any 812 + } 813 + 814 + func (p *Pages) RepoPipelineSettings(w io.Writer, params RepoPipelineSettingsParams) error { 815 + params.Active = "settings" 816 + return p.executeRepo("repo/settings/pipelines", w, params) 817 + } 818 + 702 819 type RepoIssuesParams struct { 703 820 LoggedInUser *oauth.User 704 821 RepoInfo repoinfo.RepoInfo 705 822 Active string 706 823 Issues []db.Issue 707 - DidHandleMap map[string]string 708 824 Page pagination.Page 709 825 FilteringByOpen bool 710 826 } ··· 718 834 LoggedInUser *oauth.User 719 835 RepoInfo repoinfo.RepoInfo 720 836 Active string 721 - Issue db.Issue 837 + Issue *db.Issue 722 838 Comments []db.Comment 723 839 IssueOwnerHandle string 724 - DidHandleMap map[string]string 725 840 726 841 OrderedReactionKinds []db.ReactionKind 727 842 Reactions map[db.ReactionKind]int ··· 775 890 776 891 type SingleIssueCommentParams struct { 777 892 LoggedInUser *oauth.User 778 - DidHandleMap map[string]string 779 893 RepoInfo repoinfo.RepoInfo 780 894 Issue *db.Issue 781 895 Comment *db.Comment ··· 807 921 RepoInfo repoinfo.RepoInfo 808 922 Pulls []*db.Pull 809 923 Active string 810 - DidHandleMap map[string]string 811 924 FilteringBy db.PullState 812 925 Stacks map[string]db.Stack 926 + Pipelines map[string]db.Pipeline 813 927 } 814 928 815 929 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 839 953 LoggedInUser *oauth.User 840 954 RepoInfo repoinfo.RepoInfo 841 955 Active string 842 - DidHandleMap map[string]string 843 956 Pull *db.Pull 844 957 Stack db.Stack 845 958 AbandonedPulls []*db.Pull ··· 859 972 860 973 type RepoPullPatchParams struct { 861 974 LoggedInUser *oauth.User 862 - DidHandleMap map[string]string 863 975 RepoInfo repoinfo.RepoInfo 864 976 Pull *db.Pull 865 977 Stack db.Stack ··· 877 989 878 990 type RepoPullInterdiffParams struct { 879 991 LoggedInUser *oauth.User 880 - DidHandleMap map[string]string 881 992 RepoInfo repoinfo.RepoInfo 882 993 Pull *db.Pull 883 994 Round int ··· 1068 1179 return p.executeRepo("repo/pipelines/workflow", w, params) 1069 1180 } 1070 1181 1182 + type PutStringParams struct { 1183 + LoggedInUser *oauth.User 1184 + Action string 1185 + 1186 + // this is supplied in the case of editing an existing string 1187 + String db.String 1188 + } 1189 + 1190 + func (p *Pages) PutString(w io.Writer, params PutStringParams) error { 1191 + return p.execute("strings/put", w, params) 1192 + } 1193 + 1194 + type StringsDashboardParams struct { 1195 + LoggedInUser *oauth.User 1196 + Card ProfileCard 1197 + Strings []db.String 1198 + } 1199 + 1200 + func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error { 1201 + return p.execute("strings/dashboard", w, params) 1202 + } 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 + 1213 + type SingleStringParams struct { 1214 + LoggedInUser *oauth.User 1215 + ShowRendered bool 1216 + RenderToggle bool 1217 + RenderedContents template.HTML 1218 + String db.String 1219 + Stats db.StringStats 1220 + Owner identity.Identity 1221 + } 1222 + 1223 + func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { 1224 + var style *chroma.Style = styles.Get("catpuccin-latte") 1225 + 1226 + if params.ShowRendered { 1227 + switch markup.GetFormat(params.String.Filename) { 1228 + case markup.FormatMarkdown: 1229 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 1230 + htmlString := p.rctx.RenderMarkdown(params.String.Contents) 1231 + sanitized := p.rctx.SanitizeDefault(htmlString) 1232 + params.RenderedContents = template.HTML(sanitized) 1233 + } 1234 + } 1235 + 1236 + c := params.String.Contents 1237 + formatter := chromahtml.New( 1238 + chromahtml.InlineCode(false), 1239 + chromahtml.WithLineNumbers(true), 1240 + chromahtml.WithLinkableLineNumbers(true, "L"), 1241 + chromahtml.Standalone(false), 1242 + chromahtml.WithClasses(true), 1243 + ) 1244 + 1245 + lexer := lexers.Get(filepath.Base(params.String.Filename)) 1246 + if lexer == nil { 1247 + lexer = lexers.Fallback 1248 + } 1249 + 1250 + iterator, err := lexer.Tokenise(nil, c) 1251 + if err != nil { 1252 + return fmt.Errorf("chroma tokenize: %w", err) 1253 + } 1254 + 1255 + var code bytes.Buffer 1256 + err = formatter.Format(&code, style, iterator) 1257 + if err != nil { 1258 + return fmt.Errorf("chroma format: %w", err) 1259 + } 1260 + 1261 + params.String.Contents = code.String() 1262 + return p.execute("strings/string", w, params) 1263 + } 1264 + 1071 1265 func (p *Pages) Static() http.Handler { 1072 1266 if p.dev { 1073 1267 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) ··· 1118 1312 1119 1313 func (p *Pages) Error404(w io.Writer) error { 1120 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) 1121 1319 } 1122 1320 1123 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 +
+7 -8
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 > ··· 13 13 <div 14 14 id="add-member-{{ .Id }}" 15 15 popover 16 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded drop-shadow dark:text-white"> 16 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 17 17 {{ block "addKnotMemberPopover" . }} {{ end }} 18 18 </div> 19 19 {{ end }} 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 }}
+19 -42
appview/pages/templates/layouts/base.html
··· 14 14 <title>{{ block "title" . }}{{ end }} ยท tangled</title> 15 15 {{ block "extrameta" . }}{{ end }} 16 16 </head> 17 - <body class="bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 18 - <div class="px-1"> 19 - {{ block "topbarLayout" . }} 20 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 21 - <header class="col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;"> 22 - {{ template "layouts/topbar" . }} 23 - </header> 24 - </div> 25 - {{ end }} 26 - </div> 17 + <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 18 + {{ block "topbarLayout" . }} 19 + <header class="px-1 col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;"> 20 + {{ template "layouts/topbar" . }} 21 + </header> 22 + {{ end }} 27 23 28 - <div class="px-1 flex flex-col min-h-screen gap-4"> 29 - {{ block "contentLayout" . }} 30 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 31 - <div class="col-span-1 md:col-span-2"> 32 - {{ block "contentLeft" . }} {{ end }} 33 - </div> 24 + {{ block "mainLayout" . }} 25 + <div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4"> 26 + {{ block "contentLayout" . }} 34 27 <main class="col-span-1 md:col-span-8"> 35 28 {{ block "content" . }}{{ end }} 36 29 </main> 37 - <div class="col-span-1 md:col-span-2"> 38 - {{ block "contentRight" . }} {{ end }} 39 - </div> 40 - </div> 41 - {{ end }} 42 - 43 - {{ block "contentAfterLayout" . }} 44 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 45 - <div class="col-span-1 md:col-span-2"> 46 - {{ block "contentAfterLeft" . }} {{ end }} 47 - </div> 30 + {{ end }} 31 + 32 + {{ block "contentAfterLayout" . }} 48 33 <main class="col-span-1 md:col-span-8"> 49 34 {{ block "contentAfter" . }}{{ end }} 50 35 </main> 51 - <div class="col-span-1 md:col-span-2"> 52 - {{ block "contentAfterRight" . }} {{ end }} 53 - </div> 54 - </div> 55 - {{ end }} 56 - </div> 57 - 58 - <div class="px-1 mt-16"> 59 - {{ block "footerLayout" . }} 60 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 61 - <footer class="col-span-1 md:col-start-3 md:col-span-8"> 62 - {{ template "layouts/footer" . }} 63 - </footer> 36 + {{ end }} 64 37 </div> 65 - {{ end }} 66 - </div> 38 + {{ end }} 67 39 40 + {{ block "footerLayout" . }} 41 + <footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12"> 42 + {{ template "layouts/footer" . }} 43 + </footer> 44 + {{ end }} 68 45 </body> 69 46 </html> 70 47 {{ end }}
+44 -3
appview/pages/templates/layouts/footer.html
··· 1 1 {{ define "layouts/footer" }} 2 - <div class="w-full p-4 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 - <div class="container mx-auto text-center text-gray-600 dark:text-gray-400 text-sm"> 4 - <span class="font-semibold italic">tangled</span> &mdash; made by <a href="/@oppi.li">@oppi.li</a> and <a href="/@icyphox.sh">@icyphox.sh</a> 2 + <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 + <div class="container mx-auto max-w-7xl px-4"> 4 + <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 + <div class="mb-4 md:mb-0"> 6 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 7 + tangled<sub>alpha</sub> 8 + </a> 9 + </div> 10 + 11 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 + <div class="flex flex-col gap-1"> 16 + <div class="{{ $headerStyle }}">legal</div> 17 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 19 + </div> 20 + 21 + <div class="flex flex-col gap-1"> 22 + <div class="{{ $headerStyle }}">resources</div> 23 + <a href="https://blog.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 + <a href="https://tangled.sh/@tangled.sh/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 + <a href="https://tangled.sh/@tangled.sh/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 + </div> 27 + 28 + <div class="flex flex-col gap-1"> 29 + <div class="{{ $headerStyle }}">social</div> 30 + <a href="https://chat.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 31 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 32 + <a href="https://bsky.app/profile/tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 33 + </div> 34 + 35 + <div class="flex flex-col gap-1"> 36 + <div class="{{ $headerStyle }}">contact</div> 37 + <a href="mailto:team@tangled.sh" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh</a> 38 + <a href="mailto:security@tangled.sh" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh</a> 39 + </div> 40 + </div> 41 + 42 + <div class="text-center lg:text-right flex-shrink-0"> 43 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 44 + </div> 5 45 </div> 46 + </div> 6 47 </div> 7 48 {{ end }}
+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" . }}
+46 -19
appview/pages/templates/layouts/topbar.html
··· 1 1 {{ define "layouts/topbar" }} 2 - <nav class="space-x-4 mb-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 2 + <nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 3 3 <div class="flex justify-between p-0 items-center"> 4 4 <div id="left-items"> 5 - <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 5 + <a href="/" hx-boost="true" class="flex gap-2 font-bold italic"> 6 6 tangled<sub>alpha</sub> 7 7 </a> 8 8 </div> 9 - <div class="hidden md:flex gap-4 items-center"> 10 - <a href="https://chat.tangled.sh" class="inline-flex gap-1 items-center"> 11 - {{ i "message-circle" "size-4" }} discord 12 - </a> 13 9 14 - <a href="https://web.libera.chat/#tangled" class="inline-flex gap-1 items-center"> 15 - {{ i "hash" "size-4" }} irc 16 - </a> 17 - 18 - <a href="https://tangled.sh/@tangled.sh/core" class="inline-flex gap-1 items-center"> 19 - {{ i "code" "size-4" }} source 20 - </a> 21 - </div> 22 - <div id="right-items" class="flex items-center gap-4"> 10 + <div id="right-items" class="flex items-center gap-2"> 23 11 {{ with .LoggedInUser }} 24 - <a href="/repo/new" hx-boost="true" class="btn-create hover:no-underline hover:text-white"> 25 - {{ i "plus" "w-4 h-4" }} 26 - </a> 12 + {{ block "newButton" . }} {{ end }} 27 13 {{ block "dropDown" . }} {{ end }} 28 14 {{ else }} 29 15 <a href="/login">login</a> 16 + <span class="text-gray-500 dark:text-gray-400">or</span> 17 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 18 + join now {{ i "arrow-right" "size-4" }} 19 + </a> 30 20 {{ end }} 31 21 </div> 32 22 </div> 33 23 </nav> 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 }} 31 + {{ end }} 32 + 33 + {{ define "newButton" }} 34 + <details class="relative inline-block text-left nav-dropdown"> 35 + <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 36 + {{ i "plus" "w-4 h-4" }} new 37 + </summary> 38 + <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 39 + <a href="/repo/new" class="flex items-center gap-2"> 40 + {{ i "book-plus" "w-4 h-4" }} 41 + new repository 42 + </a> 43 + <a href="/strings/new" class="flex items-center gap-2"> 44 + {{ i "line-squiggle" "w-4 h-4" }} 45 + new string 46 + </a> 47 + </div> 48 + </details> 34 49 {{ end }} 35 50 36 51 {{ define "dropDown" }} 37 - <details class="relative inline-block text-left"> 52 + <details class="relative inline-block text-left nav-dropdown"> 38 53 <summary 39 54 class="cursor-pointer list-none flex items-center" 40 55 > ··· 46 61 > 47 62 <a href="/{{ $user }}">profile</a> 48 63 <a href="/{{ $user }}?tab=repos">repositories</a> 64 + <a href="/strings/{{ $user }}">strings</a> 49 65 <a href="/knots">knots</a> 50 66 <a href="/spindles">spindles</a> 51 67 <a href="/settings">settings</a> ··· 57 73 </a> 58 74 </div> 59 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> 60 87 {{ end }}
+133
appview/pages/templates/legal/privacy.html
··· 1 + {{ define "title" }} privacy policy {{ end }} 2 + {{ define "content" }} 3 + <div class="max-w-4xl mx-auto px-4 py-8"> 4 + <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 5 + <div class="prose prose-gray dark:prose-invert max-w-none"> 6 + <h1>Privacy Policy</h1> 7 + 8 + <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p> 9 + 10 + <p>This Privacy Policy describes how Tangled ("we," "us," or "our") collects, uses, and shares your personal information when you use our platform and services (the "Service").</p> 11 + 12 + <h2>1. Information We Collect</h2> 13 + 14 + <h3>Account Information</h3> 15 + <p>When you create an account, we collect:</p> 16 + <ul> 17 + <li>Your chosen username</li> 18 + <li>Email address</li> 19 + <li>Profile information you choose to provide</li> 20 + <li>Authentication data</li> 21 + </ul> 22 + 23 + <h3>Content and Activity</h3> 24 + <p>We store:</p> 25 + <ul> 26 + <li>Code repositories and associated metadata</li> 27 + <li>Issues, pull requests, and comments</li> 28 + <li>Activity logs and usage patterns</li> 29 + <li>Public keys for authentication</li> 30 + </ul> 31 + 32 + <h2>2. Data Location and Hosting</h2> 33 + <div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 my-6"> 34 + <h3 class="text-blue-800 dark:text-blue-200 font-semibold mb-2">EU Data Hosting</h3> 35 + <p class="text-blue-700 dark:text-blue-300"> 36 + <strong>All Tangled service data is hosted within the European Union.</strong> Specifically: 37 + </p> 38 + <ul class="text-blue-700 dark:text-blue-300 mt-2"> 39 + <li><strong>Personal Data Servers (PDS):</strong> Accounts hosted on Tangled PDS (*.tngl.sh) are located in Finland</li> 40 + <li><strong>Application Data:</strong> All other service data is stored on EU-based servers</li> 41 + <li><strong>Data Processing:</strong> All data processing occurs within EU jurisdiction</li> 42 + </ul> 43 + </div> 44 + 45 + <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 my-6"> 46 + <h3 class="text-yellow-800 dark:text-yellow-200 font-semibold mb-2">External PDS Notice</h3> 47 + <p class="text-yellow-700 dark:text-yellow-300"> 48 + <strong>Important:</strong> If your account is hosted on Bluesky's PDS or other self-hosted Personal Data Servers (not *.tngl.sh), we do not control that data. The data protection, storage location, and privacy practices for such accounts are governed by the respective PDS provider's policies, not this Privacy Policy. We only control data processing within our own services and infrastructure. 49 + </p> 50 + </div> 51 + 52 + <h2>3. Third-Party Data Processors</h2> 53 + <p>We only share your data with the following third-party processors:</p> 54 + 55 + <h3>Resend (Email Services)</h3> 56 + <ul> 57 + <li><strong>Purpose:</strong> Sending transactional emails (account verification, notifications)</li> 58 + <li><strong>Data Shared:</strong> Email address and necessary message content</li> 59 + <li><strong>Location:</strong> EU-compliant email delivery service</li> 60 + </ul> 61 + 62 + <h3>Cloudflare (Image Caching)</h3> 63 + <ul> 64 + <li><strong>Purpose:</strong> Caching and optimizing image delivery</li> 65 + <li><strong>Data Shared:</strong> Public images and associated metadata for caching purposes</li> 66 + <li><strong>Location:</strong> Global CDN with EU data protection compliance</li> 67 + </ul> 68 + 69 + <h2>4. How We Use Your Information</h2> 70 + <p>We use your information to:</p> 71 + <ul> 72 + <li>Provide and maintain the Service</li> 73 + <li>Process your transactions and requests</li> 74 + <li>Send you technical notices and support messages</li> 75 + <li>Improve and develop new features</li> 76 + <li>Ensure security and prevent fraud</li> 77 + <li>Comply with legal obligations</li> 78 + </ul> 79 + 80 + <h2>5. Data Sharing and Disclosure</h2> 81 + <p>We do not sell, trade, or rent your personal information. We may share your information only in the following circumstances:</p> 82 + <ul> 83 + <li>With the third-party processors listed above</li> 84 + <li>When required by law or legal process</li> 85 + <li>To protect our rights, property, or safety, or that of our users</li> 86 + <li>In connection with a merger, acquisition, or sale of assets (with appropriate protections)</li> 87 + </ul> 88 + 89 + <h2>6. Data Security</h2> 90 + <p>We implement appropriate technical and organizational measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction. However, no method of transmission over the Internet is 100% secure.</p> 91 + 92 + <h2>7. Data Retention</h2> 93 + <p>We retain your personal information for as long as necessary to provide the Service and fulfill the purposes outlined in this Privacy Policy, unless a longer retention period is required by law.</p> 94 + 95 + <h2>8. Your Rights</h2> 96 + <p>Under applicable data protection laws, you have the right to:</p> 97 + <ul> 98 + <li>Access your personal information</li> 99 + <li>Correct inaccurate information</li> 100 + <li>Request deletion of your information</li> 101 + <li>Object to processing of your information</li> 102 + <li>Data portability</li> 103 + <li>Withdraw consent (where applicable)</li> 104 + </ul> 105 + 106 + <h2>9. Cookies and Tracking</h2> 107 + <p>We use cookies and similar technologies to:</p> 108 + <ul> 109 + <li>Maintain your login session</li> 110 + <li>Remember your preferences</li> 111 + <li>Analyze usage patterns to improve the Service</li> 112 + </ul> 113 + <p>You can control cookie settings through your browser preferences.</p> 114 + 115 + <h2>10. Children's Privacy</h2> 116 + <p>The Service is not intended for children under 16 years of age. We do not knowingly collect personal information from children under 16. If we become aware that we have collected such information, we will take steps to delete it.</p> 117 + 118 + <h2>11. International Data Transfers</h2> 119 + <p>While all our primary data processing occurs within the EU, some of our third-party processors may process data outside the EU. When this occurs, we ensure appropriate safeguards are in place, such as Standard Contractual Clauses or adequacy decisions.</p> 120 + 121 + <h2>12. Changes to This Privacy Policy</h2> 122 + <p>We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last updated" date.</p> 123 + 124 + <h2>13. Contact Information</h2> 125 + <p>If you have any questions about this Privacy Policy or wish to exercise your rights, please contact us through our platform or via email.</p> 126 + 127 + <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400"> 128 + <p>This Privacy Policy complies with the EU General Data Protection Regulation (GDPR) and other applicable data protection laws.</p> 129 + </div> 130 + </div> 131 + </div> 132 + </div> 133 + {{ end }}
+71
appview/pages/templates/legal/terms.html
··· 1 + {{ define "title" }}terms of service{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="max-w-4xl mx-auto px-4 py-8"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 + <div class="prose prose-gray dark:prose-invert max-w-none"> 7 + <h1>Terms of Service</h1> 8 + 9 + <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p> 10 + 11 + <p>Welcome to Tangled. These Terms of Service ("Terms") govern your access to and use of the Tangled platform and services (the "Service") operated by us ("Tangled," "we," "us," or "our").</p> 12 + 13 + <h2>1. Acceptance of Terms</h2> 14 + <p>By accessing or using our Service, you agree to be bound by these Terms. If you disagree with any part of these terms, then you may not access the Service.</p> 15 + 16 + <h2>2. Account Registration</h2> 17 + <p>To use certain features of the Service, you must register for an account. You agree to provide accurate, current, and complete information during the registration process and to update such information to keep it accurate, current, and complete.</p> 18 + 19 + <h2>3. Account Termination</h2> 20 + <div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 my-6"> 21 + <h3 class="text-red-800 dark:text-red-200 font-semibold mb-2">Important Notice</h3> 22 + <p class="text-red-700 dark:text-red-300"> 23 + <strong>We reserve the right to terminate, suspend, or restrict access to your account at any time, for any reason, or for no reason at all, at our sole discretion.</strong> This includes, but is not limited to, termination for violation of these Terms, inappropriate conduct, spam, abuse, or any other behavior we deem harmful to the Service or other users. 24 + </p> 25 + <p class="text-red-700 dark:text-red-300 mt-2"> 26 + Account termination may result in the loss of access to your repositories, data, and other content associated with your account. We are not obligated to provide advance notice of termination, though we may do so in our discretion. 27 + </p> 28 + </div> 29 + 30 + <h2>4. Acceptable Use</h2> 31 + <p>You agree not to use the Service to:</p> 32 + <ul> 33 + <li>Violate any applicable laws or regulations</li> 34 + <li>Infringe upon the rights of others</li> 35 + <li>Upload, store, or share content that is illegal, harmful, threatening, abusive, harassing, defamatory, vulgar, obscene, or otherwise objectionable</li> 36 + <li>Engage in spam, phishing, or other deceptive practices</li> 37 + <li>Attempt to gain unauthorized access to the Service or other users' accounts</li> 38 + <li>Interfere with or disrupt the Service or servers connected to the Service</li> 39 + </ul> 40 + 41 + <h2>5. Content and Intellectual Property</h2> 42 + <p>You retain ownership of the content you upload to the Service. By uploading content, you grant us a non-exclusive, worldwide, royalty-free license to use, reproduce, modify, and distribute your content as necessary to provide the Service.</p> 43 + 44 + <h2>6. Privacy</h2> 45 + <p>Your privacy is important to us. Please review our <a href="/privacy" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">Privacy Policy</a>, which also governs your use of the Service.</p> 46 + 47 + <h2>7. Disclaimers</h2> 48 + <p>The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make no warranties, expressed or implied, and hereby disclaim and negate all other warranties including without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights.</p> 49 + 50 + <h2>8. Limitation of Liability</h2> 51 + <p>In no event shall Tangled, nor its directors, employees, partners, agents, suppliers, or affiliates, be liable for any indirect, incidental, special, consequential, or punitive damages, including without limitation, loss of profits, data, use, goodwill, or other intangible losses, resulting from your use of the Service.</p> 52 + 53 + <h2>9. Indemnification</h2> 54 + <p>You agree to defend, indemnify, and hold harmless Tangled and its affiliates, officers, directors, employees, and agents from and against any and all claims, damages, obligations, losses, liabilities, costs, or debt, and expenses (including attorney's fees).</p> 55 + 56 + <h2>10. Governing Law</h2> 57 + <p>These Terms shall be interpreted and governed by the laws of Finland, without regard to its conflict of law provisions.</p> 58 + 59 + <h2>11. Changes to Terms</h2> 60 + <p>We reserve the right to modify or replace these Terms at any time. If a revision is material, we will try to provide at least 30 days notice prior to any new terms taking effect.</p> 61 + 62 + <h2>12. Contact Information</h2> 63 + <p>If you have any questions about these Terms of Service, please contact us through our platform or via email.</p> 64 + 65 + <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400"> 66 + <p>These terms are effective as of the last updated date shown above and will remain in effect except with respect to any changes in their provisions in the future, which will be in effect immediately after being posted on this page.</p> 67 + </div> 68 + </div> 69 + </div> 70 + </div> 71 + {{ end }}
+19 -6
appview/pages/templates/repo/blob.html
··· 5 5 6 6 {{ $title := printf "%s at %s &middot; %s" .Path .Ref .RepoInfo.FullName }} 7 7 {{ $url := printf "https://tangled.sh/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }} 8 - 8 + 9 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 - 10 + 11 11 {{ end }} 12 12 13 13 {{ define "repoContent" }} ··· 44 44 <a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a> 45 45 {{ if .RenderToggle }} 46 46 <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 47 - <a 48 - href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}" 47 + <a 48 + href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}" 49 49 hx-boost="true" 50 50 >view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a> 51 51 {{ end }} 52 52 </div> 53 53 </div> 54 54 </div> 55 - {{ if .IsBinary }} 55 + {{ if and .IsBinary .Unsupported }} 56 56 <p class="text-center text-gray-400 dark:text-gray-500"> 57 - This is a binary file and will not be displayed. 57 + Previews are not supported for this file type. 58 58 </p> 59 + {{ else if .IsBinary }} 60 + <div class="text-center"> 61 + {{ if .IsImage }} 62 + <img src="{{ .ContentSrc }}" 63 + alt="{{ .Path }}" 64 + class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" /> 65 + {{ else if .IsVideo }} 66 + <video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded"> 67 + <source src="{{ .ContentSrc }}"> 68 + Your browser does not support the video tag. 69 + </video> 70 + {{ end }} 71 + </div> 59 72 {{ else }} 60 73 <div class="overflow-auto relative"> 61 74 {{ if .ShowRendered }}
+20 -14
appview/pages/templates/repo/commit.html
··· 80 80 {{end}} 81 81 82 82 {{ define "topbarLayout" }} 83 - <header style="z-index: 20;"> 83 + <header class="px-1 col-span-full" style="z-index: 20;"> 84 84 {{ template "layouts/topbar" . }} 85 85 </header> 86 86 {{ end }} 87 87 88 - {{ define "contentLayout" }} 89 - {{ block "content" . }}{{ end }} 90 - {{ end }} 88 + {{ define "mainLayout" }} 89 + <div class="px-1 col-span-full flex flex-col gap-4"> 90 + {{ block "contentLayout" . }} 91 + {{ block "content" . }}{{ end }} 92 + {{ end }} 91 93 92 - {{ define "contentAfterLayout" }} 93 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 94 - <div class="col-span-1 md:col-span-2"> 95 - {{ block "contentAfterLeft" . }} {{ end }} 94 + {{ block "contentAfterLayout" . }} 95 + <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 96 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 97 + {{ block "contentAfterLeft" . }} {{ end }} 98 + </div> 99 + <main class="col-span-1 md:col-span-10"> 100 + {{ block "contentAfter" . }}{{ end }} 101 + </main> 96 102 </div> 97 - <main class="col-span-1 md:col-span-10"> 98 - {{ block "contentAfter" . }}{{ end }} 99 - </main> 103 + {{ end }} 100 104 </div> 101 105 {{ end }} 102 106 103 - {{ define "footerLayout" }} 104 - {{ template "layouts/footer" . }} 107 + {{ define "footerLayout" }} 108 + <footer class="px-1 col-span-full mt-12"> 109 + {{ template "layouts/footer" . }} 110 + </footer> 105 111 {{ end }} 106 112 107 113 {{ define "contentAfter" }} ··· 112 118 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 113 119 {{ template "repo/fragments/diffOpts" .DiffOpts }} 114 120 </div> 115 - <div class="sticky top-0 mt-4"> 121 + <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 116 122 {{ template "repo/fragments/diffChangedFiles" .Diff }} 117 123 </div> 118 124 {{end}}
+22 -14
appview/pages/templates/repo/compare/compare.html
··· 11 11 {{ end }} 12 12 13 13 {{ define "topbarLayout" }} 14 - {{ template "layouts/topbar" . }} 14 + <header class="px-1 col-span-full" style="z-index: 20;"> 15 + {{ template "layouts/topbar" . }} 16 + </header> 15 17 {{ end }} 16 18 17 - {{ define "contentLayout" }} 18 - {{ block "content" . }}{{ end }} 19 - {{ end }} 19 + {{ define "mainLayout" }} 20 + <div class="px-1 col-span-full flex flex-col gap-4"> 21 + {{ block "contentLayout" . }} 22 + {{ block "content" . }}{{ end }} 23 + {{ end }} 20 24 21 - {{ define "contentAfterLayout" }} 22 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 23 - <div class="col-span-1 md:col-span-2"> 24 - {{ block "contentAfterLeft" . }} {{ end }} 25 + {{ block "contentAfterLayout" . }} 26 + <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 27 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 28 + {{ block "contentAfterLeft" . }} {{ end }} 29 + </div> 30 + <main class="col-span-1 md:col-span-10"> 31 + {{ block "contentAfter" . }}{{ end }} 32 + </main> 25 33 </div> 26 - <main class="col-span-1 md:col-span-10"> 27 - {{ block "contentAfter" . }}{{ end }} 28 - </main> 34 + {{ end }} 29 35 </div> 30 36 {{ end }} 31 37 32 - {{ define "footerLayout" }} 33 - {{ template "layouts/footer" . }} 38 + {{ define "footerLayout" }} 39 + <footer class="px-1 col-span-full mt-12"> 40 + {{ template "layouts/footer" . }} 41 + </footer> 34 42 {{ end }} 35 43 36 44 {{ define "contentAfter" }} ··· 41 49 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 42 50 {{ template "repo/fragments/diffOpts" .DiffOpts }} 43 51 </div> 44 - <div class="sticky top-0 mt-4"> 52 + <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 45 53 {{ template "repo/fragments/diffChangedFiles" .Diff }} 46 54 </div> 47 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 }}
+1 -1
appview/pages/templates/repo/fragments/diffChangedFiles.html
··· 1 1 {{ define "repo/fragments/diffChangedFiles" }} 2 2 {{ $stat := .Stat }} 3 3 {{ $fileTree := fileTree .ChangedFiles }} 4 - <section class="overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto md:min-h-screen rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 4 + <section class="overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto min-h-full rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 5 5 <div class="diff-stat"> 6 6 <div class="flex gap-2 items-center"> 7 7 <strong class="text-sm uppercase dark:text-gray-200">Changed files</strong>
+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 md:min-h-screen 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 }}
+100 -130
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 }} ··· 170 146 {{ define "commitLog" }} 171 147 <div id="commit-log" class="md:col-span-1 px-2 pb-4"> 172 148 <div class="flex justify-between items-center"> 173 - <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group"> 174 - <div class="flex gap-2 items-center font-bold"> 175 - {{ i "logs" "w-4 h-4" }} commits 176 - </div> 177 - <span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 "> 178 - view {{ .TotalCommits }} commits {{ i "chevron-right" "w-4 h-4" }} 179 - </span> 149 + <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline"> 150 + {{ i "logs" "w-4 h-4" }} commits 151 + <span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ .TotalCommits }}</span> 180 152 </a> 181 153 </div> 182 154 <div class="flex flex-col gap-6"> ··· 214 186 </div> 215 187 216 188 <!-- commit info bar --> 217 - <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"> 218 190 {{ $verified := $.VerifiedCommits.IsVerified .Hash.String }} 219 191 {{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }} 220 192 {{ if $verified }} ··· 278 250 {{ define "branchList" }} 279 251 {{ if gt (len .BranchesTrunc) 0 }} 280 252 <div id="branches" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700"> 281 - <a href="/{{ .RepoInfo.FullName }}/branches" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group"> 282 - <div class="flex gap-2 items-center font-bold"> 283 - {{ i "git-branch" "w-4 h-4" }} branches 284 - </div> 285 - <span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 "> 286 - view {{ len .Branches }} branches {{ i "chevron-right" "w-4 h-4" }} 287 - </span> 253 + <a href="/{{ .RepoInfo.FullName }}/branches" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline"> 254 + {{ i "git-branch" "w-4 h-4" }} branches 255 + <span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Branches }}</span> 288 256 </a> 289 257 <div class="flex flex-col gap-1"> 290 258 {{ range .BranchesTrunc }} 291 - <div class="text-base flex items-center justify-between"> 292 - <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"> 293 261 <a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Reference.Name | urlquery }}" 294 - class="inline no-underline hover:underline dark:text-white"> 262 + class="inline-block truncate no-underline hover:underline dark:text-white"> 295 263 {{ .Reference.Name }} 296 264 </a> 297 265 {{ if .Commit }} 298 - <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 299 - <span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .Commit.Committer.When }}</span> 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> 300 268 {{ end }} 301 269 {{ if .IsDefault }} 302 - <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 303 - <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-xs font-mono">default</span> 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> 304 272 {{ end }} 305 273 </div> 306 274 {{ if ne $.Ref .Reference.Name }} 307 275 <a href="/{{ $.RepoInfo.FullName }}/compare/{{ $.Ref | urlquery }}...{{ .Reference.Name | urlquery }}" 308 - class="text-xs flex gap-2 items-center" 276 + class="text-xs flex gap-2 items-center shrink-0 ml-2" 309 277 title="Compare branches or tags"> 310 278 {{ i "git-compare" "w-3 h-3" }} compare 311 279 </a> 312 - {{end}} 280 + {{ end }} 313 281 </div> 314 282 {{ end }} 315 283 </div> ··· 321 289 {{ if gt (len .TagsTrunc) 0 }} 322 290 <div id="tags" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700"> 323 291 <div class="flex justify-between items-center"> 324 - <a href="/{{ .RepoInfo.FullName }}/tags" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group"> 325 - <div class="flex gap-2 items-center font-bold"> 326 - {{ i "tags" "w-4 h-4" }} tags 327 - </div> 328 - <span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 "> 329 - view {{ len .Tags }} tags {{ i "chevron-right" "w-4 h-4" }} 330 - </span> 292 + <a href="/{{ .RepoInfo.FullName }}/tags" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline"> 293 + {{ i "tags" "w-4 h-4" }} tags 294 + <span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Tags }}</span> 331 295 </a> 332 296 </div> 333 297 <div class="flex flex-col gap-1"> ··· 359 323 360 324 {{ define "repoAfter" }} 361 325 {{- if or .HTMLReadme .Readme -}} 362 - <section 363 - class="p-6 mt-4 rounded-br rounded-bl bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm w-full mx-auto overflow-auto {{ if not .Raw }} 364 - prose dark:prose-invert dark:[&_pre]:bg-gray-900 365 - dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 366 - dark:[&_pre]:border dark:[&_pre]:border-gray-700 367 - {{ end }}" 368 - > 369 - <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto"> 370 - {{- .Readme -}} 371 - </pre> 372 - {{- else -}} 373 - {{ .HTMLReadme }} 374 - {{- end -}}</article> 375 - </section> 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> 376 348 {{- end -}} 377 - 378 - {{ template "repo/fragments/cloneInstructions" . }} 379 349 {{ end }}
+2 -4
appview/pages/templates/repo/issues/fragments/editIssueComment.html
··· 1 1 {{ define "repo/issues/fragments/editIssueComment" }} 2 2 {{ with .Comment }} 3 3 <div id="comment-container-{{.CommentId}}"> 4 - <div class="flex items-center gap-2 mb-2 text-gray-500 text-sm"> 4 + <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 5 {{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }} 6 6 <a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a> 7 7 ··· 9 9 {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 10 10 {{ if $isIssueAuthor }} 11 11 <span class="before:content-['ยท']"></span> 12 - <span class="rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center"> 13 12 author 14 - </span> 15 13 {{ end }} 16 14 17 15 <span class="before:content-['ยท']"></span> 18 16 <a 19 17 href="#{{ .CommentId }}" 20 - class="text-gray-500 hover:text-gray-500 hover:underline no-underline" 18 + class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 21 19 id="{{ .CommentId }}"> 22 20 {{ template "repo/fragments/time" .Created }} 23 21 </a>
+8 -10
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 }} 6 + 7 + <!-- show user "hats" --> 8 + {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 9 + {{ if $isIssueAuthor }} 10 + <span class="before:content-['ยท']"></span> 11 + author 12 + {{ end }} 7 13 8 14 <span class="before:content-['ยท']"></span> 9 15 <a ··· 18 24 {{ template "repo/fragments/time" .Created }} 19 25 {{ end }} 20 26 </a> 21 - 22 - <!-- show user "hats" --> 23 - {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 24 - {{ if $isIssueAuthor }} 25 - <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 26 - author 27 - </span> 28 - {{ end }} 29 27 30 28 {{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }} 31 29 {{ if and $isCommentOwner (not .Deleted) }}
+3 -3
appview/pages/templates/repo/issues/issue.html
··· 11 11 {{ define "repoContent" }} 12 12 <header class="pb-4"> 13 13 <h1 class="text-2xl"> 14 - {{ .Issue.Title }} 14 + {{ .Issue.Title | description }} 15 15 <span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span> 16 16 </h1> 17 17 </header> ··· 54 54 "Kind" $kind 55 55 "Count" (index $.Reactions $kind) 56 56 "IsReacted" (index $.UserReacted $kind) 57 - "ThreadAt" $.Issue.IssueAt) 57 + "ThreadAt" $.Issue.AtUri) 58 58 }} 59 59 {{ end }} 60 60 </div> ··· 70 70 {{ if gt $index 0 }} 71 71 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 72 72 {{ end }} 73 - {{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "DidHandleMap" $.DidHandleMap "Issue" $.Issue "Comment" .)}} 73 + {{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "Issue" $.Issue "Comment" .)}} 74 74 </div> 75 75 {{ end }} 76 76 </section>
+2 -3
appview/pages/templates/repo/issues/issues.html
··· 45 45 href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 46 class="no-underline hover:underline" 47 47 > 48 - {{ .Title }} 48 + {{ .Title | description }} 49 49 <span class="text-gray-500">#{{ .IssueId }}</span> 50 50 </a> 51 51 </div> ··· 65 65 </span> 66 66 67 67 <span class="ml-1"> 68 - {{ $owner := index $.DidHandleMap .OwnerDid }} 69 - {{ template "user/fragments/picHandleLink" $owner }} 68 + {{ template "user/fragments/picHandleLink" .OwnerDid }} 70 69 </span> 71 70 72 71 <span class="before:content-['ยท']">
+2 -2
appview/pages/templates/repo/log.html
··· 21 21 <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-3">Commit</div> 22 22 <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-6">Message</div> 23 23 <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-1"></div> 24 - <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2">Date</div> 24 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2 justify-self-end">Date</div> 25 25 </div> 26 26 {{ range $index, $commit := .Commits }} 27 27 {{ $messageParts := splitN $commit.Message "\n\n" 2 }} ··· 85 85 {{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }} 86 86 {{ end }} 87 87 </div> 88 - <div class="align-top text-gray-500 dark:text-gray-400 col-span-2">{{ template "repo/fragments/shortTimeAgo" $commit.Committer.When }}</div> 88 + <div class="align-top justify-self-end text-gray-500 dark:text-gray-400 col-span-2">{{ template "repo/fragments/shortTimeAgo" $commit.Committer.When }}</div> 89 89 </div> 90 90 {{ end }} 91 91 </div>
+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
+6 -8
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
··· 9 9 </div> 10 10 <span class="truncate text-sm text-gray-800 dark:text-gray-200"> 11 11 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 12 - {{ .Title }} 12 + {{ .Title | description }} 13 13 </span> 14 14 </div> 15 15 16 - <div class="flex-shrink-0 flex items-center"> 16 + <div class="flex-shrink-0 flex items-center gap-2"> 17 17 {{ $latestRound := .LastRoundNumber }} 18 18 {{ $lastSubmission := index .Submissions $latestRound }} 19 19 {{ $commentCount := len $lastSubmission.Comments }} 20 20 {{ if and $pipeline $pipeline.Id }} 21 - <div class="inline-flex items-center gap-2"> 22 - {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 23 - <span class="mx-2 before:content-['ยท'] before:select-none"></span> 24 - </div> 21 + {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 22 + <span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span> 25 23 {{ end }} 26 24 <span> 27 - <div class="inline-flex items-center gap-2"> 25 + <div class="inline-flex items-center gap-1"> 28 26 {{ i "message-square" "w-3 h-3 md:hidden" }} 29 27 {{ $commentCount }} 30 28 <span class="hidden md:inline">comment{{if ne $commentCount 1}}s{{end}}</span> 31 29 </div> 32 30 </span> 33 - <span class="mx-2 before:content-['ยท'] before:select-none"></span> 31 + <span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span> 34 32 <span> 35 33 <span class="hidden md:inline">round</span> 36 34 <span class="font-mono">#{{ $latestRound }}</span>
+22 -14
appview/pages/templates/repo/pulls/interdiff.html
··· 29 29 {{ end }} 30 30 31 31 {{ define "topbarLayout" }} 32 - {{ template "layouts/topbar" . }} 32 + <header class="px-1 col-span-full" style="z-index: 20;"> 33 + {{ template "layouts/topbar" . }} 34 + </header> 33 35 {{ end }} 34 36 35 - {{ define "contentLayout" }} 36 - {{ block "content" . }}{{ end }} 37 - {{ end }} 37 + {{ define "mainLayout" }} 38 + <div class="px-1 col-span-full flex flex-col gap-4"> 39 + {{ block "contentLayout" . }} 40 + {{ block "content" . }}{{ end }} 41 + {{ end }} 38 42 39 - {{ define "contentAfterLayout" }} 40 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 41 - <div class="col-span-1 md:col-span-2"> 42 - {{ block "contentAfterLeft" . }} {{ end }} 43 + {{ block "contentAfterLayout" . }} 44 + <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 45 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 46 + {{ block "contentAfterLeft" . }} {{ end }} 47 + </div> 48 + <main class="col-span-1 md:col-span-10"> 49 + {{ block "contentAfter" . }}{{ end }} 50 + </main> 43 51 </div> 44 - <main class="col-span-1 md:col-span-10"> 45 - {{ block "contentAfter" . }}{{ end }} 46 - </main> 52 + {{ end }} 47 53 </div> 48 54 {{ end }} 49 55 50 - {{ define "footerLayout" }} 51 - {{ template "layouts/footer" . }} 56 + {{ define "footerLayout" }} 57 + <footer class="px-1 col-span-full mt-12"> 58 + {{ template "layouts/footer" . }} 59 + </footer> 52 60 {{ end }} 53 61 54 62 ··· 60 68 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 61 69 {{ template "repo/fragments/diffOpts" .DiffOpts }} 62 70 </div> 63 - <div class="sticky top-0 mt-4"> 71 + <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 64 72 {{ template "repo/fragments/interdiffFiles" .Interdiff }} 65 73 </div> 66 74 {{end}}
+22 -14
appview/pages/templates/repo/pulls/patch.html
··· 35 35 {{ end }} 36 36 37 37 {{ define "topbarLayout" }} 38 - {{ template "layouts/topbar" . }} 38 + <header class="px-1 col-span-full" style="z-index: 20;"> 39 + {{ template "layouts/topbar" . }} 40 + </header> 39 41 {{ end }} 40 42 41 - {{ define "contentLayout" }} 42 - {{ block "content" . }}{{ end }} 43 - {{ end }} 43 + {{ define "mainLayout" }} 44 + <div class="px-1 col-span-full flex flex-col gap-4"> 45 + {{ block "contentLayout" . }} 46 + {{ block "content" . }}{{ end }} 47 + {{ end }} 44 48 45 - {{ define "contentAfterLayout" }} 46 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 47 - <div class="col-span-1 md:col-span-2"> 48 - {{ block "contentAfterLeft" . }} {{ end }} 49 + {{ block "contentAfterLayout" . }} 50 + <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 51 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 52 + {{ block "contentAfterLeft" . }} {{ end }} 53 + </div> 54 + <main class="col-span-1 md:col-span-10"> 55 + {{ block "contentAfter" . }}{{ end }} 56 + </main> 49 57 </div> 50 - <main class="col-span-1 md:col-span-10"> 51 - {{ block "contentAfter" . }}{{ end }} 52 - </main> 58 + {{ end }} 53 59 </div> 54 60 {{ end }} 55 61 56 - {{ define "footerLayout" }} 57 - {{ template "layouts/footer" . }} 62 + {{ define "footerLayout" }} 63 + <footer class="px-1 col-span-full mt-12"> 64 + {{ template "layouts/footer" . }} 65 + </footer> 58 66 {{ end }} 59 67 60 68 {{ define "contentAfter" }} ··· 65 73 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 66 74 {{ template "repo/fragments/diffOpts" .DiffOpts }} 67 75 </div> 68 - <div class="sticky top-0 mt-4"> 76 + <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 69 77 {{ template "repo/fragments/diffChangedFiles" .Diff }} 70 78 </div> 71 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>
+45 -55
appview/pages/templates/repo/pulls/pulls.html
··· 50 50 <div class="px-6 py-4 z-5"> 51 51 <div class="pb-2"> 52 52 <a href="/{{ $.RepoInfo.FullName }}/pulls/{{ .PullId }}" class="dark:text-white"> 53 - {{ .Title }} 53 + {{ .Title | description }} 54 54 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 55 55 </a> 56 56 </div> 57 - <p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 58 - {{ $owner := index $.DidHandleMap .OwnerDid }} 57 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 59 58 {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 60 59 {{ $icon := "ban" }} 61 60 ··· 76 75 </span> 77 76 78 77 <span class="ml-1"> 79 - {{ template "user/fragments/picHandleLink" $owner }} 78 + {{ template "user/fragments/picHandleLink" .OwnerDid }} 80 79 </span> 81 80 82 81 <span class="before:content-['ยท']"> 83 82 {{ template "repo/fragments/time" .Created }} 84 83 </span> 85 84 85 + 86 + {{ $latestRound := .LastRoundNumber }} 87 + {{ $lastSubmission := index .Submissions $latestRound }} 88 + 86 89 <span class="before:content-['ยท']"> 87 - targeting 88 - <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 89 - {{ .TargetBranch }} 90 - </span> 91 - </span> 92 - {{ if not .IsPatchBased }} 93 - from 94 - <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 95 - {{ if .IsForkBased }} 96 - {{ if .PullSource.Repo }} 97 - <a href="/{{ $owner }}/{{ .PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .PullSource.Repo.Name }}</a>: 98 - {{- else -}} 99 - <span class="italic">[deleted fork]</span> 100 - {{- end -}} 101 - {{- end -}} 102 - {{- .PullSource.Branch -}} 90 + {{ $commentCount := len $lastSubmission.Comments }} 91 + {{ $s := "s" }} 92 + {{ if eq $commentCount 1 }} 93 + {{ $s = "" }} 94 + {{ end }} 95 + 96 + {{ len $lastSubmission.Comments}} comment{{$s}} 103 97 </span> 104 - {{ end }} 105 - <span class="before:content-['ยท']"> 106 - {{ $latestRound := .LastRoundNumber }} 107 - {{ $lastSubmission := index .Submissions $latestRound }} 108 - round 109 - <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 110 - #{{ .LastRoundNumber }} 111 - </span> 112 - {{ $commentCount := len $lastSubmission.Comments }} 113 - {{ $s := "s" }} 114 - {{ if eq $commentCount 1 }} 115 - {{ $s = "" }} 116 - {{ end }} 117 98 118 - {{ if eq $commentCount 0 }} 119 - awaiting comments 120 - {{ else }} 121 - recieved {{ len $lastSubmission.Comments}} comment{{$s}} 122 - {{ end }} 99 + <span class="before:content-['ยท']"> 100 + round 101 + <span class="font-mono"> 102 + #{{ .LastRoundNumber }} 103 + </span> 123 104 </span> 124 - </p> 105 + 106 + {{ $pipeline := index $.Pipelines .LatestSha }} 107 + {{ if and $pipeline $pipeline.Id }} 108 + <span class="before:content-['ยท']"></span> 109 + {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 110 + {{ end }} 111 + </div> 125 112 </div> 126 113 {{ if .StackId }} 127 114 {{ $otherPulls := index $.Stacks .StackId }} 128 - <details class="bg-white dark:bg-gray-800 group"> 129 - <summary class="pb-4 px-6 text-xs list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 130 - {{ $s := "s" }} 131 - {{ if eq (len $otherPulls) 1 }} 132 - {{ $s = "" }} 133 - {{ end }} 134 - <div class="group-open:hidden flex items-center gap-2"> 135 - {{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $otherPulls }} pull{{$s}} in this stack 136 - </div> 137 - <div class="hidden group-open:flex items-center gap-2"> 138 - {{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack 139 - </div> 140 - </summary> 141 - {{ block "pullList" (list $otherPulls $) }} {{ end }} 142 - </details> 115 + {{ if gt (len $otherPulls) 0 }} 116 + <details class="bg-white dark:bg-gray-800 group"> 117 + <summary class="pb-4 px-6 text-xs list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 118 + {{ $s := "s" }} 119 + {{ if eq (len $otherPulls) 1 }} 120 + {{ $s = "" }} 121 + {{ end }} 122 + <div class="group-open:hidden flex items-center gap-2"> 123 + {{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $otherPulls }} pull{{$s}} in this stack 124 + </div> 125 + <div class="hidden group-open:flex items-center gap-2"> 126 + {{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack 127 + </div> 128 + </summary> 129 + {{ block "pullList" (list $otherPulls $) }} {{ end }} 130 + </details> 131 + {{ end }} 143 132 {{ end }} 144 133 </div> 145 134 {{ end }} ··· 151 140 {{ $root := index . 1 }} 152 141 <div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900"> 153 142 {{ range $pull := $list }} 143 + {{ $pipeline := index $root.Pipelines $pull.LatestSha }} 154 144 <a href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $pull.PullId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 155 145 <div class="flex gap-2 items-center px-6"> 156 146 <div class="flex-grow min-w-0 w-full py-2"> 157 - {{ template "repo/pulls/fragments/summarizedHeader" (list $pull 0) }} 147 + {{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }} 158 148 </div> 159 149 </div> 160 150 </a>
+110
appview/pages/templates/repo/settings/access.html
··· 1 + {{ define "title" }}{{ .Tab }} settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 + 3 + {{ define "repoContent" }} 4 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2"> 5 + <div class="col-span-1"> 6 + {{ template "repo/settings/fragments/sidebar" . }} 7 + </div> 8 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 + {{ template "collaboratorSettings" . }} 10 + </div> 11 + </section> 12 + {{ end }} 13 + 14 + {{ define "collaboratorSettings" }} 15 + <div class="grid grid-cols-1 gap-4 items-center"> 16 + <div class="col-span-1"> 17 + <h2 class="text-sm pb-2 uppercase font-bold">Collaborators</h2> 18 + <p class="text-gray-500 dark:text-gray-400"> 19 + Any user added as a collaborator will be able to push commits and tags to this repository, upload releases, and workflows. 20 + </p> 21 + </div> 22 + {{ template "collaboratorsGrid" . }} 23 + </div> 24 + {{ end }} 25 + 26 + {{ define "collaboratorsGrid" }} 27 + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> 28 + {{ if .RepoInfo.Roles.CollaboratorInviteAllowed }} 29 + {{ template "addCollaboratorButton" . }} 30 + {{ end }} 31 + {{ range .Collaborators }} 32 + <div class="border border-gray-200 dark:border-gray-700 rounded p-4"> 33 + <div class="flex items-center gap-3"> 34 + <img 35 + src="{{ fullAvatar .Handle }}" 36 + alt="{{ .Handle }}" 37 + class="rounded-full h-10 w-10 border border-gray-300 dark:border-gray-600 flex-shrink-0"/> 38 + 39 + <div class="flex-1 min-w-0"> 40 + <a href="/{{ .Handle }}" class="block truncate"> 41 + {{ didOrHandle .Did .Handle }} 42 + </a> 43 + <p class="text-sm text-gray-500 dark:text-gray-400">{{ .Role }}</p> 44 + </div> 45 + </div> 46 + </div> 47 + {{ end }} 48 + </div> 49 + {{ end }} 50 + 51 + {{ define "addCollaboratorButton" }} 52 + <button 53 + class="btn block rounded p-4" 54 + popovertarget="add-collaborator-modal" 55 + popovertargetaction="toggle"> 56 + <div class="flex items-center gap-3"> 57 + <div class="w-10 h-10 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center"> 58 + {{ i "user-plus" "size-4" }} 59 + </div> 60 + 61 + <div class="text-left flex-1 min-w-0 block truncate"> 62 + Add collaborator 63 + </div> 64 + </div> 65 + </button> 66 + <div 67 + id="add-collaborator-modal" 68 + popover 69 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 70 + {{ template "addCollaboratorModal" . }} 71 + </div> 72 + {{ end }} 73 + 74 + {{ define "addCollaboratorModal" }} 75 + <form 76 + hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator" 77 + hx-indicator="#spinner" 78 + hx-swap="none" 79 + class="flex flex-col gap-2" 80 + > 81 + <label for="add-collaborator" class="uppercase p-0"> 82 + ADD COLLABORATOR 83 + </label> 84 + <p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p> 85 + <input 86 + type="text" 87 + id="add-collaborator" 88 + name="collaborator" 89 + required 90 + placeholder="@foo.bsky.social" 91 + /> 92 + <div class="flex gap-2 pt-2"> 93 + <button 94 + type="button" 95 + popovertarget="add-collaborator-modal" 96 + popovertargetaction="hide" 97 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 98 + > 99 + {{ i "x" "size-4" }} cancel 100 + </button> 101 + <button type="submit" class="btn w-1/2 flex items-center"> 102 + <span class="inline-flex gap-2 items-center">{{ i "user-plus" "size-4" }} add</span> 103 + <span id="spinner" class="group"> 104 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 105 + </span> 106 + </button> 107 + </div> 108 + <div id="add-collaborator-error" class="text-red-500 dark:text-red-400"></div> 109 + </form> 110 + {{ end }}
+29
appview/pages/templates/repo/settings/fragments/secretListing.html
··· 1 + {{ define "repo/settings/fragments/secretListing" }} 2 + {{ $root := index . 0 }} 3 + {{ $secret := index . 1 }} 4 + <div id="secret-{{$secret.Key}}" class="flex items-center justify-between p-2"> 5 + <div class="hover:no-underline flex flex-col gap-1 text-sm min-w-0 max-w-[80%]"> 6 + <span class="font-mono"> 7 + {{ $secret.Key }} 8 + </span> 9 + <div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 10 + <span>added by</span> 11 + <span>{{ template "user/fragments/picHandleLink" $secret.CreatedBy }}</span> 12 + <span class="before:content-['ยท'] before:select-none"></span> 13 + <span>{{ template "repo/fragments/shortTimeAgo" $secret.CreatedAt }}</span> 14 + </div> 15 + </div> 16 + <button 17 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 18 + title="Delete secret" 19 + hx-delete="/{{ $root.RepoInfo.FullName }}/settings/secrets" 20 + hx-swap="none" 21 + hx-vals='{"key": "{{ $secret.Key }}"}' 22 + hx-confirm="Are you sure you want to delete the secret {{ $secret.Key }}?" 23 + > 24 + {{ i "trash-2" "w-5 h-5" }} 25 + <span class="hidden md:inline">delete</span> 26 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 27 + </button> 28 + </div> 29 + {{ end }}
+16
appview/pages/templates/repo/settings/fragments/sidebar.html
··· 1 + {{ define "repo/settings/fragments/sidebar" }} 2 + {{ $active := .Tab }} 3 + {{ $tabs := .Tabs }} 4 + <div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 shadow-inner"> 5 + {{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }} 6 + {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }} 7 + {{ range $tabs }} 8 + <a href="/{{ $.RepoInfo.FullName }}/settings?tab={{.Name}}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 9 + <div class="flex gap-3 items-center p-2 {{ if eq .Name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 10 + {{ i .Icon "size-4" }} 11 + {{ .Name }} 12 + </div> 13 + </a> 14 + {{ end }} 15 + </div> 16 + {{ end }}
+70
appview/pages/templates/repo/settings/general.html
··· 1 + {{ define "title" }}{{ .Tab }} settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 + 3 + {{ define "repoContent" }} 4 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2"> 5 + <div class="col-span-1"> 6 + {{ template "repo/settings/fragments/sidebar" . }} 7 + </div> 8 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 + {{ template "branchSettings" . }} 10 + {{ template "deleteRepo" . }} 11 + <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 12 + </div> 13 + </section> 14 + {{ end }} 15 + 16 + {{ define "branchSettings" }} 17 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 18 + <div class="col-span-1 md:col-span-2"> 19 + <h2 class="text-sm pb-2 uppercase font-bold">Default Branch</h2> 20 + <p class="text-gray-500 dark:text-gray-400"> 21 + The default branch is considered the โ€œbaseโ€ branch in your repository, 22 + against which all pull requests and code commits are automatically made, 23 + unless you specify a different branch. 24 + </p> 25 + </div> 26 + <form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" hx-swap="none" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 27 + <select id="branch" name="branch" required class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 28 + <option value="" disabled selected > 29 + Choose a default branch 30 + </option> 31 + {{ range .Branches }} 32 + <option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} > 33 + {{ .Name }} 34 + </option> 35 + {{ end }} 36 + </select> 37 + <button class="btn flex gap-2 items-center" type="submit"> 38 + {{ i "check" "size-4" }} 39 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 40 + </button> 41 + </form> 42 + </div> 43 + {{ end }} 44 + 45 + {{ define "deleteRepo" }} 46 + {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 47 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 48 + <div class="col-span-1 md:col-span-2"> 49 + <h2 class="text-sm pb-2 uppercase text-red-500 dark:text-red-400 font-bold">Delete Repository</h2> 50 + <p class="text-red-500 dark:text-red-400 "> 51 + Deleting a repository is irreversible and permanent. Be certain before deleting a repository. 52 + </p> 53 + </div> 54 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 55 + <button 56 + class="btn group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center" 57 + type="button" 58 + hx-swap="none" 59 + hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 60 + hx-confirm="Are you sure you want to delete {{ $.RepoInfo.FullName }}?"> 61 + {{ i "trash-2" "size-4" }} 62 + delete 63 + <span class="ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline"> 64 + {{ i "loader-circle" "w-4 h-4" }} 65 + </span> 66 + </button> 67 + </div> 68 + </div> 69 + {{ end }} 70 + {{ end }}
+145
appview/pages/templates/repo/settings/pipelines.html
··· 1 + {{ define "title" }}{{ .Tab }} settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 + 3 + {{ define "repoContent" }} 4 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2"> 5 + <div class="col-span-1"> 6 + {{ template "repo/settings/fragments/sidebar" . }} 7 + </div> 8 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 + {{ template "spindleSettings" . }} 10 + {{ if $.CurrentSpindle }} 11 + {{ template "secretSettings" . }} 12 + {{ end }} 13 + <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 14 + </div> 15 + </section> 16 + {{ end }} 17 + 18 + {{ define "spindleSettings" }} 19 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 20 + <div class="col-span-1 md:col-span-2"> 21 + <h2 class="text-sm pb-2 uppercase font-bold">Spindle</h2> 22 + <p class="text-gray-500 dark:text-gray-400"> 23 + Choose a spindle to execute your workflows on. Only repository owners 24 + can configure spindles. Spindles can be selfhosted, 25 + <a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md"> 26 + click to learn more. 27 + </a> 28 + </p> 29 + </div> 30 + {{ if not $.RepoInfo.Roles.IsOwner }} 31 + <div class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 32 + {{ or $.CurrentSpindle "No spindle configured" }} 33 + </div> 34 + {{ else }} 35 + <form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 36 + <select 37 + id="spindle" 38 + name="spindle" 39 + required 40 + class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 41 + {{/* For some reason, we can't use an empty string in a <select> in all scenarios unless it is preceded by a disabled select?? No idea, could just be a Firefox thing? */}} 42 + <option value="[[none]]" class="py-1" {{ if not $.CurrentSpindle }}selected{{ end }}> 43 + {{ if not $.CurrentSpindle }} 44 + Choose a spindle 45 + {{ else }} 46 + Disable pipelines 47 + {{ end }} 48 + </option> 49 + {{ range $.Spindles }} 50 + <option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}> 51 + {{ . }} 52 + </option> 53 + {{ end }} 54 + </select> 55 + <button class="btn flex gap-2 items-center" type="submit" {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}> 56 + {{ i "check" "size-4" }} 57 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 58 + </button> 59 + </form> 60 + {{ end }} 61 + </div> 62 + {{ end }} 63 + 64 + {{ define "secretSettings" }} 65 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 66 + <div class="col-span-1 md:col-span-2"> 67 + <h2 class="text-sm pb-2 uppercase font-bold">SECRETS</h2> 68 + <p class="text-gray-500 dark:text-gray-400"> 69 + Secrets are accessible in workflow runs via environment variables. Anyone 70 + with collaborator access to this repository can add and use secrets in 71 + workflow runs. 72 + </p> 73 + </div> 74 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 75 + {{ template "addSecretButton" . }} 76 + </div> 77 + </div> 78 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 79 + {{ range .Secrets }} 80 + {{ template "repo/settings/fragments/secretListing" (list $ .) }} 81 + {{ else }} 82 + <div class="flex items-center justify-center p-2 text-gray-500"> 83 + no secrets added yet 84 + </div> 85 + {{ end }} 86 + </div> 87 + {{ end }} 88 + 89 + {{ define "addSecretButton" }} 90 + <button 91 + class="btn flex items-center gap-2" 92 + popovertarget="add-secret-modal" 93 + popovertargetaction="toggle"> 94 + {{ i "plus" "size-4" }} 95 + add secret 96 + </button> 97 + <div 98 + id="add-secret-modal" 99 + popover 100 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 101 + {{ template "addSecretModal" . }} 102 + </div> 103 + {{ end}} 104 + 105 + {{ define "addSecretModal" }} 106 + <form 107 + hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets" 108 + hx-indicator="#spinner" 109 + hx-swap="none" 110 + class="flex flex-col gap-2" 111 + > 112 + <p class="uppercase p-0">ADD SECRET</p> 113 + <p class="text-sm text-gray-500 dark:text-gray-400">Secrets are available as environment variables in the workflow.</p> 114 + <input 115 + type="text" 116 + id="secret-key" 117 + name="key" 118 + required 119 + placeholder="SECRET_NAME" 120 + /> 121 + <textarea 122 + type="text" 123 + id="secret-value" 124 + name="value" 125 + required 126 + placeholder="secret value"></textarea> 127 + <div class="flex gap-2 pt-2"> 128 + <button 129 + type="button" 130 + popovertarget="add-secret-modal" 131 + popovertargetaction="hide" 132 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 133 + > 134 + {{ i "x" "size-4" }} cancel 135 + </button> 136 + <button type="submit" class="btn w-1/2 flex items-center"> 137 + <span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span> 138 + <span id="spinner" class="group"> 139 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 140 + </span> 141 + </button> 142 + </div> 143 + <div id="add-secret-error" class="text-red-500 dark:text-red-400"></div> 144 + </form> 145 + {{ end }}
-138
appview/pages/templates/repo/settings.html
··· 1 - {{ define "title" }}settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 - {{ define "repoContent" }} 3 - <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 4 - Collaborators 5 - </header> 6 - 7 - <div id="collaborator-list" class="flex flex-col gap-2 mb-2"> 8 - {{ range .Collaborators }} 9 - <div id="collaborator" class="mb-2"> 10 - <a 11 - href="/{{ didOrHandle .Did .Handle }}" 12 - class="no-underline hover:underline text-black dark:text-white" 13 - > 14 - {{ didOrHandle .Did .Handle }} 15 - </a> 16 - <div> 17 - <span class="text-sm text-gray-500 dark:text-gray-400"> 18 - {{ .Role }} 19 - </span> 20 - </div> 21 - </div> 22 - {{ end }} 23 - </div> 24 - 25 - {{ if .RepoInfo.Roles.CollaboratorInviteAllowed }} 26 - <form 27 - hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator" 28 - class="group" 29 - > 30 - <label for="collaborator" class="dark:text-white"> 31 - add collaborator 32 - </label> 33 - <input 34 - type="text" 35 - id="collaborator" 36 - name="collaborator" 37 - required 38 - class="dark:bg-gray-700 dark:text-white" 39 - placeholder="enter did or handle" 40 - > 41 - <button 42 - class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700" 43 - type="text" 44 - > 45 - <span>add</span> 46 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 47 - </button> 48 - </form> 49 - {{ end }} 50 - 51 - <form 52 - hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" 53 - class="mt-6 group" 54 - > 55 - <label for="branch">default branch</label> 56 - <div class="flex gap-2 items-center"> 57 - <select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 58 - <option 59 - value="" 60 - disabled 61 - selected 62 - > 63 - Choose a default branch 64 - </option> 65 - {{ range .Branches }} 66 - <option 67 - value="{{ .Name }}" 68 - class="py-1" 69 - {{ if .IsDefault }} 70 - selected 71 - {{ end }} 72 - > 73 - {{ .Name }} 74 - </option> 75 - {{ end }} 76 - </select> 77 - <button class="btn my-2 flex gap-2 items-center" type="submit"> 78 - <span>save</span> 79 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 80 - </button> 81 - </div> 82 - </form> 83 - 84 - {{ if .RepoInfo.Roles.IsOwner }} 85 - <form 86 - hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" 87 - class="mt-6 group" 88 - > 89 - <label for="spindle">spindle</label> 90 - <div class="flex gap-2 items-center"> 91 - <select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 92 - <option 93 - value="" 94 - selected 95 - > 96 - None 97 - </option> 98 - {{ range .Spindles }} 99 - <option 100 - value="{{ . }}" 101 - class="py-1" 102 - {{ if eq . $.CurrentSpindle }} 103 - selected 104 - {{ end }} 105 - > 106 - {{ . }} 107 - </option> 108 - {{ end }} 109 - </select> 110 - <button class="btn my-2 flex gap-2 items-center" type="submit"> 111 - <span>save</span> 112 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 113 - </button> 114 - </div> 115 - </form> 116 - {{ end }} 117 - 118 - {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 119 - <form 120 - hx-confirm="Are you sure you want to delete this repository?" 121 - hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 122 - class="mt-6" 123 - hx-indicator="#delete-repo-spinner" 124 - > 125 - <label for="branch">delete repository</label> 126 - <button class="btn my-2 flex items-center" type="text"> 127 - <span>delete</span> 128 - <span id="delete-repo-spinner" class="group"> 129 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 130 - </span> 131 - </button> 132 - <span> 133 - Deleting a repository is irreversible and permanent. 134 - </span> 135 - </form> 136 - {{ end }} 137 - 138 - {{ end }}
+8 -2
appview/pages/templates/repo/tags.html
··· 97 97 {{ $isPushAllowed := $root.RepoInfo.Roles.IsPushAllowed }} 98 98 {{ $artifacts := index $root.ArtifactMap $tag.Tag.Hash }} 99 99 100 - {{ if or (gt (len $artifacts) 0) $isPushAllowed }} 101 100 <h2 class="my-4 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">artifacts</h2> 102 101 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700"> 103 102 {{ range $artifact := $artifacts }} 104 103 {{ $args := dict "LoggedInUser" $root.LoggedInUser "RepoInfo" $root.RepoInfo "Artifact" $artifact }} 105 104 {{ template "repo/fragments/artifact" $args }} 106 105 {{ end }} 106 + <div id="artifact-git-source" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 107 + <div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]"> 108 + {{ i "archive" "w-4 h-4" }} 109 + <a href="/{{ $root.RepoInfo.FullName }}/archive/{{ pathEscape (print "refs/tags/" $tag.Name) }}" class="no-underline hover:no-underline"> 110 + Source code (.tar.gz) 111 + </a> 112 + </div> 113 + </div> 107 114 {{ if $isPushAllowed }} 108 115 {{ block "uploadArtifact" (list $root $tag) }} {{ end }} 109 116 {{ end }} 110 117 </div> 111 - {{ end }} 112 118 {{ end }} 113 119 114 120 {{ define "uploadArtifact" }}
+5 -4
appview/pages/templates/repo/tree.html
··· 54 54 55 55 {{ range .Files }} 56 56 <div class="grid grid-cols-12 gap-4 items-center py-1"> 57 - <div class="col-span-6 md:col-span-3"> 57 + <div class="col-span-8 md:col-span-4"> 58 58 {{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) $.TreePath .Name }} 59 59 {{ $icon := "folder" }} 60 60 {{ $iconStyle := "size-4 fill-current" }} ··· 65 65 {{ end }} 66 66 <a href="{{ $link }}" class="{{ $linkstyle }}"> 67 67 <div class="flex items-center gap-2"> 68 - {{ i $icon $iconStyle }}{{ .Name }} 68 + {{ i $icon $iconStyle "flex-shrink-0" }} 69 + <span class="truncate">{{ .Name }}</span> 69 70 </div> 70 71 </a> 71 72 </div> 72 73 73 - <div class="col-span-0 md:col-span-7 hidden md:block overflow-hidden"> 74 + <div class="col-span-0 md:col-span-6 hidden md:block overflow-hidden"> 74 75 {{ with .LastCommit }} 75 76 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400 block truncate">{{ .Message }}</a> 76 77 {{ end }} 77 78 </div> 78 79 79 - <div class="col-span-6 md:col-span-2 text-right"> 80 + <div class="col-span-4 md:col-span-2 text-sm text-right"> 80 81 {{ with .LastCommit }} 81 82 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a> 82 83 {{ end }}
-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
+3 -3
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 13 13 <div 14 14 id="add-member-{{ .Instance }}" 15 15 popover 16 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded drop-shadow dark:text-white"> 17 - {{ block "addMemberPopover" . }} {{ end }} 16 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 17 + {{ block "addSpindleMemberPopover" . }} {{ end }} 18 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"
+57
appview/pages/templates/strings/dashboard.html
··· 1 + {{ define "title" }}strings by {{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 + <meta property="og:type" content="profile" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" /> 7 + <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 + {{ end }} 9 + 10 + 11 + {{ define "content" }} 12 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 13 + <div class="md:col-span-3 order-1 md:order-1"> 14 + {{ template "user/fragments/profileCard" .Card }} 15 + </div> 16 + <div id="all-strings" class="md:col-span-8 order-2 md:order-2"> 17 + {{ block "allStrings" . }}{{ end }} 18 + </div> 19 + </div> 20 + {{ end }} 21 + 22 + {{ define "allStrings" }} 23 + <p class="text-sm font-bold p-2 dark:text-white">ALL STRINGS</p> 24 + <div id="strings" class="grid grid-cols-1 gap-4 mb-6"> 25 + {{ range .Strings }} 26 + {{ template "singleString" (list $ .) }} 27 + {{ else }} 28 + <p class="px-6 dark:text-white">This user does not have any strings yet.</p> 29 + {{ end }} 30 + </div> 31 + {{ end }} 32 + 33 + {{ define "singleString" }} 34 + {{ $root := index . 0 }} 35 + {{ $s := index . 1 }} 36 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 37 + <div class="font-medium dark:text-white flex gap-2 items-center"> 38 + <a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 39 + </div> 40 + {{ with $s.Description }} 41 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 42 + {{ . }} 43 + </div> 44 + {{ end }} 45 + 46 + {{ $stat := $s.Stats }} 47 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto"> 48 + <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 49 + <span class="select-none [&:before]:content-['ยท']"></span> 50 + {{ with $s.Edited }} 51 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 52 + {{ else }} 53 + {{ template "repo/fragments/shortTimeAgo" $s.Created }} 54 + {{ end }} 55 + </div> 56 + </div> 57 + {{ end }}
+90
appview/pages/templates/strings/fragments/form.html
··· 1 + {{ define "strings/fragments/form" }} 2 + <form 3 + {{ if eq .Action "new" }} 4 + hx-post="/strings/new" 5 + {{ else }} 6 + hx-post="/strings/{{.String.Did}}/{{.String.Rkey}}/edit" 7 + {{ end }} 8 + hx-indicator="#new-button" 9 + class="p-6 pb-4 dark:text-white flex flex-col gap-2 bg-white dark:bg-gray-800 drop-shadow-sm rounded" 10 + hx-swap="none"> 11 + <div class="flex flex-col md:flex-row md:items-center gap-2"> 12 + <input 13 + type="text" 14 + id="filename" 15 + name="filename" 16 + placeholder="Filename" 17 + required 18 + value="{{ .String.Filename }}" 19 + class="md:max-w-64 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" 20 + > 21 + <input 22 + type="text" 23 + id="description" 24 + name="description" 25 + value="{{ .String.Description }}" 26 + placeholder="Description ..." 27 + class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" 28 + > 29 + </div> 30 + <textarea 31 + name="content" 32 + id="content-textarea" 33 + wrap="off" 34 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 font-mono" 35 + rows="20" 36 + spellcheck="false" 37 + placeholder="Paste your string here!" 38 + required>{{ .String.Contents }}</textarea> 39 + <div class="flex justify-between items-center"> 40 + <div id="content-stats" class="text-sm text-gray-500 dark:text-gray-400"> 41 + <span id="line-count">0 lines</span> 42 + <span class="select-none px-1 [&:before]:content-['ยท']"></span> 43 + <span id="byte-count">0 bytes</span> 44 + </div> 45 + <div id="actions" class="flex gap-2 items-center"> 46 + {{ if eq .Action "edit" }} 47 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 " 48 + href="/strings/{{ .String.Did }}/{{ .String.Rkey }}"> 49 + {{ i "x" "size-4" }} 50 + <span class="hidden md:inline">cancel</span> 51 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 52 + </a> 53 + {{ end }} 54 + <button 55 + type="submit" 56 + id="new-button" 57 + class="w-fit btn-create rounded flex items-center py-0 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group" 58 + > 59 + <span class="inline-flex items-center gap-2"> 60 + {{ i "arrow-up" "w-4 h-4" }} 61 + publish 62 + </span> 63 + <span class="pl-2 hidden group-[.htmx-request]:inline"> 64 + {{ i "loader-circle" "w-4 h-4 animate-spin" }} 65 + </span> 66 + </button> 67 + </div> 68 + </div> 69 + <script> 70 + (function() { 71 + const textarea = document.getElementById('content-textarea'); 72 + const lineCount = document.getElementById('line-count'); 73 + const byteCount = document.getElementById('byte-count'); 74 + function updateStats() { 75 + const content = textarea.value; 76 + const lines = content === '' ? 0 : content.split('\n').length; 77 + const bytes = new TextEncoder().encode(content).length; 78 + lineCount.textContent = `${lines} line${lines !== 1 ? 's' : ''}`; 79 + byteCount.textContent = `${bytes} byte${bytes !== 1 ? 's' : ''}`; 80 + } 81 + textarea.addEventListener('input', updateStats); 82 + textarea.addEventListener('paste', () => { 83 + setTimeout(updateStats, 0); 84 + }); 85 + updateStats(); 86 + })(); 87 + </script> 88 + <div id="error" class="error dark:text-red-400"></div> 89 + </form> 90 + {{ end }}
+17
appview/pages/templates/strings/put.html
··· 1 + {{ define "title" }}publish a new string{{ end }} 2 + 3 + {{ define "topbar" }} 4 + {{ template "layouts/topbar" $ }} 5 + {{ end }} 6 + 7 + {{ define "content" }} 8 + <div class="px-6 py-2 mb-4"> 9 + {{ if eq .Action "new" }} 10 + <p class="text-xl font-bold dark:text-white">Create a new string</p> 11 + <p class="">Store and share code snippets with ease.</p> 12 + {{ else }} 13 + <p class="text-xl font-bold dark:text-white">Edit string</p> 14 + {{ end }} 15 + </div> 16 + {{ template "strings/fragments/form" . }} 17 + {{ end }}
+88
appview/pages/templates/strings/string.html
··· 1 + {{ define "title" }}{{ .String.Filename }} ยท by {{ didOrHandle .Owner.DID.String .Owner.Handle.String }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 5 + <meta property="og:title" content="{{ .String.Filename }} ยท by {{ $ownerId }}" /> 6 + <meta property="og:type" content="object" /> 7 + <meta property="og:url" content="https://tangled.sh/strings/{{ $ownerId }}/{{ .String.Rkey }}" /> 8 + <meta property="og:description" content="{{ .String.Description }}" /> 9 + {{ end }} 10 + 11 + {{ define "topbar" }} 12 + {{ template "layouts/topbar" $ }} 13 + {{ end }} 14 + 15 + {{ define "content" }} 16 + {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 17 + <section id="string-header" class="mb-4 py-2 px-6 dark:text-white"> 18 + <div class="text-lg flex items-center justify-between"> 19 + <div> 20 + <a href="/strings/{{ $ownerId }}">{{ $ownerId }}</a> 21 + <span class="select-none">/</span> 22 + <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 23 + </div> 24 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 25 + <div class="flex gap-2 text-base"> 26 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 27 + hx-boost="true" 28 + href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> 29 + {{ i "pencil" "size-4" }} 30 + <span class="hidden md:inline">edit</span> 31 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 32 + </a> 33 + <button 34 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group p-2" 35 + title="Delete string" 36 + hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/" 37 + hx-swap="none" 38 + hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?" 39 + > 40 + {{ i "trash-2" "size-4" }} 41 + <span class="hidden md:inline">delete</span> 42 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 43 + </button> 44 + </div> 45 + {{ end }} 46 + </div> 47 + <span> 48 + {{ with .String.Description }} 49 + {{ . }} 50 + {{ end }} 51 + </span> 52 + </section> 53 + <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white"> 54 + <div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 55 + <span> 56 + {{ .String.Filename }} 57 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 58 + <span> 59 + {{ with .String.Edited }} 60 + edited {{ template "repo/fragments/shortTimeAgo" . }} 61 + {{ else }} 62 + {{ template "repo/fragments/shortTimeAgo" .String.Created }} 63 + {{ end }} 64 + </span> 65 + </span> 66 + <div> 67 + <span>{{ .Stats.LineCount }} lines</span> 68 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 69 + <span>{{ byteFmt .Stats.ByteCount }}</span> 70 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 71 + <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}/raw">view raw</a> 72 + {{ if .RenderToggle }} 73 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 74 + <a href="?code={{ .ShowRendered }}" hx-boost="true"> 75 + view {{ if .ShowRendered }}code{{ else }}rendered{{ end }} 76 + </a> 77 + {{ end }} 78 + </div> 79 + </div> 80 + <div class="overflow-x-auto overflow-y-hidden relative"> 81 + {{ if .ShowRendered }} 82 + <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 83 + {{ else }} 84 + <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div> 85 + {{ end }} 86 + </div> 87 + </section> 88 + {{ end }}
+65
appview/pages/templates/strings/timeline.html
··· 1 + {{ define "title" }} all strings {{ end }} 2 + 3 + {{ define "topbar" }} 4 + {{ template "layouts/topbar" $ }} 5 + {{ end }} 6 + 7 + {{ define "content" }} 8 + {{ block "timeline" $ }}{{ end }} 9 + {{ end }} 10 + 11 + {{ define "timeline" }} 12 + <div> 13 + <div class="p-6"> 14 + <p class="text-xl font-bold dark:text-white">All strings</p> 15 + </div> 16 + 17 + <div class="flex flex-col gap-4"> 18 + {{ range $i, $s := .Strings }} 19 + <div class="relative"> 20 + {{ if ne $i 0 }} 21 + <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 22 + {{ end }} 23 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 24 + {{ template "stringCard" $s }} 25 + </div> 26 + </div> 27 + {{ end }} 28 + </div> 29 + </div> 30 + {{ end }} 31 + 32 + {{ define "stringCard" }} 33 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 34 + <div class="font-medium dark:text-white flex gap-2 items-center"> 35 + <a href="/strings/{{ resolve .Did.String }}/{{ .Rkey }}">{{ .Filename }}</a> 36 + </div> 37 + {{ with .Description }} 38 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 39 + {{ . }} 40 + </div> 41 + {{ end }} 42 + 43 + {{ template "stringCardInfo" . }} 44 + </div> 45 + {{ end }} 46 + 47 + {{ define "stringCardInfo" }} 48 + {{ $stat := .Stats }} 49 + {{ $resolved := resolve .Did.String }} 50 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto"> 51 + <a href="/strings/{{ $resolved }}" class="flex items-center"> 52 + {{ template "user/fragments/picHandle" $resolved }} 53 + </a> 54 + <span class="select-none [&:before]:content-['ยท']"></span> 55 + <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 56 + <span class="select-none [&:before]:content-['ยท']"></span> 57 + {{ with .Edited }} 58 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 59 + {{ else }} 60 + {{ template "repo/fragments/shortTimeAgo" .Created }} 61 + {{ end }} 62 + </div> 63 + {{ end }} 64 + 65 +
+183
appview/pages/templates/timeline/timeline.html
··· 1 + {{ define "title" }}timeline{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="timeline ยท tangled" /> 5 + <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.sh" /> 7 + <meta property="og:description" content="tightly-knit social coding" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + {{ if .LoggedInUser }} 12 + {{ else }} 13 + {{ block "hero" $ }}{{ end }} 14 + {{ end }} 15 + 16 + {{ block "trending" $ }}{{ end }} 17 + {{ block "timeline" $ }}{{ end }} 18 + {{ end }} 19 + 20 + {{ define "hero" }} 21 + <div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl"> 22 + <div class="font-bold text-4xl">tightly-knit<br>social coding.</div> 23 + 24 + <p class="text-lg"> 25 + tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>. 26 + </p> 27 + <p class="text-lg"> 28 + we envision a place where developers have complete ownership of their 29 + code, open source communities can freely self-govern and most 30 + importantly, coding can be social and fun again. 31 + </p> 32 + 33 + <div class="flex gap-6 items-center"> 34 + <a href="/signup" class="no-underline hover:no-underline "> 35 + <button class="btn-create flex gap-2 px-4 items-center"> 36 + join now {{ i "arrow-right" "size-4" }} 37 + </button> 38 + </a> 39 + </div> 40 + </div> 41 + {{ end }} 42 + 43 + {{ define "trending" }} 44 + <div class="w-full md:mx-0 py-4"> 45 + <div class="px-6 pb-4"> 46 + <h3 class="text-xl font-bold dark:text-white flex items-center gap-2"> 47 + Trending 48 + {{ i "trending-up" "size-4 flex-shrink-0" }} 49 + </h3> 50 + </div> 51 + <div class="flex gap-4 overflow-x-auto scrollbar-hide items-stretch"> 52 + {{ range $index, $repo := .Repos }} 53 + <div class="flex-none h-full border border-gray-200 dark:border-gray-700 rounded-sm w-96"> 54 + {{ template "user/fragments/repoCard" (list $ $repo true) }} 55 + </div> 56 + {{ else }} 57 + <div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm"> 58 + <div class="text-sm text-gray-500 dark:text-gray-400 text-center"> 59 + No trending repositories this week 60 + </div> 61 + </div> 62 + {{ end }} 63 + </div> 64 + </div> 65 + {{ end }} 66 + 67 + {{ define "timeline" }} 68 + <div class="py-4"> 69 + <div class="px-6 pb-4"> 70 + <p class="text-xl font-bold dark:text-white">Timeline</p> 71 + </div> 72 + 73 + <div class="flex flex-col gap-4"> 74 + {{ range $i, $e := .Timeline }} 75 + <div class="relative"> 76 + {{ if ne $i 0 }} 77 + <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 78 + {{ end }} 79 + {{ with $e }} 80 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 81 + {{ if .Repo }} 82 + {{ block "repoEvent" (list $ .Repo .Source) }} {{ end }} 83 + {{ else if .Star }} 84 + {{ block "starEvent" (list $ .Star) }} {{ end }} 85 + {{ else if .Follow }} 86 + {{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} {{ end }} 87 + {{ end }} 88 + </div> 89 + {{ end }} 90 + </div> 91 + {{ end }} 92 + </div> 93 + </div> 94 + {{ end }} 95 + 96 + {{ define "repoEvent" }} 97 + {{ $root := index . 0 }} 98 + {{ $repo := index . 1 }} 99 + {{ $source := index . 2 }} 100 + {{ $userHandle := resolve $repo.Did }} 101 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 102 + {{ template "user/fragments/picHandleLink" $repo.Did }} 103 + {{ with $source }} 104 + {{ $sourceDid := resolve .Did }} 105 + forked 106 + <a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline"> 107 + {{ $sourceDid }}/{{ .Name }} 108 + </a> 109 + to 110 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 111 + {{ else }} 112 + created 113 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline"> 114 + {{ $repo.Name }} 115 + </a> 116 + {{ end }} 117 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 118 + </div> 119 + {{ with $repo }} 120 + {{ template "user/fragments/repoCard" (list $root . true) }} 121 + {{ end }} 122 + {{ end }} 123 + 124 + {{ define "starEvent" }} 125 + {{ $root := index . 0 }} 126 + {{ $star := index . 1 }} 127 + {{ with $star }} 128 + {{ $starrerHandle := resolve .StarredByDid }} 129 + {{ $repoOwnerHandle := resolve .Repo.Did }} 130 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 131 + {{ template "user/fragments/picHandleLink" $starrerHandle }} 132 + starred 133 + <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 134 + {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 135 + </a> 136 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 137 + </div> 138 + {{ with .Repo }} 139 + {{ template "user/fragments/repoCard" (list $root . true) }} 140 + {{ end }} 141 + {{ end }} 142 + {{ end }} 143 + 144 + 145 + {{ define "followEvent" }} 146 + {{ $root := index . 0 }} 147 + {{ $follow := index . 1 }} 148 + {{ $profile := index . 2 }} 149 + {{ $stat := index . 3 }} 150 + 151 + {{ $userHandle := resolve $follow.UserDid }} 152 + {{ $subjectHandle := resolve $follow.SubjectDid }} 153 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 154 + {{ template "user/fragments/picHandleLink" $userHandle }} 155 + followed 156 + {{ template "user/fragments/picHandleLink" $subjectHandle }} 157 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 158 + </div> 159 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 160 + <div class="flex-shrink-0 max-h-full w-24 h-24"> 161 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 162 + </div> 163 + 164 + <div class="flex-1 min-h-0 justify-around flex flex-col"> 165 + <a href="/{{ $subjectHandle }}"> 166 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 167 + </a> 168 + {{ with $profile }} 169 + {{ with .Description }} 170 + <p class="text-sm pb-2 md:pb-2">{{.}}</p> 171 + {{ end }} 172 + {{ end }} 173 + {{ with $stat }} 174 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 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="/login" class="no-underline hover:no-underline "> 38 - <button class="btn flex gap-2 px-4 items-center"> 39 - join now {{ i "arrow-right" "size-4" }} 40 - </button> 41 - </a> 42 - </div> 43 - </div> 44 - {{ end }} 45 - 46 - {{ define "timeline" }} 47 - <div> 48 - <div class="p-6"> 49 - <p class="text-xl font-bold dark:text-white">Timeline</p> 50 - </div> 51 - 52 - <div class="flex flex-col gap-4"> 53 - {{ range $i, $e := .Timeline }} 54 - <div class="relative"> 55 - {{ if ne $i 0 }} 56 - <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 57 - {{ end }} 58 - {{ with $e }} 59 - <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 60 - {{ if .Repo }} 61 - {{ block "repoEvent" (list $ .Repo .Source) }} {{ end }} 62 - {{ else if .Star }} 63 - {{ block "starEvent" (list $ .Star) }} {{ end }} 64 - {{ else if .Follow }} 65 - {{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} {{ end }} 66 - {{ end }} 67 - </div> 68 - {{ end }} 69 - </div> 70 - {{ end }} 71 - </div> 72 - </div> 73 - {{ end }} 74 - 75 - {{ define "repoEvent" }} 76 - {{ $root := index . 0 }} 77 - {{ $repo := index . 1 }} 78 - {{ $source := index . 2 }} 79 - {{ $userHandle := index $root.DidHandleMap $repo.Did }} 80 - <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 81 - {{ template "user/fragments/picHandleLink" $userHandle }} 82 - {{ with $source }} 83 - forked 84 - <a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}"class="no-underline hover:underline"> 85 - {{ index $root.DidHandleMap .Did }}/{{ .Name }} 86 - </a> 87 - to 88 - <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 89 - {{ else }} 90 - created 91 - <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline"> 92 - {{ $repo.Name }} 93 - </a> 94 - {{ end }} 95 - <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 96 - </div> 97 - {{ with $repo }} 98 - {{ template "user/fragments/repoCard" (list $root . true) }} 99 - {{ end }} 100 - {{ end }} 101 - 102 - {{ define "starEvent" }} 103 - {{ $root := index . 0 }} 104 - {{ $star := index . 1 }} 105 - {{ with $star }} 106 - {{ $starrerHandle := index $root.DidHandleMap .StarredByDid }} 107 - {{ $repoOwnerHandle := index $root.DidHandleMap .Repo.Did }} 108 - <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 109 - {{ template "user/fragments/picHandleLink" $starrerHandle }} 110 - starred 111 - <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 112 - {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 113 - </a> 114 - <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 115 - </div> 116 - {{ with .Repo }} 117 - {{ template "user/fragments/repoCard" (list $root . true) }} 118 - {{ end }} 119 - {{ end }} 120 - {{ end }} 121 - 122 - 123 - {{ define "followEvent" }} 124 - {{ $root := index . 0 }} 125 - {{ $follow := index . 1 }} 126 - {{ $profile := index . 2 }} 127 - {{ $stat := index . 3 }} 128 - 129 - {{ $userHandle := index $root.DidHandleMap $follow.UserDid }} 130 - {{ $subjectHandle := index $root.DidHandleMap $follow.SubjectDid }} 131 - <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 132 - {{ template "user/fragments/picHandleLink" $userHandle }} 133 - followed 134 - {{ template "user/fragments/picHandleLink" $subjectHandle }} 135 - <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 136 - </div> 137 - <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 138 - <div class="flex-shrink-0 max-h-full w-24 h-24"> 139 - <img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 140 - </div> 141 - 142 - <div class="flex-1 min-h-0 justify-around flex flex-col"> 143 - <a href="/{{ $subjectHandle }}"> 144 - <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 145 - </a> 146 - {{ with $profile }} 147 - {{ with .Description }} 148 - <p class="text-sm pb-2 md:pb-2">{{.}}</p> 149 - {{ end }} 150 - {{ end }} 151 - {{ with $stat }} 152 - <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 153 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 154 - <span id="followers">{{ .Followers }} followers</span> 155 - <span class="select-none after:content-['ยท']"></span> 156 - <span id="following">{{ .Following }} following</span> 157 - </div> 158 - {{ end }} 159 - </div> 160 - </div> 161 - {{ end }}
+104
appview/pages/templates/user/completeSignup.html
··· 1 + {{ define "user/completeSignup" }} 2 + <!doctype html> 3 + <html lang="en" class="dark:bg-gray-900"> 4 + <head> 5 + <meta charset="UTF-8" /> 6 + <meta 7 + name="viewport" 8 + content="width=device-width, initial-scale=1.0" 9 + /> 10 + <meta 11 + property="og:title" 12 + content="complete signup ยท tangled" 13 + /> 14 + <meta 15 + property="og:url" 16 + content="https://tangled.sh/complete-signup" 17 + /> 18 + <meta 19 + property="og:description" 20 + content="complete your signup for tangled" 21 + /> 22 + <script src="/static/htmx.min.js"></script> 23 + <link 24 + rel="stylesheet" 25 + href="/static/tw.css?{{ cssContentHash }}" 26 + type="text/css" 27 + /> 28 + <title>complete signup &middot; tangled</title> 29 + </head> 30 + <body class="flex items-center justify-center min-h-screen"> 31 + <main class="max-w-md px-6 -mt-4"> 32 + <h1 33 + class="text-center text-2xl font-semibold italic dark:text-white" 34 + > 35 + tangled 36 + </h1> 37 + <h2 class="text-center text-xl italic dark:text-white"> 38 + tightly-knit social coding. 39 + </h2> 40 + <form 41 + class="mt-4 max-w-sm mx-auto flex flex-col gap-4" 42 + hx-post="/signup/complete" 43 + hx-swap="none" 44 + hx-disabled-elt="#complete-signup-button" 45 + > 46 + <div class="flex flex-col"> 47 + <label for="code">verification code</label> 48 + <input 49 + type="text" 50 + id="code" 51 + name="code" 52 + tabindex="1" 53 + required 54 + placeholder="tngl-sh-foo-bar" 55 + /> 56 + <span class="text-sm text-gray-500 mt-1"> 57 + Enter the code sent to your email. 58 + </span> 59 + </div> 60 + 61 + <div class="flex flex-col"> 62 + <label for="username">username</label> 63 + <input 64 + type="text" 65 + id="username" 66 + name="username" 67 + tabindex="2" 68 + required 69 + placeholder="jason" 70 + /> 71 + <span class="text-sm text-gray-500 mt-1"> 72 + Your complete handle will be of the form <code>user.tngl.sh</code>. 73 + </span> 74 + </div> 75 + 76 + <div class="flex flex-col"> 77 + <label for="password">password</label> 78 + <input 79 + type="password" 80 + id="password" 81 + name="password" 82 + tabindex="3" 83 + required 84 + /> 85 + <span class="text-sm text-gray-500 mt-1"> 86 + Choose a strong password for your account. 87 + </span> 88 + </div> 89 + 90 + <button 91 + class="btn-create w-full my-2 mt-6 text-base" 92 + type="submit" 93 + id="complete-signup-button" 94 + tabindex="4" 95 + > 96 + <span>complete signup</span> 97 + </button> 98 + </form> 99 + <p id="signup-error" class="error w-full"></p> 100 + <p id="signup-msg" class="dark:text-white w-full"></p> 101 + </main> 102 + </body> 103 + </html> 104 + {{ end }}
+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 }}
+22 -18
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"> 5 - {{ if .AvatarUri }} 6 6 <div class="w-3/4 aspect-square relative"> 7 - <img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ .AvatarUri }}" /> 7 + <img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ fullAvatar .UserDid }}" /> 8 8 </div> 9 - {{ end }} 10 9 </div> 11 10 <div class="col-span-2"> 12 - <p title="{{ didOrHandle .UserDid .UserHandle }}" 13 - class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 14 - {{ didOrHandle .UserDid .UserHandle }} 15 - </p> 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> 16 18 17 19 <div class="md:hidden"> 18 - {{ block "followerFollowing" (list .Followers .Following) }} {{ end }} 20 + {{ block "followerFollowing" (list . $userIdent) }} {{ end }} 19 21 </div> 20 22 </div> 21 23 <div class="col-span-3 md:col-span-full"> ··· 28 30 {{ end }} 29 31 30 32 <div class="hidden md:block"> 31 - {{ block "followerFollowing" (list $.Followers $.Following) }} {{ end }} 33 + {{ block "followerFollowing" (list $ $userIdent) }} {{ end }} 32 34 </div> 33 35 34 36 <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> ··· 41 43 {{ if .IncludeBluesky }} 42 44 <div class="flex items-center gap-2"> 43 45 <span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" }}</span> 44 - <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ didOrHandle $.UserDid $.UserHandle }}</a> 46 + <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ $userIdent }}</a> 45 47 </div> 46 48 {{ end }} 47 49 {{ range $link := .Links }} ··· 87 89 {{ end }} 88 90 89 91 {{ define "followerFollowing" }} 90 - {{ $followers := index . 0 }} 91 - {{ $following := index . 1 }} 92 - <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 93 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 94 - <span id="followers">{{ $followers }} followers</span> 95 - <span class="select-none after:content-['ยท']"></span> 96 - <span id="following">{{ $following }} following</span> 97 - </div> 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 }} 98 102 {{ end }} 99 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 -
+14 -34
appview/pages/templates/user/login.html
··· 3 3 <html lang="en" class="dark:bg-gray-900"> 4 4 <head> 5 5 <meta charset="UTF-8" /> 6 - <meta 7 - name="viewport" 8 - content="width=device-width, initial-scale=1.0" 9 - /> 10 - <meta 11 - property="og:title" 12 - content="login ยท tangled" 13 - /> 14 - <meta 15 - property="og:url" 16 - content="https://tangled.sh/login" 17 - /> 18 - <meta 19 - property="og:description" 20 - content="login to tangled" 21 - /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 + <meta property="og:title" content="login ยท tangled" /> 8 + <meta property="og:url" content="https://tangled.sh/login" /> 9 + <meta property="og:description" content="login to for tangled" /> 22 10 <script src="/static/htmx.min.js"></script> 23 - <link 24 - rel="stylesheet" 25 - href="/static/tw.css?{{ cssContentHash }}" 26 - type="text/css" 27 - /> 11 + <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 28 12 <title>login &middot; tangled</title> 29 13 </head> 30 14 <body class="flex items-center justify-center min-h-screen"> 31 15 <main class="max-w-md px-6 -mt-4"> 32 - <h1 33 - class="text-center text-2xl font-semibold italic dark:text-white" 34 - > 16 + <h1 class="text-center text-2xl font-semibold italic dark:text-white" > 35 17 tangled 36 18 </h1> 37 19 <h2 class="text-center text-xl italic dark:text-white"> ··· 51 33 name="handle" 52 34 tabindex="1" 53 35 required 36 + placeholder="akshay.tngl.sh" 54 37 /> 55 38 <span class="text-sm text-gray-500 mt-1"> 56 - Use your 57 - <a href="https://bsky.app">Bluesky</a> handle to log 58 - in. You will then be redirected to your PDS to 59 - complete authentication. 39 + Use your <a href="https://atproto.com">ATProto</a> 40 + handle to log in. If you're unsure, this is likely 41 + your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 60 42 </span> 61 43 </div> 44 + <input type="hidden" name="return_url" value="{{ .ReturnUrl }}"> 62 45 63 46 <button 64 - class="btn w-full my-2 mt-6" 47 + class="btn w-full my-2 mt-6 text-base " 65 48 type="submit" 66 49 id="login-button" 67 50 tabindex="3" ··· 70 53 </button> 71 54 </form> 72 55 <p class="text-sm text-gray-500"> 73 - Join our <a href="https://chat.tangled.sh">Discord</a> or 74 - IRC channel: 75 - <a href="https://web.libera.chat/#tangled" 76 - ><code>#tangled</code> on Libera Chat</a 77 - >. 56 + Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now! 78 57 </p> 58 + 79 59 <p id="login-msg" class="error w-full"></p> 80 60 </main> 81 61 </body>
+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 }}
+53
appview/pages/templates/user/signup.html
··· 1 + {{ define "user/signup" }} 2 + <!doctype html> 3 + <html lang="en" class="dark:bg-gray-900"> 4 + <head> 5 + <meta charset="UTF-8" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 + <meta property="og:title" content="signup ยท tangled" /> 8 + <meta property="og:url" content="https://tangled.sh/signup" /> 9 + <meta property="og:description" content="sign up for tangled" /> 10 + <script src="/static/htmx.min.js"></script> 11 + <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 + <title>sign up &middot; tangled</title> 13 + </head> 14 + <body class="flex items-center justify-center min-h-screen"> 15 + <main class="max-w-md px-6 -mt-4"> 16 + <h1 class="text-center text-2xl font-semibold italic dark:text-white" >tangled</h1> 17 + <h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2> 18 + <form 19 + class="mt-4 max-w-sm mx-auto" 20 + hx-post="/signup" 21 + hx-swap="none" 22 + hx-disabled-elt="#signup-button" 23 + > 24 + <div class="flex flex-col mt-2"> 25 + <label for="email">email</label> 26 + <input 27 + type="email" 28 + id="email" 29 + name="email" 30 + tabindex="4" 31 + required 32 + placeholder="jason@bourne.co" 33 + /> 34 + </div> 35 + <span class="text-sm text-gray-500 mt-1"> 36 + You will receive an email with an invite code. Enter your 37 + invite code, desired username, and password in the next 38 + page to complete your registration. 39 + </span> 40 + <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 41 + <span>join now</span> 42 + </button> 43 + </form> 44 + <p class="text-sm text-gray-500"> 45 + Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>. 46 + </p> 47 + 48 + <p id="signup-msg" class="error w-full"></p> 49 + </main> 50 + </body> 51 + </html> 52 + {{ end }} 53 +
+169 -177
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", 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, 271 266 } 272 - case 400: 273 - return types.MergeCheckResponse{ 274 - Error: "failed to check merge status: does this knot support PRs?", 275 - } 267 + } 268 + 269 + result := types.MergeCheckResponse{ 270 + IsConflicted: resp.Is_conflicted, 271 + Conflicts: conflicts, 276 272 } 277 273 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 - } 274 + if resp.Message != nil { 275 + result.Message = *resp.Message 284 276 } 285 - defer resp.Body.Close() 286 277 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 - } 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 { ··· 555 506 556 507 // we want to group all stacked PRs into just one list 557 508 stacks := make(map[string]db.Stack) 509 + var shas []string 558 510 n := 0 559 511 for _, p := range pulls { 512 + // store the sha for later 513 + shas = append(shas, p.LatestSha()) 560 514 // this PR is stacked 561 515 if p.StackId != "" { 562 516 // we have already seen this PR stack ··· 575 529 } 576 530 pulls = pulls[:n] 577 531 578 - identsToResolve := make([]string, len(pulls)) 579 - for i, pull := range pulls { 580 - identsToResolve[i] = pull.OwnerDid 532 + repoInfo := f.RepoInfo(user) 533 + ps, err := db.GetPipelineStatuses( 534 + s.db, 535 + db.FilterEq("repo_owner", repoInfo.OwnerDid), 536 + db.FilterEq("repo_name", repoInfo.Name), 537 + db.FilterEq("knot", repoInfo.Knot), 538 + db.FilterIn("sha", shas), 539 + ) 540 + if err != nil { 541 + log.Printf("failed to fetch pipeline statuses: %s", err) 542 + // non-fatal 581 543 } 582 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 583 - didHandleMap := make(map[string]string) 584 - for _, identity := range resolvedIds { 585 - if !identity.Handle.IsInvalidHandle() { 586 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 587 - } else { 588 - didHandleMap[identity.DID.String()] = identity.DID.String() 589 - } 544 + m := make(map[string]db.Pipeline) 545 + for _, p := range ps { 546 + m[p.Sha] = p 590 547 } 591 548 592 549 s.pages.RepoPulls(w, pages.RepoPullsParams{ 593 550 LoggedInUser: s.oauth.GetUser(r), 594 551 RepoInfo: f.RepoInfo(user), 595 552 Pulls: pulls, 596 - DidHandleMap: didHandleMap, 597 553 FilteringBy: state, 598 554 Stacks: stacks, 555 + Pipelines: m, 599 556 }) 600 557 } 601 558 ··· 650 607 createdAt := time.Now().Format(time.RFC3339) 651 608 ownerDid := user.Did 652 609 653 - pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId) 610 + pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId) 654 611 if err != nil { 655 612 log.Println("failed to get pull at", err) 656 613 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 657 614 return 658 615 } 659 616 660 - atUri := f.RepoAt.String() 617 + atUri := f.RepoAt().String() 661 618 client, err := s.oauth.AuthorizedClient(r) 662 619 if err != nil { 663 620 log.Println("failed to get authorized client", err) ··· 686 643 687 644 comment := &db.PullComment{ 688 645 OwnerDid: user.Did, 689 - RepoAt: f.RepoAt.String(), 646 + RepoAt: f.RepoAt().String(), 690 647 PullId: pull.PullId, 691 648 Body: body, 692 649 CommentAt: atResp.Uri, ··· 732 689 return 733 690 } 734 691 735 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 692 + result, err := us.Branches(f.OwnerDid(), f.Name) 736 693 if err != nil { 737 694 log.Println("failed to fetch branches", err) 738 695 return ··· 780 737 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 781 738 return 782 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 + } 783 745 } 784 746 785 747 // Validate we have at least one valid PR creation method ··· 856 818 return 857 819 } 858 820 859 - comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch) 821 + comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, targetBranch, sourceBranch) 860 822 if err != nil { 861 823 log.Println("failed to compare", err) 862 824 s.pages.Notice(w, "pull", err.Error()) ··· 902 864 return 903 865 } 904 866 905 - secret, err := db.GetRegistrationKey(s.db, fork.Knot) 906 - if err != nil { 907 - log.Println("failed to fetch registration key:", err) 908 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 909 - return 910 - } 911 - 912 - sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev) 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 + ) 913 873 if err != nil { 914 - log.Println("failed to create signed client:", err) 874 + log.Printf("failed to connect to knot server: %v", err) 915 875 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 916 876 return 917 877 } ··· 923 883 return 924 884 } 925 885 926 - resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch) 927 - if err != nil { 928 - log.Println("failed to create hidden ref:", err, resp.StatusCode) 929 - 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()) 930 897 return 931 898 } 932 899 933 - switch resp.StatusCode { 934 - case 404: 935 - case 400: 936 - 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) 937 906 return 938 907 } 939 908 ··· 958 927 return 959 928 } 960 929 961 - forkAtUri, err := syntax.ParseATURI(fork.AtUri) 962 - if err != nil { 963 - log.Println("failed to parse fork AT URI", err) 964 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 965 - return 966 - } 930 + forkAtUri := fork.RepoAt() 931 + forkAtUriStr := forkAtUri.String() 967 932 968 933 pullSource := &db.PullSource{ 969 934 Branch: sourceBranch, ··· 971 936 } 972 937 recordPullSource := &tangled.RepoPull_Source{ 973 938 Branch: sourceBranch, 974 - Repo: &fork.AtUri, 939 + Repo: &forkAtUriStr, 975 940 Sha: sourceRev, 976 941 } 977 942 ··· 1047 1012 Body: body, 1048 1013 TargetBranch: targetBranch, 1049 1014 OwnerDid: user.Did, 1050 - RepoAt: f.RepoAt, 1015 + RepoAt: f.RepoAt(), 1051 1016 Rkey: rkey, 1052 1017 Submissions: []*db.PullSubmission{ 1053 1018 &initialSubmission, ··· 1060 1025 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1061 1026 return 1062 1027 } 1063 - pullId, err := db.NextPullId(tx, f.RepoAt) 1028 + pullId, err := db.NextPullId(tx, f.RepoAt()) 1064 1029 if err != nil { 1065 1030 log.Println("failed to get pull id", err) 1066 1031 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 1075 1040 Val: &tangled.RepoPull{ 1076 1041 Title: title, 1077 1042 PullId: int64(pullId), 1078 - TargetRepo: string(f.RepoAt), 1043 + TargetRepo: string(f.RepoAt()), 1079 1044 TargetBranch: targetBranch, 1080 1045 Patch: patch, 1081 1046 Source: recordPullSource, ··· 1253 1218 return 1254 1219 } 1255 1220 1256 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1221 + result, err := us.Branches(f.OwnerDid(), f.Name) 1257 1222 if err != nil { 1258 1223 log.Println("failed to reach knotserver", err) 1259 1224 return ··· 1337 1302 return 1338 1303 } 1339 1304 1340 - targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName) 1305 + targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.Name) 1341 1306 if err != nil { 1342 1307 log.Println("failed to reach knotserver for target branches", err) 1343 1308 return ··· 1453 1418 return 1454 1419 } 1455 1420 1456 - 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) 1457 1422 if err != nil { 1458 1423 log.Printf("compare request failed: %s", err) 1459 1424 s.pages.Notice(w, "resubmit-error", err.Error()) ··· 1503 1468 return 1504 1469 } 1505 1470 1506 - 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 + ) 1507 1478 if err != nil { 1508 - log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err) 1509 - 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) 1510 1480 return 1511 1481 } 1512 1482 1513 - // update the hidden tracking branch to latest 1514 - signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev) 1515 - if err != nil { 1516 - log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err) 1517 - 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()) 1518 1494 return 1519 1495 } 1520 - 1521 - resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch) 1522 - if err != nil || resp.StatusCode != http.StatusNoContent { 1523 - log.Printf("failed to update tracking branch: %s", err) 1524 - 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.") 1525 1499 return 1526 1500 } 1527 1501 ··· 1637 1611 Val: &tangled.RepoPull{ 1638 1612 Title: pull.Title, 1639 1613 PullId: int64(pull.PullId), 1640 - TargetRepo: string(f.RepoAt), 1614 + TargetRepo: string(f.RepoAt()), 1641 1615 TargetBranch: pull.TargetBranch, 1642 1616 Patch: patch, // new patch 1643 1617 Source: recordPullSource, ··· 1753 1727 1754 1728 // deleted pulls are marked as deleted in the DB 1755 1729 for _, p := range deletions { 1730 + // do not do delete already merged PRs 1731 + if p.State == db.PullMerged { 1732 + continue 1733 + } 1734 + 1756 1735 err := db.DeletePull(tx, p.RepoAt, p.PullId) 1757 1736 if err != nil { 1758 1737 log.Println("failed to delete pull", err, p.PullId) ··· 1792 1771 for id := range updated { 1793 1772 op, _ := origById[id] 1794 1773 np, _ := newById[id] 1774 + 1775 + // do not update already merged PRs 1776 + if op.State == db.PullMerged { 1777 + continue 1778 + } 1795 1779 1796 1780 submission := np.Submissions[np.LastRoundNumber()] 1797 1781 ··· 1937 1921 1938 1922 patch := pullsToMerge.CombinedPatch() 1939 1923 1940 - secret, err := db.GetRegistrationKey(s.db, f.Knot) 1941 - if err != nil { 1942 - log.Printf("no registration key found for domain %s: %s\n", f.Knot, err) 1943 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1944 - return 1945 - } 1946 - 1947 1924 ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 1948 1925 if err != nil { 1949 1926 log.Printf("resolving identity: %s", err) ··· 1956 1933 log.Printf("failed to get primary email: %s", err) 1957 1934 } 1958 1935 1959 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 1960 - if err != nil { 1961 - log.Printf("failed to create signed client for %s: %s", f.Knot, err) 1962 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1963 - 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, 1964 1944 } 1965 1945 1966 - // Merge the pull request 1967 - 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 + ) 1968 1960 if err != nil { 1969 - log.Printf("failed to merge pull request: %s", err) 1961 + log.Printf("failed to connect to knot server: %v", err) 1970 1962 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1971 1963 return 1972 1964 } 1973 1965 1974 - if resp.StatusCode != http.StatusOK { 1975 - log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode) 1976 - 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()) 1977 1969 return 1978 1970 } 1979 1971 ··· 1986 1978 defer tx.Rollback() 1987 1979 1988 1980 for _, p := range pullsToMerge { 1989 - err := db.MergePull(tx, f.RepoAt, p.PullId) 1981 + err := db.MergePull(tx, f.RepoAt(), p.PullId) 1990 1982 if err != nil { 1991 1983 log.Printf("failed to update pull request status in database: %s", err) 1992 1984 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 2002 1994 return 2003 1995 } 2004 1996 2005 - 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)) 2006 1998 } 2007 1999 2008 2000 func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { ··· 2054 2046 2055 2047 for _, p := range pullsToClose { 2056 2048 // Close the pull in the database 2057 - err = db.ClosePull(tx, f.RepoAt, p.PullId) 2049 + err = db.ClosePull(tx, f.RepoAt(), p.PullId) 2058 2050 if err != nil { 2059 2051 log.Println("failed to close pull", err) 2060 2052 s.pages.Notice(w, "pull-close", "Failed to close pull.") ··· 2122 2114 2123 2115 for _, p := range pullsToReopen { 2124 2116 // Close the pull in the database 2125 - err = db.ReopenPull(tx, f.RepoAt, p.PullId) 2117 + err = db.ReopenPull(tx, f.RepoAt(), p.PullId) 2126 2118 if err != nil { 2127 2119 log.Println("failed to close pull", err) 2128 2120 s.pages.Notice(w, "pull-close", "Failed to close pull.") ··· 2174 2166 Body: body, 2175 2167 TargetBranch: targetBranch, 2176 2168 OwnerDid: user.Did, 2177 - RepoAt: f.RepoAt, 2169 + RepoAt: f.RepoAt(), 2178 2170 Rkey: rkey, 2179 2171 Submissions: []*db.PullSubmission{ 2180 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 - }
+634 -248
appview/repo/repo.go
··· 8 8 "fmt" 9 9 "io" 10 10 "log" 11 + "log/slog" 11 12 "net/http" 12 13 "net/url" 14 + "path/filepath" 13 15 "slices" 14 16 "strconv" 15 17 "strings" 16 18 "time" 17 19 20 + comatproto "github.com/bluesky-social/indigo/api/atproto" 21 + lexutil "github.com/bluesky-social/indigo/lex/util" 18 22 "tangled.sh/tangled.sh/core/api/tangled" 19 23 "tangled.sh/tangled.sh/core/appview/commitverify" 20 24 "tangled.sh/tangled.sh/core/appview/config" ··· 24 28 "tangled.sh/tangled.sh/core/appview/pages" 25 29 "tangled.sh/tangled.sh/core/appview/pages/markup" 26 30 "tangled.sh/tangled.sh/core/appview/reporesolver" 31 + xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 27 32 "tangled.sh/tangled.sh/core/eventconsumer" 28 33 "tangled.sh/tangled.sh/core/idresolver" 29 34 "tangled.sh/tangled.sh/core/knotclient" ··· 31 36 "tangled.sh/tangled.sh/core/rbac" 32 37 "tangled.sh/tangled.sh/core/tid" 33 38 "tangled.sh/tangled.sh/core/types" 39 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 34 40 35 41 securejoin "github.com/cyphar/filepath-securejoin" 36 42 "github.com/go-chi/chi/v5" 37 43 "github.com/go-git/go-git/v5/plumbing" 38 44 39 - comatproto "github.com/bluesky-social/indigo/api/atproto" 40 - lexutil "github.com/bluesky-social/indigo/lex/util" 45 + "github.com/bluesky-social/indigo/atproto/syntax" 41 46 ) 42 47 43 48 type Repo struct { ··· 50 55 db *db.DB 51 56 enforcer *rbac.Enforcer 52 57 notifier notify.Notifier 58 + logger *slog.Logger 59 + serviceAuth *serviceauth.ServiceAuth 53 60 } 54 61 55 62 func New( ··· 62 69 config *config.Config, 63 70 notifier notify.Notifier, 64 71 enforcer *rbac.Enforcer, 72 + logger *slog.Logger, 65 73 ) *Repo { 66 74 return &Repo{oauth: oauth, 67 75 repoResolver: repoResolver, ··· 72 80 db: db, 73 81 notifier: notifier, 74 82 enforcer: enforcer, 83 + logger: logger, 75 84 } 76 85 } 77 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 + 78 106 func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { 79 107 f, err := rp.repoResolver.Resolve(r) 80 108 if err != nil { ··· 98 126 return 99 127 } 100 128 101 - repolog, err := us.Log(f.OwnerDid(), f.RepoName, ref, page) 129 + repolog, err := us.Log(f.OwnerDid(), f.Name, ref, page) 102 130 if err != nil { 131 + rp.pages.Error503(w) 103 132 log.Println("failed to reach knotserver", err) 104 133 return 105 134 } 106 135 107 - tagResult, err := us.Tags(f.OwnerDid(), f.RepoName) 136 + tagResult, err := us.Tags(f.OwnerDid(), f.Name) 108 137 if err != nil { 138 + rp.pages.Error503(w) 109 139 log.Println("failed to reach knotserver", err) 110 140 return 111 141 } ··· 119 149 tagMap[hash] = append(tagMap[hash], tag.Name) 120 150 } 121 151 122 - branchResult, err := us.Branches(f.OwnerDid(), f.RepoName) 152 + branchResult, err := us.Branches(f.OwnerDid(), f.Name) 123 153 if err != nil { 154 + rp.pages.Error503(w) 124 155 log.Println("failed to reach knotserver", err) 125 156 return 126 157 } ··· 187 218 return 188 219 } 189 220 190 - repoAt := f.RepoAt 221 + repoAt := f.RepoAt() 191 222 rkey := repoAt.RecordKey().String() 192 223 if rkey == "" { 193 224 log.Println("invalid aturi for repo", err) ··· 237 268 Record: &lexutil.LexiconTypeDecoder{ 238 269 Val: &tangled.Repo{ 239 270 Knot: f.Knot, 240 - Name: f.RepoName, 271 + Name: f.Name, 241 272 Owner: user.Did, 242 - CreatedAt: f.CreatedAt, 273 + CreatedAt: f.Created.Format(time.RFC3339), 243 274 Description: &newDescription, 244 275 Spindle: &f.Spindle, 245 276 }, ··· 285 316 return 286 317 } 287 318 288 - 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)) 289 320 if err != nil { 321 + rp.pages.Error503(w) 290 322 log.Println("failed to reach knotserver", err) 291 323 return 292 324 } ··· 350 382 if !rp.config.Core.Dev { 351 383 protocol = "https" 352 384 } 353 - 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)) 354 391 if err != nil { 392 + rp.pages.Error503(w) 355 393 log.Println("failed to reach knotserver", err) 356 394 return 357 395 } 358 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 + 359 405 body, err := io.ReadAll(resp.Body) 360 406 if err != nil { 361 407 log.Printf("Error reading response body: %v", err) ··· 380 426 user := rp.oauth.GetUser(r) 381 427 382 428 var breadcrumbs [][]string 383 - 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)}) 384 430 if treePath != "" { 385 431 for idx, elem := range strings.Split(treePath, "/") { 386 432 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) ··· 411 457 return 412 458 } 413 459 414 - result, err := us.Tags(f.OwnerDid(), f.RepoName) 460 + result, err := us.Tags(f.OwnerDid(), f.Name) 415 461 if err != nil { 462 + rp.pages.Error503(w) 416 463 log.Println("failed to reach knotserver", err) 417 464 return 418 465 } 419 466 420 - 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())) 421 468 if err != nil { 422 469 log.Println("failed grab artifacts", err) 423 470 return ··· 468 515 return 469 516 } 470 517 471 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 518 + result, err := us.Branches(f.OwnerDid(), f.Name) 472 519 if err != nil { 520 + rp.pages.Error503(w) 473 521 log.Println("failed to reach knotserver", err) 474 522 return 475 523 } ··· 497 545 if !rp.config.Core.Dev { 498 546 protocol = "https" 499 547 } 500 - 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)) 501 549 if err != nil { 550 + rp.pages.Error503(w) 502 551 log.Println("failed to reach knotserver", err) 552 + return 553 + } 554 + 555 + if resp.StatusCode == http.StatusNotFound { 556 + rp.pages.Error404(w) 503 557 return 504 558 } 505 559 ··· 517 571 } 518 572 519 573 var breadcrumbs [][]string 520 - 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)}) 521 575 if filePath != "" { 522 576 for idx, elem := range strings.Split(filePath, "/") { 523 577 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) ··· 530 584 if markup.GetFormat(result.Path) == markup.FormatMarkdown { 531 585 renderToggle = true 532 586 showRendered = r.URL.Query().Get("code") != "true" 587 + } 588 + 589 + var unsupported bool 590 + var isImage bool 591 + var isVideo bool 592 + var contentSrc string 593 + 594 + if result.IsBinary { 595 + ext := strings.ToLower(filepath.Ext(result.Path)) 596 + switch ext { 597 + case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 598 + isImage = true 599 + case ".mp4", ".webm", ".ogg", ".mov", ".avi": 600 + isVideo = true 601 + default: 602 + unsupported = true 603 + } 604 + 605 + // fetch the actual binary content like in RepoBlobRaw 606 + 607 + blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Name, ref, filePath) 608 + contentSrc = blobURL 609 + if !rp.config.Core.Dev { 610 + contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 611 + } 533 612 } 534 613 535 614 user := rp.oauth.GetUser(r) ··· 540 619 BreadCrumbs: breadcrumbs, 541 620 ShowRendered: showRendered, 542 621 RenderToggle: renderToggle, 622 + Unsupported: unsupported, 623 + IsImage: isImage, 624 + IsVideo: isVideo, 625 + ContentSrc: contentSrc, 543 626 }) 544 627 } 545 628 ··· 547 630 f, err := rp.repoResolver.Resolve(r) 548 631 if err != nil { 549 632 log.Println("failed to get repo and knot", err) 633 + w.WriteHeader(http.StatusBadRequest) 550 634 return 551 635 } 552 636 ··· 557 641 if !rp.config.Core.Dev { 558 642 protocol = "https" 559 643 } 560 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 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) 561 660 if err != nil { 562 661 log.Println("failed to reach knotserver", err) 662 + rp.pages.Error503(w) 563 663 return 564 664 } 665 + defer resp.Body.Close() 565 666 566 - body, err := io.ReadAll(resp.Body) 567 - if err != nil { 568 - log.Printf("Error reading response body: %v", err) 667 + // forward 304 not modified 668 + if resp.StatusCode == http.StatusNotModified { 669 + w.WriteHeader(http.StatusNotModified) 670 + return 671 + } 672 + 673 + if resp.StatusCode != http.StatusOK { 674 + log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) 675 + w.WriteHeader(resp.StatusCode) 676 + _, _ = io.Copy(w, resp.Body) 569 677 return 570 678 } 571 679 572 - var result types.RepoBlobResponse 573 - err = json.Unmarshal(body, &result) 680 + contentType := resp.Header.Get("Content-Type") 681 + body, err := io.ReadAll(resp.Body) 574 682 if err != nil { 575 - log.Println("failed to parse response:", err) 683 + log.Printf("error reading response body from knotserver: %v", err) 684 + w.WriteHeader(http.StatusInternalServerError) 576 685 return 577 686 } 578 687 579 - if result.IsBinary { 580 - w.Header().Set("Content-Type", "application/octet-stream") 688 + if strings.Contains(contentType, "text/plain") { 689 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 690 + w.Write(body) 691 + } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 692 + w.Header().Set("Content-Type", contentType) 581 693 w.Write(body) 694 + } else { 695 + w.WriteHeader(http.StatusUnsupportedMediaType) 696 + w.Write([]byte("unsupported content type")) 582 697 return 583 698 } 584 - 585 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 586 - w.Write([]byte(result.Contents)) 587 699 } 588 700 589 701 // modify the spindle configured for this repo 590 702 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 703 + user := rp.oauth.GetUser(r) 704 + l := rp.logger.With("handler", "EditSpindle") 705 + l = l.With("did", user.Did) 706 + l = l.With("handle", user.Handle) 707 + 708 + errorId := "operation-error" 709 + fail := func(msg string, err error) { 710 + l.Error(msg, "err", err) 711 + rp.pages.Notice(w, errorId, msg) 712 + } 713 + 591 714 f, err := rp.repoResolver.Resolve(r) 592 715 if err != nil { 593 - log.Println("failed to get repo and knot", err) 594 - w.WriteHeader(http.StatusBadRequest) 716 + fail("Failed to resolve repo. Try again later", err) 595 717 return 596 718 } 597 719 598 - repoAt := f.RepoAt 720 + repoAt := f.RepoAt() 599 721 rkey := repoAt.RecordKey().String() 600 722 if rkey == "" { 601 - log.Println("invalid aturi for repo", err) 602 - w.WriteHeader(http.StatusInternalServerError) 723 + fail("Failed to resolve repo. Try again later", err) 603 724 return 604 725 } 605 726 606 - user := rp.oauth.GetUser(r) 607 - 608 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 609 729 client, err := rp.oauth.AuthorizedClient(r) 610 730 if err != nil { 611 - log.Println("failed to get client") 612 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.") 731 + fail("Failed to authorize. Try again later.", err) 613 732 return 614 733 } 615 734 616 - // ensure that this is a valid spindle for this user 617 - validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 618 - if err != nil { 619 - log.Println("failed to get valid spindles") 620 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.") 621 - 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 + } 622 747 } 623 748 624 - if !slices.Contains(validSpindles, newSpindle) { 625 - log.Println("newSpindle not present in validSpindles", "newSpindle", newSpindle, "validSpindles", validSpindles) 626 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.") 627 - return 749 + spindlePtr := &newSpindle 750 + if removingSpindle { 751 + spindlePtr = nil 628 752 } 629 753 630 754 // optimistic update 631 - err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle) 755 + err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr) 632 756 if err != nil { 633 - log.Println("failed to perform update-spindle query", err) 634 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.") 757 + fail("Failed to update spindle. Try again later.", err) 635 758 return 636 759 } 637 760 638 761 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 639 762 if err != nil { 640 - // failed to get record 641 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, no record found on PDS.") 763 + fail("Failed to update spindle, no record found on PDS.", err) 642 764 return 643 765 } 644 766 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ ··· 649 771 Record: &lexutil.LexiconTypeDecoder{ 650 772 Val: &tangled.Repo{ 651 773 Knot: f.Knot, 652 - Name: f.RepoName, 774 + Name: f.Name, 653 775 Owner: user.Did, 654 - CreatedAt: f.CreatedAt, 776 + CreatedAt: f.Created.Format(time.RFC3339), 655 777 Description: &f.Description, 656 - Spindle: &newSpindle, 778 + Spindle: spindlePtr, 657 779 }, 658 780 }, 659 781 }) 660 782 661 783 if err != nil { 662 - log.Println("failed to perform update-spindle query", err) 663 - // failed to get record 664 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, unable to save to PDS.") 784 + fail("Failed to update spindle, unable to save to PDS.", err) 665 785 return 666 786 } 667 787 668 - // add this spindle to spindle stream 669 - rp.spindlestream.AddSource( 670 - context.Background(), 671 - eventconsumer.NewSpindleSource(newSpindle), 672 - ) 788 + if !removingSpindle { 789 + // add this spindle to spindle stream 790 + rp.spindlestream.AddSource( 791 + context.Background(), 792 + eventconsumer.NewSpindleSource(newSpindle), 793 + ) 794 + } 673 795 674 - w.Write(fmt.Append(nil, "spindle set to: ", newSpindle)) 796 + rp.pages.HxRefresh(w) 675 797 } 676 798 677 799 func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { 800 + user := rp.oauth.GetUser(r) 801 + l := rp.logger.With("handler", "AddCollaborator") 802 + l = l.With("did", user.Did) 803 + l = l.With("handle", user.Handle) 804 + 678 805 f, err := rp.repoResolver.Resolve(r) 679 806 if err != nil { 680 - log.Println("failed to get repo and knot", err) 807 + l.Error("failed to get repo and knot", "err", err) 681 808 return 809 + } 810 + 811 + errorId := "add-collaborator-error" 812 + fail := func(msg string, err error) { 813 + l.Error(msg, "err", err) 814 + rp.pages.Notice(w, errorId, msg) 682 815 } 683 816 684 817 collaborator := r.FormValue("collaborator") 685 818 if collaborator == "" { 686 - http.Error(w, "malformed form", http.StatusBadRequest) 819 + fail("Invalid form.", nil) 687 820 return 688 821 } 689 822 823 + // remove a single leading `@`, to make @handle work with ResolveIdent 824 + collaborator = strings.TrimPrefix(collaborator, "@") 825 + 690 826 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator) 691 827 if err != nil { 692 - w.Write([]byte("failed to resolve collaborator did to a handle")) 828 + fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err) 693 829 return 694 830 } 695 - log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot) 696 831 697 - // TODO: create an atproto record for this 698 - 699 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 700 - if err != nil { 701 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 832 + if collaboratorIdent.DID.String() == user.Did { 833 + fail("You seem to be adding yourself as a collaborator.", nil) 702 834 return 703 835 } 836 + l = l.With("collaborator", collaboratorIdent.Handle) 837 + l = l.With("knot", f.Knot) 704 838 705 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 839 + // announce this relation into the firehose, store into owners' pds 840 + client, err := rp.oauth.AuthorizedClient(r) 706 841 if err != nil { 707 - log.Println("failed to create client to ", f.Knot) 842 + fail("Failed to write to PDS.", err) 708 843 return 709 844 } 710 845 711 - ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 846 + // emit a record 847 + currentUser := rp.oauth.GetUser(r) 848 + rkey := tid.TID() 849 + createdAt := time.Now() 850 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 851 + Collection: tangled.RepoCollaboratorNSID, 852 + Repo: currentUser.Did, 853 + Rkey: rkey, 854 + Record: &lexutil.LexiconTypeDecoder{ 855 + Val: &tangled.RepoCollaborator{ 856 + Subject: collaboratorIdent.DID.String(), 857 + Repo: string(f.RepoAt()), 858 + CreatedAt: createdAt.Format(time.RFC3339), 859 + }}, 860 + }) 861 + // invalid record 712 862 if err != nil { 713 - log.Printf("failed to make request to %s: %s", f.Knot, err) 863 + fail("Failed to write record to PDS.", err) 714 864 return 715 865 } 716 866 717 - if ksResp.StatusCode != http.StatusNoContent { 718 - w.Write(fmt.Append(nil, "knotserver failed to add collaborator: ", err)) 719 - return 720 - } 867 + aturi := resp.Uri 868 + l = l.With("at-uri", aturi) 869 + l.Info("wrote record to PDS") 721 870 722 871 tx, err := rp.db.BeginTx(r.Context(), nil) 723 872 if err != nil { 724 - log.Println("failed to start tx") 725 - w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 873 + fail("Failed to add collaborator.", err) 726 874 return 727 875 } 728 - defer func() { 729 - tx.Rollback() 730 - err = rp.enforcer.E.LoadPolicy() 731 - if err != nil { 732 - log.Println("failed to rollback policies") 876 + 877 + rollback := func() { 878 + err1 := tx.Rollback() 879 + err2 := rp.enforcer.E.LoadPolicy() 880 + err3 := rollbackRecord(context.Background(), aturi, client) 881 + 882 + // ignore txn complete errors, this is okay 883 + if errors.Is(err1, sql.ErrTxDone) { 884 + err1 = nil 733 885 } 734 - }() 886 + 887 + if errs := errors.Join(err1, err2, err3); errs != nil { 888 + l.Error("failed to rollback changes", "errs", errs) 889 + return 890 + } 891 + } 892 + defer rollback() 735 893 736 894 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 737 895 if err != nil { 738 - w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 896 + fail("Failed to add collaborator permissions.", err) 739 897 return 740 898 } 741 899 742 - err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot) 900 + err = db.AddCollaborator(rp.db, db.Collaborator{ 901 + Did: syntax.DID(currentUser.Did), 902 + Rkey: rkey, 903 + SubjectDid: collaboratorIdent.DID, 904 + RepoAt: f.RepoAt(), 905 + Created: createdAt, 906 + }) 743 907 if err != nil { 744 - w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 908 + fail("Failed to add collaborator.", err) 745 909 return 746 910 } 747 911 748 912 err = tx.Commit() 749 913 if err != nil { 750 - log.Println("failed to commit changes", err) 751 - http.Error(w, err.Error(), http.StatusInternalServerError) 914 + fail("Failed to add collaborator.", err) 752 915 return 753 916 } 754 917 755 918 err = rp.enforcer.E.SavePolicy() 756 919 if err != nil { 757 - log.Println("failed to update ACLs", err) 758 - http.Error(w, err.Error(), http.StatusInternalServerError) 920 + fail("Failed to update collaborator permissions.", err) 759 921 return 760 922 } 761 923 762 - w.Write(fmt.Append(nil, "added collaborator: ", collaboratorIdent.Handle.String())) 924 + // clear aturi to when everything is successful 925 + aturi = "" 763 926 927 + rp.pages.HxRefresh(w) 764 928 } 765 929 766 930 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 767 931 user := rp.oauth.GetUser(r) 768 932 933 + noticeId := "operation-error" 769 934 f, err := rp.repoResolver.Resolve(r) 770 935 if err != nil { 771 936 log.Println("failed to get repo and knot", err) ··· 778 943 log.Println("failed to get authorized client", err) 779 944 return 780 945 } 781 - repoRkey := f.RepoAt.RecordKey().String() 782 946 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 783 947 Collection: tangled.RepoNSID, 784 948 Repo: user.Did, 785 - Rkey: repoRkey, 949 + Rkey: f.Rkey, 786 950 }) 787 951 if err != nil { 788 952 log.Printf("failed to delete record: %s", err) 789 - rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 953 + rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.") 790 954 return 791 955 } 792 - log.Println("removed repo record ", f.RepoAt.String()) 956 + log.Println("removed repo record ", f.RepoAt().String()) 793 957 794 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 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 + ) 795 964 if err != nil { 796 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 965 + log.Println("failed to connect to knot server:", err) 797 966 return 798 967 } 799 968 800 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 801 - if err != nil { 802 - log.Println("failed to create client to ", f.Knot) 803 - return 804 - } 805 - 806 - ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName) 807 - if err != nil { 808 - 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()) 809 980 return 810 981 } 811 - 812 - if ksResp.StatusCode != http.StatusNoContent { 813 - log.Println("failed to remove repo from knot, continuing anyway ", f.Knot) 814 - } else { 815 - log.Println("removed repo from knot ", f.Knot) 816 - } 982 + log.Println("deleted repo from knot") 817 983 818 984 tx, err := rp.db.BeginTx(r.Context(), nil) 819 985 if err != nil { ··· 832 998 // remove collaborator RBAC 833 999 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 834 1000 if err != nil { 835 - rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators") 1001 + rp.pages.Notice(w, noticeId, "Failed to remove collaborators") 836 1002 return 837 1003 } 838 1004 for _, c := range repoCollaborators { ··· 844 1010 // remove repo RBAC 845 1011 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 846 1012 if err != nil { 847 - rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") 1013 + rp.pages.Notice(w, noticeId, "Failed to update RBAC rules") 848 1014 return 849 1015 } 850 1016 851 1017 // remove repo from db 852 - err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName) 1018 + err = db.RemoveRepo(tx, f.OwnerDid(), f.Name) 853 1019 if err != nil { 854 - rp.pages.Notice(w, "settings-delete", "Failed to update appview") 1020 + rp.pages.Notice(w, noticeId, "Failed to update appview") 855 1021 return 856 1022 } 857 1023 log.Println("removed repo from db") ··· 880 1046 return 881 1047 } 882 1048 1049 + noticeId := "operation-error" 883 1050 branch := r.FormValue("branch") 884 1051 if branch == "" { 885 1052 http.Error(w, "malformed form", http.StatusBadRequest) 886 1053 return 887 1054 } 888 1055 889 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 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 + ) 890 1062 if err != nil { 891 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 1063 + log.Println("failed to connect to knot server:", err) 1064 + rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 892 1065 return 893 1066 } 894 1067 895 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 896 - if err != nil { 897 - log.Println("failed to create client to ", f.Knot) 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()) 898 1079 return 899 1080 } 900 1081 901 - ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch) 1082 + rp.pages.HxRefresh(w) 1083 + } 1084 + 1085 + func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1086 + user := rp.oauth.GetUser(r) 1087 + l := rp.logger.With("handler", "Secrets") 1088 + l = l.With("handle", user.Handle) 1089 + l = l.With("did", user.Did) 1090 + 1091 + f, err := rp.repoResolver.Resolve(r) 902 1092 if err != nil { 903 - log.Printf("failed to make request to %s: %s", f.Knot, err) 1093 + log.Println("failed to get repo and knot", err) 904 1094 return 905 1095 } 906 1096 907 - if ksResp.StatusCode != http.StatusNoContent { 908 - rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.") 1097 + if f.Spindle == "" { 1098 + log.Println("empty spindle cannot add/rm secret", err) 909 1099 return 910 1100 } 911 1101 912 - w.Write(fmt.Append(nil, "default branch set to: ", branch)) 913 - } 1102 + lxm := tangled.RepoAddSecretNSID 1103 + if r.Method == http.MethodDelete { 1104 + lxm = tangled.RepoRemoveSecretNSID 1105 + } 914 1106 915 - func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 916 - f, err := rp.repoResolver.Resolve(r) 1107 + spindleClient, err := rp.oauth.ServiceClient( 1108 + r, 1109 + oauth.WithService(f.Spindle), 1110 + oauth.WithLxm(lxm), 1111 + oauth.WithExp(60), 1112 + oauth.WithDev(rp.config.Core.Dev), 1113 + ) 917 1114 if err != nil { 918 - log.Println("failed to get repo and knot", err) 1115 + log.Println("failed to create spindle client", err) 1116 + return 1117 + } 1118 + 1119 + key := r.FormValue("key") 1120 + if key == "" { 1121 + w.WriteHeader(http.StatusBadRequest) 919 1122 return 920 1123 } 921 1124 922 1125 switch r.Method { 923 - case http.MethodGet: 924 - // for now, this is just pubkeys 925 - user := rp.oauth.GetUser(r) 926 - repoCollaborators, err := f.Collaborators(r.Context()) 927 - if err != nil { 928 - log.Println("failed to get collaborators", err) 929 - } 1126 + case http.MethodPut: 1127 + errorId := "add-secret-error" 930 1128 931 - isCollaboratorInviteAllowed := false 932 - if user != nil { 933 - ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 934 - if err == nil && ok { 935 - isCollaboratorInviteAllowed = true 936 - } 1129 + value := r.FormValue("value") 1130 + if value == "" { 1131 + w.WriteHeader(http.StatusBadRequest) 1132 + return 937 1133 } 938 1134 939 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1135 + err = tangled.RepoAddSecret( 1136 + r.Context(), 1137 + spindleClient, 1138 + &tangled.RepoAddSecret_Input{ 1139 + Repo: f.RepoAt().String(), 1140 + Key: key, 1141 + Value: value, 1142 + }, 1143 + ) 940 1144 if err != nil { 941 - log.Println("failed to create unsigned client", err) 1145 + l.Error("Failed to add secret.", "err", err) 1146 + rp.pages.Notice(w, errorId, "Failed to add secret.") 942 1147 return 943 1148 } 944 1149 945 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1150 + case http.MethodDelete: 1151 + errorId := "operation-error" 1152 + 1153 + err = tangled.RepoRemoveSecret( 1154 + r.Context(), 1155 + spindleClient, 1156 + &tangled.RepoRemoveSecret_Input{ 1157 + Repo: f.RepoAt().String(), 1158 + Key: key, 1159 + }, 1160 + ) 946 1161 if err != nil { 947 - log.Println("failed to reach knotserver", err) 1162 + l.Error("Failed to delete secret.", "err", err) 1163 + rp.pages.Notice(w, errorId, "Failed to delete secret.") 948 1164 return 949 1165 } 1166 + } 950 1167 951 - // all spindles that this user is a member of 952 - spindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 953 - if err != nil { 954 - log.Println("failed to fetch spindles", err) 955 - return 1168 + rp.pages.HxRefresh(w) 1169 + } 1170 + 1171 + type tab = map[string]any 1172 + 1173 + var ( 1174 + // would be great to have ordered maps right about now 1175 + settingsTabs []tab = []tab{ 1176 + {"Name": "general", "Icon": "sliders-horizontal"}, 1177 + {"Name": "access", "Icon": "users"}, 1178 + {"Name": "pipelines", "Icon": "layers-2"}, 1179 + } 1180 + ) 1181 + 1182 + func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 1183 + tabVal := r.URL.Query().Get("tab") 1184 + if tabVal == "" { 1185 + tabVal = "general" 1186 + } 1187 + 1188 + switch tabVal { 1189 + case "general": 1190 + rp.generalSettings(w, r) 1191 + 1192 + case "access": 1193 + rp.accessSettings(w, r) 1194 + 1195 + case "pipelines": 1196 + rp.pipelineSettings(w, r) 1197 + } 1198 + } 1199 + 1200 + func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1201 + f, err := rp.repoResolver.Resolve(r) 1202 + user := rp.oauth.GetUser(r) 1203 + 1204 + us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1205 + if err != nil { 1206 + log.Println("failed to create unsigned client", err) 1207 + return 1208 + } 1209 + 1210 + result, err := us.Branches(f.OwnerDid(), f.Name) 1211 + if err != nil { 1212 + rp.pages.Error503(w) 1213 + log.Println("failed to reach knotserver", err) 1214 + return 1215 + } 1216 + 1217 + rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 1218 + LoggedInUser: user, 1219 + RepoInfo: f.RepoInfo(user), 1220 + Branches: result.Branches, 1221 + Tabs: settingsTabs, 1222 + Tab: "general", 1223 + }) 1224 + } 1225 + 1226 + func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 1227 + f, err := rp.repoResolver.Resolve(r) 1228 + user := rp.oauth.GetUser(r) 1229 + 1230 + repoCollaborators, err := f.Collaborators(r.Context()) 1231 + if err != nil { 1232 + log.Println("failed to get collaborators", err) 1233 + } 1234 + 1235 + rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 1236 + LoggedInUser: user, 1237 + RepoInfo: f.RepoInfo(user), 1238 + Tabs: settingsTabs, 1239 + Tab: "access", 1240 + Collaborators: repoCollaborators, 1241 + }) 1242 + } 1243 + 1244 + func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 1245 + f, err := rp.repoResolver.Resolve(r) 1246 + user := rp.oauth.GetUser(r) 1247 + 1248 + // all spindles that the repo owner is a member of 1249 + spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 1250 + if err != nil { 1251 + log.Println("failed to fetch spindles", err) 1252 + return 1253 + } 1254 + 1255 + var secrets []*tangled.RepoListSecrets_Secret 1256 + if f.Spindle != "" { 1257 + if spindleClient, err := rp.oauth.ServiceClient( 1258 + r, 1259 + oauth.WithService(f.Spindle), 1260 + oauth.WithLxm(tangled.RepoListSecretsNSID), 1261 + oauth.WithExp(60), 1262 + oauth.WithDev(rp.config.Core.Dev), 1263 + ); err != nil { 1264 + log.Println("failed to create spindle client", err) 1265 + } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 1266 + log.Println("failed to fetch secrets", err) 1267 + } else { 1268 + secrets = resp.Secrets 956 1269 } 1270 + } 957 1271 958 - rp.pages.RepoSettings(w, pages.RepoSettingsParams{ 959 - LoggedInUser: user, 960 - RepoInfo: f.RepoInfo(user), 961 - Collaborators: repoCollaborators, 962 - IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 963 - Branches: result.Branches, 964 - Spindles: spindles, 965 - CurrentSpindle: f.Spindle, 1272 + slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { 1273 + return strings.Compare(a.Key, b.Key) 1274 + }) 1275 + 1276 + var dids []string 1277 + for _, s := range secrets { 1278 + dids = append(dids, s.CreatedBy) 1279 + } 1280 + resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) 1281 + 1282 + // convert to a more manageable form 1283 + var niceSecret []map[string]any 1284 + for id, s := range secrets { 1285 + when, _ := time.Parse(time.RFC3339, s.CreatedAt) 1286 + niceSecret = append(niceSecret, map[string]any{ 1287 + "Id": id, 1288 + "Key": s.Key, 1289 + "CreatedAt": when, 1290 + "CreatedBy": resolvedIdents[id].Handle.String(), 966 1291 }) 967 1292 } 1293 + 1294 + rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 1295 + LoggedInUser: user, 1296 + RepoInfo: f.RepoInfo(user), 1297 + Tabs: settingsTabs, 1298 + Tab: "pipelines", 1299 + Spindles: spindles, 1300 + CurrentSpindle: f.Spindle, 1301 + Secrets: niceSecret, 1302 + }) 968 1303 } 969 1304 970 1305 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1306 + ref := chi.URLParam(r, "ref") 1307 + 971 1308 user := rp.oauth.GetUser(r) 972 1309 f, err := rp.repoResolver.Resolve(r) 973 1310 if err != nil { ··· 977 1314 978 1315 switch r.Method { 979 1316 case http.MethodPost: 980 - 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 + ) 981 1323 if err != nil { 982 - 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.") 983 1325 return 984 1326 } 985 1327 986 - client, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 987 - if err != nil { 988 - 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.") 989 1331 return 990 1332 } 991 1333 992 - var uri string 993 - if rp.config.Core.Dev { 994 - uri = "http" 995 - } else { 996 - uri = "https" 997 - } 998 - forkName := fmt.Sprintf("%s", f.RepoName) 999 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1000 - 1001 - _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref) 1002 - if err != nil { 1003 - 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()) 1004 1346 return 1005 1347 } 1006 1348 ··· 1033 1375 }) 1034 1376 1035 1377 case http.MethodPost: 1378 + l := rp.logger.With("handler", "ForkRepo") 1036 1379 1037 - knot := r.FormValue("knot") 1038 - if knot == "" { 1380 + targetKnot := r.FormValue("knot") 1381 + if targetKnot == "" { 1039 1382 rp.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 1040 1383 return 1041 1384 } 1385 + l = l.With("targetKnot", targetKnot) 1042 1386 1043 - 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") 1044 1388 if err != nil || !ok { 1045 1389 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1046 1390 return 1047 1391 } 1048 1392 1049 - forkName := fmt.Sprintf("%s", f.RepoName) 1050 - 1393 + // choose a name for a fork 1394 + forkName := f.Name 1051 1395 // this check is *only* to see if the forked repo name already exists 1052 1396 // in the user's account. 1053 - existingRepo, err := db.GetRepo(rp.db, user.Did, f.RepoName) 1397 + existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name) 1054 1398 if err != nil { 1055 1399 if errors.Is(err, sql.ErrNoRows) { 1056 1400 // no existing repo with this name found, we can use the name as is ··· 1063 1407 // repo with this name already exists, append random string 1064 1408 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1065 1409 } 1066 - secret, err := db.GetRegistrationKey(rp.db, knot) 1067 - if err != nil { 1068 - rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot)) 1069 - return 1070 - } 1071 - 1072 - client, err := knotclient.NewSignedClient(knot, secret, rp.config.Core.Dev) 1073 - if err != nil { 1074 - rp.pages.Notice(w, "repo", "Failed to reach knot server.") 1075 - return 1076 - } 1410 + l = l.With("forkName", forkName) 1077 1411 1078 - var uri string 1412 + uri := "https" 1079 1413 if rp.config.Core.Dev { 1080 1414 uri = "http" 1081 - } else { 1082 - uri = "https" 1083 1415 } 1084 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1085 - sourceAt := f.RepoAt.String() 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() 1086 1421 1422 + // create an atproto record for this fork 1087 1423 rkey := tid.TID() 1088 1424 repo := &db.Repo{ 1089 1425 Did: user.Did, 1090 1426 Name: forkName, 1091 - Knot: knot, 1427 + Knot: targetKnot, 1092 1428 Rkey: rkey, 1093 1429 Source: sourceAt, 1094 1430 } 1095 1431 1096 - tx, err := rp.db.BeginTx(r.Context(), nil) 1097 - if err != nil { 1098 - log.Println(err) 1099 - rp.pages.Notice(w, "repo", "Failed to save repository information.") 1100 - return 1101 - } 1102 - defer func() { 1103 - tx.Rollback() 1104 - err = rp.enforcer.E.LoadPolicy() 1105 - if err != nil { 1106 - log.Println("failed to rollback policies") 1107 - } 1108 - }() 1109 - 1110 - resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName) 1111 - if err != nil { 1112 - rp.pages.Notice(w, "repo", "Failed to create repository on knot server.") 1113 - return 1114 - } 1115 - 1116 - switch resp.StatusCode { 1117 - case http.StatusConflict: 1118 - rp.pages.Notice(w, "repo", "A repository with that name already exists.") 1119 - return 1120 - case http.StatusInternalServerError: 1121 - rp.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 1122 - case http.StatusNoContent: 1123 - // continue 1124 - } 1125 - 1126 1432 xrpcClient, err := rp.oauth.AuthorizedClient(r) 1127 1433 if err != nil { 1128 - log.Println("failed to get authorized client", err) 1129 - 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.") 1130 1436 return 1131 1437 } 1132 1438 ··· 1145 1451 }}, 1146 1452 }) 1147 1453 if err != nil { 1148 - log.Printf("failed to create record: %s", err) 1454 + l.Error("failed to write to PDS", "err", err) 1149 1455 rp.pages.Notice(w, "repo", "Failed to announce repository creation.") 1150 1456 return 1151 1457 } 1152 - log.Println("created repo record: ", atresp.Uri) 1458 + 1459 + aturi := atresp.Uri 1460 + l = l.With("aturi", aturi) 1461 + l.Info("wrote to PDS") 1462 + 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() 1153 1490 1154 - repo.AtUri = atresp.Uri 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 + 1155 1516 err = db.AddRepo(tx, repo) 1156 1517 if err != nil { 1157 1518 log.Println(err) ··· 1161 1522 1162 1523 // acls 1163 1524 p, _ := securejoin.SecureJoin(user.Did, forkName) 1164 - err = rp.enforcer.AddRepo(user.Did, knot, p) 1525 + err = rp.enforcer.AddRepo(user.Did, targetKnot, p) 1165 1526 if err != nil { 1166 1527 log.Println(err) 1167 1528 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") ··· 1182 1543 return 1183 1544 } 1184 1545 1546 + // reset the ATURI because the transaction completed successfully 1547 + aturi = "" 1548 + 1549 + rp.notifier.NewRepo(r.Context(), repo) 1185 1550 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1186 - return 1551 + } 1552 + } 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 1187 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 1188 1574 } 1189 1575 1190 1576 func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { ··· 1202 1588 return 1203 1589 } 1204 1590 1205 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1591 + result, err := us.Branches(f.OwnerDid(), f.Name) 1206 1592 if err != nil { 1207 1593 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1208 1594 log.Println("failed to reach knotserver", err) ··· 1232 1618 head = queryHead 1233 1619 } 1234 1620 1235 - tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1621 + tags, err := us.Tags(f.OwnerDid(), f.Name) 1236 1622 if err != nil { 1237 1623 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1238 1624 log.Println("failed to reach knotserver", err) ··· 1294 1680 return 1295 1681 } 1296 1682 1297 - branches, err := us.Branches(f.OwnerDid(), f.RepoName) 1683 + branches, err := us.Branches(f.OwnerDid(), f.Name) 1298 1684 if err != nil { 1299 1685 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1300 1686 log.Println("failed to reach knotserver", err) 1301 1687 return 1302 1688 } 1303 1689 1304 - tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1690 + tags, err := us.Tags(f.OwnerDid(), f.Name) 1305 1691 if err != nil { 1306 1692 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1307 1693 log.Println("failed to reach knotserver", err) 1308 1694 return 1309 1695 } 1310 1696 1311 - formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head) 1697 + formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head) 1312 1698 if err != nil { 1313 1699 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1314 1700 log.Println("failed to compare", err)
+7
appview/repo/router.go
··· 10 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 11 r := chi.NewRouter() 12 12 r.Get("/", rp.RepoIndex) 13 + r.Get("/feed.atom", rp.RepoAtomFeed) 13 14 r.Get("/commits/{ref}", rp.RepoLog) 14 15 r.Route("/tree/{ref}", func(r chi.Router) { 15 16 r.Get("/", rp.RepoIndex) ··· 37 38 }) 38 39 r.Get("/blob/{ref}/*", rp.RepoBlob) 39 40 r.Get("/raw/{ref}/*", rp.RepoBlobRaw) 41 + 42 + // intentionally doesn't use /* as this isn't 43 + // a file path 44 + r.Get("/archive/{ref}", rp.DownloadArchive) 40 45 41 46 r.Route("/fork", func(r chi.Router) { 42 47 r.Use(middleware.AuthMiddleware(rp.oauth)) ··· 74 79 r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator) 75 80 r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo) 76 81 r.Put("/branches/default", rp.SetDefaultBranch) 82 + r.Put("/secrets", rp.Secrets) 83 + r.Delete("/secrets", rp.Secrets) 77 84 }) 78 85 }) 79 86
+41 -107
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 89 return p 135 90 } 136 91 137 - func (f *ResolvedRepo) DidSlashRepo() string { 138 - p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 139 - return p 140 - } 141 - 142 92 func (f *ResolvedRepo) Collaborators(ctx context.Context) ([]pages.Collaborator, error) { 143 93 repoCollaborators, err := f.rr.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 144 94 if err != nil { ··· 149 99 for _, item := range repoCollaborators { 150 100 // currently only two roles: owner and member 151 101 var role string 152 - if item[3] == "repo:owner" { 102 + switch item[3] { 103 + case "repo:owner": 153 104 role = "owner" 154 - } else if item[3] == "repo:collaborator" { 105 + case "repo:collaborator": 155 106 role = "collaborator" 156 - } else { 107 + default: 157 108 continue 158 109 } 159 110 ··· 186 137 // this function is a bit weird since it now returns RepoInfo from an entirely different 187 138 // package. we should refactor this or get rid of RepoInfo entirely. 188 139 func (f *ResolvedRepo) RepoInfo(user *oauth.User) repoinfo.RepoInfo { 140 + repoAt := f.RepoAt() 189 141 isStarred := false 190 142 if user != nil { 191 - isStarred = db.GetStarStatus(f.rr.execer, user.Did, syntax.ATURI(f.RepoAt)) 143 + isStarred = db.GetStarStatus(f.rr.execer, user.Did, repoAt) 192 144 } 193 145 194 - starCount, err := db.GetStarCount(f.rr.execer, f.RepoAt) 146 + starCount, err := db.GetStarCount(f.rr.execer, repoAt) 195 147 if err != nil { 196 - log.Println("failed to get star count for ", f.RepoAt) 148 + log.Println("failed to get star count for ", repoAt) 197 149 } 198 - issueCount, err := db.GetIssueCount(f.rr.execer, f.RepoAt) 150 + issueCount, err := db.GetIssueCount(f.rr.execer, repoAt) 199 151 if err != nil { 200 - log.Println("failed to get issue count for ", f.RepoAt) 152 + log.Println("failed to get issue count for ", repoAt) 201 153 } 202 - pullCount, err := db.GetPullCount(f.rr.execer, f.RepoAt) 154 + pullCount, err := db.GetPullCount(f.rr.execer, repoAt) 203 155 if err != nil { 204 - log.Println("failed to get issue count for ", f.RepoAt) 156 + log.Println("failed to get issue count for ", repoAt) 205 157 } 206 - source, err := db.GetRepoSource(f.rr.execer, f.RepoAt) 158 + source, err := db.GetRepoSource(f.rr.execer, repoAt) 207 159 if errors.Is(err, sql.ErrNoRows) { 208 160 source = "" 209 161 } else if err != nil { 210 - log.Println("failed to get repo source for ", f.RepoAt, err) 162 + log.Println("failed to get repo source for ", repoAt, err) 211 163 } 212 164 213 165 var sourceRepo *db.Repo ··· 227 179 } 228 180 229 181 knot := f.Knot 230 - var disableFork bool 231 - us, err := knotclient.NewUnsignedClient(knot, f.rr.config.Core.Dev) 232 - if err != nil { 233 - log.Printf("failed to create unsigned client for %s: %v", knot, err) 234 - } else { 235 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 236 - if err != nil { 237 - log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err) 238 - } 239 - 240 - if len(result.Branches) == 0 { 241 - disableFork = true 242 - } 243 - } 244 182 245 183 repoInfo := repoinfo.RepoInfo{ 246 184 OwnerDid: f.OwnerDid(), 247 185 OwnerHandle: f.OwnerHandle(), 248 - Name: f.RepoName, 249 - RepoAt: f.RepoAt, 186 + Name: f.Name, 187 + RepoAt: repoAt, 250 188 Description: f.Description, 251 - Ref: f.Ref, 252 189 IsStarred: isStarred, 253 190 Knot: knot, 254 191 Spindle: f.Spindle, ··· 258 195 IssueCount: issueCount, 259 196 PullCount: pullCount, 260 197 }, 261 - DisableFork: disableFork, 262 - CurrentDir: f.CurrentDir, 198 + CurrentDir: f.CurrentDir, 199 + Ref: f.Ref, 263 200 } 264 201 265 202 if sourceRepo != nil { ··· 283 220 // after the ref. for example: 284 221 // 285 222 // /@icyphox.sh/foorepo/blob/main/abc/xyz/ => abc/xyz/ 286 - func extractPathAfterRef(fullPath, ref string) string { 223 + func extractPathAfterRef(fullPath string) string { 287 224 fullPath = strings.TrimPrefix(fullPath, "/") 288 225 289 - ref = url.PathEscape(ref) 226 + // match blob/, tree/, or raw/ followed by any ref and then a slash 227 + // 228 + // captures everything after the final slash 229 + pattern := `(?:blob|tree|raw)/[^/]+/(.*)$` 290 230 291 - prefixes := []string{ 292 - fmt.Sprintf("blob/%s/", ref), 293 - fmt.Sprintf("tree/%s/", ref), 294 - fmt.Sprintf("raw/%s/", ref), 295 - } 231 + re := regexp.MustCompile(pattern) 232 + matches := re.FindStringSubmatch(fullPath) 296 233 297 - for _, prefix := range prefixes { 298 - idx := strings.Index(fullPath, prefix) 299 - if idx != -1 { 300 - return fullPath[idx+len(prefix):] 301 - } 234 + if len(matches) > 1 { 235 + return matches[1] 302 236 } 303 237 304 238 return ""
+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 }
+104
appview/signup/requests.go
··· 1 + package signup 2 + 3 + // We have this extra code here for now since the xrpcclient package 4 + // only supports OAuth'd requests; these are unauthenticated or use PDS admin auth. 5 + 6 + import ( 7 + "bytes" 8 + "encoding/json" 9 + "fmt" 10 + "io" 11 + "net/http" 12 + "net/url" 13 + ) 14 + 15 + // makePdsRequest is a helper method to make requests to the PDS service 16 + func (s *Signup) makePdsRequest(method, endpoint string, body interface{}, useAuth bool) (*http.Response, error) { 17 + jsonData, err := json.Marshal(body) 18 + if err != nil { 19 + return nil, err 20 + } 21 + 22 + url := fmt.Sprintf("%s/xrpc/%s", s.config.Pds.Host, endpoint) 23 + req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonData)) 24 + if err != nil { 25 + return nil, err 26 + } 27 + 28 + req.Header.Set("Content-Type", "application/json") 29 + 30 + if useAuth { 31 + req.SetBasicAuth("admin", s.config.Pds.AdminSecret) 32 + } 33 + 34 + return http.DefaultClient.Do(req) 35 + } 36 + 37 + // handlePdsError processes error responses from the PDS service 38 + func (s *Signup) handlePdsError(resp *http.Response, action string) error { 39 + var errorResp struct { 40 + Error string `json:"error"` 41 + Message string `json:"message"` 42 + } 43 + 44 + respBody, _ := io.ReadAll(resp.Body) 45 + if err := json.Unmarshal(respBody, &errorResp); err == nil && errorResp.Message != "" { 46 + return fmt.Errorf("Failed to %s: %s - %s.", action, errorResp.Error, errorResp.Message) 47 + } 48 + 49 + // Fallback if we couldn't parse the error 50 + return fmt.Errorf("failed to %s, status code: %d", action, resp.StatusCode) 51 + } 52 + 53 + func (s *Signup) inviteCodeRequest() (string, error) { 54 + body := map[string]any{"useCount": 1} 55 + 56 + resp, err := s.makePdsRequest("POST", "com.atproto.server.createInviteCode", body, true) 57 + if err != nil { 58 + return "", err 59 + } 60 + defer resp.Body.Close() 61 + 62 + if resp.StatusCode != http.StatusOK { 63 + return "", s.handlePdsError(resp, "create invite code") 64 + } 65 + 66 + var result map[string]string 67 + json.NewDecoder(resp.Body).Decode(&result) 68 + return result["code"], nil 69 + } 70 + 71 + func (s *Signup) createAccountRequest(username, password, email, code string) (string, error) { 72 + parsedURL, err := url.Parse(s.config.Pds.Host) 73 + if err != nil { 74 + return "", fmt.Errorf("invalid PDS host URL: %w", err) 75 + } 76 + 77 + pdsDomain := parsedURL.Hostname() 78 + 79 + body := map[string]string{ 80 + "email": email, 81 + "handle": fmt.Sprintf("%s.%s", username, pdsDomain), 82 + "password": password, 83 + "inviteCode": code, 84 + } 85 + 86 + resp, err := s.makePdsRequest("POST", "com.atproto.server.createAccount", body, false) 87 + if err != nil { 88 + return "", err 89 + } 90 + defer resp.Body.Close() 91 + 92 + if resp.StatusCode != http.StatusOK { 93 + return "", s.handlePdsError(resp, "create account") 94 + } 95 + 96 + var result struct { 97 + DID string `json:"did"` 98 + } 99 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 100 + return "", fmt.Errorf("failed to decode create account response: %w", err) 101 + } 102 + 103 + return result.DID, nil 104 + }
+256
appview/signup/signup.go
··· 1 + package signup 2 + 3 + import ( 4 + "bufio" 5 + "fmt" 6 + "log/slog" 7 + "net/http" 8 + "os" 9 + "strings" 10 + 11 + "github.com/go-chi/chi/v5" 12 + "github.com/posthog/posthog-go" 13 + "tangled.sh/tangled.sh/core/appview/config" 14 + "tangled.sh/tangled.sh/core/appview/db" 15 + "tangled.sh/tangled.sh/core/appview/dns" 16 + "tangled.sh/tangled.sh/core/appview/email" 17 + "tangled.sh/tangled.sh/core/appview/pages" 18 + "tangled.sh/tangled.sh/core/appview/state/userutil" 19 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 20 + "tangled.sh/tangled.sh/core/idresolver" 21 + ) 22 + 23 + type Signup struct { 24 + config *config.Config 25 + db *db.DB 26 + cf *dns.Cloudflare 27 + posthog posthog.Client 28 + xrpc *xrpcclient.Client 29 + idResolver *idresolver.Resolver 30 + pages *pages.Pages 31 + l *slog.Logger 32 + disallowedNicknames map[string]bool 33 + } 34 + 35 + func New(cfg *config.Config, database *db.DB, pc posthog.Client, idResolver *idresolver.Resolver, pages *pages.Pages, l *slog.Logger) *Signup { 36 + var cf *dns.Cloudflare 37 + if cfg.Cloudflare.ApiToken != "" && cfg.Cloudflare.ZoneId != "" { 38 + var err error 39 + cf, err = dns.NewCloudflare(cfg) 40 + if err != nil { 41 + l.Warn("failed to create cloudflare client, signup will be disabled", "error", err) 42 + } 43 + } 44 + 45 + disallowedNicknames := loadDisallowedNicknames(cfg.Core.DisallowedNicknamesFile, l) 46 + 47 + return &Signup{ 48 + config: cfg, 49 + db: database, 50 + posthog: pc, 51 + idResolver: idResolver, 52 + cf: cf, 53 + pages: pages, 54 + l: l, 55 + disallowedNicknames: disallowedNicknames, 56 + } 57 + } 58 + 59 + func loadDisallowedNicknames(filepath string, logger *slog.Logger) map[string]bool { 60 + disallowed := make(map[string]bool) 61 + 62 + if filepath == "" { 63 + logger.Debug("no disallowed nicknames file configured") 64 + return disallowed 65 + } 66 + 67 + file, err := os.Open(filepath) 68 + if err != nil { 69 + logger.Warn("failed to open disallowed nicknames file", "file", filepath, "error", err) 70 + return disallowed 71 + } 72 + defer file.Close() 73 + 74 + scanner := bufio.NewScanner(file) 75 + lineNum := 0 76 + for scanner.Scan() { 77 + lineNum++ 78 + line := strings.TrimSpace(scanner.Text()) 79 + if line == "" || strings.HasPrefix(line, "#") { 80 + continue // skip empty lines and comments 81 + } 82 + 83 + nickname := strings.ToLower(line) 84 + if userutil.IsValidSubdomain(nickname) { 85 + disallowed[nickname] = true 86 + } else { 87 + logger.Warn("invalid nickname format in disallowed nicknames file", 88 + "file", filepath, "line", lineNum, "nickname", nickname) 89 + } 90 + } 91 + 92 + if err := scanner.Err(); err != nil { 93 + logger.Error("error reading disallowed nicknames file", "file", filepath, "error", err) 94 + } 95 + 96 + logger.Info("loaded disallowed nicknames", "count", len(disallowed), "file", filepath) 97 + return disallowed 98 + } 99 + 100 + // isNicknameAllowed checks if a nickname is allowed (not in the disallowed list) 101 + func (s *Signup) isNicknameAllowed(nickname string) bool { 102 + return !s.disallowedNicknames[strings.ToLower(nickname)] 103 + } 104 + 105 + func (s *Signup) Router() http.Handler { 106 + r := chi.NewRouter() 107 + r.Get("/", s.signup) 108 + r.Post("/", s.signup) 109 + r.Get("/complete", s.complete) 110 + r.Post("/complete", s.complete) 111 + 112 + return r 113 + } 114 + 115 + func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 116 + switch r.Method { 117 + case http.MethodGet: 118 + s.pages.Signup(w) 119 + case http.MethodPost: 120 + if s.cf == nil { 121 + http.Error(w, "signup is disabled", http.StatusFailedDependency) 122 + } 123 + emailId := r.FormValue("email") 124 + 125 + noticeId := "signup-msg" 126 + if !email.IsValidEmail(emailId) { 127 + s.pages.Notice(w, noticeId, "Invalid email address.") 128 + return 129 + } 130 + 131 + exists, err := db.CheckEmailExistsAtAll(s.db, emailId) 132 + if err != nil { 133 + s.l.Error("failed to check email existence", "error", err) 134 + s.pages.Notice(w, noticeId, "Failed to complete signup. Try again later.") 135 + return 136 + } 137 + if exists { 138 + s.pages.Notice(w, noticeId, "Email already exists.") 139 + return 140 + } 141 + 142 + code, err := s.inviteCodeRequest() 143 + if err != nil { 144 + s.l.Error("failed to create invite code", "error", err) 145 + s.pages.Notice(w, noticeId, "Failed to create invite code.") 146 + return 147 + } 148 + 149 + em := email.Email{ 150 + APIKey: s.config.Resend.ApiKey, 151 + From: s.config.Resend.SentFrom, 152 + To: emailId, 153 + Subject: "Verify your Tangled account", 154 + Text: `Copy and paste this code below to verify your account on Tangled. 155 + ` + code, 156 + Html: `<p>Copy and paste this code below to verify your account on Tangled.</p> 157 + <p><code>` + code + `</code></p>`, 158 + } 159 + 160 + err = email.SendEmail(em) 161 + if err != nil { 162 + s.l.Error("failed to send email", "error", err) 163 + s.pages.Notice(w, noticeId, "Failed to send email.") 164 + return 165 + } 166 + err = db.AddInflightSignup(s.db, db.InflightSignup{ 167 + Email: emailId, 168 + InviteCode: code, 169 + }) 170 + if err != nil { 171 + s.l.Error("failed to add inflight signup", "error", err) 172 + s.pages.Notice(w, noticeId, "Failed to complete sign up. Try again later.") 173 + return 174 + } 175 + 176 + s.pages.HxRedirect(w, "/signup/complete") 177 + } 178 + } 179 + 180 + func (s *Signup) complete(w http.ResponseWriter, r *http.Request) { 181 + switch r.Method { 182 + case http.MethodGet: 183 + s.pages.CompleteSignup(w) 184 + case http.MethodPost: 185 + username := r.FormValue("username") 186 + password := r.FormValue("password") 187 + code := r.FormValue("code") 188 + 189 + if !userutil.IsValidSubdomain(username) { 190 + s.pages.Notice(w, "signup-error", "Invalid username. Username must be 4โ€“63 characters, lowercase letters, digits, or hyphens, and can't start or end with a hyphen.") 191 + return 192 + } 193 + 194 + if !s.isNicknameAllowed(username) { 195 + s.pages.Notice(w, "signup-error", "This username is not available. Please choose a different one.") 196 + return 197 + } 198 + 199 + email, err := db.GetEmailForCode(s.db, code) 200 + if err != nil { 201 + s.l.Error("failed to get email for code", "error", err) 202 + s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 203 + return 204 + } 205 + 206 + did, err := s.createAccountRequest(username, password, email, code) 207 + if err != nil { 208 + s.l.Error("failed to create account", "error", err) 209 + s.pages.Notice(w, "signup-error", err.Error()) 210 + return 211 + } 212 + 213 + if s.cf == nil { 214 + s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration") 215 + s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.") 216 + return 217 + } 218 + 219 + err = s.cf.CreateDNSRecord(r.Context(), dns.Record{ 220 + Type: "TXT", 221 + Name: "_atproto." + username, 222 + Content: fmt.Sprintf(`"did=%s"`, did), 223 + TTL: 6400, 224 + Proxied: false, 225 + }) 226 + if err != nil { 227 + s.l.Error("failed to create DNS record", "error", err) 228 + s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.") 229 + return 230 + } 231 + 232 + err = db.AddEmail(s.db, db.Email{ 233 + Did: did, 234 + Address: email, 235 + Verified: true, 236 + Primary: true, 237 + }) 238 + if err != nil { 239 + s.l.Error("failed to add email", "error", err) 240 + s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 241 + return 242 + } 243 + 244 + s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now 245 + <a class="underline text-black dark:text-white" href="/login">login</a> 246 + with <code>%s.tngl.sh</code>.`, username)) 247 + 248 + go func() { 249 + err := db.DeleteInflightSignup(s.db, email) 250 + if err != nil { 251 + s.l.Error("failed to delete inflight signup", "error", err) 252 + } 253 + }() 254 + return 255 + } 256 + }
+24 -25
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) ··· 303 290 s.Enforcer.E.LoadPolicy() 304 291 }() 305 292 293 + // remove spindle members first 294 + err = db.RemoveSpindleMember( 295 + tx, 296 + db.FilterEq("did", user.Did), 297 + db.FilterEq("instance", instance), 298 + ) 299 + if err != nil { 300 + l.Error("failed to remove spindle members", "err", err) 301 + fail() 302 + return 303 + } 304 + 306 305 err = db.DeleteSpindle( 307 306 tx, 308 307 db.FilterEq("owner", user.Did), ··· 401 400 } 402 401 403 402 // begin verification 404 - 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) 405 404 if err != nil { 406 405 l.Error("verification failed", "err", err) 407 406 408 - if errors.Is(err, verify.FetchError) { 409 - 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.") 410 409 return 411 410 } 412 411 413 - if e, ok := err.(*verify.OwnerMismatch); ok { 412 + if e, ok := err.(*serververify.OwnerMismatch); ok { 414 413 s.Pages.Notice(w, noticeId, e.Error()) 415 414 return 416 415 } ··· 419 418 return 420 419 } 421 420 422 - rowId, err := verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did) 421 + rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 423 422 if err != nil { 424 423 l.Error("failed to mark verified", "err", err) 425 424 s.Pages.Notice(w, noticeId, err.Error()) ··· 607 606 608 607 if string(spindles[0].Owner) != user.Did { 609 608 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 610 - s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.") 609 + s.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.") 611 610 return 612 611 } 613 612 614 613 member := r.FormValue("member") 615 614 if member == "" { 616 615 l.Error("empty member") 617 - s.Pages.Notice(w, noticeId, "Failed to add member, empty form.") 616 + s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") 618 617 return 619 618 } 620 619 l = l.With("member", member) ··· 622 621 memberId, err := s.IdResolver.ResolveIdent(r.Context(), member) 623 622 if err != nil { 624 623 l.Error("failed to resolve member identity to handle", "err", err) 625 - s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 624 + s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 626 625 return 627 626 } 628 627 if memberId.Handle.IsInvalidHandle() { 629 628 l.Error("failed to resolve member identity to handle") 630 - s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 629 + s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 631 630 return 632 631 } 633 632
-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
+337 -105
appview/state/profile.go
··· 1 1 package state 2 2 3 3 import ( 4 - "crypto/hmac" 5 - "crypto/sha256" 6 - "encoding/hex" 4 + "context" 7 5 "fmt" 8 6 "log" 9 7 "net/http" ··· 16 14 "github.com/bluesky-social/indigo/atproto/syntax" 17 15 lexutil "github.com/bluesky-social/indigo/lex/util" 18 16 "github.com/go-chi/chi/v5" 17 + "github.com/gorilla/feeds" 19 18 "tangled.sh/tangled.sh/core/api/tangled" 20 19 "tangled.sh/tangled.sh/core/appview/db" 20 + "tangled.sh/tangled.sh/core/appview/oauth" 21 21 "tangled.sh/tangled.sh/core/appview/pages" 22 22 ) 23 23 ··· 25 25 tabVal := r.URL.Query().Get("tab") 26 26 switch tabVal { 27 27 case "": 28 - s.profilePage(w, r) 28 + s.profileHomePage(w, r) 29 29 case "repos": 30 30 s.reposPage(w, r) 31 + case "followers": 32 + s.followersPage(w, r) 33 + case "following": 34 + s.followingPage(w, r) 31 35 } 32 36 } 33 37 34 - 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 { 35 45 didOrHandle := chi.URLParam(r, "user") 36 46 if didOrHandle == "" { 37 - http.Error(w, "Bad request", http.StatusBadRequest) 38 - return 47 + http.Error(w, "bad request", http.StatusBadRequest) 48 + return nil 39 49 } 40 50 41 51 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 42 52 if !ok { 43 - s.pages.Error404(w) 44 - return 53 + log.Printf("malformed middleware") 54 + w.WriteHeader(http.StatusInternalServerError) 55 + return nil 45 56 } 57 + did := ident.DID.String() 46 58 47 - profile, err := db.GetProfile(s.db, ident.DID.String()) 59 + profile, err := db.GetProfile(s.db, did) 48 60 if err != nil { 49 - 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) 75 + } 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 + }, 50 88 } 89 + } 51 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 52 98 repos, err := db.GetRepos( 53 99 s.db, 54 100 0, 55 - db.FilterEq("did", ident.DID.String()), 101 + db.FilterEq("did", id.DID), 56 102 ) 57 103 if err != nil { 58 - log.Printf("getting repos for %s: %s", ident.DID.String(), err) 104 + log.Printf("getting repos for %s: %s", id.DID, err) 59 105 } 60 106 107 + profile := pageWithProfile.Card.Profile 61 108 // filter out ones that are pinned 62 109 pinnedRepos := []db.Repo{} 63 110 for i, r := range repos { ··· 72 119 } 73 120 } 74 121 75 - collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String()) 122 + collaboratingRepos, err := db.CollaboratingIn(s.db, id.DID.String()) 76 123 if err != nil { 77 - log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err) 124 + log.Printf("getting collaborating repos for %s: %s", id.DID, err) 78 125 } 79 126 80 127 pinnedCollaboratingRepos := []db.Repo{} ··· 85 132 } 86 133 } 87 134 88 - timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String()) 135 + timeline, err := db.MakeProfileTimeline(s.db, id.DID.String()) 89 136 if err != nil { 90 - 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) 91 138 } 92 139 93 140 var didsToResolve []string ··· 109 156 } 110 157 } 111 158 112 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 113 - didHandleMap := make(map[string]string) 114 - for _, identity := range resolvedIds { 115 - if !identity.Handle.IsInvalidHandle() { 116 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 117 - } else { 118 - didHandleMap[identity.DID.String()] = identity.DID.String() 119 - } 120 - } 121 - 122 - followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String()) 123 - if err != nil { 124 - log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 125 - } 126 - 127 - loggedInUser := s.oauth.GetUser(r) 128 - followStatus := db.IsNotFollowing 129 - if loggedInUser != nil { 130 - followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 131 - } 132 - 133 159 now := time.Now() 134 160 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 135 161 punchcard, err := db.MakePunchcard( 136 162 s.db, 137 - db.FilterEq("did", ident.DID.String()), 163 + db.FilterEq("did", id.DID), 138 164 db.FilterGte("date", startOfYear.Format(time.DateOnly)), 139 165 db.FilterLte("date", now.Format(time.DateOnly)), 140 166 ) 141 167 if err != nil { 142 - 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) 143 169 } 144 170 145 - profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 146 - s.pages.ProfilePage(w, pages.ProfilePageParams{ 147 - LoggedInUser: loggedInUser, 171 + s.pages.ProfileHomePage(w, pages.ProfileHomePageParams{ 172 + LoggedInUser: pageWithProfile.LoggedInUser, 148 173 Repos: pinnedRepos, 149 174 CollaboratingRepos: pinnedCollaboratingRepos, 150 - DidHandleMap: didHandleMap, 151 - Card: pages.ProfileCard{ 152 - UserDid: ident.DID.String(), 153 - UserHandle: ident.Handle.String(), 154 - AvatarUri: profileAvatarUri, 155 - Profile: profile, 156 - FollowStatus: followStatus, 157 - Followers: followers, 158 - Following: following, 159 - }, 160 - Punchcard: punchcard, 161 - ProfileTimeline: timeline, 175 + Card: pageWithProfile.Card, 176 + Punchcard: punchcard, 177 + ProfileTimeline: timeline, 162 178 }) 163 179 } 164 180 165 181 func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 166 - ident, ok := r.Context().Value("resolvedId").(identity.Identity) 167 - if !ok { 168 - s.pages.Error404(w) 182 + pageWithProfile := s.profilePage(w, r) 183 + if pageWithProfile == nil { 169 184 return 170 185 } 171 186 172 - profile, err := db.GetProfile(s.db, ident.DID.String()) 173 - if err != nil { 174 - log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 175 - } 176 - 187 + id := pageWithProfile.Id 177 188 repos, err := db.GetRepos( 178 189 s.db, 179 190 0, 180 - db.FilterEq("did", ident.DID.String()), 191 + db.FilterEq("did", id.DID), 181 192 ) 182 193 if err != nil { 183 - log.Printf("getting repos for %s: %s", ident.DID.String(), err) 194 + log.Printf("getting repos for %s: %s", id.DID, err) 184 195 } 185 196 186 - loggedInUser := s.oauth.GetUser(r) 187 - followStatus := db.IsNotFollowing 188 - if loggedInUser != nil { 189 - followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 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 190 214 } 191 215 192 - 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()) 193 220 if err != nil { 194 - 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 242 + } 243 + 244 + followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids) 245 + if err != nil { 246 + log.Printf("getting follow counts for %s: %s", followDids, err) 195 247 } 196 248 197 - profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 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 + } 198 262 199 - s.pages.ReposPage(w, pages.ReposPageParams{ 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{ 200 294 LoggedInUser: loggedInUser, 201 - Repos: repos, 202 - DidHandleMap: map[string]string{ident.DID.String(): ident.Handle.String()}, 203 - Card: pages.ProfileCard{ 204 - UserDid: ident.DID.String(), 205 - UserHandle: ident.Handle.String(), 206 - AvatarUri: profileAvatarUri, 207 - Profile: profile, 208 - FollowStatus: followStatus, 209 - Followers: followers, 210 - Following: following, 211 - }, 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, 212 325 }) 213 326 } 214 327 215 - func (s *State) GetAvatarUri(handle string) string { 216 - secret := s.config.Avatar.SharedSecret 217 - h := hmac.New(sha256.New, []byte(secret)) 218 - h.Write([]byte(handle)) 219 - signature := hex.EncodeToString(h.Sum(nil)) 220 - return fmt.Sprintf("%s/%s/%s", s.config.Avatar.Host, signature, handle) 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()) 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 221 468 } 222 469 223 470 func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { ··· 422 669 }) 423 670 } 424 671 425 - var didsToResolve []string 426 - for _, r := range allRepos { 427 - didsToResolve = append(didsToResolve, r.Did) 428 - } 429 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 430 - didHandleMap := make(map[string]string) 431 - for _, identity := range resolvedIds { 432 - if !identity.Handle.IsInvalidHandle() { 433 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 434 - } else { 435 - didHandleMap[identity.DID.String()] = identity.DID.String() 436 - } 437 - } 438 - 439 672 s.pages.EditPinsFragment(w, pages.EditPinsParams{ 440 673 LoggedInUser: user, 441 674 Profile: profile, 442 675 AllRepos: allRepos, 443 - DidHandleMap: didHandleMap, 444 676 }) 445 677 }
+50 -10
appview/state/router.go
··· 14 14 "tangled.sh/tangled.sh/core/appview/pulls" 15 15 "tangled.sh/tangled.sh/core/appview/repo" 16 16 "tangled.sh/tangled.sh/core/appview/settings" 17 + "tangled.sh/tangled.sh/core/appview/signup" 17 18 "tangled.sh/tangled.sh/core/appview/spindles" 18 19 "tangled.sh/tangled.sh/core/appview/state/userutil" 20 + avstrings "tangled.sh/tangled.sh/core/appview/strings" 19 21 "tangled.sh/tangled.sh/core/log" 20 22 ) 21 23 ··· 29 31 s.idResolver, 30 32 s.pages, 31 33 ) 34 + 35 + router.Get("/favicon.svg", s.Favicon) 36 + router.Get("/favicon.ico", s.Favicon) 37 + 38 + userRouter := s.UserRouter(&middleware) 39 + standardRouter := s.StandardRouter(&middleware) 32 40 33 41 router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { 34 42 pat := chi.URLParam(r, "*") 35 43 if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") { 36 - s.UserRouter(&middleware).ServeHTTP(w, r) 44 + userRouter.ServeHTTP(w, r) 37 45 } else { 38 46 // Check if the first path element is a valid handle without '@' or a flattened DID 39 47 pathParts := strings.SplitN(pat, "/", 2) ··· 56 64 return 57 65 } 58 66 } 59 - s.StandardRouter(&middleware).ServeHTTP(w, r) 67 + standardRouter.ServeHTTP(w, r) 60 68 } 61 69 }) 62 70 ··· 65 73 66 74 func (s *State) UserRouter(mw *middleware.Middleware) http.Handler { 67 75 r := chi.NewRouter() 68 - 69 - // strip @ from user 70 - r.Use(middleware.StripLeadingAt) 71 76 72 77 r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) { 73 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 + }) 74 86 75 87 r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { 76 88 r.Use(mw.GoImport()) 77 - 78 89 r.Mount("/", s.RepoRouter(mw)) 79 90 r.Mount("/issues", s.IssuesRouter(mw)) 80 91 r.Mount("/pulls", s.PullsRouter(mw)) ··· 135 146 }) 136 147 137 148 r.Mount("/settings", s.SettingsRouter()) 138 - r.Mount("/knots", s.KnotsRouter(mw)) 149 + r.Mount("/strings", s.StringsRouter(mw)) 150 + r.Mount("/knots", s.KnotsRouter()) 139 151 r.Mount("/spindles", s.SpindlesRouter()) 152 + r.Mount("/signup", s.SignupRouter()) 140 153 r.Mount("/", s.OAuthRouter()) 141 154 142 155 r.Get("/keys/{user}", s.Keys) 156 + r.Get("/terms", s.TermsOfService) 157 + r.Get("/privacy", s.PrivacyPolicy) 143 158 144 159 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 145 160 s.pages.Error404(w) ··· 180 195 return spindles.Router() 181 196 } 182 197 183 - func (s *State) KnotsRouter(mw *middleware.Middleware) http.Handler { 198 + func (s *State) KnotsRouter() http.Handler { 184 199 logger := log.New("knots") 185 200 186 201 knots := &knots.Knots{ ··· 194 209 Logger: logger, 195 210 } 196 211 197 - return knots.Router(mw) 212 + return knots.Router() 213 + } 214 + 215 + func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler { 216 + logger := log.New("strings") 217 + 218 + strs := &avstrings.Strings{ 219 + Db: s.db, 220 + OAuth: s.oauth, 221 + Pages: s.pages, 222 + Config: s.config, 223 + Enforcer: s.enforcer, 224 + IdResolver: s.idResolver, 225 + Knotstream: s.knotstream, 226 + Logger: logger, 227 + } 228 + 229 + return strs.Router(mw) 198 230 } 199 231 200 232 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { ··· 208 240 } 209 241 210 242 func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler { 211 - repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer) 243 + logger := log.New("repo") 244 + repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger) 212 245 return repo.Router(mw) 213 246 } 214 247 ··· 216 249 pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer) 217 250 return pipes.Router(mw) 218 251 } 252 + 253 + func (s *State) SignupRouter() http.Handler { 254 + logger := log.New("signup") 255 + 256 + sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, logger) 257 + return sig.Router() 258 + }
+138 -70
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" ··· 23 26 "tangled.sh/tangled.sh/core/appview/notify" 24 27 "tangled.sh/tangled.sh/core/appview/oauth" 25 28 "tangled.sh/tangled.sh/core/appview/pages" 26 - posthog_service "tangled.sh/tangled.sh/core/appview/posthog" 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) ··· 93 98 tangled.ActorProfileNSID, 94 99 tangled.SpindleMemberNSID, 95 100 tangled.SpindleNSID, 101 + tangled.StringNSID, 96 102 }, 97 103 nil, 98 104 slog.Default(), ··· 133 139 134 140 var notifiers []notify.Notifier 135 141 if !config.Core.Dev { 136 - notifiers = append(notifiers, posthog_service.NewPosthogNotifier(posthog)) 142 + notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) 137 143 } 138 144 notifier := notify.NewMergedNotifier(notifiers...) 139 145 ··· 151 157 repoResolver, 152 158 knotstream, 153 159 spindlestream, 160 + slog.Default(), 154 161 } 155 162 156 163 return state, nil 157 164 } 158 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 + 179 + func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 180 + user := s.oauth.GetUser(r) 181 + s.pages.TermsOfService(w, pages.TermsOfServiceParams{ 182 + LoggedInUser: user, 183 + }) 184 + } 185 + 186 + func (s *State) PrivacyPolicy(w http.ResponseWriter, r *http.Request) { 187 + user := s.oauth.GetUser(r) 188 + s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{ 189 + LoggedInUser: user, 190 + }) 191 + } 192 + 159 193 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 160 194 user := s.oauth.GetUser(r) 161 195 ··· 165 199 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 166 200 } 167 201 168 - var didsToResolve []string 169 - for _, ev := range timeline { 170 - if ev.Repo != nil { 171 - didsToResolve = append(didsToResolve, ev.Repo.Did) 172 - if ev.Source != nil { 173 - didsToResolve = append(didsToResolve, ev.Source.Did) 174 - } 175 - } 176 - if ev.Follow != nil { 177 - didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid) 178 - } 179 - if ev.Star != nil { 180 - didsToResolve = append(didsToResolve, ev.Star.StarredByDid, ev.Star.Repo.Did) 181 - } 182 - } 183 - 184 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 185 - didHandleMap := make(map[string]string) 186 - for _, identity := range resolvedIds { 187 - if !identity.Handle.IsInvalidHandle() { 188 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 189 - } else { 190 - didHandleMap[identity.DID.String()] = identity.DID.String() 191 - } 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 192 207 } 193 208 194 209 s.pages.Timeline(w, pages.TimelineParams{ 195 210 LoggedInUser: user, 196 211 Timeline: timeline, 197 - DidHandleMap: didHandleMap, 212 + Repos: repos, 198 213 }) 199 - 200 - return 201 214 } 202 215 203 216 func (s *State) Keys(w http.ResponseWriter, r *http.Request) { ··· 264 277 return nil 265 278 } 266 279 280 + func stripGitExt(name string) string { 281 + return strings.TrimSuffix(name, ".git") 282 + } 283 + 267 284 func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { 268 285 switch r.Method { 269 286 case http.MethodGet: ··· 280 297 }) 281 298 282 299 case http.MethodPost: 300 + l := s.logger.With("handler", "NewRepo") 301 + 283 302 user := s.oauth.GetUser(r) 303 + l = l.With("did", user.Did) 304 + l = l.With("handle", user.Handle) 284 305 306 + // form validation 285 307 domain := r.FormValue("domain") 286 308 if domain == "" { 287 309 s.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 288 310 return 289 311 } 312 + l = l.With("knot", domain) 290 313 291 314 repoName := r.FormValue("name") 292 315 if repoName == "" { ··· 298 321 s.pages.Notice(w, "repo", err.Error()) 299 322 return 300 323 } 324 + repoName = stripGitExt(repoName) 325 + l = l.With("repoName", repoName) 301 326 302 327 defaultBranch := r.FormValue("branch") 303 328 if defaultBranch == "" { 304 329 defaultBranch = "main" 305 330 } 331 + l = l.With("defaultBranch", defaultBranch) 306 332 307 333 description := r.FormValue("description") 308 334 335 + // ACL validation 309 336 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create") 310 337 if err != nil || !ok { 338 + l.Info("unauthorized") 311 339 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 312 340 return 313 341 } 314 342 343 + // Check for existing repos 315 344 existingRepo, err := db.GetRepo(s.db, user.Did, repoName) 316 345 if err == nil && existingRepo != nil { 317 - s.pages.Notice(w, "repo", fmt.Sprintf("A repo by this name already exists on %s", existingRepo.Knot)) 318 - return 319 - } 320 - 321 - secret, err := db.GetRegistrationKey(s.db, domain) 322 - if err != nil { 323 - s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain)) 324 - return 325 - } 326 - 327 - client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 328 - if err != nil { 329 - s.pages.Notice(w, "repo", "Failed to connect to knot server.") 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)) 330 348 return 331 349 } 332 350 351 + // create atproto record for this repo 333 352 rkey := tid.TID() 334 353 repo := &db.Repo{ 335 354 Did: user.Did, ··· 341 360 342 361 xrpcClient, err := s.oauth.AuthorizedClient(r) 343 362 if err != nil { 363 + l.Info("PDS write failed", "err", err) 344 364 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 345 365 return 346 366 } ··· 359 379 }}, 360 380 }) 361 381 if err != nil { 362 - log.Printf("failed to create record: %s", err) 382 + l.Info("PDS write failed", "err", err) 363 383 s.pages.Notice(w, "repo", "Failed to announce repository creation.") 364 384 return 365 385 } 366 - log.Println("created repo record: ", atresp.Uri) 386 + 387 + aturi := atresp.Uri 388 + l = l.With("aturi", aturi) 389 + l.Info("wrote to PDS") 367 390 368 391 tx, err := s.db.BeginTx(r.Context(), nil) 369 392 if err != nil { 370 - log.Println(err) 393 + l.Info("txn failed", "err", err) 371 394 s.pages.Notice(w, "repo", "Failed to save repository information.") 372 395 return 373 396 } 374 - defer func() { 375 - tx.Rollback() 376 - err = s.enforcer.E.LoadPolicy() 377 - if err != nil { 378 - 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 379 410 } 380 - }() 381 411 382 - resp, err := client.NewRepo(user.Did, repoName, defaultBranch) 412 + if errs := errors.Join(err1, err2, err3); errs != nil { 413 + l.Error("failed to rollback changes", "errs", errs) 414 + return 415 + } 416 + } 417 + defer rollback() 418 + 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 + ) 383 425 if err != nil { 384 - 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.") 385 428 return 386 429 } 387 430 388 - switch resp.StatusCode { 389 - case http.StatusConflict: 390 - 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()) 391 441 return 392 - case http.StatusInternalServerError: 393 - s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 394 - case http.StatusNoContent: 395 - // continue 396 442 } 397 443 398 - repo.AtUri = atresp.Uri 399 444 err = db.AddRepo(tx, repo) 400 445 if err != nil { 401 - log.Println(err) 446 + l.Error("db write failed", "err", err) 402 447 s.pages.Notice(w, "repo", "Failed to save repository information.") 403 448 return 404 449 } ··· 407 452 p, _ := securejoin.SecureJoin(user.Did, repoName) 408 453 err = s.enforcer.AddRepo(user.Did, domain, p) 409 454 if err != nil { 410 - log.Println(err) 455 + l.Error("acl setup failed", "err", err) 411 456 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 412 457 return 413 458 } 414 459 415 460 err = tx.Commit() 416 461 if err != nil { 417 - log.Println("failed to commit changes", err) 462 + l.Error("txn commit failed", "err", err) 418 463 http.Error(w, err.Error(), http.StatusInternalServerError) 419 464 return 420 465 } 421 466 422 467 err = s.enforcer.E.SavePolicy() 423 468 if err != nil { 424 - log.Println("failed to update ACLs", err) 469 + l.Error("acl save failed", "err", err) 425 470 http.Error(w, err.Error(), http.StatusInternalServerError) 426 471 return 427 472 } 428 473 429 - s.notifier.NewRepo(r.Context(), repo) 474 + // reset the ATURI because the transaction completed successfully 475 + aturi = "" 430 476 477 + s.notifier.NewRepo(r.Context(), repo) 431 478 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 432 - return 479 + } 480 + } 481 + 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 433 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 434 502 }
+6
appview/state/userutil/userutil.go
··· 51 51 func IsDid(s string) bool { 52 52 return didRegex.MatchString(s) 53 53 } 54 + 55 + var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`) 56 + 57 + func IsValidSubdomain(name string) bool { 58 + return len(name) >= 4 && len(name) <= 63 && subdomainRegex.MatchString(name) 59 + }
+465
appview/strings/strings.go
··· 1 + package strings 2 + 3 + import ( 4 + "fmt" 5 + "log/slog" 6 + "net/http" 7 + "path" 8 + "slices" 9 + "strconv" 10 + "time" 11 + 12 + "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.sh/tangled.sh/core/appview/config" 14 + "tangled.sh/tangled.sh/core/appview/db" 15 + "tangled.sh/tangled.sh/core/appview/middleware" 16 + "tangled.sh/tangled.sh/core/appview/oauth" 17 + "tangled.sh/tangled.sh/core/appview/pages" 18 + "tangled.sh/tangled.sh/core/appview/pages/markup" 19 + "tangled.sh/tangled.sh/core/eventconsumer" 20 + "tangled.sh/tangled.sh/core/idresolver" 21 + "tangled.sh/tangled.sh/core/rbac" 22 + "tangled.sh/tangled.sh/core/tid" 23 + 24 + "github.com/bluesky-social/indigo/api/atproto" 25 + "github.com/bluesky-social/indigo/atproto/identity" 26 + "github.com/bluesky-social/indigo/atproto/syntax" 27 + lexutil "github.com/bluesky-social/indigo/lex/util" 28 + "github.com/go-chi/chi/v5" 29 + ) 30 + 31 + type Strings struct { 32 + Db *db.DB 33 + OAuth *oauth.OAuth 34 + Pages *pages.Pages 35 + Config *config.Config 36 + Enforcer *rbac.Enforcer 37 + IdResolver *idresolver.Resolver 38 + Logger *slog.Logger 39 + Knotstream *eventconsumer.Consumer 40 + } 41 + 42 + func (s *Strings) Router(mw *middleware.Middleware) http.Handler { 43 + r := chi.NewRouter() 44 + 45 + r. 46 + Get("/", s.timeline) 47 + 48 + r. 49 + With(mw.ResolveIdent()). 50 + Route("/{user}", func(r chi.Router) { 51 + r.Get("/", s.dashboard) 52 + 53 + r.Route("/{rkey}", func(r chi.Router) { 54 + r.Get("/", s.contents) 55 + r.Delete("/", s.delete) 56 + r.Get("/raw", s.contents) 57 + r.Get("/edit", s.edit) 58 + r.Post("/edit", s.edit) 59 + r. 60 + With(middleware.AuthMiddleware(s.OAuth)). 61 + Post("/comment", s.comment) 62 + }) 63 + }) 64 + 65 + r. 66 + With(middleware.AuthMiddleware(s.OAuth)). 67 + Route("/new", func(r chi.Router) { 68 + r.Get("/", s.create) 69 + r.Post("/", s.create) 70 + }) 71 + 72 + return r 73 + } 74 + 75 + func (s *Strings) timeline(w http.ResponseWriter, r *http.Request) { 76 + l := s.Logger.With("handler", "timeline") 77 + 78 + strings, err := db.GetStrings(s.Db, 50) 79 + if err != nil { 80 + l.Error("failed to fetch string", "err", err) 81 + w.WriteHeader(http.StatusInternalServerError) 82 + return 83 + } 84 + 85 + s.Pages.StringsTimeline(w, pages.StringTimelineParams{ 86 + LoggedInUser: s.OAuth.GetUser(r), 87 + Strings: strings, 88 + }) 89 + } 90 + 91 + func (s *Strings) contents(w http.ResponseWriter, r *http.Request) { 92 + l := s.Logger.With("handler", "contents") 93 + 94 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 95 + if !ok { 96 + l.Error("malformed middleware") 97 + w.WriteHeader(http.StatusInternalServerError) 98 + return 99 + } 100 + l = l.With("did", id.DID, "handle", id.Handle) 101 + 102 + rkey := chi.URLParam(r, "rkey") 103 + if rkey == "" { 104 + l.Error("malformed url, empty rkey") 105 + w.WriteHeader(http.StatusBadRequest) 106 + return 107 + } 108 + l = l.With("rkey", rkey) 109 + 110 + strings, err := db.GetStrings( 111 + s.Db, 112 + 0, 113 + db.FilterEq("did", id.DID), 114 + db.FilterEq("rkey", rkey), 115 + ) 116 + if err != nil { 117 + l.Error("failed to fetch string", "err", err) 118 + w.WriteHeader(http.StatusInternalServerError) 119 + return 120 + } 121 + if len(strings) < 1 { 122 + l.Error("string not found") 123 + s.Pages.Error404(w) 124 + return 125 + } 126 + if len(strings) != 1 { 127 + l.Error("incorrect number of records returned", "len(strings)", len(strings)) 128 + w.WriteHeader(http.StatusInternalServerError) 129 + return 130 + } 131 + string := strings[0] 132 + 133 + if path.Base(r.URL.Path) == "raw" { 134 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 135 + if string.Filename != "" { 136 + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", string.Filename)) 137 + } 138 + w.Header().Set("Content-Length", strconv.Itoa(len(string.Contents))) 139 + 140 + _, err = w.Write([]byte(string.Contents)) 141 + if err != nil { 142 + l.Error("failed to write raw response", "err", err) 143 + } 144 + return 145 + } 146 + 147 + var showRendered, renderToggle bool 148 + if markup.GetFormat(string.Filename) == markup.FormatMarkdown { 149 + renderToggle = true 150 + showRendered = r.URL.Query().Get("code") != "true" 151 + } 152 + 153 + s.Pages.SingleString(w, pages.SingleStringParams{ 154 + LoggedInUser: s.OAuth.GetUser(r), 155 + RenderToggle: renderToggle, 156 + ShowRendered: showRendered, 157 + String: string, 158 + Stats: string.Stats(), 159 + Owner: id, 160 + }) 161 + } 162 + 163 + func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { 164 + l := s.Logger.With("handler", "dashboard") 165 + 166 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 167 + if !ok { 168 + l.Error("malformed middleware") 169 + w.WriteHeader(http.StatusInternalServerError) 170 + return 171 + } 172 + l = l.With("did", id.DID, "handle", id.Handle) 173 + 174 + all, err := db.GetStrings( 175 + s.Db, 176 + 0, 177 + db.FilterEq("did", id.DID), 178 + ) 179 + if err != nil { 180 + l.Error("failed to fetch strings", "err", err) 181 + w.WriteHeader(http.StatusInternalServerError) 182 + return 183 + } 184 + 185 + slices.SortFunc(all, func(a, b db.String) int { 186 + if a.Created.After(b.Created) { 187 + return -1 188 + } else { 189 + return 1 190 + } 191 + }) 192 + 193 + profile, err := db.GetProfile(s.Db, id.DID.String()) 194 + if err != nil { 195 + l.Error("failed to fetch user profile", "err", err) 196 + w.WriteHeader(http.StatusInternalServerError) 197 + return 198 + } 199 + loggedInUser := s.OAuth.GetUser(r) 200 + followStatus := db.IsNotFollowing 201 + if loggedInUser != nil { 202 + followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String()) 203 + } 204 + 205 + followStats, err := db.GetFollowerFollowingCount(s.Db, id.DID.String()) 206 + if err != nil { 207 + l.Error("failed to get follow stats", "err", err) 208 + } 209 + 210 + s.Pages.StringsDashboard(w, pages.StringsDashboardParams{ 211 + LoggedInUser: s.OAuth.GetUser(r), 212 + Card: pages.ProfileCard{ 213 + UserDid: id.DID.String(), 214 + UserHandle: id.Handle.String(), 215 + Profile: profile, 216 + FollowStatus: followStatus, 217 + FollowersCount: followStats.Followers, 218 + FollowingCount: followStats.Following, 219 + }, 220 + Strings: all, 221 + }) 222 + } 223 + 224 + func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { 225 + l := s.Logger.With("handler", "edit") 226 + 227 + user := s.OAuth.GetUser(r) 228 + 229 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 230 + if !ok { 231 + l.Error("malformed middleware") 232 + w.WriteHeader(http.StatusInternalServerError) 233 + return 234 + } 235 + l = l.With("did", id.DID, "handle", id.Handle) 236 + 237 + rkey := chi.URLParam(r, "rkey") 238 + if rkey == "" { 239 + l.Error("malformed url, empty rkey") 240 + w.WriteHeader(http.StatusBadRequest) 241 + return 242 + } 243 + l = l.With("rkey", rkey) 244 + 245 + // get the string currently being edited 246 + all, err := db.GetStrings( 247 + s.Db, 248 + 0, 249 + db.FilterEq("did", id.DID), 250 + db.FilterEq("rkey", rkey), 251 + ) 252 + if err != nil { 253 + l.Error("failed to fetch string", "err", err) 254 + w.WriteHeader(http.StatusInternalServerError) 255 + return 256 + } 257 + if len(all) != 1 { 258 + l.Error("incorrect number of records returned", "len(strings)", len(all)) 259 + w.WriteHeader(http.StatusInternalServerError) 260 + return 261 + } 262 + first := all[0] 263 + 264 + // verify that the logged in user owns this string 265 + if user.Did != id.DID.String() { 266 + l.Error("unauthorized request", "expected", id.DID, "got", user.Did) 267 + w.WriteHeader(http.StatusUnauthorized) 268 + return 269 + } 270 + 271 + switch r.Method { 272 + case http.MethodGet: 273 + // return the form with prefilled fields 274 + s.Pages.PutString(w, pages.PutStringParams{ 275 + LoggedInUser: s.OAuth.GetUser(r), 276 + Action: "edit", 277 + String: first, 278 + }) 279 + case http.MethodPost: 280 + fail := func(msg string, err error) { 281 + l.Error(msg, "err", err) 282 + s.Pages.Notice(w, "error", msg) 283 + } 284 + 285 + filename := r.FormValue("filename") 286 + if filename == "" { 287 + fail("Empty filename.", nil) 288 + return 289 + } 290 + 291 + content := r.FormValue("content") 292 + if content == "" { 293 + fail("Empty contents.", nil) 294 + return 295 + } 296 + 297 + description := r.FormValue("description") 298 + 299 + // construct new string from form values 300 + entry := db.String{ 301 + Did: first.Did, 302 + Rkey: first.Rkey, 303 + Filename: filename, 304 + Description: description, 305 + Contents: content, 306 + Created: first.Created, 307 + } 308 + 309 + record := entry.AsRecord() 310 + 311 + client, err := s.OAuth.AuthorizedClient(r) 312 + if err != nil { 313 + fail("Failed to create record.", err) 314 + return 315 + } 316 + 317 + // first replace the existing record in the PDS 318 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 319 + if err != nil { 320 + fail("Failed to updated existing record.", err) 321 + return 322 + } 323 + resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 324 + Collection: tangled.StringNSID, 325 + Repo: entry.Did.String(), 326 + Rkey: entry.Rkey, 327 + SwapRecord: ex.Cid, 328 + Record: &lexutil.LexiconTypeDecoder{ 329 + Val: &record, 330 + }, 331 + }) 332 + if err != nil { 333 + fail("Failed to updated existing record.", err) 334 + return 335 + } 336 + l := l.With("aturi", resp.Uri) 337 + l.Info("edited string") 338 + 339 + // if that went okay, updated the db 340 + if err = db.AddString(s.Db, entry); err != nil { 341 + fail("Failed to update string.", err) 342 + return 343 + } 344 + 345 + // if that went okay, redir to the string 346 + s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 347 + } 348 + 349 + } 350 + 351 + func (s *Strings) create(w http.ResponseWriter, r *http.Request) { 352 + l := s.Logger.With("handler", "create") 353 + user := s.OAuth.GetUser(r) 354 + 355 + switch r.Method { 356 + case http.MethodGet: 357 + s.Pages.PutString(w, pages.PutStringParams{ 358 + LoggedInUser: s.OAuth.GetUser(r), 359 + Action: "new", 360 + }) 361 + case http.MethodPost: 362 + fail := func(msg string, err error) { 363 + l.Error(msg, "err", err) 364 + s.Pages.Notice(w, "error", msg) 365 + } 366 + 367 + filename := r.FormValue("filename") 368 + if filename == "" { 369 + fail("Empty filename.", nil) 370 + return 371 + } 372 + 373 + content := r.FormValue("content") 374 + if content == "" { 375 + fail("Empty contents.", nil) 376 + return 377 + } 378 + 379 + description := r.FormValue("description") 380 + 381 + string := db.String{ 382 + Did: syntax.DID(user.Did), 383 + Rkey: tid.TID(), 384 + Filename: filename, 385 + Description: description, 386 + Contents: content, 387 + Created: time.Now(), 388 + } 389 + 390 + record := string.AsRecord() 391 + 392 + client, err := s.OAuth.AuthorizedClient(r) 393 + if err != nil { 394 + fail("Failed to create record.", err) 395 + return 396 + } 397 + 398 + resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 399 + Collection: tangled.StringNSID, 400 + Repo: user.Did, 401 + Rkey: string.Rkey, 402 + Record: &lexutil.LexiconTypeDecoder{ 403 + Val: &record, 404 + }, 405 + }) 406 + if err != nil { 407 + fail("Failed to create record.", err) 408 + return 409 + } 410 + l := l.With("aturi", resp.Uri) 411 + l.Info("created record") 412 + 413 + // insert into DB 414 + if err = db.AddString(s.Db, string); err != nil { 415 + fail("Failed to create string.", err) 416 + return 417 + } 418 + 419 + // successful 420 + s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 421 + } 422 + } 423 + 424 + func (s *Strings) delete(w http.ResponseWriter, r *http.Request) { 425 + l := s.Logger.With("handler", "create") 426 + user := s.OAuth.GetUser(r) 427 + fail := func(msg string, err error) { 428 + l.Error(msg, "err", err) 429 + s.Pages.Notice(w, "error", msg) 430 + } 431 + 432 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 433 + if !ok { 434 + l.Error("malformed middleware") 435 + w.WriteHeader(http.StatusInternalServerError) 436 + return 437 + } 438 + l = l.With("did", id.DID, "handle", id.Handle) 439 + 440 + rkey := chi.URLParam(r, "rkey") 441 + if rkey == "" { 442 + l.Error("malformed url, empty rkey") 443 + w.WriteHeader(http.StatusBadRequest) 444 + return 445 + } 446 + 447 + if user.Did != id.DID.String() { 448 + fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) 449 + return 450 + } 451 + 452 + if err := db.DeleteString( 453 + s.Db, 454 + db.FilterEq("did", user.Did), 455 + db.FilterEq("rkey", rkey), 456 + ); err != nil { 457 + fail("Failed to delete string.", err) 458 + return 459 + } 460 + 461 + s.Pages.HxRedirect(w, "/strings/"+user.Handle) 462 + } 463 + 464 + func (s *Strings) comment(w http.ResponseWriter, r *http.Request) { 465 + }
+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 + }
+3 -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{}, 40 39 tangled.PublicKey{}, 41 40 tangled.Repo{}, 42 41 tangled.RepoArtifact{}, 42 + tangled.RepoCollaborator{}, 43 43 tangled.RepoIssue{}, 44 44 tangled.RepoIssueComment{}, 45 45 tangled.RepoIssueState{}, ··· 49 49 tangled.RepoPullStatus{}, 50 50 tangled.Spindle{}, 51 51 tangled.SpindleMember{}, 52 + tangled.String{}, 52 53 ); err != nil { 53 54 panic(err) 54 55 }
+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 -18
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. Replace the existing secret in 60 - `nix/vm.nix` (`KNOT_SERVER_SECRET`) with the newly generated 61 - secret. 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. 61 + 62 + If you don't want to [set up a spindle](#running-a-spindle), 63 + you can use any placeholder value. 62 64 63 - You can now start a lightweight NixOS VM using 64 - `nixos-shell` like so: 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 change the `owner` field for the spindle in 95 - `nix/vm.nix` to your own DID. The above VM should already 96 - be running a spindle on `localhost:6555`. You can head to 97 - the spindle dashboard on `http://localhost:3000/spindles`, 98 - and register a spindle with hostname `localhost:6555`. It 99 - should instantly be verified. You can then configure each 100 - repository to use this spindle and run CI jobs. 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. 101 106 102 107 Of interest when debugging spindles: 103 108
+27 -7
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 ``` ··· 89 95 systemctl start knotserver 90 96 ``` 91 97 92 - The last step is to configure a reverse proxy like Nginx or Caddy to front yourself 98 + The last step is to configure a reverse proxy like Nginx or Caddy to front your 93 99 knot. Here's an example configuration for Nginx: 94 100 95 101 ``` ··· 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 ··· 191 199 ``` 192 200 193 201 Make sure to restart your SSH server! 202 + 203 + #### MOTD (message of the day) 204 + 205 + To configure the MOTD used ("Welcome to this knot!" by default), edit the 206 + `/home/git/motd` file: 207 + 208 + ``` 209 + printf "Hi from this knot!\n" > /home/git/motd 210 + ``` 211 + 212 + Note that you should add a newline at the end if setting a non-empty message 213 + since the knot won't do this for you.
+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 + ```
+285
docs/spindle/openbao.md
··· 1 + # spindle secrets with openbao 2 + 3 + This document covers setting up Spindle to use OpenBao for secrets 4 + management via OpenBao Proxy instead of the default SQLite backend. 5 + 6 + ## overview 7 + 8 + Spindle now uses OpenBao Proxy for secrets management. The proxy handles 9 + authentication automatically using AppRole credentials, while Spindle 10 + connects to the local proxy instead of directly to the OpenBao server. 11 + 12 + This approach provides better security, automatic token renewal, and 13 + simplified application code. 14 + 15 + ## installation 16 + 17 + Install OpenBao from nixpkgs: 18 + 19 + ```bash 20 + nix shell nixpkgs#openbao # for a local server 21 + ``` 22 + 23 + ## setup 24 + 25 + The setup process can is documented for both local development and production. 26 + 27 + ### local development 28 + 29 + Start OpenBao in dev mode: 30 + 31 + ```bash 32 + bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201 33 + ``` 34 + 35 + This starts OpenBao on `http://localhost:8201` with a root token. 36 + 37 + Set up environment for bao CLI: 38 + 39 + ```bash 40 + export BAO_ADDR=http://localhost:8200 41 + export BAO_TOKEN=root 42 + ``` 43 + 44 + ### production 45 + 46 + You would typically use a systemd service with a configuration file. Refer to 47 + [@tangled.sh/infra](https://tangled.sh/@tangled.sh/infra) for how this can be 48 + achieved using Nix. 49 + 50 + Then, initialize the bao server: 51 + ```bash 52 + bao operator init -key-shares=1 -key-threshold=1 53 + ``` 54 + 55 + This will print out an unseal key and a root key. Save them somewhere (like a password manager). Then unseal the vault to begin setting it up: 56 + ```bash 57 + bao operator unseal <unseal_key> 58 + ``` 59 + 60 + All steps below remain the same across both dev and production setups. 61 + 62 + ### configure openbao server 63 + 64 + Create the spindle KV mount: 65 + 66 + ```bash 67 + bao secrets enable -path=spindle -version=2 kv 68 + ``` 69 + 70 + Set up AppRole authentication and policy: 71 + 72 + Create a policy file `spindle-policy.hcl`: 73 + 74 + ```hcl 75 + # Full access to spindle KV v2 data 76 + path "spindle/data/*" { 77 + capabilities = ["create", "read", "update", "delete"] 78 + } 79 + 80 + # Access to metadata for listing and management 81 + path "spindle/metadata/*" { 82 + capabilities = ["list", "read", "delete", "update"] 83 + } 84 + 85 + # Allow listing at root level 86 + path "spindle/" { 87 + capabilities = ["list"] 88 + } 89 + 90 + # Required for connection testing and health checks 91 + path "auth/token/lookup-self" { 92 + capabilities = ["read"] 93 + } 94 + ``` 95 + 96 + Apply the policy and create an AppRole: 97 + 98 + ```bash 99 + bao policy write spindle-policy spindle-policy.hcl 100 + bao auth enable approle 101 + bao write auth/approle/role/spindle \ 102 + token_policies="spindle-policy" \ 103 + token_ttl=1h \ 104 + token_max_ttl=4h \ 105 + bind_secret_id=true \ 106 + secret_id_ttl=0 \ 107 + secret_id_num_uses=0 108 + ``` 109 + 110 + Get the credentials: 111 + 112 + ```bash 113 + # Get role ID (static) 114 + ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id) 115 + 116 + # Generate secret ID 117 + SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id) 118 + 119 + echo "Role ID: $ROLE_ID" 120 + echo "Secret ID: $SECRET_ID" 121 + ``` 122 + 123 + ### create proxy configuration 124 + 125 + Create the credential files: 126 + 127 + ```bash 128 + # Create directory for OpenBao files 129 + mkdir -p /tmp/openbao 130 + 131 + # Save credentials 132 + echo "$ROLE_ID" > /tmp/openbao/role-id 133 + echo "$SECRET_ID" > /tmp/openbao/secret-id 134 + chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id 135 + ``` 136 + 137 + Create a proxy configuration file `/tmp/openbao/proxy.hcl`: 138 + 139 + ```hcl 140 + # OpenBao server connection 141 + vault { 142 + address = "http://localhost:8200" 143 + } 144 + 145 + # Auto-Auth using AppRole 146 + auto_auth { 147 + method "approle" { 148 + mount_path = "auth/approle" 149 + config = { 150 + role_id_file_path = "/tmp/openbao/role-id" 151 + secret_id_file_path = "/tmp/openbao/secret-id" 152 + } 153 + } 154 + 155 + # Optional: write token to file for debugging 156 + sink "file" { 157 + config = { 158 + path = "/tmp/openbao/token" 159 + mode = 0640 160 + } 161 + } 162 + } 163 + 164 + # Proxy listener for Spindle 165 + listener "tcp" { 166 + address = "127.0.0.1:8201" 167 + tls_disable = true 168 + } 169 + 170 + # Enable API proxy with auto-auth token 171 + api_proxy { 172 + use_auto_auth_token = true 173 + } 174 + 175 + # Enable response caching 176 + cache { 177 + use_auto_auth_token = true 178 + } 179 + 180 + # Logging 181 + log_level = "info" 182 + ``` 183 + 184 + ### start the proxy 185 + 186 + Start OpenBao Proxy: 187 + 188 + ```bash 189 + bao proxy -config=/tmp/openbao/proxy.hcl 190 + ``` 191 + 192 + The proxy will authenticate with OpenBao and start listening on 193 + `127.0.0.1:8201`. 194 + 195 + ### configure spindle 196 + 197 + Set these environment variables for Spindle: 198 + 199 + ```bash 200 + export SPINDLE_SERVER_SECRETS_PROVIDER=openbao 201 + export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201 202 + export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle 203 + ``` 204 + 205 + Start Spindle: 206 + 207 + Spindle will now connect to the local proxy, which handles all 208 + authentication automatically. 209 + 210 + ## production setup for proxy 211 + 212 + For production, you'll want to run the proxy as a service: 213 + 214 + Place your production configuration in `/etc/openbao/proxy.hcl` with 215 + proper TLS settings for the vault connection. 216 + 217 + ## verifying setup 218 + 219 + Test the proxy directly: 220 + 221 + ```bash 222 + # Check proxy health 223 + curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health 224 + 225 + # Test token lookup through proxy 226 + curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self 227 + ``` 228 + 229 + Test OpenBao operations through the server: 230 + 231 + ```bash 232 + # List all secrets 233 + bao kv list spindle/ 234 + 235 + # Add a test secret via Spindle API, then check it exists 236 + bao kv list spindle/repos/ 237 + 238 + # Get a specific secret 239 + bao kv get spindle/repos/your_repo_path/SECRET_NAME 240 + ``` 241 + 242 + ## how it works 243 + 244 + - Spindle connects to OpenBao Proxy on localhost (typically port 8200 or 8201) 245 + - The proxy authenticates with OpenBao using AppRole credentials 246 + - All Spindle requests go through the proxy, which injects authentication tokens 247 + - Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}` 248 + - Repository paths like `did:plc:alice/myrepo` become `did_plc_alice_myrepo` 249 + - The proxy handles all token renewal automatically 250 + - Spindle no longer manages tokens or authentication directly 251 + 252 + ## troubleshooting 253 + 254 + **Connection refused**: Check that the OpenBao Proxy is running and 255 + listening on the configured address. 256 + 257 + **403 errors**: Verify the AppRole credentials are correct and the policy 258 + has the necessary permissions. 259 + 260 + **404 route errors**: The spindle KV mount probably doesn't exist - run 261 + the mount creation step again. 262 + 263 + **Proxy authentication failures**: Check the proxy logs and verify the 264 + role-id and secret-id files are readable and contain valid credentials. 265 + 266 + **Secret not found after writing**: This can indicate policy permission 267 + issues. Verify the policy includes both `spindle/data/*` and 268 + `spindle/metadata/*` paths with appropriate capabilities. 269 + 270 + Check proxy logs: 271 + 272 + ```bash 273 + # If running as systemd service 274 + journalctl -u openbao-proxy -f 275 + 276 + # If running directly, check the console output 277 + ``` 278 + 279 + Test AppRole authentication manually: 280 + 281 + ```bash 282 + bao write auth/approle/login \ 283 + role_id="$(cat /tmp/openbao/role-id)" \ 284 + secret_id="$(cat /tmp/openbao/secret-id)" 285 + ```
+26 -3
docs/spindle/pipeline.md
··· 4 4 repo. Generally: 5 5 6 6 * Pipelines are defined in YAML. 7 - * Dependencies can be specified from 8 - [Nixpkgs](https://search.nixos.org) or custom registries. 9 - * Environment variables can be set globally or per-step. 7 + * Workflows can run using different *engines*. 8 + 9 + The most barebones workflow looks like this: 10 + 11 + ```yaml 12 + when: 13 + - event: ["push"] 14 + branch: ["main"] 15 + 16 + engine: "nixery" 17 + 18 + # optional 19 + clone: 20 + skip: false 21 + depth: 50 22 + submodules: true 23 + ``` 24 + 25 + The `when` and `engine` fields are required, while every other aspect 26 + of how the definition is parsed is up to the engine. Currently, a spindle 27 + provides at least one of these built-in engines: 28 + 29 + ## `nixery` 30 + 31 + The Nixery engine uses an instance of [Nixery](https://nixery.dev) to run 32 + steps that use dependencies from [Nixpkgs](https://github.com/NixOS/nixpkgs). 10 33 11 34 Here's an example that uses all fields: 12 35
+1 -1
eventconsumer/cursor/sqlite.go
··· 21 21 } 22 22 23 23 func NewSQLiteStore(dbPath string, opts ...SqliteStoreOpt) (*SqliteStore, error) { 24 - db, err := sql.Open("sqlite3", dbPath) 24 + db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1") 25 25 if err != nil { 26 26 return nil, fmt.Errorf("failed to open sqlite database: %w", err) 27 27 }
+10 -31
flake.lock
··· 1 1 { 2 2 "nodes": { 3 - "gitignore": { 4 - "inputs": { 5 - "nixpkgs": [ 6 - "nixpkgs" 7 - ] 8 - }, 9 - "locked": { 10 - "lastModified": 1709087332, 11 - "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", 12 - "owner": "hercules-ci", 13 - "repo": "gitignore.nix", 14 - "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", 15 - "type": "github" 16 - }, 17 - "original": { 18 - "owner": "hercules-ci", 19 - "repo": "gitignore.nix", 20 - "type": "github" 21 - } 22 - }, 23 3 "flake-utils": { 24 4 "inputs": { 25 5 "systems": "systems" ··· 46 26 ] 47 27 }, 48 28 "locked": { 49 - "lastModified": 1751702058, 50 - "narHash": "sha256-/GTdqFzFw/Y9DSNAfzvzyCMlKjUyRKMPO+apIuaTU4A=", 29 + "lastModified": 1754078208, 30 + "narHash": "sha256-YVoIFDCDpYuU3riaDEJ3xiGdPOtsx4sR5eTzHTytPV8=", 51 31 "owner": "nix-community", 52 32 "repo": "gomod2nix", 53 - "rev": "664ad7a2df4623037e315e4094346bff5c44e9ee", 33 + "rev": "7f963246a71626c7fc70b431a315c4388a0c95cf", 54 34 "type": "github" 55 35 }, 56 36 "original": { ··· 99 79 "indigo": { 100 80 "flake": false, 101 81 "locked": { 102 - "lastModified": 1745333930, 103 - "narHash": "sha256-83fIHqDE+dfnZ88HaNuwfKFO+R0RKAM1WxMfNh/Matk=", 82 + "lastModified": 1753693716, 83 + "narHash": "sha256-DMIKnCJRODQXEHUxA+7mLzRALmnZhkkbHlFT2rCQYrE=", 104 84 "owner": "oppiliappan", 105 85 "repo": "indigo", 106 - "rev": "e4e59280737b8676611fc077a228d47b3e8e9491", 86 + "rev": "5f170569da9360f57add450a278d73538092d8ca", 107 87 "type": "github" 108 88 }, 109 89 "original": { ··· 128 108 "lucide-src": { 129 109 "flake": false, 130 110 "locked": { 131 - "lastModified": 1742302029, 132 - "narHash": "sha256-OyPVtpnC4/AAmPq84Wt1r1Gcs48d9KG+UBCtZK87e9k=", 111 + "lastModified": 1754044466, 112 + "narHash": "sha256-+exBR2OToB1iv7ZQI2S4B0lXA/QRvC9n6U99UxGpJGs=", 133 113 "type": "tarball", 134 - "url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip" 114 + "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 135 115 }, 136 116 "original": { 137 117 "type": "tarball", 138 - "url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip" 118 + "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 139 119 } 140 120 }, 141 121 "nixpkgs": { ··· 156 136 }, 157 137 "root": { 158 138 "inputs": { 159 - "gitignore": "gitignore", 160 139 "gomod2nix": "gomod2nix", 161 140 "htmx-src": "htmx-src", 162 141 "htmx-ws-src": "htmx-ws-src",
+103 -29
flake.nix
··· 22 22 flake = false; 23 23 }; 24 24 lucide-src = { 25 - url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"; 25 + url = "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"; 26 26 flake = false; 27 27 }; 28 28 inter-fonts-src = { ··· 37 37 url = "https://sqlite.org/2024/sqlite-amalgamation-3450100.zip"; 38 38 flake = false; 39 39 }; 40 - gitignore = { 41 - url = "github:hercules-ci/gitignore.nix"; 42 - inputs.nixpkgs.follows = "nixpkgs"; 43 - }; 44 40 }; 45 41 46 42 outputs = { ··· 51 47 htmx-src, 52 48 htmx-ws-src, 53 49 lucide-src, 54 - gitignore, 55 50 inter-fonts-src, 56 51 sqlite-lib-src, 57 52 ibm-plex-mono-src, ··· 62 57 63 58 mkPackageSet = pkgs: 64 59 pkgs.lib.makeScope pkgs.newScope (self: { 65 - inherit (gitignore.lib) gitignoreSource; 60 + src = let 61 + fs = pkgs.lib.fileset; 62 + in 63 + fs.toSource { 64 + root = ./.; 65 + fileset = fs.difference (fs.intersection (fs.gitTracked ./.) (fs.fileFilter (file: !(file.hasExt "nix")) ./.)) (fs.maybeMissing ./.jj); 66 + }; 66 67 buildGoApplication = 67 68 (self.callPackage "${gomod2nix}/builder" { 68 69 gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix; ··· 74 75 }; 75 76 genjwks = self.callPackage ./nix/pkgs/genjwks.nix {}; 76 77 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 77 - appview = self.callPackage ./nix/pkgs/appview.nix { 78 + appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix { 78 79 inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src; 79 80 }; 81 + appview = self.callPackage ./nix/pkgs/appview.nix {}; 80 82 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 81 83 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 82 84 knot = self.callPackage ./nix/pkgs/knot.nix {}; ··· 92 94 staticPackages = mkPackageSet pkgs.pkgsStatic; 93 95 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 94 96 in { 95 - appview = packages.appview; 96 - lexgen = packages.lexgen; 97 - knot = packages.knot; 98 - knot-unwrapped = packages.knot-unwrapped; 99 - spindle = packages.spindle; 100 - genjwks = packages.genjwks; 101 - sqlite-lib = packages.sqlite-lib; 97 + inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib; 102 98 103 99 pkgsStatic-appview = staticPackages.appview; 104 100 pkgsStatic-knot = staticPackages.knot; ··· 110 106 pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot; 111 107 pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped; 112 108 pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle; 109 + 110 + treefmt-wrapper = pkgs.treefmt.withConfig { 111 + settings.formatter = { 112 + alejandra = { 113 + command = pkgs.lib.getExe pkgs.alejandra; 114 + includes = ["*.nix"]; 115 + }; 116 + 117 + gofmt = { 118 + command = pkgs.lib.getExe' pkgs.go "gofmt"; 119 + options = ["-w"]; 120 + includes = ["*.go"]; 121 + }; 122 + 123 + # prettier = let 124 + # wrapper = pkgs.runCommandLocal "prettier-wrapper" {nativeBuildInputs = [pkgs.makeWrapper];} '' 125 + # makeWrapper ${pkgs.prettier}/bin/prettier "$out" --add-flags "--plugin=${pkgs.prettier-plugin-go-template}/lib/node_modules/prettier-plugin-go-template/lib/index.js" 126 + # ''; 127 + # in { 128 + # command = wrapper; 129 + # options = ["-w"]; 130 + # includes = ["*.html"]; 131 + # # causes Go template plugin errors: https://github.com/NiklasPor/prettier-plugin-go-template/issues/120 132 + # excludes = ["appview/pages/templates/layouts/repobase.html" "appview/pages/templates/repo/tags.html"]; 133 + # }; 134 + }; 135 + }; 113 136 }); 114 137 defaultPackage = forAllSystems (system: self.packages.${system}.appview); 115 - formatter = forAllSystems (system: nixpkgsFor.${system}.alejandra); 116 138 devShells = forAllSystems (system: let 117 139 pkgs = nixpkgsFor.${system}; 118 140 packages' = self.packages.${system}; ··· 131 153 pkgs.tailwindcss 132 154 pkgs.nixos-shell 133 155 pkgs.redis 156 + pkgs.coreutils # for those of us who are on systems that use busybox (alpine) 134 157 packages'.lexgen 158 + packages'.treefmt-wrapper 135 159 ]; 136 160 shellHook = '' 137 - mkdir -p appview/pages/static/{fonts,icons} 138 - cp -f ${htmx-src} appview/pages/static/htmx.min.js 139 - cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js 140 - cp -rf ${lucide-src}/*.svg appview/pages/static/icons/ 141 - cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/ 142 - cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/ 143 - cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/ 161 + mkdir -p appview/pages/static 162 + # no preserve is needed because watch-tailwind will want to be able to overwrite 163 + cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 144 164 export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)" 145 165 ''; 146 166 env.CGO_ENABLED = 1; ··· 148 168 }); 149 169 apps = forAllSystems (system: let 150 170 pkgs = nixpkgsFor."${system}"; 171 + packages' = self.packages.${system}; 151 172 air-watcher = name: arg: 152 173 pkgs.writeShellScriptBin "run" 153 174 '' ··· 164 185 ${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css 165 186 ''; 166 187 in { 188 + fmt = { 189 + type = "app"; 190 + program = pkgs.lib.getExe packages'.treefmt-wrapper; 191 + }; 167 192 watch-appview = { 168 193 type = "app"; 169 - program = ''${air-watcher "appview" ""}/bin/run''; 194 + program = toString (pkgs.writeShellScript "watch-appview" '' 195 + echo "copying static files to appview/pages/static..." 196 + ${pkgs.coreutils}/bin/cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 197 + ${air-watcher "appview" ""}/bin/run 198 + ''); 170 199 }; 171 200 watch-knot = { 172 201 type = "app"; ··· 176 205 type = "app"; 177 206 program = ''${tailwind-watcher}/bin/run''; 178 207 }; 179 - vm = { 208 + vm = let 209 + guestSystem = 210 + if pkgs.stdenv.hostPlatform.isAarch64 211 + then "aarch64-linux" 212 + else "x86_64-linux"; 213 + in { 180 214 type = "app"; 181 - program = toString (pkgs.writeShellScript "vm" '' 182 - ${pkgs.nixos-shell}/bin/nixos-shell --flake .#vm 183 - ''); 215 + program = 216 + (pkgs.writeShellApplication { 217 + name = "launch-vm"; 218 + text = '' 219 + rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 220 + cd "$rootDir" 221 + 222 + mkdir -p nix/vm-data/{knot,repos,spindle,spindle-logs} 223 + 224 + export TANGLED_VM_DATA_DIR="$rootDir/nix/vm-data" 225 + exec ${pkgs.lib.getExe 226 + (import ./nix/vm.nix { 227 + inherit nixpkgs self; 228 + system = guestSystem; 229 + hostSystem = system; 230 + }).config.system.build.vm} 231 + ''; 232 + }) 233 + + /bin/launch-vm; 184 234 }; 185 235 gomod2nix = { 186 236 type = "app"; ··· 188 238 ${gomod2nix.legacyPackages.${system}.gomod2nix}/bin/gomod2nix generate --outdir ./nix 189 239 ''); 190 240 }; 241 + lexgen = { 242 + type = "app"; 243 + program = 244 + (pkgs.writeShellApplication { 245 + name = "lexgen"; 246 + text = '' 247 + if ! command -v lexgen > /dev/null; then 248 + echo "error: must be executed from devshell" 249 + exit 1 250 + fi 251 + 252 + rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 253 + cd "$rootDir" 254 + 255 + rm -f api/tangled/* 256 + lexgen --build-file lexicon-build-config.json lexicons 257 + sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/* 258 + ${pkgs.gotools}/bin/goimports -w api/tangled/* 259 + go run cmd/gen.go 260 + lexgen --build-file lexicon-build-config.json lexicons 261 + rm api/tangled/*.bak 262 + ''; 263 + }) 264 + + /bin/lexgen; 265 + }; 191 266 }); 192 267 193 268 nixosModules.appview = { ··· 217 292 218 293 services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 219 294 }; 220 - nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;}; 221 295 }; 222 296 }
+36 -14
go.mod
··· 1 1 module tangled.sh/tangled.sh/core 2 2 3 - go 1.24.0 4 - 5 - toolchain go1.24.3 3 + go 1.24.4 6 4 7 5 require ( 8 6 github.com/Blank-Xu/sql-adapter v1.1.1 7 + github.com/alecthomas/assert/v2 v2.11.0 9 8 github.com/alecthomas/chroma/v2 v2.15.0 10 9 github.com/avast/retry-go/v4 v4.6.1 11 10 github.com/bluekeyes/go-gitdiff v0.8.1 ··· 13 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 14 13 github.com/carlmjohnson/versioninfo v0.22.5 15 14 github.com/casbin/casbin/v2 v2.103.0 15 + github.com/cloudflare/cloudflare-go v0.115.0 16 16 github.com/cyphar/filepath-securejoin v0.4.1 17 17 github.com/dgraph-io/ristretto v0.2.0 18 18 github.com/docker/docker v28.2.2+incompatible ··· 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 - github.com/gorilla/websocket v1.5.3 27 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 27 28 github.com/hiddeco/sshsig v0.2.0 28 29 github.com/hpcloud/tail v1.0.0 29 30 github.com/ipfs/go-cid v0.5.0 30 31 github.com/lestrrat-go/jwx/v2 v2.1.6 31 32 github.com/mattn/go-sqlite3 v1.14.24 32 33 github.com/microcosm-cc/bluemonday v1.0.27 34 + github.com/openbao/openbao/api/v2 v2.3.0 33 35 github.com/posthog/posthog-go v1.5.5 34 - github.com/redis/go-redis/v9 v9.3.0 36 + github.com/redis/go-redis/v9 v9.7.3 35 37 github.com/resend/resend-go/v2 v2.15.0 36 38 github.com/sethvargo/go-envconfig v1.1.0 37 39 github.com/stretchr/testify v1.10.0 38 40 github.com/urfave/cli/v3 v3.3.3 39 41 github.com/whyrusleeping/cbor-gen v0.3.1 40 - 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 41 44 golang.org/x/crypto v0.40.0 42 - golang.org/x/net v0.41.0 45 + golang.org/x/net v0.42.0 46 + golang.org/x/sync v0.16.0 43 47 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 44 48 gopkg.in/yaml.v3 v3.0.1 45 49 tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 ··· 48 52 require ( 49 53 dario.cat/mergo v1.0.1 // indirect 50 54 github.com/Microsoft/go-winio v0.6.2 // indirect 51 - github.com/ProtonMail/go-crypto v1.2.0 // indirect 55 + github.com/ProtonMail/go-crypto v1.3.0 // indirect 56 + github.com/alecthomas/repr v0.4.0 // indirect 52 57 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 53 58 github.com/aymerick/douceur v0.2.0 // indirect 54 59 github.com/beorn7/perks v1.0.1 // indirect 55 60 github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect 56 61 github.com/casbin/govaluate v1.3.0 // indirect 62 + github.com/cenkalti/backoff/v4 v4.3.0 // indirect 57 63 github.com/cespare/xxhash/v2 v2.3.0 // indirect 58 - github.com/cloudflare/circl v1.6.0 // indirect 64 + github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect 59 65 github.com/containerd/errdefs v1.0.0 // indirect 60 66 github.com/containerd/errdefs/pkg v0.3.0 // indirect 61 67 github.com/containerd/log v0.1.0 // indirect ··· 68 74 github.com/docker/go-units v0.5.0 // indirect 69 75 github.com/emirpasic/gods v1.18.1 // indirect 70 76 github.com/felixge/httpsnoop v1.0.4 // indirect 77 + github.com/fsnotify/fsnotify v1.6.0 // indirect 71 78 github.com/go-enry/go-oniguruma v1.2.1 // indirect 72 79 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 73 80 github.com/go-git/go-billy/v5 v5.6.2 // indirect 81 + github.com/go-jose/go-jose/v3 v3.0.4 // indirect 74 82 github.com/go-logr/logr v1.4.3 // indirect 75 83 github.com/go-logr/stdr v1.2.2 // indirect 76 84 github.com/go-redis/cache/v9 v9.0.0 // indirect 85 + github.com/go-test/deep v1.1.1 // indirect 77 86 github.com/goccy/go-json v0.10.5 // indirect 78 87 github.com/gogo/protobuf v1.3.2 // indirect 79 88 github.com/golang-jwt/jwt/v5 v5.2.3 // indirect 80 89 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 90 + github.com/golang/mock v1.6.0 // indirect 91 + github.com/google/go-querystring v1.1.0 // indirect 81 92 github.com/gorilla/css v1.0.1 // indirect 82 93 github.com/gorilla/securecookie v1.1.2 // indirect 94 + github.com/hashicorp/errwrap v1.1.0 // indirect 83 95 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 96 + github.com/hashicorp/go-multierror v1.1.1 // indirect 84 97 github.com/hashicorp/go-retryablehttp v0.7.8 // indirect 98 + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect 99 + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect 100 + github.com/hashicorp/go-sockaddr v1.0.7 // indirect 85 101 github.com/hashicorp/golang-lru v1.0.2 // indirect 86 102 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 103 + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect 104 + github.com/hexops/gotextdiff v1.0.3 // indirect 87 105 github.com/ipfs/bbloom v0.0.4 // indirect 88 106 github.com/ipfs/boxo v0.33.0 // indirect 89 107 github.com/ipfs/go-block-format v0.2.2 // indirect ··· 105 123 github.com/lestrrat-go/option v1.0.1 // indirect 106 124 github.com/mattn/go-isatty v0.0.20 // indirect 107 125 github.com/minio/sha256-simd v1.0.1 // indirect 126 + github.com/mitchellh/mapstructure v1.5.0 // indirect 108 127 github.com/moby/docker-image-spec v1.3.1 // indirect 109 128 github.com/moby/sys/atomicwriter v0.1.0 // indirect 110 129 github.com/moby/term v0.5.2 // indirect ··· 116 135 github.com/multiformats/go-multihash v0.2.3 // indirect 117 136 github.com/multiformats/go-varint v0.0.7 // indirect 118 137 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 138 + github.com/onsi/gomega v1.37.0 // indirect 119 139 github.com/opencontainers/go-digest v1.0.0 // indirect 120 140 github.com/opencontainers/image-spec v1.1.1 // indirect 121 - github.com/opentracing/opentracing-go v1.2.0 // indirect 141 + github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect 122 142 github.com/pjbgf/sha1cd v0.3.2 // indirect 123 143 github.com/pkg/errors v0.9.1 // indirect 124 144 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect ··· 127 147 github.com/prometheus/client_model v0.6.2 // indirect 128 148 github.com/prometheus/common v0.64.0 // indirect 129 149 github.com/prometheus/procfs v0.16.1 // indirect 150 + github.com/ryanuber/go-glob v1.0.0 // indirect 130 151 github.com/segmentio/asm v1.2.0 // indirect 131 152 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 132 153 github.com/spaolacci/murmur3 v1.1.0 // indirect ··· 138 159 go.opentelemetry.io/auto/sdk v1.1.0 // indirect 139 160 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect 140 161 go.opentelemetry.io/otel v1.37.0 // indirect 162 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect 141 163 go.opentelemetry.io/otel/metric v1.37.0 // indirect 142 164 go.opentelemetry.io/otel/trace v1.37.0 // indirect 143 165 go.opentelemetry.io/proto/otlp v1.6.0 // indirect ··· 145 167 go.uber.org/multierr v1.11.0 // indirect 146 168 go.uber.org/zap v1.27.0 // indirect 147 169 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 148 - golang.org/x/sync v0.15.0 // indirect 149 170 golang.org/x/sys v0.34.0 // indirect 171 + golang.org/x/text v0.27.0 // indirect 150 172 golang.org/x/time v0.12.0 // indirect 151 - google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect 152 - google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect 153 - google.golang.org/grpc v1.72.1 // indirect 173 + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect 174 + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect 175 + google.golang.org/grpc v1.73.0 // indirect 154 176 google.golang.org/protobuf v1.36.6 // indirect 155 177 gopkg.in/fsnotify.v1 v1.4.7 // indirect 156 178 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
+92 -97
go.sum
··· 7 7 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 8 8 github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 9 9 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 10 - github.com/ProtonMail/go-crypto v1.2.0 h1:+PhXXn4SPGd+qk76TlEePBfOfivE0zkWFenhGhFLzWs= 11 - github.com/ProtonMail/go-crypto v1.2.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 10 + github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= 11 + github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 12 12 github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 13 13 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 14 14 github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= ··· 23 23 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 24 24 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 25 25 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 26 - github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e h1:DVD+HxQsDCVJtAkjfIKZVaBNc3kayHaU+A2TJZkFdp4= 27 - github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e/go.mod h1:ovyxp8AMO1Hoe838vMJUbqHTZaAR8ABM3g3TXu+A5Ng= 28 26 github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ= 29 27 github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q= 30 28 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= ··· 53 51 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 54 52 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 55 53 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 56 - github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= 57 - github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 54 + github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d h1:IiIprFGH6SqstblP0Y9NIo3eaUJGkI/YDOFVSL64Uq4= 55 + github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 56 + github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM= 57 + github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU= 58 58 github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= 59 59 github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= 60 60 github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= ··· 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= ··· 93 94 github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 94 95 github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 95 96 github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 96 - github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 97 - github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 97 + github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 98 + github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 98 99 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 99 100 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 100 101 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 101 - github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= 102 102 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 103 + github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= 104 + github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= 103 105 github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 104 106 github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 105 107 github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= ··· 116 118 github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= 117 119 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03 h1:LumE+tQdnYW24a9RoO08w64LHTzkNkdUqBD/0QPtlEY= 118 120 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM= 121 + github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= 122 + github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= 119 123 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 120 124 github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 121 - github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 122 - github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 123 125 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 124 126 github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 125 127 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= ··· 127 129 github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0= 128 130 github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI= 129 131 github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= 132 + github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= 133 + github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 130 134 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 131 135 github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 132 136 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 133 137 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 134 138 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 135 - github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 136 - github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 137 - github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 138 139 github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= 139 140 github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 140 141 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 141 142 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 142 - github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= 143 143 github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= 144 + github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= 145 + github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= 144 146 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 145 147 github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 146 148 github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= ··· 153 155 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 154 156 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 155 157 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 158 + github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 156 159 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 157 160 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 158 161 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 159 162 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 160 163 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 164 + github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 165 + github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 161 166 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 162 167 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 163 168 github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= ··· 169 174 github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 170 175 github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 171 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= 172 179 github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 173 180 github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 174 181 github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 175 182 github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 176 - github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 177 - github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 183 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= 184 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= 178 185 github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= 179 186 github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= 187 + github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 188 + github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 189 + github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 180 190 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 181 191 github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 182 192 github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 183 193 github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= 184 - github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= 185 - github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= 194 + github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 195 + github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 186 196 github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= 187 197 github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= 198 + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= 199 + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= 200 + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= 201 + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= 202 + github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= 203 + github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= 188 204 github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 189 205 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 190 206 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 191 207 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 208 + github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= 209 + github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= 192 210 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 193 211 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 194 212 github.com/hiddeco/sshsig v0.2.0 h1:gMWllgKCITXdydVkDL+Zro0PU96QI55LwUwebSwNTSw= ··· 198 216 github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 199 217 github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= 200 218 github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= 201 - github.com/ipfs/boxo v0.30.0 h1:7afsoxPGGqfoH7Dum/wOTGUB9M5fb8HyKPMlLfBvIEQ= 202 - github.com/ipfs/boxo v0.30.0/go.mod h1:BPqgGGyHB9rZZcPSzah2Dc9C+5Or3U1aQe7EH1H7370= 203 219 github.com/ipfs/boxo v0.33.0 h1:9ow3chwkDzMj0Deq4AWRUEI7WnIIV7SZhPTzzG2mmfw= 204 220 github.com/ipfs/boxo v0.33.0/go.mod h1:3IPh7YFcCIcKp6o02mCHovrPntoT5Pctj/7j4syh/RM= 205 - github.com/ipfs/go-block-format v0.2.1 h1:96kW71XGNNa+mZw/MTzJrCpMhBWCrd9kBLoKm9Iip/Q= 206 - github.com/ipfs/go-block-format v0.2.1/go.mod h1:frtvXHMQhM6zn7HvEQu+Qz5wSTj+04oEH/I+NjDgEjk= 207 221 github.com/ipfs/go-block-format v0.2.2 h1:uecCTgRwDIXyZPgYspaLXoMiMmxQpSx2aq34eNc4YvQ= 208 222 github.com/ipfs/go-block-format v0.2.2/go.mod h1:vmuefuWU6b+9kIU0vZJgpiJt1yicQz9baHXE8qR+KB8= 209 223 github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg= ··· 218 232 github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo= 219 233 github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= 220 234 github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs= 221 - github.com/ipfs/go-ipld-cbor v0.2.0 h1:VHIW3HVIjcMd8m4ZLZbrYpwjzqlVUfjLM7oK4T5/YF0= 222 - github.com/ipfs/go-ipld-cbor v0.2.0/go.mod h1:Cp8T7w1NKcu4AQJLqK0tWpd1nkgTxEVB5C6kVpLW6/0= 223 235 github.com/ipfs/go-ipld-cbor v0.2.1 h1:H05yEJbK/hxg0uf2AJhyerBDbjOuHX4yi+1U/ogRa7E= 224 236 github.com/ipfs/go-ipld-cbor v0.2.1/go.mod h1:x9Zbeq8CoE5R2WicYgBMcr/9mnkQ0lHddYWJP2sMV3A= 225 - github.com/ipfs/go-ipld-format v0.6.1 h1:lQLmBM/HHbrXvjIkrydRXkn+gc0DE5xO5fqelsCKYOQ= 226 - github.com/ipfs/go-ipld-format v0.6.1/go.mod h1:8TOH1Hj+LFyqM2PjSqI2/ZnyO0KlfhHbJLkbxFa61hs= 227 237 github.com/ipfs/go-ipld-format v0.6.2 h1:bPZQ+A05ol0b3lsJSl0bLvwbuQ+HQbSsdGTy4xtYUkU= 228 238 github.com/ipfs/go-ipld-format v0.6.2/go.mod h1:nni2xFdHKx5lxvXJ6brt/pndtGxKAE+FPR1rg4jTkyk= 229 239 github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= ··· 233 243 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 234 244 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 235 245 github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 236 - github.com/ipfs/go-test v0.2.1 h1:/D/a8xZ2JzkYqcVcV/7HYlCnc7bv/pKHQiX5TdClkPE= 237 - github.com/ipfs/go-test v0.2.1/go.mod h1:dzu+KB9cmWjuJnXFDYJwC25T3j1GcN57byN+ixmK39M= 238 - github.com/ipfs/go-test v0.2.2 h1:1yjYyfbdt1w93lVzde6JZ2einh3DIV40at4rVoyEcE8= 239 246 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 240 247 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 241 248 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= ··· 247 254 github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= 248 255 github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 249 256 github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 250 - github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= 251 - github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 252 257 github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= 253 258 github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 254 259 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= ··· 259 264 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 260 265 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 261 266 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 262 - github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= 263 - github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= 264 267 github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= 265 268 github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= 266 269 github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= ··· 273 276 github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 274 277 github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 275 278 github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 276 - github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8= 277 - github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg= 278 - github.com/libp2p/go-libp2p v0.41.1 h1:8ecNQVT5ev/jqALTvisSJeVNvXYJyK4NhQx1nNRXQZE= 279 - github.com/libp2p/go-libp2p v0.41.1/go.mod h1:DcGTovJzQl/I7HMrby5ZRjeD0kQkGiy+9w6aEkSZpRI= 280 - github.com/libp2p/go-libp2p v0.42.0 h1:A8foZk+ZEhZTv0Jb++7xUFlrFhBDv4j2Vh/uq4YX+KE= 281 - github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 282 - github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 279 + github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 280 + github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 283 281 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 284 282 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 285 283 github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= ··· 288 286 github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 289 287 github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 290 288 github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 289 + github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 290 + github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 291 291 github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= 292 292 github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= 293 293 github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= ··· 304 304 github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 305 305 github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= 306 306 github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= 307 - github.com/multiformats/go-multiaddr v0.15.0 h1:zB/HeaI/apcZiTDwhY5YqMvNVl/oQYvs3XySU+qeAVo= 308 - github.com/multiformats/go-multiaddr v0.15.0/go.mod h1:JSVUmXDjsVFiW7RjIFMP7+Ev+h1DTbiJgVeTV/tcmP0= 309 - github.com/multiformats/go-multiaddr v0.16.0 h1:oGWEVKioVQcdIOBlYM8BH1rZDWOGJSqr9/BKl6zQ4qc= 310 307 github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 311 308 github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= 312 - github.com/multiformats/go-multicodec v0.9.0 h1:pb/dlPnzee/Sxv/j4PmkDRxCOi3hXTz3IbPKOXWJkmg= 313 - github.com/multiformats/go-multicodec v0.9.0/go.mod h1:L3QTQvMIaVBkXOXXtVmYE+LI16i14xuaojr/H7Ai54k= 314 - github.com/multiformats/go-multicodec v0.9.2 h1:YrlXCuqxjqm3bXl+vBq5LKz5pz4mvAsugdqy78k0pXQ= 315 309 github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 316 310 github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 317 311 github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= ··· 343 337 github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg= 344 338 github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM= 345 339 github.com/onsi/gomega v1.25.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM= 346 - github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= 347 - github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= 340 + github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= 341 + github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= 342 + github.com/openbao/openbao/api/v2 v2.3.0 h1:61FO3ILtpKoxbD9kTWeGaCq8pz1sdt4dv2cmTXsiaAc= 343 + github.com/openbao/openbao/api/v2 v2.3.0/go.mod h1:T47WKHb7DqHa3Ms3xicQtl5EiPE+U8diKjb9888okWs= 348 344 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 349 345 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 350 346 github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= 351 347 github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= 352 - github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= 353 348 github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 349 + github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A= 350 + github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU= 354 351 github.com/oppiliappan/chroma/v2 v2.19.0 h1:PN7/pb+6JRKCva30NPTtRJMlrOyzgpPpIroNzy4ekHU= 355 352 github.com/oppiliappan/chroma/v2 v2.19.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= 356 353 github.com/oppiliappan/go-git/v5 v5.17.0 h1:CuJnpcIDxr0oiNaSHMconovSWnowHznVDG+AhjGuSEo= ··· 371 368 github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 372 369 github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 373 370 github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 374 - github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= 375 - github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= 376 371 github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4= 377 372 github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= 378 373 github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 379 374 github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 380 375 github.com/redis/go-redis/v9 v9.0.0-rc.4/go.mod h1:Vo3EsyWnicKnSKCA7HhgnvnyA74wOA69Cd2Meli5mmA= 381 - github.com/redis/go-redis/v9 v9.3.0 h1:RiVDjmig62jIWp7Kk4XVLs0hzV6pI3PyTnnL0cnn0u0= 382 - github.com/redis/go-redis/v9 v9.3.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= 376 + github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= 377 + github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= 383 378 github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= 384 379 github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 385 380 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= ··· 387 382 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 388 383 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 389 384 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 385 + github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= 386 + github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= 390 387 github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 391 388 github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 392 389 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= ··· 431 428 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 432 429 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 433 430 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 431 + github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 434 432 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 435 - github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= 436 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= 437 438 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 438 439 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 439 440 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 440 441 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 441 442 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 442 443 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 443 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= 444 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= 445 444 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= 446 445 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= 447 - go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= 448 - go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= 449 446 go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= 450 447 go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= 451 - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk= 452 - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME= 448 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= 449 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= 453 450 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU= 454 451 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0/go.mod h1:2HpZxxQurfGxJlJDblybejHB6RX6pmExPNe517hREw4= 455 - go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= 456 - go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= 457 452 go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= 458 453 go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= 459 - go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= 460 - go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= 461 454 go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= 462 - go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= 463 - go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= 455 + go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= 464 456 go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= 465 - go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= 466 - go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= 457 + go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= 467 458 go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= 468 459 go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= 469 460 go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI= ··· 488 479 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 489 480 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 490 481 golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= 491 - golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 492 - golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 482 + golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 493 483 golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= 494 484 golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 495 - golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= 496 - golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= 497 485 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 498 486 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 499 487 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 500 488 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 501 489 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 502 490 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 491 + golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 503 492 golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= 504 493 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 505 494 golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= 506 495 golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 496 + golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 507 497 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 508 498 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 509 499 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= ··· 512 502 golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 513 503 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 514 504 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 505 + golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 515 506 golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= 516 507 golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 517 508 golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= ··· 521 512 golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= 522 513 golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= 523 514 golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= 524 - golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 525 - golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 526 - golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= 527 - golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= 515 + golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 516 + golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 517 + golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= 518 + golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= 528 519 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 529 520 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 530 521 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= ··· 532 523 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 533 524 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 534 525 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 535 - golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 536 - golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 537 - golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= 538 - golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 526 + golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 527 + golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 539 528 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 540 529 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 541 530 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 547 536 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 548 537 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 549 538 golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 539 + golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 550 540 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 541 + golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 551 542 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 552 543 golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 553 544 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= ··· 555 546 golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 556 547 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 557 548 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 549 + golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 558 550 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 559 551 golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 560 552 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 561 553 golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 554 + golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 562 555 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 563 - golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 564 - golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 556 + golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 557 + golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 565 558 golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= 566 559 golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 567 560 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= ··· 570 563 golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= 571 564 golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= 572 565 golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= 573 - golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 574 - golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 566 + golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 567 + golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 568 + golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 575 569 golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= 570 + golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= 576 571 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 577 572 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 578 573 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= ··· 580 575 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 581 576 golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 582 577 golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 583 - golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 584 - golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 578 + golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 579 + golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 580 + golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 585 581 golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= 586 - golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= 587 - golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 582 + golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= 588 583 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 589 584 golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 590 585 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= ··· 598 593 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 599 594 golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 600 595 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 596 + golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 601 597 golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= 602 598 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 603 599 golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= 604 600 golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= 601 + golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 605 602 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 606 603 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 607 604 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 608 605 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 609 606 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= 610 607 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 611 - google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0= 612 - google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto= 613 - google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34= 614 - google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= 615 - google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= 616 - google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= 608 + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= 609 + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= 610 + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= 611 + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= 612 + google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= 613 + google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= 617 614 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 618 615 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 619 616 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= ··· 650 647 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 651 648 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 652 649 lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 653 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421 h1:ZQNKE1HKWjfQBizxDb2XLhrJcgZqE0MLFIU1iggaF90= 654 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421/go.mod h1:Wad0H70uyyY4qZryU/1Ic+QZyw41YxN5QfzNAEBaXkQ= 655 650 tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 h1:z1os1aRIqeo5e8d0Tx7hk+LH8OdZZeIOY0zw9VB/ZoU= 656 651 tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1/go.mod h1:+oQi9S6IIDll0nxLZVhuzOPX8WKLCYEnE6M5kUKupDg= 657 652 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc=
+19 -3
guard/guard.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "errors" 5 6 "fmt" 7 + "io" 6 8 "log/slog" 7 9 "net/http" 8 10 "net/url" ··· 43 45 Usage: "internal API endpoint", 44 46 Value: "http://localhost:5444", 45 47 }, 48 + &cli.StringFlag{ 49 + Name: "motd-file", 50 + Usage: "path to message of the day file", 51 + Value: "/home/git/motd", 52 + }, 46 53 }, 47 54 } 48 55 } ··· 54 61 gitDir := cmd.String("git-dir") 55 62 logPath := cmd.String("log-path") 56 63 endpoint := cmd.String("internal-api") 64 + motdFile := cmd.String("motd-file") 57 65 58 66 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 59 67 if err != nil { ··· 149 157 "fullPath", fullPath, 150 158 "client", clientIP) 151 159 152 - if gitCommand == "git-upload-pack" { 153 - fmt.Fprintf(os.Stderr, "\x02%s\n", "Welcome to this knot!") 160 + var motdReader io.Reader 161 + if reader, err := os.Open(motdFile); err != nil { 162 + if !errors.Is(err, os.ErrNotExist) { 163 + l.Error("failed to read motd file", "error", err) 164 + } 165 + motdReader = strings.NewReader("Welcome to this knot!\n") 154 166 } else { 155 - fmt.Fprintf(os.Stderr, "%s\n", "Welcome to this knot!") 167 + motdReader = reader 168 + } 169 + if gitCommand == "git-upload-pack" { 170 + io.WriteString(os.Stderr, "\x02") 156 171 } 172 + io.Copy(os.Stderr, motdReader) 157 173 158 174 gitCmd := exec.Command(gitCommand, fullPath) 159 175 gitCmd.Stdout = os.Stdout
+84 -8
input.css
··· 13 13 @font-face { 14 14 font-family: "InterVariable"; 15 15 src: url("/static/fonts/InterVariable-Italic.woff2") format("woff2"); 16 - font-weight: 400; 16 + font-weight: normal; 17 17 font-style: italic; 18 18 font-display: swap; 19 19 } 20 20 21 21 @font-face { 22 22 font-family: "InterVariable"; 23 - src: url("/static/fonts/InterVariable.woff2") format("woff2"); 24 - font-weight: 600; 23 + src: url("/static/fonts/InterDisplay-Bold.woff2") format("woff2"); 24 + font-weight: bold; 25 25 font-style: normal; 26 26 font-display: swap; 27 27 } 28 28 29 29 @font-face { 30 + font-family: "InterVariable"; 31 + src: url("/static/fonts/InterDisplay-BoldItalic.woff2") format("woff2"); 32 + font-weight: bold; 33 + font-style: italic; 34 + font-display: swap; 35 + } 36 + 37 + @font-face { 30 38 font-family: "IBMPlexMono"; 31 39 src: url("/static/fonts/IBMPlexMono-Regular.woff2") format("woff2"); 32 40 font-weight: normal; 41 + font-style: normal; 42 + font-display: swap; 43 + } 44 + 45 + @font-face { 46 + font-family: "IBMPlexMono"; 47 + src: url("/static/fonts/IBMPlexMono-Italic.woff2") format("woff2"); 48 + font-weight: normal; 49 + font-style: italic; 50 + font-display: swap; 51 + } 52 + 53 + @font-face { 54 + font-family: "IBMPlexMono"; 55 + src: url("/static/fonts/IBMPlexMono-Bold.woff2") format("woff2"); 56 + font-weight: bold; 57 + font-style: normal; 58 + font-display: swap; 59 + } 60 + 61 + @font-face { 62 + font-family: "IBMPlexMono"; 63 + src: url("/static/fonts/IBMPlexMono-BoldItalic.woff2") format("woff2"); 64 + font-weight: bold; 33 65 font-style: italic; 34 66 font-display: swap; 35 67 } ··· 46 78 @supports (font-variation-settings: normal) { 47 79 html { 48 80 font-feature-settings: 49 - "ss01" 1, 50 81 "kern" 1, 51 82 "liga" 1, 52 83 "cv05" 1, ··· 70 101 details summary::-webkit-details-marker { 71 102 display: none; 72 103 } 104 + 105 + code { 106 + @apply font-mono rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white; 107 + } 73 108 } 74 109 75 110 @layer components { ··· 98 133 disabled:before:bg-green-400 dark:disabled:before:bg-green-600; 99 134 } 100 135 136 + .prose hr { 137 + @apply my-2; 138 + } 139 + 140 + .prose li:has(input) { 141 + @apply list-none; 142 + } 143 + 144 + .prose ul:has(input) { 145 + @apply pl-2; 146 + } 147 + 148 + .prose .heading .anchor { 149 + @apply no-underline mx-2 opacity-0; 150 + } 151 + 152 + .prose .heading:hover .anchor { 153 + @apply opacity-70; 154 + } 155 + 156 + .prose .heading .anchor:hover { 157 + @apply opacity-70; 158 + } 159 + 160 + .prose a.footnote-backref { 161 + @apply no-underline; 162 + } 163 + 164 + .prose li { 165 + @apply my-0 py-0; 166 + } 167 + 168 + .prose ul, .prose ol { 169 + @apply my-1 py-0; 170 + } 171 + 101 172 .prose img { 102 173 display: inline; 103 - margin-left: 0; 104 - margin-right: 0; 174 + margin: 0; 105 175 vertical-align: middle; 176 + } 177 + 178 + .prose input { 179 + @apply inline-block my-0 mb-1 mx-1; 180 + } 181 + 182 + .prose input[type="checkbox"] { 183 + @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500; 106 184 } 107 185 } 108 186 @layer utilities { ··· 123 201 /* PreWrapper */ 124 202 .chroma { 125 203 color: #4c4f69; 126 - background-color: #eff1f5; 127 204 } 128 205 /* Error */ 129 206 .chroma .err { ··· 460 537 /* PreWrapper */ 461 538 .chroma { 462 539 color: #cad3f5; 463 - background-color: #24273a; 464 540 } 465 541 /* Error */ 466 542 .chroma .err {
+19 -4
jetstream/jetstream.go
··· 52 52 j.mu.Unlock() 53 53 } 54 54 55 + func (j *JetstreamClient) RemoveDid(did string) { 56 + if did == "" { 57 + return 58 + } 59 + 60 + if j.logDids { 61 + j.l.Info("removing did from in-memory filter", "did", did) 62 + } 63 + j.mu.Lock() 64 + delete(j.wantedDids, did) 65 + j.mu.Unlock() 66 + } 67 + 55 68 type processor func(context.Context, *models.Event) error 56 69 57 70 func (j *JetstreamClient) withDidFilter(processFunc processor) processor { 58 - // empty filter => all dids allowed 59 - if len(j.wantedDids) == 0 { 60 - return processFunc 61 - } 62 71 // since this closure references j.WantedDids; it should auto-update 63 72 // existing instances of the closure when j.WantedDids is mutated 64 73 return func(ctx context.Context, evt *models.Event) error { 74 + 75 + // empty filter => all dids allowed 76 + if len(j.wantedDids) == 0 { 77 + return processFunc(ctx, evt) 78 + } 79 + 65 80 if _, ok := j.wantedDids[evt.Did]; ok { 66 81 return processFunc(ctx, evt) 67 82 } else {
-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
knotserver/file.go
··· 10 10 "tangled.sh/tangled.sh/core/types" 11 11 ) 12 12 13 - func (h *Handle) listFiles(files []types.NiceTree, data map[string]any, w http.ResponseWriter) { 14 - data["files"] = files 15 - 16 - writeJSON(w, data) 17 - return 18 - } 19 - 20 13 func countLines(r io.Reader) (int, error) { 21 14 buf := make([]byte, 32*1024) 22 15 bufLen := 0 ··· 52 45 53 46 resp.Lines = lc 54 47 writeJSON(w, resp) 55 - return 56 48 }
+8 -10
knotserver/git/fork.go
··· 10 10 ) 11 11 12 12 func Fork(repoPath, source string) error { 13 - _, err := git.PlainClone(repoPath, true, &git.CloneOptions{ 14 - URL: source, 15 - SingleBranch: false, 16 - }) 17 - 18 - if err != nil { 13 + cloneCmd := exec.Command("git", "clone", "--bare", source, repoPath) 14 + if err := cloneCmd.Run(); err != nil { 19 15 return fmt.Errorf("failed to bare clone repository: %w", err) 20 16 } 21 17 22 - err = exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden").Run() 23 - if err != nil { 18 + configureCmd := exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden") 19 + if err := configureCmd.Run(); err != nil { 24 20 return fmt.Errorf("failed to configure hidden refs: %w", err) 25 21 } 26 22 27 23 return nil 28 24 } 29 25 30 - func (g *GitRepo) Sync(branch string) error { 26 + func (g *GitRepo) Sync() error { 27 + branch := g.h.String() 28 + 31 29 fetchOpts := &git.FetchOptions{ 32 30 RefSpecs: []config.RefSpec{ 33 - config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/heads/%s", branch, branch)), 31 + config.RefSpec("+" + branch + ":" + branch), // +refs/heads/master:refs/heads/master 34 32 }, 35 33 } 36 34
+19 -12
knotserver/git/post_receive.go
··· 3 3 import ( 4 4 "bufio" 5 5 "context" 6 + "errors" 6 7 "fmt" 7 8 "io" 8 9 "strings" ··· 57 58 ByEmail map[string]int 58 59 } 59 60 60 - func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) RefUpdateMeta { 61 + func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) (RefUpdateMeta, error) { 62 + var errs error 63 + 61 64 commitCount, err := g.newCommitCount(line) 62 - if err != nil { 63 - // TODO: log this 64 - } 65 + errors.Join(errs, err) 65 66 66 67 isDefaultRef, err := g.isDefaultBranch(line) 67 - if err != nil { 68 - // TODO: log this 69 - } 68 + errors.Join(errs, err) 70 69 71 70 ctx, cancel := context.WithTimeout(context.Background(), time.Second*2) 72 71 defer cancel() 73 72 breakdown, err := g.AnalyzeLanguages(ctx) 74 - if err != nil { 75 - // TODO: log this 76 - } 73 + errors.Join(errs, err) 77 74 78 75 return RefUpdateMeta{ 79 76 CommitCount: commitCount, 80 77 IsDefaultRef: isDefaultRef, 81 78 LangBreakdown: breakdown, 82 - } 79 + }, errs 83 80 } 84 81 85 82 func (g *GitRepo) newCommitCount(line PostReceiveLine) (CommitCount, error) { ··· 95 92 args := []string{fmt.Sprintf("--max-count=%d", 100)} 96 93 97 94 if line.OldSha.IsZero() { 98 - // just git rev-list <newsha> 95 + // git rev-list <newsha> ^other-branches --not ^this-branch 99 96 args = append(args, line.NewSha.String()) 97 + 98 + branches, _ := g.Branches() 99 + for _, b := range branches { 100 + if !strings.Contains(line.Ref, b.Name) { 101 + args = append(args, fmt.Sprintf("^%s", b.Name)) 102 + } 103 + } 104 + 105 + args = append(args, "--not") 106 + args = append(args, fmt.Sprintf("^%s", line.Ref)) 100 107 } else { 101 108 // git rev-list <oldsha>..<newsha> 102 109 args = append(args, fmt.Sprintf("%s..%s", line.OldSha.String(), line.NewSha.String()))
+5
knotserver/git.go
··· 129 129 // If the appview gave us the repository owner's handle we can attempt to 130 130 // construct the correct ssh url. 131 131 ownerHandle := r.Header.Get("x-tangled-repo-owner-handle") 132 + ownerHandle = strings.TrimPrefix(ownerHandle, "@") 132 133 if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") { 133 134 hostname := d.c.Server.Hostname 134 135 if strings.Contains(hostname, ":") { 135 136 hostname = strings.Split(hostname, ":")[0] 137 + } 138 + 139 + if hostname == "knot1.tangled.sh" { 140 + hostname = "tangled.sh" 136 141 } 137 142 138 143 fmt.Fprintf(w, " Try:\ngit remote set-url --push origin git@%s:%s/%s\n\n... and push again.", hostname, ownerHandle, unqualifiedRepoName)
+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 - }
+120 -75
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 + } 215 + 216 + // duplicated from add collaborator 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 + 226 + repoAt, err := syntax.ParseATURI(record.Repo) 227 + if err != nil { 228 + return err 229 + } 230 + 231 + resolver := idresolver.DefaultResolver() 232 + 233 + subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 234 + if err != nil || subjectId.Handle.IsInvalidHandle() { 235 + return err 236 + } 237 + 238 + // TODO: fix this for good, we need to fetch the record here unfortunately 239 + // resolve this aturi to extract the repo record 240 + owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 241 + if err != nil || owner.Handle.IsInvalidHandle() { 242 + return fmt.Errorf("failed to resolve handle: %w", err) 243 + } 244 + 245 + xrpcc := xrpc.Client{ 246 + Host: owner.PDSEndpoint(), 247 + } 248 + 249 + resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 250 + if err != nil { 251 + return err 252 + } 253 + 254 + repo := resp.Value.Val.(*tangled.Repo) 255 + didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 256 + 257 + // check perms for this user 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) 264 + } 265 + 266 + if err := h.db.AddDid(subjectId.DID.String()); err != nil { 267 + return err 268 + } 269 + h.jc.AddDid(subjectId.DID.String()) 270 + 271 + if err := h.e.AddCollaborator(subjectId.DID.String(), rbac.ThisServer, didSlashRepo); err != nil { 272 + return err 273 + } 274 + 275 + return h.fetchAndAddKeys(ctx, subjectId.DID.String()) 214 276 } 215 277 216 278 func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error { ··· 240 302 return fmt.Errorf("error reading response body: %w", err) 241 303 } 242 304 243 - for _, key := range strings.Split(string(plaintext), "\n") { 305 + for key := range strings.SplitSeq(string(plaintext), "\n") { 244 306 if key == "" { 245 307 continue 246 308 } ··· 257 319 } 258 320 259 321 func (h *Handle) processMessages(ctx context.Context, event *models.Event) error { 260 - did := event.Did 261 322 if event.Kind != models.EventKindCommit { 262 323 return nil 263 324 } ··· 266 327 defer func() { 267 328 eventTime := event.TimeUS 268 329 lastTimeUs := eventTime + 1 269 - fmt.Println("lastTimeUs", lastTimeUs) 270 330 if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil { 271 331 err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 272 332 } 273 333 }() 274 - 275 - raw := json.RawMessage(event.Commit.Record) 276 334 277 335 switch event.Commit.Collection { 278 336 case tangled.PublicKeyNSID: 279 - var record tangled.PublicKey 280 - if err := json.Unmarshal(raw, &record); err != nil { 281 - return fmt.Errorf("failed to unmarshal record: %w", err) 282 - } 283 - if err := h.processPublicKey(ctx, did, record); err != nil { 284 - return fmt.Errorf("failed to process public key: %w", err) 285 - } 286 - 337 + err = h.processPublicKey(ctx, event) 287 338 case tangled.KnotMemberNSID: 288 - var record tangled.KnotMember 289 - if err := json.Unmarshal(raw, &record); err != nil { 290 - return fmt.Errorf("failed to unmarshal record: %w", err) 291 - } 292 - if err := h.processKnotMember(ctx, did, record); err != nil { 293 - return fmt.Errorf("failed to process knot member: %w", err) 294 - } 339 + err = h.processKnotMember(ctx, event) 295 340 case tangled.RepoPullNSID: 296 - var record tangled.RepoPull 297 - if err := json.Unmarshal(raw, &record); err != nil { 298 - return fmt.Errorf("failed to unmarshal record: %w", err) 299 - } 300 - if err := h.processPull(ctx, did, record); err != nil { 301 - return fmt.Errorf("failed to process knot member: %w", err) 302 - } 341 + err = h.processPull(ctx, event) 342 + case tangled.RepoCollaboratorNSID: 343 + err = h.processCollaborator(ctx, event) 303 344 } 304 345 305 - return err 346 + if err != nil { 347 + h.l.Debug("failed to process event", "nsid", event.Commit.Collection, "err", err) 348 + } 349 + 350 + return nil 306 351 }
+20 -39
knotserver/internal.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 7 8 "log/slog" 8 9 "net/http" ··· 46 47 } 47 48 48 49 w.WriteHeader(http.StatusNoContent) 49 - return 50 50 } 51 51 52 52 func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) { ··· 62 62 data = append(data, j) 63 63 } 64 64 writeJSON(w, data) 65 - return 66 65 } 67 66 68 67 type PushOptions struct { ··· 145 144 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) 146 145 } 147 146 148 - meta := gr.RefUpdateMeta(line) 147 + var errs error 148 + meta, err := gr.RefUpdateMeta(line) 149 + errors.Join(errs, err) 149 150 150 151 metaRecord := meta.AsRecord() 151 152 ··· 169 170 EventJson: string(eventJson), 170 171 } 171 172 172 - return h.db.InsertEvent(event, h.n) 173 + return errors.Join(errs, h.db.InsertEvent(event, h.n)) 173 174 } 174 175 175 176 func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error { ··· 197 198 return err 198 199 } 199 200 200 - pipelineParseErrors := []string{} 201 - 202 - var pipeline workflow.Pipeline 201 + var pipeline workflow.RawPipeline 203 202 for _, e := range workflowDir { 204 203 if !e.IsFile { 205 204 continue ··· 211 210 continue 212 211 } 213 212 214 - wf, err := workflow.FromFile(e.Name, contents) 215 - if err != nil { 216 - h.l.Error("failed to parse workflow", "err", err, "path", fpath) 217 - pipelineParseErrors = append(pipelineParseErrors, fmt.Sprintf("- at %s: %s\n", fpath, err)) 218 - continue 219 - } 220 - 221 - pipeline = append(pipeline, wf) 213 + pipeline = append(pipeline, workflow.RawWorkflow{ 214 + Name: e.Name, 215 + Contents: contents, 216 + }) 222 217 } 223 218 224 219 trigger := tangled.Pipeline_PushTriggerData{ ··· 239 234 }, 240 235 } 241 236 242 - cp := compiler.Compile(pipeline) 237 + cp := compiler.Compile(compiler.Parse(pipeline)) 243 238 eventJson, err := json.Marshal(cp) 244 239 if err != nil { 245 240 return err 246 241 } 247 242 243 + for _, e := range compiler.Diagnostics.Errors { 244 + *clientMsgs = append(*clientMsgs, e.String()) 245 + } 246 + 248 247 if pushOptions.verboseCi { 249 - hasDiagnostics := false 250 - if len(pipelineParseErrors) > 0 { 251 - hasDiagnostics = true 252 - *clientMsgs = append(*clientMsgs, "error: failed to parse workflow(s):") 253 - for _, error := range pipelineParseErrors { 254 - *clientMsgs = append(*clientMsgs, error) 255 - } 248 + if compiler.Diagnostics.IsEmpty() { 249 + *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 256 250 } 257 - if len(compiler.Diagnostics.Errors) > 0 { 258 - hasDiagnostics = true 259 - *clientMsgs = append(*clientMsgs, "error(s) on pipeline:") 260 - for _, error := range compiler.Diagnostics.Errors { 261 - *clientMsgs = append(*clientMsgs, fmt.Sprintf("- %s:", error)) 262 - } 263 - } 264 - if len(compiler.Diagnostics.Warnings) > 0 { 265 - hasDiagnostics = true 266 - *clientMsgs = append(*clientMsgs, "warning(s) on pipeline:") 267 - for _, warning := range compiler.Diagnostics.Warnings { 268 - *clientMsgs = append(*clientMsgs, fmt.Sprintf("- at %s: %s: %s", warning.Path, warning.Type, warning.Reason)) 269 - } 270 - } 271 - if !hasDiagnostics { 272 - *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 251 + 252 + for _, w := range compiler.Diagnostics.Warnings { 253 + *clientMsgs = append(*clientMsgs, w.String()) 273 254 } 274 255 } 275 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 -1271
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 } 92 - 93 - var ( 94 - commits []*object.Commit 95 - total int 96 - branches []types.Branch 97 - files []types.NiceTree 98 - tags []object.Tag 99 - ) 100 - 101 - var wg sync.WaitGroup 102 - errorsCh := make(chan error, 5) 103 - 104 - wg.Add(1) 105 - go func() { 106 - defer wg.Done() 107 - cs, err := gr.Commits(0, 60) 108 - if err != nil { 109 - errorsCh <- fmt.Errorf("commits: %w", err) 110 - return 111 - } 112 - commits = cs 113 - }() 114 - 115 - wg.Add(1) 116 - go func() { 117 - defer wg.Done() 118 - t, err := gr.TotalCommits() 119 - if err != nil { 120 - errorsCh <- fmt.Errorf("calculating total: %w", err) 121 - return 122 - } 123 - total = t 124 - }() 125 - 126 - wg.Add(1) 127 - go func() { 128 - defer wg.Done() 129 - bs, err := gr.Branches() 130 - if err != nil { 131 - errorsCh <- fmt.Errorf("fetching branches: %w", err) 132 - return 133 - } 134 - branches = bs 135 - }() 136 - 137 - wg.Add(1) 138 - go func() { 139 - defer wg.Done() 140 - ts, err := gr.Tags() 141 - if err != nil { 142 - errorsCh <- fmt.Errorf("fetching tags: %w", err) 143 - return 144 - } 145 - tags = ts 146 - }() 147 - 148 - wg.Add(1) 149 - go func() { 150 - defer wg.Done() 151 - fs, err := gr.FileTree(r.Context(), "") 152 - if err != nil { 153 - errorsCh <- fmt.Errorf("fetching filetree: %w", err) 154 - return 155 - } 156 - files = fs 157 - }() 54 + h.l.Info("owner set", "did", h.c.Server.Owner) 55 + h.jc.AddDid(h.c.Server.Owner) 158 56 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 240 - } 241 - 242 - files, err := gr.FileTree(r.Context(), treePath) 243 - if err != nil { 244 - writeError(w, err.Error(), http.StatusInternalServerError) 245 - l.Error("file tree", "error", err.Error()) 246 - return 60 + return nil, fmt.Errorf("failed to get all dids: %w", err) 247 61 } 248 - 249 - resp := types.RepoTreeResponse{ 250 - Ref: ref, 251 - Parent: treePath, 252 - Description: getDescription(path), 253 - DotDot: filepath.Dir(treePath), 254 - Files: files, 62 + for _, d := range dids { 63 + jc.AddDid(d) 255 64 } 256 65 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 - if !strings.HasPrefix(mimeType, "image/") && !strings.HasPrefix(mimeType, "video/") { 290 - l.Error("attempted to serve non-image/video file", "mimetype", mimeType) 291 - writeError(w, "only image and video files can be accessed directly", http.StatusForbidden) 292 - return 293 - } 92 + r.Route("/tree/{ref}", func(r chi.Router) { 93 + r.Get("/", h.RepoIndex) 94 + r.Get("/*", h.RepoTree) 95 + }) 294 96 295 - w.Header().Set("Cache-Control", "public, max-age=86400") // cache for 24 hours 296 - w.Header().Set("ETag", fmt.Sprintf("%x", sha256.Sum256(contents))) 297 - w.Header().Set("Content-Type", mimeType) 298 - w.Write(contents) 299 - } 97 + r.Route("/blob/{ref}", func(r chi.Router) { 98 + r.Get("/*", h.Blob) 99 + }) 300 100 301 - func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) { 302 - treePath := chi.URLParam(r, "*") 303 - ref := chi.URLParam(r, "ref") 304 - ref, _ = url.PathUnescape(ref) 101 + r.Route("/raw/{ref}", func(r chi.Router) { 102 + r.Get("/*", h.BlobRaw) 103 + }) 305 104 306 - 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 + }) 307 116 308 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 309 - gr, err := git.Open(path, ref) 310 - if err != nil { 311 - notFound(w) 312 - return 313 - } 117 + // xrpc apis 118 + r.Mount("/xrpc", h.XrpcRouter()) 314 119 315 - var isBinaryFile bool = false 316 - contents, err := gr.FileContent(treePath) 317 - if errors.Is(err, git.ErrBinaryFile) { 318 - isBinaryFile = true 319 - } else if errors.Is(err, object.ErrFileNotFound) { 320 - notFound(w) 321 - return 322 - } else if err != nil { 323 - writeError(w, err.Error(), http.StatusInternalServerError) 324 - return 325 - } 326 - 327 - bytes := []byte(contents) 328 - // safe := string(sanitize(bytes)) 329 - sizeHint := len(bytes) 330 - 331 - resp := types.RepoBlobResponse{ 332 - Ref: ref, 333 - Contents: string(bytes), 334 - Path: treePath, 335 - IsBinary: isBinaryFile, 336 - SizeHint: uint64(sizeHint), 337 - } 338 - 339 - h.showFile(resp, w, l) 340 - } 341 - 342 - func (h *Handle) Archive(w http.ResponseWriter, r *http.Request) { 343 - name := chi.URLParam(r, "name") 344 - file := chi.URLParam(r, "file") 345 - 346 - l := h.l.With("handler", "Archive", "name", name, "file", file) 347 - 348 - // TODO: extend this to add more files compression (e.g.: xz) 349 - if !strings.HasSuffix(file, ".tar.gz") { 350 - notFound(w) 351 - return 352 - } 353 - 354 - ref := strings.TrimSuffix(file, ".tar.gz") 355 - 356 - // This allows the browser to use a proper name for the file when 357 - // downloading 358 - filename := fmt.Sprintf("%s-%s.tar.gz", name, ref) 359 - setContentDisposition(w, filename) 360 - setGZipMIME(w) 361 - 362 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 363 - gr, err := git.Open(path, ref) 364 - if err != nil { 365 - notFound(w) 366 - return 367 - } 368 - 369 - gw := gzip.NewWriter(w) 370 - defer gw.Close() 371 - 372 - prefix := fmt.Sprintf("%s-%s", name, ref) 373 - err = gr.WriteTar(gw, prefix) 374 - if err != nil { 375 - // once we start writing to the body we can't report error anymore 376 - // so we are only left with printing the error. 377 - l.Error("writing tar file", "error", err.Error()) 378 - return 379 - } 380 - 381 - err = gw.Flush() 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("flushing?", "error", err.Error()) 386 - return 387 - } 388 - } 389 - 390 - func (h *Handle) Log(w http.ResponseWriter, r *http.Request) { 391 - ref := chi.URLParam(r, "ref") 392 - ref, _ = url.PathUnescape(ref) 393 - 394 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 395 - 396 - l := h.l.With("handler", "Log", "ref", ref, "path", path) 397 - 398 - gr, err := git.Open(path, ref) 399 - if err != nil { 400 - notFound(w) 401 - return 402 - } 403 - 404 - // Get page parameters 405 - page := 1 406 - pageSize := 30 407 - 408 - if pageParam := r.URL.Query().Get("page"); pageParam != "" { 409 - if p, err := strconv.Atoi(pageParam); err == nil && p > 0 { 410 - page = p 411 - } 412 - } 413 - 414 - if pageSizeParam := r.URL.Query().Get("per_page"); pageSizeParam != "" { 415 - if ps, err := strconv.Atoi(pageSizeParam); err == nil && ps > 0 { 416 - pageSize = ps 417 - } 418 - } 419 - 420 - // convert to offset/limit 421 - offset := (page - 1) * pageSize 422 - limit := pageSize 423 - 424 - commits, err := gr.Commits(offset, limit) 425 - if err != nil { 426 - writeError(w, err.Error(), http.StatusInternalServerError) 427 - l.Error("fetching commits", "error", err.Error()) 428 - return 429 - } 430 - 431 - total := len(commits) 120 + // Socket that streams git oplogs 121 + r.Get("/events", h.Events) 432 122 433 - resp := types.RepoLogResponse{ 434 - Commits: commits, 435 - Ref: ref, 436 - Description: getDescription(path), 437 - Log: true, 438 - Total: total, 439 - Page: page, 440 - PerPage: pageSize, 441 - } 123 + // All public keys on the knot. 124 + r.Get("/keys", h.Keys) 442 125 443 - writeJSON(w, resp) 444 - return 126 + return r, nil 445 127 } 446 128 447 - func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) { 448 - ref := chi.URLParam(r, "ref") 449 - ref, _ = url.PathUnescape(ref) 450 - 451 - l := h.l.With("handler", "Diff", "ref", ref) 129 + func (h *Handle) XrpcRouter() http.Handler { 130 + logger := tlog.New("knots") 452 131 453 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 454 - gr, err := git.Open(path, ref) 455 - if err != nil { 456 - notFound(w) 457 - return 458 - } 459 - 460 - diff, err := gr.Diff() 461 - if err != nil { 462 - writeError(w, err.Error(), http.StatusInternalServerError) 463 - l.Error("getting diff", "error", err.Error()) 464 - return 465 - } 132 + serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 466 133 467 - resp := types.RepoCommitResponse{ 468 - Ref: ref, 469 - 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, 470 143 } 471 - 472 - writeJSON(w, resp) 473 - return 144 + return xrpc.Router() 474 145 } 475 146 476 - func (h *Handle) Tags(w http.ResponseWriter, r *http.Request) { 477 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 478 - l := h.l.With("handler", "Refs") 479 - 480 - gr, err := git.Open(path, "") 481 - if err != nil { 482 - notFound(w) 483 - return 484 - } 485 - 486 - tags, err := gr.Tags() 487 - if err != nil { 488 - // Non-fatal, we *should* have at least one branch to show. 489 - l.Warn("getting tags", "error", err.Error()) 490 - } 147 + // version is set during build time. 148 + var version string 491 149 492 - rtags := []*types.TagReference{} 493 - for _, tag := range tags { 494 - var target *object.Tag 495 - if tag.Target != plumbing.ZeroHash { 496 - target = &tag 497 - } 498 - tr := types.TagReference{ 499 - 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 500 156 } 501 157 502 - tr.Reference = types.Reference{ 503 - Name: tag.Name, 504 - 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 + } 505 164 } 506 165 507 - if tag.Message != "" { 508 - tr.Message = tag.Message 166 + if modVer == "" { 167 + version = "unknown" 509 168 } 510 - 511 - rtags = append(rtags, &tr) 512 169 } 513 170 514 - resp := types.RepoTagsResponse{ 515 - Tags: rtags, 516 - } 517 - 518 - writeJSON(w, resp) 519 - return 520 - } 521 - 522 - func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) { 523 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 524 - 525 - gr, err := git.PlainOpen(path) 526 - if err != nil { 527 - notFound(w) 528 - return 529 - } 530 - 531 - branches, _ := gr.Branches() 532 - 533 - resp := types.RepoBranchesResponse{ 534 - Branches: branches, 535 - } 536 - 537 - writeJSON(w, resp) 538 - return 171 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 172 + fmt.Fprintf(w, "knotserver/%s", version) 539 173 } 540 174 541 - func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) { 542 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 543 - branchName := chi.URLParam(r, "branch") 544 - branchName, _ = url.PathUnescape(branchName) 545 - 546 - l := h.l.With("handler", "Branch") 547 - 548 - gr, err := git.PlainOpen(path) 549 - if err != nil { 550 - notFound(w) 551 - return 552 - } 553 - 554 - ref, err := gr.Branch(branchName) 555 - if err != nil { 556 - l.Error("getting branch", "error", err.Error()) 557 - writeError(w, err.Error(), http.StatusInternalServerError) 558 - return 559 - } 175 + func (h *Handle) configureOwner() error { 176 + cfgOwner := h.c.Server.Owner 560 177 561 - commit, err := gr.Commit(ref.Hash()) 562 - if err != nil { 563 - l.Error("getting commit object", "error", err.Error()) 564 - writeError(w, err.Error(), http.StatusInternalServerError) 565 - return 566 - } 178 + rbacDomain := "thisserver" 567 179 568 - defaultBranch, err := gr.FindMainBranch() 569 - isDefault := false 180 + existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain) 570 181 if err != nil { 571 - l.Error("getting default branch", "error", err.Error()) 572 - // do not quit though 573 - } else if defaultBranch == branchName { 574 - isDefault = true 575 - } 576 - 577 - resp := types.RepoBranchResponse{ 578 - Branch: types.Branch{ 579 - Reference: types.Reference{ 580 - Name: ref.Name().Short(), 581 - Hash: ref.Hash().String(), 582 - }, 583 - Commit: commit, 584 - IsDefault: isDefault, 585 - }, 182 + return err 586 183 } 587 184 588 - writeJSON(w, resp) 589 - return 590 - } 591 - 592 - func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) { 593 - l := h.l.With("handler", "Keys") 594 - 595 - switch r.Method { 596 - case http.MethodGet: 597 - keys, err := h.db.GetAllPublicKeys() 598 - if err != nil { 599 - writeError(w, err.Error(), http.StatusInternalServerError) 600 - l.Error("getting public keys", "error", err.Error()) 601 - return 602 - } 603 - 604 - data := make([]map[string]any, 0) 605 - for _, key := range keys { 606 - j := key.JSON() 607 - data = append(data, j) 608 - } 609 - writeJSON(w, data) 610 - return 185 + switch len(existing) { 186 + case 0: 187 + // no owner configured, continue 188 + case 1: 189 + // find existing owner 190 + existingOwner := existing[0] 611 191 612 - case http.MethodPut: 613 - pk := db.PublicKey{} 614 - if err := json.NewDecoder(r.Body).Decode(&pk); err != nil { 615 - writeError(w, "invalid request body", http.StatusBadRequest) 616 - return 192 + // no ownership change, this is okay 193 + if existingOwner == h.c.Server.Owner { 194 + break 617 195 } 618 196 619 - _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key)) 197 + // remove existing owner 198 + err = h.e.RemoveKnotOwner(rbacDomain, existingOwner) 620 199 if err != nil { 621 - writeError(w, "invalid pubkey", http.StatusBadRequest) 622 - } 623 - 624 - if err := h.db.AddPublicKey(pk); err != nil { 625 - writeError(w, err.Error(), http.StatusInternalServerError) 626 - l.Error("adding public key", "error", err.Error()) 627 - return 200 + return nil 628 201 } 629 - 630 - w.WriteHeader(http.StatusNoContent) 631 - return 202 + default: 203 + return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", h.c.Server.DBPath) 632 204 } 633 - } 634 205 635 - func (h *Handle) NewRepo(w http.ResponseWriter, r *http.Request) { 636 - l := h.l.With("handler", "NewRepo") 637 - 638 - data := struct { 639 - Did string `json:"did"` 640 - Name string `json:"name"` 641 - DefaultBranch string `json:"default_branch,omitempty"` 642 - }{} 643 - 644 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 645 - writeError(w, "invalid request body", http.StatusBadRequest) 646 - return 647 - } 648 - 649 - if data.DefaultBranch == "" { 650 - data.DefaultBranch = h.c.Repo.MainBranch 651 - } 652 - 653 - did := data.Did 654 - name := data.Name 655 - defaultBranch := data.DefaultBranch 656 - 657 - if err := validateRepoName(name); err != nil { 658 - l.Error("creating repo", "error", err.Error()) 659 - writeError(w, err.Error(), http.StatusBadRequest) 660 - return 661 - } 662 - 663 - relativeRepoPath := filepath.Join(did, name) 664 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 665 - err := git.InitBare(repoPath, defaultBranch) 666 - if err != nil { 667 - l.Error("initializing bare repo", "error", err.Error()) 668 - if errors.Is(err, gogit.ErrRepositoryAlreadyExists) { 669 - writeError(w, "That repo already exists!", http.StatusConflict) 670 - return 671 - } else { 672 - writeError(w, err.Error(), http.StatusInternalServerError) 673 - return 674 - } 675 - } 676 - 677 - // add perms for this user to access the repo 678 - err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 679 - if err != nil { 680 - l.Error("adding repo permissions", "error", err.Error()) 681 - writeError(w, err.Error(), http.StatusInternalServerError) 682 - return 683 - } 684 - 685 - hook.SetupRepo( 686 - hook.Config( 687 - hook.WithScanPath(h.c.Repo.ScanPath), 688 - hook.WithInternalApi(h.c.Server.InternalListenAddr), 689 - ), 690 - repoPath, 691 - ) 692 - 693 - w.WriteHeader(http.StatusNoContent) 694 - } 695 - 696 - func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) { 697 - l := h.l.With("handler", "RepoForkSync") 698 - 699 - data := struct { 700 - Did string `json:"did"` 701 - Source string `json:"source"` 702 - Name string `json:"name,omitempty"` 703 - HiddenRef string `json:"hiddenref"` 704 - }{} 705 - 706 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 707 - writeError(w, "invalid request body", http.StatusBadRequest) 708 - return 709 - } 710 - 711 - did := data.Did 712 - source := data.Source 713 - 714 - if did == "" || source == "" { 715 - l.Error("invalid request body, empty did or name") 716 - w.WriteHeader(http.StatusBadRequest) 717 - return 718 - } 719 - 720 - var name string 721 - if data.Name != "" { 722 - name = data.Name 723 - } else { 724 - name = filepath.Base(source) 725 - } 726 - 727 - branch := chi.URLParam(r, "branch") 728 - branch, _ = url.PathUnescape(branch) 729 - 730 - relativeRepoPath := filepath.Join(did, name) 731 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 732 - 733 - gr, err := git.PlainOpen(repoPath) 734 - if err != nil { 735 - log.Println(err) 736 - notFound(w) 737 - return 738 - } 739 - 740 - forkCommit, err := gr.ResolveRevision(branch) 741 - if err != nil { 742 - l.Error("error resolving ref revision", "msg", err.Error()) 743 - writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest) 744 - return 745 - } 746 - 747 - sourceCommit, err := gr.ResolveRevision(data.HiddenRef) 748 - if err != nil { 749 - l.Error("error resolving hidden ref revision", "msg", err.Error()) 750 - writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest) 751 - return 752 - } 753 - 754 - status := types.UpToDate 755 - if forkCommit.Hash.String() != sourceCommit.Hash.String() { 756 - isAncestor, err := forkCommit.IsAncestor(sourceCommit) 757 - if err != nil { 758 - log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err) 759 - return 760 - } 761 - 762 - if isAncestor { 763 - status = types.FastForwardable 764 - } else { 765 - status = types.Conflict 766 - } 767 - } 768 - 769 - w.Header().Set("Content-Type", "application/json") 770 - json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status}) 771 - } 772 - 773 - func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) { 774 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 775 - ref := chi.URLParam(r, "ref") 776 - ref, _ = url.PathUnescape(ref) 777 - 778 - l := h.l.With("handler", "RepoLanguages") 779 - 780 - gr, err := git.Open(repoPath, ref) 781 - if err != nil { 782 - l.Error("opening repo", "error", err.Error()) 783 - notFound(w) 784 - return 785 - } 786 - 787 - ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 788 - defer cancel() 789 - 790 - sizes, err := gr.AnalyzeLanguages(ctx) 791 - if err != nil { 792 - l.Error("failed to analyze languages", "error", err.Error()) 793 - writeError(w, err.Error(), http.StatusNoContent) 794 - return 795 - } 796 - 797 - resp := types.RepoLanguageResponse{Languages: sizes} 798 - 799 - writeJSON(w, resp) 800 - } 801 - 802 - func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) { 803 - l := h.l.With("handler", "RepoForkSync") 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 - branch := chi.URLParam(r, "branch") 833 - branch, _ = url.PathUnescape(branch) 834 - 835 - relativeRepoPath := filepath.Join(did, name) 836 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 837 - 838 - gr, err := git.PlainOpen(repoPath) 839 - if err != nil { 840 - log.Println(err) 841 - notFound(w) 842 - return 843 - } 844 - 845 - err = gr.Sync(branch) 846 - if err != nil { 847 - l.Error("error syncing repo fork", "error", err.Error()) 848 - writeError(w, err.Error(), http.StatusInternalServerError) 849 - return 850 - } 851 - 852 - w.WriteHeader(http.StatusNoContent) 853 - } 854 - 855 - func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) { 856 - l := h.l.With("handler", "RepoFork") 857 - 858 - data := struct { 859 - Did string `json:"did"` 860 - Source string `json:"source"` 861 - Name string `json:"name,omitempty"` 862 - }{} 863 - 864 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 865 - writeError(w, "invalid request body", http.StatusBadRequest) 866 - return 867 - } 868 - 869 - did := data.Did 870 - source := data.Source 871 - 872 - if did == "" || source == "" { 873 - l.Error("invalid request body, empty did or name") 874 - w.WriteHeader(http.StatusBadRequest) 875 - return 876 - } 877 - 878 - var name string 879 - if data.Name != "" { 880 - name = data.Name 881 - } else { 882 - name = filepath.Base(source) 883 - } 884 - 885 - relativeRepoPath := filepath.Join(did, name) 886 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 887 - 888 - err := git.Fork(repoPath, source) 889 - if err != nil { 890 - l.Error("forking repo", "error", err.Error()) 891 - writeError(w, err.Error(), http.StatusInternalServerError) 892 - return 893 - } 894 - 895 - // add perms for this user to access the repo 896 - err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 897 - if err != nil { 898 - l.Error("adding repo permissions", "error", err.Error()) 899 - writeError(w, err.Error(), http.StatusInternalServerError) 900 - return 901 - } 902 - 903 - hook.SetupRepo( 904 - hook.Config( 905 - hook.WithScanPath(h.c.Repo.ScanPath), 906 - hook.WithInternalApi(h.c.Server.InternalListenAddr), 907 - ), 908 - repoPath, 909 - ) 910 - 911 - w.WriteHeader(http.StatusNoContent) 912 - } 913 - 914 - func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) { 915 - l := h.l.With("handler", "RemoveRepo") 916 - 917 - data := struct { 918 - Did string `json:"did"` 919 - Name string `json:"name"` 920 - }{} 921 - 922 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 923 - writeError(w, "invalid request body", http.StatusBadRequest) 924 - return 925 - } 926 - 927 - did := data.Did 928 - name := data.Name 929 - 930 - if did == "" || name == "" { 931 - l.Error("invalid request body, empty did or name") 932 - w.WriteHeader(http.StatusBadRequest) 933 - return 934 - } 935 - 936 - relativeRepoPath := filepath.Join(did, name) 937 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 938 - err := os.RemoveAll(repoPath) 939 - if err != nil { 940 - l.Error("removing repo", "error", err.Error()) 941 - writeError(w, err.Error(), http.StatusInternalServerError) 942 - return 943 - } 944 - 945 - w.WriteHeader(http.StatusNoContent) 946 - 947 - } 948 - func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) { 949 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 950 - 951 - data := types.MergeRequest{} 952 - 953 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 954 - writeError(w, err.Error(), http.StatusBadRequest) 955 - h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err) 956 - return 957 - } 958 - 959 - mo := &git.MergeOptions{ 960 - AuthorName: data.AuthorName, 961 - AuthorEmail: data.AuthorEmail, 962 - CommitBody: data.CommitBody, 963 - CommitMessage: data.CommitMessage, 964 - } 965 - 966 - patch := data.Patch 967 - branch := data.Branch 968 - gr, err := git.Open(path, branch) 969 - if err != nil { 970 - notFound(w) 971 - return 972 - } 973 - 974 - mo.FormatPatch = patchutil.IsFormatPatch(patch) 975 - 976 - if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil { 977 - var mergeErr *git.ErrMerge 978 - if errors.As(err, &mergeErr) { 979 - conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 980 - for i, conflict := range mergeErr.Conflicts { 981 - conflicts[i] = types.ConflictInfo{ 982 - Filename: conflict.Filename, 983 - Reason: conflict.Reason, 984 - } 985 - } 986 - response := types.MergeCheckResponse{ 987 - IsConflicted: true, 988 - Conflicts: conflicts, 989 - Message: mergeErr.Message, 990 - } 991 - writeConflict(w, response) 992 - h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr) 993 - } else { 994 - writeError(w, err.Error(), http.StatusBadRequest) 995 - h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error()) 996 - } 997 - return 998 - } 999 - 1000 - w.WriteHeader(http.StatusOK) 1001 - } 1002 - 1003 - func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) { 1004 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1005 - 1006 - var data struct { 1007 - Patch string `json:"patch"` 1008 - Branch string `json:"branch"` 1009 - } 1010 - 1011 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1012 - writeError(w, err.Error(), http.StatusBadRequest) 1013 - h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err) 1014 - return 1015 - } 1016 - 1017 - patch := data.Patch 1018 - branch := data.Branch 1019 - gr, err := git.Open(path, branch) 1020 - if err != nil { 1021 - notFound(w) 1022 - return 1023 - } 1024 - 1025 - err = gr.MergeCheck([]byte(patch), branch) 1026 - if err == nil { 1027 - response := types.MergeCheckResponse{ 1028 - IsConflicted: false, 1029 - } 1030 - writeJSON(w, response) 1031 - return 1032 - } 1033 - 1034 - var mergeErr *git.ErrMerge 1035 - if errors.As(err, &mergeErr) { 1036 - conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 1037 - for i, conflict := range mergeErr.Conflicts { 1038 - conflicts[i] = types.ConflictInfo{ 1039 - Filename: conflict.Filename, 1040 - Reason: conflict.Reason, 1041 - } 1042 - } 1043 - response := types.MergeCheckResponse{ 1044 - IsConflicted: true, 1045 - Conflicts: conflicts, 1046 - Message: mergeErr.Message, 1047 - } 1048 - writeConflict(w, response) 1049 - h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error()) 1050 - return 1051 - } 1052 - writeError(w, err.Error(), http.StatusInternalServerError) 1053 - h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error()) 1054 - } 1055 - 1056 - func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) { 1057 - rev1 := chi.URLParam(r, "rev1") 1058 - rev1, _ = url.PathUnescape(rev1) 1059 - 1060 - rev2 := chi.URLParam(r, "rev2") 1061 - rev2, _ = url.PathUnescape(rev2) 1062 - 1063 - l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2) 1064 - 1065 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1066 - gr, err := git.PlainOpen(path) 1067 - if err != nil { 1068 - notFound(w) 1069 - return 1070 - } 1071 - 1072 - commit1, err := gr.ResolveRevision(rev1) 1073 - if err != nil { 1074 - l.Error("error resolving revision 1", "msg", err.Error()) 1075 - writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest) 1076 - return 1077 - } 1078 - 1079 - commit2, err := gr.ResolveRevision(rev2) 1080 - if err != nil { 1081 - l.Error("error resolving revision 2", "msg", err.Error()) 1082 - writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest) 1083 - return 1084 - } 1085 - 1086 - rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 1087 - if err != nil { 1088 - l.Error("error comparing revisions", "msg", err.Error()) 1089 - writeError(w, "error comparing revisions", http.StatusBadRequest) 1090 - return 1091 - } 1092 - 1093 - writeJSON(w, types.RepoFormatPatchResponse{ 1094 - Rev1: commit1.Hash.String(), 1095 - Rev2: commit2.Hash.String(), 1096 - FormatPatch: formatPatch, 1097 - Patch: rawPatch, 1098 - }) 1099 - return 1100 - } 1101 - 1102 - func (h *Handle) NewHiddenRef(w http.ResponseWriter, r *http.Request) { 1103 - l := h.l.With("handler", "NewHiddenRef") 1104 - 1105 - forkRef := chi.URLParam(r, "forkRef") 1106 - forkRef, _ = url.PathUnescape(forkRef) 1107 - 1108 - remoteRef := chi.URLParam(r, "remoteRef") 1109 - remoteRef, _ = url.PathUnescape(remoteRef) 1110 - 1111 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1112 - gr, err := git.PlainOpen(path) 1113 - if err != nil { 1114 - notFound(w) 1115 - return 1116 - } 1117 - 1118 - err = gr.TrackHiddenRemoteRef(forkRef, remoteRef) 1119 - if err != nil { 1120 - l.Error("error tracking hidden remote ref", "msg", err.Error()) 1121 - writeError(w, "error tracking hidden remote ref", http.StatusBadRequest) 1122 - return 1123 - } 1124 - 1125 - w.WriteHeader(http.StatusNoContent) 1126 - return 1127 - } 1128 - 1129 - func (h *Handle) AddMember(w http.ResponseWriter, r *http.Request) { 1130 - l := h.l.With("handler", "AddMember") 1131 - 1132 - data := struct { 1133 - Did string `json:"did"` 1134 - }{} 1135 - 1136 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1137 - writeError(w, "invalid request body", http.StatusBadRequest) 1138 - return 1139 - } 1140 - 1141 - did := data.Did 1142 - 1143 - if err := h.db.AddDid(did); err != nil { 1144 - l.Error("adding did", "error", err.Error()) 1145 - writeError(w, err.Error(), http.StatusInternalServerError) 1146 - return 1147 - } 1148 - h.jc.AddDid(did) 1149 - 1150 - if err := h.e.AddKnotMember(rbac.ThisServer, did); err != nil { 1151 - l.Error("adding member", "error", err.Error()) 1152 - writeError(w, err.Error(), http.StatusInternalServerError) 1153 - return 1154 - } 1155 - 1156 - if err := h.fetchAndAddKeys(r.Context(), did); err != nil { 1157 - l.Error("fetching and adding keys", "error", err.Error()) 1158 - writeError(w, err.Error(), http.StatusInternalServerError) 1159 - return 1160 - } 1161 - 1162 - w.WriteHeader(http.StatusNoContent) 1163 - } 1164 - 1165 - func (h *Handle) AddRepoCollaborator(w http.ResponseWriter, r *http.Request) { 1166 - l := h.l.With("handler", "AddRepoCollaborator") 1167 - 1168 - data := struct { 1169 - Did string `json:"did"` 1170 - }{} 1171 - 1172 - ownerDid := chi.URLParam(r, "did") 1173 - repo := chi.URLParam(r, "name") 1174 - 1175 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1176 - writeError(w, "invalid request body", http.StatusBadRequest) 1177 - return 1178 - } 1179 - 1180 - if err := h.db.AddDid(data.Did); err != nil { 1181 - l.Error("adding did", "error", err.Error()) 1182 - writeError(w, err.Error(), http.StatusInternalServerError) 1183 - return 1184 - } 1185 - h.jc.AddDid(data.Did) 1186 - 1187 - repoName, _ := securejoin.SecureJoin(ownerDid, repo) 1188 - if err := h.e.AddCollaborator(data.Did, rbac.ThisServer, repoName); err != nil { 1189 - l.Error("adding repo collaborator", "error", err.Error()) 1190 - writeError(w, err.Error(), http.StatusInternalServerError) 1191 - return 1192 - } 1193 - 1194 - if err := h.fetchAndAddKeys(r.Context(), data.Did); err != nil { 1195 - l.Error("fetching and adding keys", "error", err.Error()) 1196 - writeError(w, err.Error(), http.StatusInternalServerError) 1197 - return 1198 - } 1199 - 1200 - w.WriteHeader(http.StatusNoContent) 1201 - } 1202 - 1203 - func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) { 1204 - l := h.l.With("handler", "DefaultBranch") 1205 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1206 - 1207 - gr, err := git.Open(path, "") 1208 - if err != nil { 1209 - notFound(w) 1210 - return 1211 - } 1212 - 1213 - branch, err := gr.FindMainBranch() 1214 - if err != nil { 1215 - writeError(w, err.Error(), http.StatusInternalServerError) 1216 - l.Error("getting default branch", "error", err.Error()) 1217 - return 1218 - } 1219 - 1220 - writeJSON(w, types.RepoDefaultBranchResponse{ 1221 - Branch: branch, 1222 - }) 1223 - } 1224 - 1225 - func (h *Handle) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1226 - l := h.l.With("handler", "SetDefaultBranch") 1227 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1228 - 1229 - data := struct { 1230 - Branch string `json:"branch"` 1231 - }{} 1232 - 1233 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1234 - writeError(w, err.Error(), http.StatusBadRequest) 1235 - return 1236 - } 1237 - 1238 - gr, err := git.PlainOpen(path) 1239 - if err != nil { 1240 - notFound(w) 1241 - return 1242 - } 1243 - 1244 - err = gr.SetDefaultBranch(data.Branch) 1245 - if err != nil { 1246 - writeError(w, err.Error(), http.StatusInternalServerError) 1247 - l.Error("setting default branch", "error", err.Error()) 1248 - return 1249 - } 1250 - 1251 - w.WriteHeader(http.StatusNoContent) 1252 - } 1253 - 1254 - func (h *Handle) Init(w http.ResponseWriter, r *http.Request) { 1255 - l := h.l.With("handler", "Init") 1256 - 1257 - if h.knotInitialized { 1258 - writeError(w, "knot already initialized", http.StatusConflict) 1259 - return 1260 - } 1261 - 1262 - data := struct { 1263 - Did string `json:"did"` 1264 - }{} 1265 - 1266 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1267 - l.Error("failed to decode request body", "error", err.Error()) 1268 - writeError(w, "invalid request body", http.StatusBadRequest) 1269 - return 1270 - } 1271 - 1272 - if data.Did == "" { 1273 - l.Error("empty DID in request", "did", data.Did) 1274 - writeError(w, "did is empty", http.StatusBadRequest) 1275 - return 1276 - } 1277 - 1278 - if err := h.db.AddDid(data.Did); err != nil { 1279 - l.Error("failed to add DID", "error", err.Error()) 1280 - writeError(w, err.Error(), http.StatusInternalServerError) 1281 - return 1282 - } 1283 - h.jc.AddDid(data.Did) 1284 - 1285 - if err := h.e.AddKnotOwner(rbac.ThisServer, data.Did); err != nil { 1286 - l.Error("adding owner", "error", err.Error()) 1287 - writeError(w, err.Error(), http.StatusInternalServerError) 1288 - return 1289 - } 1290 - 1291 - if err := h.fetchAndAddKeys(r.Context(), data.Did); err != nil { 1292 - l.Error("fetching and adding keys", "error", err.Error()) 1293 - writeError(w, err.Error(), http.StatusInternalServerError) 1294 - return 1295 - } 1296 - 1297 - close(h.init) 1298 - 1299 - mac := hmac.New(sha256.New, []byte(h.c.Server.Secret)) 1300 - mac.Write([]byte("ok")) 1301 - w.Header().Add("X-Signature", hex.EncodeToString(mac.Sum(nil))) 1302 - 1303 - w.WriteHeader(http.StatusNoContent) 1304 - } 1305 - 1306 - func (h *Handle) Health(w http.ResponseWriter, r *http.Request) { 1307 - w.Write([]byte("ok")) 1308 - } 1309 - 1310 - func validateRepoName(name string) error { 1311 - // check for path traversal attempts 1312 - if name == "." || name == ".." || 1313 - strings.Contains(name, "/") || strings.Contains(name, "\\") { 1314 - return fmt.Errorf("Repository name contains invalid path characters") 1315 - } 1316 - 1317 - // check for sequences that could be used for traversal when normalized 1318 - if strings.Contains(name, "./") || strings.Contains(name, "../") || 1319 - strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { 1320 - return fmt.Errorf("Repository name contains invalid path sequence") 1321 - } 1322 - 1323 - // then continue with character validation 1324 - for _, char := range name { 1325 - if !((char >= 'a' && char <= 'z') || 1326 - (char >= 'A' && char <= 'Z') || 1327 - (char >= '0' && char <= '9') || 1328 - char == '-' || char == '_' || char == '.') { 1329 - return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") 1330 - } 1331 - } 1332 - 1333 - // additional check to prevent multiple sequential dots 1334 - if strings.Contains(name, "..") { 1335 - return fmt.Errorf("Repository name cannot contain sequential dots") 1336 - } 1337 - 1338 - // if all checks pass 1339 - return nil 206 + return h.e.AddKnotOwner(rbacDomain, cfgOwner) 1340 207 }
+1
knotserver/server.go
··· 76 76 tangled.PublicKeyNSID, 77 77 tangled.KnotMemberNSID, 78 78 tangled.RepoPullNSID, 79 + tangled.RepoCollaboratorNSID, 79 80 }, nil, logger, db, true, c.Server.LogDids) 80 81 if err != nil { 81 82 logger.Error("failed to setup jetstream", "error", err)
+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("InvalidRepo"), 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 - }
+14 -12
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 - actorDid, ok := r.Context().Value(ActorDid).(*syntax.DID) 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 86 - w.WriteHeader(http.StatusNoContent) 88 + w.WriteHeader(http.StatusOK) 87 89 }
+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 + }
-37
lexicons/addSecret.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.addSecret", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "description": "Add a CI secret", 8 - "input": { 9 - "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": [ 13 - "repo", 14 - "key", 15 - "value" 16 - ], 17 - "properties": { 18 - "repo": { 19 - "type": "string", 20 - "format": "at-uri" 21 - }, 22 - "key": { 23 - "type": "string", 24 - "maxLength": 50, 25 - "minLength": 1 26 - }, 27 - "value": { 28 - "type": "string", 29 - "maxLength": 200, 30 - "minLength": 1 31 - } 32 - } 33 - } 34 - } 35 - } 36 - } 37 - }
-52
lexicons/artifact.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.artifact", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "name", 14 - "repo", 15 - "tag", 16 - "createdAt", 17 - "artifact" 18 - ], 19 - "properties": { 20 - "name": { 21 - "type": "string", 22 - "description": "name of the artifact" 23 - }, 24 - "repo": { 25 - "type": "string", 26 - "format": "at-uri", 27 - "description": "repo that this artifact is being uploaded to" 28 - }, 29 - "tag": { 30 - "type": "bytes", 31 - "description": "hash of the tag object that this artifact is attached to (only annotated tags are supported)", 32 - "minLength": 20, 33 - "maxLength": 20 34 - }, 35 - "createdAt": { 36 - "type": "string", 37 - "format": "datetime", 38 - "description": "time of creation of this artifact" 39 - }, 40 - "artifact": { 41 - "type": "blob", 42 - "description": "the artifact", 43 - "accept": [ 44 - "*/*" 45 - ], 46 - "maxSize": 52428800 47 - } 48 - } 49 - } 50 - } 51 - } 52 - }
-29
lexicons/defaultBranch.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.setDefaultBranch", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "description": "Set the default branch for a repository", 8 - "input": { 9 - "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": [ 13 - "repo", 14 - "defaultBranch" 15 - ], 16 - "properties": { 17 - "repo": { 18 - "type": "string", 19 - "format": "at-uri" 20 - }, 21 - "defaultBranch": { 22 - "type": "string" 23 - } 24 - } 25 - } 26 - } 27 - } 28 - } 29 - }
+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 + }
-67
lexicons/listSecrets.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.listSecrets", 4 - "defs": { 5 - "main": { 6 - "type": "query", 7 - "parameters": { 8 - "type": "params", 9 - "required": [ 10 - "repo" 11 - ], 12 - "properties": { 13 - "repo": { 14 - "type": "string", 15 - "format": "at-uri" 16 - } 17 - } 18 - }, 19 - "output": { 20 - "encoding": "application/json", 21 - "schema": { 22 - "type": "object", 23 - "required": [ 24 - "secrets" 25 - ], 26 - "properties": { 27 - "secrets": { 28 - "type": "array", 29 - "items": { 30 - "type": "ref", 31 - "ref": "#secret" 32 - } 33 - } 34 - } 35 - } 36 - } 37 - }, 38 - "secret": { 39 - "type": "object", 40 - "required": [ 41 - "repo", 42 - "key", 43 - "createdAt", 44 - "createdBy" 45 - ], 46 - "properties": { 47 - "repo": { 48 - "type": "string", 49 - "format": "at-uri" 50 - }, 51 - "key": { 52 - "type": "string", 53 - "maxLength": 50, 54 - "minLength": 1 55 - }, 56 - "createdAt": { 57 - "type": "string", 58 - "format": "datetime" 59 - }, 60 - "createdBy": { 61 - "type": "string", 62 - "format": "did" 63 - } 64 - } 65 - } 66 - } 67 - }
+207
lexicons/pipeline/pipeline.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.pipeline", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "triggerMetadata", 14 + "workflows" 15 + ], 16 + "properties": { 17 + "triggerMetadata": { 18 + "type": "ref", 19 + "ref": "#triggerMetadata" 20 + }, 21 + "workflows": { 22 + "type": "array", 23 + "items": { 24 + "type": "ref", 25 + "ref": "#workflow" 26 + } 27 + } 28 + } 29 + } 30 + }, 31 + "triggerMetadata": { 32 + "type": "object", 33 + "required": [ 34 + "kind", 35 + "repo" 36 + ], 37 + "properties": { 38 + "kind": { 39 + "type": "string", 40 + "enum": [ 41 + "push", 42 + "pull_request", 43 + "manual" 44 + ] 45 + }, 46 + "repo": { 47 + "type": "ref", 48 + "ref": "#triggerRepo" 49 + }, 50 + "push": { 51 + "type": "ref", 52 + "ref": "#pushTriggerData" 53 + }, 54 + "pullRequest": { 55 + "type": "ref", 56 + "ref": "#pullRequestTriggerData" 57 + }, 58 + "manual": { 59 + "type": "ref", 60 + "ref": "#manualTriggerData" 61 + } 62 + } 63 + }, 64 + "triggerRepo": { 65 + "type": "object", 66 + "required": [ 67 + "knot", 68 + "did", 69 + "repo", 70 + "defaultBranch" 71 + ], 72 + "properties": { 73 + "knot": { 74 + "type": "string" 75 + }, 76 + "did": { 77 + "type": "string", 78 + "format": "did" 79 + }, 80 + "repo": { 81 + "type": "string" 82 + }, 83 + "defaultBranch": { 84 + "type": "string" 85 + } 86 + } 87 + }, 88 + "pushTriggerData": { 89 + "type": "object", 90 + "required": [ 91 + "ref", 92 + "newSha", 93 + "oldSha" 94 + ], 95 + "properties": { 96 + "ref": { 97 + "type": "string" 98 + }, 99 + "newSha": { 100 + "type": "string", 101 + "minLength": 40, 102 + "maxLength": 40 103 + }, 104 + "oldSha": { 105 + "type": "string", 106 + "minLength": 40, 107 + "maxLength": 40 108 + } 109 + } 110 + }, 111 + "pullRequestTriggerData": { 112 + "type": "object", 113 + "required": [ 114 + "sourceBranch", 115 + "targetBranch", 116 + "sourceSha", 117 + "action" 118 + ], 119 + "properties": { 120 + "sourceBranch": { 121 + "type": "string" 122 + }, 123 + "targetBranch": { 124 + "type": "string" 125 + }, 126 + "sourceSha": { 127 + "type": "string", 128 + "minLength": 40, 129 + "maxLength": 40 130 + }, 131 + "action": { 132 + "type": "string" 133 + } 134 + } 135 + }, 136 + "manualTriggerData": { 137 + "type": "object", 138 + "properties": { 139 + "inputs": { 140 + "type": "array", 141 + "items": { 142 + "type": "ref", 143 + "ref": "#pair" 144 + } 145 + } 146 + } 147 + }, 148 + "workflow": { 149 + "type": "object", 150 + "required": [ 151 + "name", 152 + "engine", 153 + "clone", 154 + "raw" 155 + ], 156 + "properties": { 157 + "name": { 158 + "type": "string" 159 + }, 160 + "engine": { 161 + "type": "string" 162 + }, 163 + "clone": { 164 + "type": "ref", 165 + "ref": "#cloneOpts" 166 + }, 167 + "raw": { 168 + "type": "string" 169 + } 170 + } 171 + }, 172 + "cloneOpts": { 173 + "type": "object", 174 + "required": [ 175 + "skip", 176 + "depth", 177 + "submodules" 178 + ], 179 + "properties": { 180 + "skip": { 181 + "type": "boolean" 182 + }, 183 + "depth": { 184 + "type": "integer" 185 + }, 186 + "submodules": { 187 + "type": "boolean" 188 + } 189 + } 190 + }, 191 + "pair": { 192 + "type": "object", 193 + "required": [ 194 + "key", 195 + "value" 196 + ], 197 + "properties": { 198 + "key": { 199 + "type": "string" 200 + }, 201 + "value": { 202 + "type": "string" 203 + } 204 + } 205 + } 206 + } 207 + }
-263
lexicons/pipeline.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.pipeline", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "triggerMetadata", 14 - "workflows" 15 - ], 16 - "properties": { 17 - "triggerMetadata": { 18 - "type": "ref", 19 - "ref": "#triggerMetadata" 20 - }, 21 - "workflows": { 22 - "type": "array", 23 - "items": { 24 - "type": "ref", 25 - "ref": "#workflow" 26 - } 27 - } 28 - } 29 - } 30 - }, 31 - "triggerMetadata": { 32 - "type": "object", 33 - "required": [ 34 - "kind", 35 - "repo" 36 - ], 37 - "properties": { 38 - "kind": { 39 - "type": "string", 40 - "enum": [ 41 - "push", 42 - "pull_request", 43 - "manual" 44 - ] 45 - }, 46 - "repo": { 47 - "type": "ref", 48 - "ref": "#triggerRepo" 49 - }, 50 - "push": { 51 - "type": "ref", 52 - "ref": "#pushTriggerData" 53 - }, 54 - "pullRequest": { 55 - "type": "ref", 56 - "ref": "#pullRequestTriggerData" 57 - }, 58 - "manual": { 59 - "type": "ref", 60 - "ref": "#manualTriggerData" 61 - } 62 - } 63 - }, 64 - "triggerRepo": { 65 - "type": "object", 66 - "required": [ 67 - "knot", 68 - "did", 69 - "repo", 70 - "defaultBranch" 71 - ], 72 - "properties": { 73 - "knot": { 74 - "type": "string" 75 - }, 76 - "did": { 77 - "type": "string", 78 - "format": "did" 79 - }, 80 - "repo": { 81 - "type": "string" 82 - }, 83 - "defaultBranch": { 84 - "type": "string" 85 - } 86 - } 87 - }, 88 - "pushTriggerData": { 89 - "type": "object", 90 - "required": [ 91 - "ref", 92 - "newSha", 93 - "oldSha" 94 - ], 95 - "properties": { 96 - "ref": { 97 - "type": "string" 98 - }, 99 - "newSha": { 100 - "type": "string", 101 - "minLength": 40, 102 - "maxLength": 40 103 - }, 104 - "oldSha": { 105 - "type": "string", 106 - "minLength": 40, 107 - "maxLength": 40 108 - } 109 - } 110 - }, 111 - "pullRequestTriggerData": { 112 - "type": "object", 113 - "required": [ 114 - "sourceBranch", 115 - "targetBranch", 116 - "sourceSha", 117 - "action" 118 - ], 119 - "properties": { 120 - "sourceBranch": { 121 - "type": "string" 122 - }, 123 - "targetBranch": { 124 - "type": "string" 125 - }, 126 - "sourceSha": { 127 - "type": "string", 128 - "minLength": 40, 129 - "maxLength": 40 130 - }, 131 - "action": { 132 - "type": "string" 133 - } 134 - } 135 - }, 136 - "manualTriggerData": { 137 - "type": "object", 138 - "properties": { 139 - "inputs": { 140 - "type": "array", 141 - "items": { 142 - "type": "ref", 143 - "ref": "#pair" 144 - } 145 - } 146 - } 147 - }, 148 - "workflow": { 149 - "type": "object", 150 - "required": [ 151 - "name", 152 - "dependencies", 153 - "steps", 154 - "environment", 155 - "clone" 156 - ], 157 - "properties": { 158 - "name": { 159 - "type": "string" 160 - }, 161 - "dependencies": { 162 - "type": "array", 163 - "items": { 164 - "type": "ref", 165 - "ref": "#dependency" 166 - } 167 - }, 168 - "steps": { 169 - "type": "array", 170 - "items": { 171 - "type": "ref", 172 - "ref": "#step" 173 - } 174 - }, 175 - "environment": { 176 - "type": "array", 177 - "items": { 178 - "type": "ref", 179 - "ref": "#pair" 180 - } 181 - }, 182 - "clone": { 183 - "type": "ref", 184 - "ref": "#cloneOpts" 185 - } 186 - } 187 - }, 188 - "dependency": { 189 - "type": "object", 190 - "required": [ 191 - "registry", 192 - "packages" 193 - ], 194 - "properties": { 195 - "registry": { 196 - "type": "string" 197 - }, 198 - "packages": { 199 - "type": "array", 200 - "items": { 201 - "type": "string" 202 - } 203 - } 204 - } 205 - }, 206 - "cloneOpts": { 207 - "type": "object", 208 - "required": [ 209 - "skip", 210 - "depth", 211 - "submodules" 212 - ], 213 - "properties": { 214 - "skip": { 215 - "type": "boolean" 216 - }, 217 - "depth": { 218 - "type": "integer" 219 - }, 220 - "submodules": { 221 - "type": "boolean" 222 - } 223 - } 224 - }, 225 - "step": { 226 - "type": "object", 227 - "required": [ 228 - "name", 229 - "command" 230 - ], 231 - "properties": { 232 - "name": { 233 - "type": "string" 234 - }, 235 - "command": { 236 - "type": "string" 237 - }, 238 - "environment": { 239 - "type": "array", 240 - "items": { 241 - "type": "ref", 242 - "ref": "#pair" 243 - } 244 - } 245 - } 246 - }, 247 - "pair": { 248 - "type": "object", 249 - "required": [ 250 - "key", 251 - "value" 252 - ], 253 - "properties": { 254 - "key": { 255 - "type": "string" 256 - }, 257 - "value": { 258 - "type": "string" 259 - } 260 - } 261 - } 262 - } 263 - }
-31
lexicons/removeSecret.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.removeSecret", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "description": "Remove a CI secret", 8 - "input": { 9 - "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": [ 13 - "repo", 14 - "key" 15 - ], 16 - "properties": { 17 - "repo": { 18 - "type": "string", 19 - "format": "at-uri" 20 - }, 21 - "key": { 22 - "type": "string", 23 - "maxLength": 50, 24 - "minLength": 1 25 - } 26 - } 27 - } 28 - } 29 - } 30 - } 31 - }
+37
lexicons/repo/addSecret.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.addSecret", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Add a CI secret", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "key", 15 + "value" 16 + ], 17 + "properties": { 18 + "repo": { 19 + "type": "string", 20 + "format": "at-uri" 21 + }, 22 + "key": { 23 + "type": "string", 24 + "maxLength": 50, 25 + "minLength": 1 26 + }, 27 + "value": { 28 + "type": "string", 29 + "maxLength": 200, 30 + "minLength": 1 31 + } 32 + } 33 + } 34 + } 35 + } 36 + } 37 + }
+52
lexicons/repo/artifact.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.artifact", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "name", 14 + "repo", 15 + "tag", 16 + "createdAt", 17 + "artifact" 18 + ], 19 + "properties": { 20 + "name": { 21 + "type": "string", 22 + "description": "name of the artifact" 23 + }, 24 + "repo": { 25 + "type": "string", 26 + "format": "at-uri", 27 + "description": "repo that this artifact is being uploaded to" 28 + }, 29 + "tag": { 30 + "type": "bytes", 31 + "description": "hash of the tag object that this artifact is attached to (only annotated tags are supported)", 32 + "minLength": 20, 33 + "maxLength": 20 34 + }, 35 + "createdAt": { 36 + "type": "string", 37 + "format": "datetime", 38 + "description": "time of creation of this artifact" 39 + }, 40 + "artifact": { 41 + "type": "blob", 42 + "description": "the artifact", 43 + "accept": [ 44 + "*/*" 45 + ], 46 + "maxSize": 52428800 47 + } 48 + } 49 + } 50 + } 51 + } 52 + }
+36
lexicons/repo/collaborator.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.collaborator", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "subject", 14 + "repo", 15 + "createdAt" 16 + ], 17 + "properties": { 18 + "subject": { 19 + "type": "string", 20 + "format": "did" 21 + }, 22 + "repo": { 23 + "type": "string", 24 + "description": "repo to add this user to", 25 + "format": "at-uri" 26 + }, 27 + "createdAt": { 28 + "type": "string", 29 + "format": "datetime" 30 + } 31 + } 32 + } 33 + } 34 + } 35 + } 36 +
+33
lexicons/repo/create.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.create", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create a new repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "rkey" 14 + ], 15 + "properties": { 16 + "rkey": { 17 + "type": "string", 18 + "description": "Rkey of the repository record" 19 + }, 20 + "defaultBranch": { 21 + "type": "string", 22 + "description": "Default branch to push to" 23 + }, 24 + "source": { 25 + "type": "string", 26 + "description": "A source URL to clone from, populate this when forking or importing a repository." 27 + } 28 + } 29 + } 30 + } 31 + } 32 + } 33 + }
+29
lexicons/repo/defaultBranch.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.setDefaultBranch", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Set the default branch for a repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "defaultBranch" 15 + ], 16 + "properties": { 17 + "repo": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "defaultBranch": { 22 + "type": "string" 23 + } 24 + } 25 + } 26 + } 27 + } 28 + } 29 + }
+32
lexicons/repo/delete.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.delete", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Delete a repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["did", "name", "rkey"], 13 + "properties": { 14 + "did": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the repository owner" 18 + }, 19 + "name": { 20 + "type": "string", 21 + "description": "Name of the repository to delete" 22 + }, 23 + "rkey": { 24 + "type": "string", 25 + "description": "Rkey of the repository record" 26 + } 27 + } 28 + } 29 + } 30 + } 31 + } 32 + }
+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 + }
+67
lexicons/repo/listSecrets.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.listSecrets", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": [ 10 + "repo" 11 + ], 12 + "properties": { 13 + "repo": { 14 + "type": "string", 15 + "format": "at-uri" 16 + } 17 + } 18 + }, 19 + "output": { 20 + "encoding": "application/json", 21 + "schema": { 22 + "type": "object", 23 + "required": [ 24 + "secrets" 25 + ], 26 + "properties": { 27 + "secrets": { 28 + "type": "array", 29 + "items": { 30 + "type": "ref", 31 + "ref": "#secret" 32 + } 33 + } 34 + } 35 + } 36 + } 37 + }, 38 + "secret": { 39 + "type": "object", 40 + "required": [ 41 + "repo", 42 + "key", 43 + "createdAt", 44 + "createdBy" 45 + ], 46 + "properties": { 47 + "repo": { 48 + "type": "string", 49 + "format": "at-uri" 50 + }, 51 + "key": { 52 + "type": "string", 53 + "maxLength": 50, 54 + "minLength": 1 55 + }, 56 + "createdAt": { 57 + "type": "string", 58 + "format": "datetime" 59 + }, 60 + "createdBy": { 61 + "type": "string", 62 + "format": "did" 63 + } 64 + } 65 + } 66 + } 67 + }
+52
lexicons/repo/merge.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.merge", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Merge a patch into a repository branch", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["did", "name", "patch", "branch"], 13 + "properties": { 14 + "did": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the repository owner" 18 + }, 19 + "name": { 20 + "type": "string", 21 + "description": "Name of the repository" 22 + }, 23 + "patch": { 24 + "type": "string", 25 + "description": "Patch content to merge" 26 + }, 27 + "branch": { 28 + "type": "string", 29 + "description": "Target branch to merge into" 30 + }, 31 + "authorName": { 32 + "type": "string", 33 + "description": "Author name for the merge commit" 34 + }, 35 + "authorEmail": { 36 + "type": "string", 37 + "description": "Author email for the merge commit" 38 + }, 39 + "commitBody": { 40 + "type": "string", 41 + "description": "Additional commit message body" 42 + }, 43 + "commitMessage": { 44 + "type": "string", 45 + "description": "Merge commit message" 46 + } 47 + } 48 + } 49 + } 50 + } 51 + } 52 + }
+79
lexicons/repo/mergeCheck.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.mergeCheck", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Check if a merge is possible between two branches", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["did", "name", "patch", "branch"], 13 + "properties": { 14 + "did": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the repository owner" 18 + }, 19 + "name": { 20 + "type": "string", 21 + "description": "Name of the repository" 22 + }, 23 + "patch": { 24 + "type": "string", 25 + "description": "Patch or pull request to check for merge conflicts" 26 + }, 27 + "branch": { 28 + "type": "string", 29 + "description": "Target branch to merge into" 30 + } 31 + } 32 + } 33 + }, 34 + "output": { 35 + "encoding": "application/json", 36 + "schema": { 37 + "type": "object", 38 + "required": ["is_conflicted"], 39 + "properties": { 40 + "is_conflicted": { 41 + "type": "boolean", 42 + "description": "Whether the merge has conflicts" 43 + }, 44 + "conflicts": { 45 + "type": "array", 46 + "description": "List of files with merge conflicts", 47 + "items": { 48 + "type": "ref", 49 + "ref": "#conflictInfo" 50 + } 51 + }, 52 + "message": { 53 + "type": "string", 54 + "description": "Additional message about the merge check" 55 + }, 56 + "error": { 57 + "type": "string", 58 + "description": "Error message if check failed" 59 + } 60 + } 61 + } 62 + } 63 + }, 64 + "conflictInfo": { 65 + "type": "object", 66 + "required": ["filename", "reason"], 67 + "properties": { 68 + "filename": { 69 + "type": "string", 70 + "description": "Name of the conflicted file" 71 + }, 72 + "reason": { 73 + "type": "string", 74 + "description": "Reason for the conflict" 75 + } 76 + } 77 + } 78 + } 79 + }
+31
lexicons/repo/removeSecret.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.removeSecret", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Remove a CI secret", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "key" 15 + ], 16 + "properties": { 17 + "repo": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "key": { 22 + "type": "string", 23 + "maxLength": 50, 24 + "minLength": 1 25 + } 26 + } 27 + } 28 + } 29 + } 30 + } 31 + }
+54
lexicons/repo/repo.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "name", 14 + "knot", 15 + "owner", 16 + "createdAt" 17 + ], 18 + "properties": { 19 + "name": { 20 + "type": "string", 21 + "description": "name of the repo" 22 + }, 23 + "owner": { 24 + "type": "string", 25 + "format": "did" 26 + }, 27 + "knot": { 28 + "type": "string", 29 + "description": "knot where the repo was created" 30 + }, 31 + "spindle": { 32 + "type": "string", 33 + "description": "CI runner to send jobs to and receive results from" 34 + }, 35 + "description": { 36 + "type": "string", 37 + "format": "datetime", 38 + "minGraphemes": 1, 39 + "maxGraphemes": 140 40 + }, 41 + "source": { 42 + "type": "string", 43 + "format": "uri", 44 + "description": "source of the repo" 45 + }, 46 + "createdAt": { 47 + "type": "string", 48 + "format": "datetime" 49 + } 50 + } 51 + } 52 + } 53 + } 54 + }
-54
lexicons/repo.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "name", 14 - "knot", 15 - "owner", 16 - "createdAt" 17 - ], 18 - "properties": { 19 - "name": { 20 - "type": "string", 21 - "description": "name of the repo" 22 - }, 23 - "owner": { 24 - "type": "string", 25 - "format": "did" 26 - }, 27 - "knot": { 28 - "type": "string", 29 - "description": "knot where the repo was created" 30 - }, 31 - "spindle": { 32 - "type": "string", 33 - "description": "CI runner to send jobs to and receive results from" 34 - }, 35 - "description": { 36 - "type": "string", 37 - "format": "datetime", 38 - "minGraphemes": 1, 39 - "maxGraphemes": 140 40 - }, 41 - "source": { 42 - "type": "string", 43 - "format": "uri", 44 - "description": "source of the repo" 45 - }, 46 - "createdAt": { 47 - "type": "string", 48 - "format": "datetime" 49 - } 50 - } 51 - } 52 - } 53 - } 54 - }
+25
lexicons/spindle/spindle.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.spindle", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "any", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "createdAt" 14 + ], 15 + "properties": { 16 + "createdAt": { 17 + "type": "string", 18 + "format": "datetime" 19 + } 20 + } 21 + } 22 + } 23 + } 24 + } 25 +
-25
lexicons/spindle.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.spindle", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "any", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "createdAt" 14 - ], 15 - "properties": { 16 - "createdAt": { 17 - "type": "string", 18 - "format": "datetime" 19 - } 20 - } 21 - } 22 - } 23 - } 24 - } 25 -
+40
lexicons/string/string.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.string", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "filename", 14 + "description", 15 + "createdAt", 16 + "contents" 17 + ], 18 + "properties": { 19 + "filename": { 20 + "type": "string", 21 + "maxGraphemes": 140, 22 + "minGraphemes": 1 23 + }, 24 + "description": { 25 + "type": "string", 26 + "maxGraphemes": 280 27 + }, 28 + "createdAt": { 29 + "type": "string", 30 + "format": "datetime" 31 + }, 32 + "contents": { 33 + "type": "string", 34 + "minGraphemes": 1 35 + } 36 + } 37 + } 38 + } 39 + } 40 + }
+3 -1
log/log.go
··· 9 9 // NewHandler sets up a new slog.Handler with the service name 10 10 // as an attribute 11 11 func NewHandler(name string) slog.Handler { 12 - handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{}) 12 + handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ 13 + Level: slog.LevelDebug, 14 + }) 13 15 14 16 var attrs []slog.Attr 15 17 attrs = append(attrs, slog.Attr{Key: "service", Value: slog.StringValue(name)})
+133 -61
nix/gomod2nix.toml
··· 11 11 version = "v0.6.2" 12 12 hash = "sha256-tVNWDUMILZbJvarcl/E7tpSnkn7urqgSHa2Eaka5vSU=" 13 13 [mod."github.com/ProtonMail/go-crypto"] 14 - version = "v1.2.0" 15 - hash = "sha256-5fKgWUz6BoyFNNZ1OD9QjhBrhNEBCuVfO2WqH+X59oo=" 14 + version = "v1.3.0" 15 + hash = "sha256-TUG+C4MyeWglOmiwiW2/NUVurFHXLgEPRd3X9uQ1NGI=" 16 + [mod."github.com/alecthomas/assert/v2"] 17 + version = "v2.11.0" 18 + hash = "sha256-tDJCDKZ0R4qNA7hgMKWrpDyogt1802LCJDBCExxdqaU=" 16 19 [mod."github.com/alecthomas/chroma/v2"] 17 20 version = "v2.19.0" 18 21 hash = "sha256-dxsu43a+PvHg2jYR0Tfys6a8x6IVR+9oCGAh+fvL3SM=" 19 22 replaced = "github.com/oppiliappan/chroma/v2" 23 + [mod."github.com/alecthomas/repr"] 24 + version = "v0.4.0" 25 + hash = "sha256-CyAzMSTfLGHDtfGXi91y7XMVpPUDNOKjsznb+osl9dU=" 20 26 [mod."github.com/anmitsu/go-shlex"] 21 27 version = "v0.0.0-20200514113438-38f4b401e2be" 22 28 hash = "sha256-L3Ak4X2z7WXq7vMKuiHCOJ29nlpajUQ08Sfb9T0yP54=" ··· 34 40 hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" 35 41 replaced = "tangled.sh/oppi.li/go-gitdiff" 36 42 [mod."github.com/bluesky-social/indigo"] 37 - version = "v0.0.0-20250520232546-236dd575c91e" 38 - hash = "sha256-SmwhGkAKcB/oGwYP68U5192fAUhui6D0GWYiJOeB1/0=" 43 + version = "v0.0.0-20250724221105-5827c8fb61bb" 44 + hash = "sha256-uDYmzP4/mT7xP62LIL4QIOlkaKWS/IT1uho+udyVOAI=" 39 45 [mod."github.com/bluesky-social/jetstream"] 40 46 version = "v0.0.0-20241210005130-ea96859b93d1" 41 47 hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" ··· 51 57 [mod."github.com/casbin/govaluate"] 52 58 version = "v1.3.0" 53 59 hash = "sha256-vDUFEGt8oL4n/PHwlMZPjmaLvcpGTN4HEIRGl2FPxUA=" 60 + [mod."github.com/cenkalti/backoff/v4"] 61 + version = "v4.3.0" 62 + hash = "sha256-wfVjNZsGG1WoNC5aL+kdcy6QXPgZo4THAevZ1787md8=" 54 63 [mod."github.com/cespare/xxhash/v2"] 55 64 version = "v2.3.0" 56 65 hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY=" 57 66 [mod."github.com/cloudflare/circl"] 58 - version = "v1.6.0" 59 - hash = "sha256-a+SVfnHYC8Fb+NQLboNg5P9sry+WutzuNetVHFVAAo0=" 67 + version = "v1.6.2-0.20250618153321-aa837fd1539d" 68 + hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y=" 69 + [mod."github.com/cloudflare/cloudflare-go"] 70 + version = "v0.115.0" 71 + hash = "sha256-jezmDs6IsHA4rag7DzcHDfDgde0vU4iKgCN9+0XDViw=" 60 72 [mod."github.com/containerd/errdefs"] 61 73 version = "v1.0.0" 62 74 hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI=" ··· 105 117 [mod."github.com/felixge/httpsnoop"] 106 118 version = "v1.0.4" 107 119 hash = "sha256-c1JKoRSndwwOyOxq9ddCe+8qn7mG9uRq2o/822x5O/c=" 120 + [mod."github.com/fsnotify/fsnotify"] 121 + version = "v1.6.0" 122 + hash = "sha256-DQesOCweQPEwmAn6s7DCP/Dwy8IypC+osbpfsvpkdP0=" 108 123 [mod."github.com/gliderlabs/ssh"] 109 124 version = "v0.3.8" 110 125 hash = "sha256-FW+91qCB3rfTm0I1VmqfwA7o+2kDys2JHOudKKyxWwc=" ··· 127 142 version = "v5.17.0" 128 143 hash = "sha256-gya68abB6GtejUqr60DyU7NIGtNzHQVCAeDTYKk1evQ=" 129 144 replaced = "github.com/oppiliappan/go-git/v5" 145 + [mod."github.com/go-jose/go-jose/v3"] 146 + version = "v3.0.4" 147 + hash = "sha256-RrLHCu9l6k0XVobdZQJ9Sx/VTQcWjrdLR5BEG7yXTEQ=" 130 148 [mod."github.com/go-logr/logr"] 131 - version = "v1.4.2" 132 - hash = "sha256-/W6qGilFlZNTb9Uq48xGZ4IbsVeSwJiAMLw4wiNYHLI=" 149 + version = "v1.4.3" 150 + hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA=" 133 151 [mod."github.com/go-logr/stdr"] 134 152 version = "v1.2.2" 135 153 hash = "sha256-rRweAP7XIb4egtT1f2gkz4sYOu7LDHmcJ5iNsJUd0sE=" 136 154 [mod."github.com/go-redis/cache/v9"] 137 155 version = "v9.0.0" 138 156 hash = "sha256-b4S3K4KoZhF0otw6FRIOq/PTdHGrb/LumB4GKo4khsY=" 157 + [mod."github.com/go-test/deep"] 158 + version = "v1.1.1" 159 + hash = "sha256-WvPrTvUPmbQb4R6DrvSB9O3zm0IOk+n14YpnSl2deR8=" 139 160 [mod."github.com/goccy/go-json"] 140 161 version = "v0.10.5" 141 162 hash = "sha256-/EtlGihP0/7oInzMC5E0InZ4b5Ad3s4xOpqotloi3xw=" ··· 143 164 version = "v1.3.2" 144 165 hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c=" 145 166 [mod."github.com/golang-jwt/jwt/v5"] 146 - version = "v5.2.2" 147 - hash = "sha256-C0MhDguxWR6dQUrNVQ5xaFUReSV6CVEBAijG3b4wnX4=" 167 + version = "v5.2.3" 168 + hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo=" 148 169 [mod."github.com/golang/groupcache"] 149 170 version = "v0.0.0-20241129210726-2c02b8208cf8" 150 171 hash = "sha256-AdLZ3dJLe/yduoNvZiXugZxNfmwJjNQyQGsIdzYzH74=" 172 + [mod."github.com/golang/mock"] 173 + version = "v1.6.0" 174 + hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno=" 175 + [mod."github.com/google/go-querystring"] 176 + version = "v1.1.0" 177 + hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY=" 151 178 [mod."github.com/google/uuid"] 152 179 version = "v1.6.0" 153 180 hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw=" 154 181 [mod."github.com/gorilla/css"] 155 182 version = "v1.0.1" 156 183 hash = "sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A=" 184 + [mod."github.com/gorilla/feeds"] 185 + version = "v1.2.0" 186 + hash = "sha256-ptczizo27t6Bsq6rHJ4WiHmBRP54UC5yNfHghAqOBQk=" 157 187 [mod."github.com/gorilla/securecookie"] 158 188 version = "v1.1.2" 159 189 hash = "sha256-KeMHNM9emxX+N0WYiZsTii7n8sNsmjWwbnQ9SaJfTKE=" ··· 161 191 version = "v1.4.0" 162 192 hash = "sha256-cLK2z1uOEz7Wah/LclF65ptYMqzuvaRnfIGYqtn3b7g=" 163 193 [mod."github.com/gorilla/websocket"] 164 - version = "v1.5.3" 165 - hash = "sha256-vTIGEFMEi+30ZdO6ffMNJ/kId6pZs5bbyqov8xe9BM0=" 194 + version = "v1.5.4-0.20250319132907-e064f32e3674" 195 + hash = "sha256-a8n6oe20JDpwThClgAyVhJDi6QVaS0qzT4PvRxlQ9to=" 196 + [mod."github.com/hashicorp/errwrap"] 197 + version = "v1.1.0" 198 + hash = "sha256-6lwuMQOfBq+McrViN3maJTIeh4f8jbEqvLy2c9FvvFw=" 166 199 [mod."github.com/hashicorp/go-cleanhttp"] 167 200 version = "v0.5.2" 168 201 hash = "sha256-N9GOKYo7tK6XQUFhvhImtL7PZW/mr4C4Manx/yPVvcQ=" 202 + [mod."github.com/hashicorp/go-multierror"] 203 + version = "v1.1.1" 204 + hash = "sha256-ANzPEUJIZIlToxR89Mn7Db73d9LGI51ssy7eNnUgmlA=" 169 205 [mod."github.com/hashicorp/go-retryablehttp"] 170 - version = "v0.7.7" 171 - hash = "sha256-XZjxncyLPwy6YBHR3DF5bEl1y72or0JDUncTIsb/eIU=" 206 + version = "v0.7.8" 207 + hash = "sha256-4LZwKaFBbpKi9lSq5y6lOlYHU6WMnQdGNMxTd33rN80=" 208 + [mod."github.com/hashicorp/go-secure-stdlib/parseutil"] 209 + version = "v0.2.0" 210 + hash = "sha256-mb27ZKw5VDTmNj1QJvxHVR0GyY7UdacLJ0jWDV3nQd8=" 211 + [mod."github.com/hashicorp/go-secure-stdlib/strutil"] 212 + version = "v0.1.2" 213 + hash = "sha256-UmCMzjamCW1d9KNvNzELqKf1ElHOXPz+ZtdJkI+DV0A=" 214 + [mod."github.com/hashicorp/go-sockaddr"] 215 + version = "v1.0.7" 216 + hash = "sha256-p6eDOrGzN1jMmT/F/f/VJMq0cKNFhUcEuVVwTE6vSrs=" 172 217 [mod."github.com/hashicorp/golang-lru"] 173 218 version = "v1.0.2" 174 219 hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48=" 175 220 [mod."github.com/hashicorp/golang-lru/v2"] 176 221 version = "v2.0.7" 177 222 hash = "sha256-t1bcXLgrQNOYUVyYEZ0knxcXpsTk4IuJZDjKvyJX75g=" 223 + [mod."github.com/hashicorp/hcl"] 224 + version = "v1.0.1-vault-7" 225 + hash = "sha256-xqYtjCJQVsg04Yj2Uy2Q5bi6X6cDRYhJD/SUEWaHMDM=" 226 + [mod."github.com/hexops/gotextdiff"] 227 + version = "v1.0.3" 228 + hash = "sha256-wVs5uJs2KHU1HnDCDdSe0vIgNZylvs8oNidDxwA3+O0=" 178 229 [mod."github.com/hiddeco/sshsig"] 179 230 version = "v0.2.0" 180 231 hash = "sha256-Yc8Ip4XxrL5plb7Lq0ziYFznteVDZnskoyOZDIMsWOU=" ··· 185 236 version = "v0.0.4" 186 237 hash = "sha256-4k778kBlNul2Rc4xuNQ9WA4kT0V7x5X9odZrT+2xjTU=" 187 238 [mod."github.com/ipfs/boxo"] 188 - version = "v0.30.0" 189 - hash = "sha256-PWH+nlIZZlqB/PuiBX9X4McLZF4gKR1MEnjvutKT848=" 239 + version = "v0.33.0" 240 + hash = "sha256-C85D/TjyoWNKvRFvl2A9hBjpDPhC//hGB2jRoDmXM38=" 190 241 [mod."github.com/ipfs/go-block-format"] 191 - version = "v0.2.1" 192 - hash = "sha256-npEV0Axe6zJlzN00/GwiegE9HKsuDR6RhsAfPyphOl8=" 242 + version = "v0.2.2" 243 + hash = "sha256-kz87tlGneqEARGnjA1Lb0K5CqD1lgxhBD68rfmzZZGU=" 193 244 [mod."github.com/ipfs/go-cid"] 194 245 version = "v0.5.0" 195 246 hash = "sha256-BuZKkcBXrnx7mM1c9SP4LdzZoaAoai9U49vITGrYJQk=" ··· 203 254 version = "v1.1.1" 204 255 hash = "sha256-cpEohOsf4afYRGTdsWh84TCVGIDzJo2hSjWy7NtNtvY=" 205 256 [mod."github.com/ipfs/go-ipld-cbor"] 206 - version = "v0.2.0" 207 - hash = "sha256-bvHFCIQqim3/+xzl1bld3NxKY8WoeCO3HpdTfUsXvlc=" 257 + version = "v0.2.1" 258 + hash = "sha256-ONBX/YO/knnmp+12fC13KsKVeo/vdWOI3SDyqCBxRE4=" 208 259 [mod."github.com/ipfs/go-ipld-format"] 209 - version = "v0.6.1" 210 - hash = "sha256-v1zLYYGaoDxsgOW5joQGWHEHZoJjIXc6tLVgTomZ2z4=" 260 + version = "v0.6.2" 261 + hash = "sha256-nGLM/n/hy+0q1VIzQUvF4D3aKvL238ALIEziQQhVMkU=" 211 262 [mod."github.com/ipfs/go-log"] 212 263 version = "v1.0.5" 213 264 hash = "sha256-WQarHZo2y/rH6ixLsOlN5fFZeLUqsOTMnvdxszP2Qj4=" ··· 224 275 version = "v1.18.0" 225 276 hash = "sha256-jc5pMU/HCBFOShMcngVwNMhz9wolxjOb579868LtOuk=" 226 277 [mod."github.com/klauspost/cpuid/v2"] 227 - version = "v2.2.10" 228 - hash = "sha256-o21Tk5sD7WhhLUoqSkymnjLbzxl0mDJCTC1ApfZJrC0=" 278 + version = "v2.3.0" 279 + hash = "sha256-50JhbQyT67BK38HIdJihPtjV7orYp96HknI2VP7A9Yc=" 229 280 [mod."github.com/lestrrat-go/blackmagic"] 230 - version = "v1.0.3" 231 - hash = "sha256-1wyfD6fPopJF/UmzfAEa0N1zuUzVuHIpdcxks1kqxxw=" 281 + version = "v1.0.4" 282 + hash = "sha256-HmWOpwoPDNMwLdOi7onNn3Sb+ZsAa3Ai3gVBbXmQ0e8=" 232 283 [mod."github.com/lestrrat-go/httpcc"] 233 284 version = "v1.0.1" 234 285 hash = "sha256-SMRSwJpqDIs/xL0l2e8vP0W65qtCHX2wigcOeqPJmos=" ··· 256 307 [mod."github.com/minio/sha256-simd"] 257 308 version = "v1.0.1" 258 309 hash = "sha256-4hfGDIQaWq8fvtGzHDhoK9v2IocXnJY7OAL6saMJbmA=" 310 + [mod."github.com/mitchellh/mapstructure"] 311 + version = "v1.5.0" 312 + hash = "sha256-ztVhGQXs67MF8UadVvG72G3ly0ypQW0IRDdOOkjYwoE=" 259 313 [mod."github.com/moby/docker-image-spec"] 260 314 version = "v1.3.1" 261 315 hash = "sha256-xwSNLmMagzywdGJIuhrWl1r7cIWBYCOMNYbuDDT6Jhs=" ··· 289 343 [mod."github.com/munnerz/goautoneg"] 290 344 version = "v0.0.0-20191010083416-a7dc8b61c822" 291 345 hash = "sha256-79URDDFenmGc9JZu+5AXHToMrtTREHb3BC84b/gym9Q=" 346 + [mod."github.com/onsi/gomega"] 347 + version = "v1.37.0" 348 + hash = "sha256-PfHFYp365MwBo+CUZs+mN5QEk3Kqe9xrBX+twWfIc9o=" 349 + [mod."github.com/openbao/openbao/api/v2"] 350 + version = "v2.3.0" 351 + hash = "sha256-1bIyvL3GdzPUfsM+gxuKMaH5jKxMaucZQgL6/DfbmDM=" 292 352 [mod."github.com/opencontainers/go-digest"] 293 353 version = "v1.0.0" 294 354 hash = "sha256-cfVDjHyWItmUGZ2dzQhCHgmOmou8v7N+itDkLZVkqkQ=" ··· 296 356 version = "v1.1.1" 297 357 hash = "sha256-bxBjtl+6846Ed3QHwdssOrNvlHV6b+Dn17zPISSQGP8=" 298 358 [mod."github.com/opentracing/opentracing-go"] 299 - version = "v1.2.0" 300 - hash = "sha256-kKTKFGXOsCF6QdVzI++GgaRzv2W+kWq5uDXOJChvLxM=" 359 + version = "v1.2.1-0.20220228012449-10b1cf09e00b" 360 + hash = "sha256-77oWcDviIoGWHVAotbgmGRpLGpH5AUy+pM15pl3vRrw=" 301 361 [mod."github.com/pjbgf/sha1cd"] 302 362 version = "v0.3.2" 303 363 hash = "sha256-jdbiRhU8xc1C5c8m7BSCj71PUXHY3f7TWFfxDKKpUMk=" ··· 320 380 version = "v0.6.2" 321 381 hash = "sha256-q6Fh6v8iNJN9ypD47LjWmx66YITa3FyRjZMRsuRTFeQ=" 322 382 [mod."github.com/prometheus/common"] 323 - version = "v0.63.0" 324 - hash = "sha256-TbUZNkN4ZA7eC/MlL1v2V5OL28QRnftSuaWQZ944zBE=" 383 + version = "v0.64.0" 384 + hash = "sha256-uy3KO60F2Cvhamz3fWQALGSsy13JiTk3NfpXgRuwqtI=" 325 385 [mod."github.com/prometheus/procfs"] 326 386 version = "v0.16.1" 327 387 hash = "sha256-OBCvKlLW2obct35p0L9Q+1ZrxZjpTmbgHMP2rng9hpo=" 328 388 [mod."github.com/redis/go-redis/v9"] 329 - version = "v9.3.0" 330 - hash = "sha256-PNXDX3BH92d2jL/AkdK0eWMorh387Y6duwYNhsqNe+w=" 389 + version = "v9.7.3" 390 + hash = "sha256-7ip5Ns/NEnFmVLr5iN8m3gS4RrzVAYJ7pmJeeaTmjjo=" 331 391 [mod."github.com/resend/resend-go/v2"] 332 392 version = "v2.15.0" 333 393 hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg=" 394 + [mod."github.com/ryanuber/go-glob"] 395 + version = "v1.0.0" 396 + hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY=" 334 397 [mod."github.com/segmentio/asm"] 335 398 version = "v1.2.0" 336 399 hash = "sha256-zbNuKxNrUDUc6IlmRQNuJQzVe5Ol/mqp7srDg9IMMqs=" ··· 363 426 version = "v0.3.1" 364 427 hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc=" 365 428 [mod."github.com/yuin/goldmark"] 366 - version = "v1.4.13" 367 - 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=" 368 434 [mod."gitlab.com/yawning/secp256k1-voi"] 369 435 version = "v0.0.0-20230925100816-f2616030848b" 370 436 hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA=" ··· 375 441 version = "v1.1.0" 376 442 hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo=" 377 443 [mod."go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"] 378 - version = "v0.61.0" 379 - hash = "sha256-4pfXD7ErXhexSynXiEEQSAkWoPwHd7PEDE3M1Zi5gLM=" 444 + version = "v0.62.0" 445 + hash = "sha256-WcDogpsvFxGOVqc+/ljlZ10nrOU2q0rPteukGyWWmfc=" 380 446 [mod."go.opentelemetry.io/otel"] 381 - version = "v1.36.0" 382 - hash = "sha256-j8wojdCtKal3LKojanHA8KXXQ0FkbWONpO8tUxpJDko=" 447 + version = "v1.37.0" 448 + hash = "sha256-zWpyp9K8/Te86uhNjamchZctTdAnmHhoVw9m4ACfSoo=" 449 + [mod."go.opentelemetry.io/otel/exporters/otlp/otlptrace"] 450 + version = "v1.33.0" 451 + hash = "sha256-D5BMzmtN1d3pRnxIcvDOyQrjerK1JoavtYjJLhPKv/I=" 383 452 [mod."go.opentelemetry.io/otel/metric"] 384 - version = "v1.36.0" 385 - hash = "sha256-z6Uqi4HhUljWIYd58svKK5MqcGbpcac+/M8JeTrUtJ8=" 453 + version = "v1.37.0" 454 + hash = "sha256-BWnkdldA3xzGhnaConzMAuQzOnugytIvrP6GjkZVAYg=" 386 455 [mod."go.opentelemetry.io/otel/trace"] 387 - version = "v1.36.0" 388 - hash = "sha256-owWD9x1lp8aIJqYt058BXPUsIMHdk3RI0escso0BxwA=" 456 + version = "v1.37.0" 457 + hash = "sha256-FBeLOb5qmIiE9VmbgCf1l/xpndBqHkRiaPt1PvoKrVY=" 389 458 [mod."go.opentelemetry.io/proto/otlp"] 390 459 version = "v1.6.0" 391 460 hash = "sha256-1kjkJ9cqkvx3ib6ytLcw+Adp4xqD3ShF97lpzt/BeCg=" ··· 399 468 version = "v1.27.0" 400 469 hash = "sha256-8655KDrulc4Das3VRduO9MjCn8ZYD5WkULjCvruaYsU=" 401 470 [mod."golang.org/x/crypto"] 402 - version = "v0.38.0" 403 - hash = "sha256-5tTXlXQBlfW1sSNDAIalOpsERbTJlZqbwCIiih4T4rY=" 471 + version = "v0.40.0" 472 + hash = "sha256-I6p2fqvz63P9MwAuoQrljI7IUbfZQvCem0ii4Q2zZng=" 404 473 [mod."golang.org/x/exp"] 405 - version = "v0.0.0-20250408133849-7e4ce0ab07d0" 406 - hash = "sha256-Lw/WupSM8gcq0JzPSAaBqj9l1uZ68ANhaIaQzPhRpy8=" 474 + version = "v0.0.0-20250620022241-b7579e27df2b" 475 + hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig=" 407 476 [mod."golang.org/x/net"] 408 - version = "v0.40.0" 409 - hash = "sha256-BhDOHTP8RekXDQDf9HlORSmI2aPacLo53fRXtTgCUH8=" 477 + version = "v0.42.0" 478 + hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s=" 410 479 [mod."golang.org/x/sync"] 411 - version = "v0.14.0" 412 - hash = "sha256-YNQLeFMeXN9y0z4OyXV/LJ4hA54q+ljm1ytcy80O6r4=" 480 + version = "v0.16.0" 481 + hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks=" 413 482 [mod."golang.org/x/sys"] 414 - version = "v0.33.0" 415 - hash = "sha256-wlOzIOUgAiGAtdzhW/KPl/yUVSH/lvFZfs5XOuJ9LOQ=" 483 + version = "v0.34.0" 484 + hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50=" 485 + [mod."golang.org/x/text"] 486 + version = "v0.27.0" 487 + hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8=" 416 488 [mod."golang.org/x/time"] 417 - version = "v0.8.0" 418 - hash = "sha256-EA+qRisDJDPQ2g4pcfP4RyQaB7CJKkAn68EbNfBzXdQ=" 489 + version = "v0.12.0" 490 + hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw=" 419 491 [mod."golang.org/x/xerrors"] 420 492 version = "v0.0.0-20240903120638-7835f813f4da" 421 493 hash = "sha256-bE7CcrnAvryNvM26ieJGXqbAtuLwHaGcmtVMsVnksqo=" 422 494 [mod."google.golang.org/genproto/googleapis/api"] 423 - version = "v0.0.0-20250519155744-55703ea1f237" 424 - hash = "sha256-ivktx8ipWgWZgchh4FjKoWL7kU8kl/TtIavtZq/F5SQ=" 495 + version = "v0.0.0-20250603155806-513f23925822" 496 + hash = "sha256-0CS432v9zVhkVLqFpZtxBX8rvVqP67lb7qQ3es7RqIU=" 425 497 [mod."google.golang.org/genproto/googleapis/rpc"] 426 - version = "v0.0.0-20250519155744-55703ea1f237" 498 + version = "v0.0.0-20250603155806-513f23925822" 427 499 hash = "sha256-WK7iDtAhH19NPe3TywTQlGjDawNaDKWnxhFL9PgVUwM=" 428 500 [mod."google.golang.org/grpc"] 429 - version = "v1.72.1" 430 - hash = "sha256-5JczomNvroKWtIYKDgXwaIaEfuNEK//MHPhJQiaxMXs=" 501 + version = "v1.73.0" 502 + hash = "sha256-LfVlwip++q2DX70RU6CxoXglx1+r5l48DwlFD05G11c=" 431 503 [mod."google.golang.org/protobuf"] 432 504 version = "v1.36.6" 433 505 hash = "sha256-lT5qnefI5FDJnowz9PEkAGylH3+fE+A3DJDkAyy9RMc=" ··· 450 522 version = "v1.4.1" 451 523 hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc=" 452 524 [mod."tangled.sh/icyphox.sh/atproto-oauth"] 453 - version = "v0.0.0-20250526154904-3906c5336421" 454 - hash = "sha256-CvR8jic0YZfj0a8ubPj06FiMMR/1K9kHoZhLQw1LItM=" 525 + version = "v0.0.0-20250724194903-28e660378cb1" 526 + hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
+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 = {
+54 -20
nix/modules/knot.nix
··· 58 58 }; 59 59 }; 60 60 61 + motd = mkOption { 62 + type = types.nullOr types.str; 63 + default = null; 64 + description = '' 65 + Message of the day 66 + 67 + The contents are shown as-is; eg. you will want to add a newline if 68 + setting a non-empty message since the knot won't do this for you. 69 + ''; 70 + }; 71 + 72 + motdFile = mkOption { 73 + type = types.nullOr types.path; 74 + default = null; 75 + description = '' 76 + File containing message of the day 77 + 78 + The contents are shown as-is; eg. you will want to add a newline if 79 + setting a non-empty message since the knot won't do this for you. 80 + ''; 81 + }; 82 + 61 83 server = { 62 84 listenAddr = mkOption { 63 85 type = types.str; ··· 71 93 description = "Internal address for inter-service communication"; 72 94 }; 73 95 74 - secretFile = mkOption { 75 - type = lib.types.path; 76 - example = "KNOT_SERVER_SECRET=<hash>"; 77 - 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)"; 78 100 }; 79 101 80 102 dbPath = mkOption { ··· 104 126 cfg.package 105 127 ]; 106 128 107 - system.activationScripts.gitConfig = '' 108 - mkdir -p "${cfg.repo.scanPath}" 109 - chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}" 110 - 111 - mkdir -p "${cfg.stateDir}/.config/git" 112 - cat > "${cfg.stateDir}/.config/git/config" << EOF 113 - [user] 114 - name = Git User 115 - email = git@example.com 116 - [receive] 117 - advertisePushOptions = true 118 - EOF 119 - chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}" 120 - ''; 121 - 122 129 users.users.${cfg.gitUser} = { 123 130 isSystemUser = true; 124 131 useDefaultShell = true; ··· 154 161 description = "knot service"; 155 162 after = ["network.target" "sshd.service"]; 156 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 + 157 190 serviceConfig = { 158 191 User = cfg.gitUser; 192 + PermissionsStartOnly = true; 159 193 WorkingDirectory = cfg.stateDir; 160 194 Environment = [ 161 195 "KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}" ··· 165 199 "KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 166 200 "KNOT_SERVER_DB_PATH=${cfg.server.dbPath}" 167 201 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 202 + "KNOT_SERVER_OWNER=${cfg.server.owner}" 168 203 ]; 169 - EnvironmentFile = cfg.server.secretFile; 170 204 ExecStart = "${cfg.package}/bin/knot server"; 171 205 Restart = "always"; 172 206 };
+24 -2
nix/modules/spindle.nix
··· 54 54 example = "did:plc:qfpnj4og54vl56wngdriaxug"; 55 55 description = "DID of owner (required)"; 56 56 }; 57 + 58 + secrets = { 59 + provider = mkOption { 60 + type = types.str; 61 + default = "sqlite"; 62 + description = "Backend to use for secret management, valid options are 'sqlite', and 'openbao'."; 63 + }; 64 + 65 + openbao = { 66 + proxyAddr = mkOption { 67 + type = types.str; 68 + default = "http://127.0.0.1:8200"; 69 + }; 70 + mount = mkOption { 71 + type = types.str; 72 + default = "spindle"; 73 + }; 74 + }; 75 + }; 57 76 }; 58 77 59 78 pipelines = { ··· 89 108 "SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}" 90 109 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 91 110 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 92 - "SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 93 - "SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 111 + "SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}" 112 + "SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}" 113 + "SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}" 114 + "SPINDLE_NIXERY_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 115 + "SPINDLE_NIXERY_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 94 116 ]; 95 117 ExecStart = "${cfg.package}/bin/spindle"; 96 118 Restart = "always";
+29
nix/pkgs/appview-static-files.nix
··· 1 + { 2 + runCommandLocal, 3 + htmx-src, 4 + htmx-ws-src, 5 + lucide-src, 6 + inter-fonts-src, 7 + ibm-plex-mono-src, 8 + sqlite-lib, 9 + tailwindcss, 10 + src, 11 + }: 12 + runCommandLocal "appview-static-files" { 13 + # TOOD(winter): figure out why this is even required after 14 + # changing the libraries that the tailwindcss binary loads 15 + sandboxProfile = '' 16 + (allow file-read* (subpath "/System/Library/OpenSSL")) 17 + ''; 18 + } '' 19 + mkdir -p $out/{fonts,icons} && cd $out 20 + cp -f ${htmx-src} htmx.min.js 21 + cp -f ${htmx-ws-src} htmx-ext-ws.min.js 22 + cp -rf ${lucide-src}/*.svg icons/ 23 + cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/ 24 + cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 25 + cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 26 + # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 27 + # for whatever reason (produces broken css), so we are doing this instead 28 + cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css 29 + ''
+5 -17
nix/pkgs/appview.nix
··· 1 1 { 2 2 buildGoApplication, 3 3 modules, 4 - htmx-src, 5 - htmx-ws-src, 6 - lucide-src, 7 - inter-fonts-src, 8 - ibm-plex-mono-src, 9 - tailwindcss, 4 + appview-static-files, 10 5 sqlite-lib, 11 - gitignoreSource, 6 + src, 12 7 }: 13 8 buildGoApplication { 14 9 pname = "appview"; 15 10 version = "0.1.0"; 16 - src = gitignoreSource ../..; 17 - inherit modules; 11 + inherit src modules; 18 12 19 13 postUnpack = '' 20 14 pushd source 21 - mkdir -p appview/pages/static/{fonts,icons} 22 - cp -f ${htmx-src} appview/pages/static/htmx.min.js 23 - cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js 24 - cp -rf ${lucide-src}/*.svg appview/pages/static/icons/ 25 - cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/ 26 - cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/ 27 - cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/ 28 - ${tailwindcss}/bin/tailwindcss -i input.css -o appview/pages/static/tw.css 15 + mkdir -p appview/pages/static 16 + cp -frv ${appview-static-files}/* appview/pages/static 29 17 popd 30 18 ''; 31 19
+7 -3
nix/pkgs/genjwks.nix
··· 1 1 { 2 - gitignoreSource, 3 2 buildGoApplication, 4 3 modules, 5 4 }: 6 5 buildGoApplication { 7 6 pname = "genjwks"; 8 7 version = "0.1.0"; 9 - src = gitignoreSource ../..; 8 + src = ../../cmd/genjwks; 9 + postPatch = '' 10 + ln -s ${../../go.mod} ./go.mod 11 + ''; 12 + postInstall = '' 13 + mv $out/bin/core $out/bin/genjwks 14 + ''; 10 15 inherit modules; 11 - subPackages = ["cmd/genjwks"]; 12 16 doCheck = false; 13 17 CGO_ENABLED = 0; 14 18 }
+2 -3
nix/pkgs/knot-unwrapped.nix
··· 2 2 buildGoApplication, 3 3 modules, 4 4 sqlite-lib, 5 - gitignoreSource, 5 + src, 6 6 }: 7 7 buildGoApplication { 8 8 pname = "knot"; 9 9 version = "0.1.0"; 10 - src = gitignoreSource ../..; 11 - inherit modules; 10 + inherit src modules; 12 11 13 12 doCheck = false; 14 13
+1 -1
nix/pkgs/lexgen.nix
··· 7 7 version = "0.1.0"; 8 8 src = indigo; 9 9 subPackages = ["cmd/lexgen"]; 10 - vendorHash = "sha256-pGc29fgJFq8LP7n/pY1cv6ExZl88PAeFqIbFEhB3xXs="; 10 + vendorHash = "sha256-VbDrcN4r5b7utRFQzVsKgDsVgdQLSXl7oZ5kdPA/huw="; 11 11 doCheck = false; 12 12 }
+2 -3
nix/pkgs/spindle.nix
··· 2 2 buildGoApplication, 3 3 modules, 4 4 sqlite-lib, 5 - gitignoreSource, 5 + src, 6 6 }: 7 7 buildGoApplication { 8 8 pname = "spindle"; 9 9 version = "0.1.0"; 10 - src = gitignoreSource ../..; 11 - inherit modules; 10 + inherit src modules; 12 11 13 12 doCheck = false; 14 13
+121 -63
nix/vm.nix
··· 1 1 { 2 2 nixpkgs, 3 + system, 4 + hostSystem, 3 5 self, 4 - }: 5 - nixpkgs.lib.nixosSystem { 6 - system = "x86_64-linux"; 7 - modules = [ 8 - self.nixosModules.knot 9 - self.nixosModules.spindle 10 - ({ 11 - config, 12 - pkgs, 13 - ... 14 - }: { 15 - virtualisation = { 16 - memorySize = 2048; 17 - diskSize = 10 * 1024; 18 - cores = 2; 19 - forwardPorts = [ 20 - # ssh 21 - { 22 - from = "host"; 23 - host.port = 2222; 24 - guest.port = 22; 25 - } 26 - # knot 27 - { 28 - from = "host"; 29 - host.port = 6000; 30 - guest.port = 6000; 31 - } 32 - # spindle 33 - { 34 - from = "host"; 35 - host.port = 6555; 36 - guest.port = 6555; 37 - } 38 - ]; 39 - }; 40 - services.getty.autologinUser = "root"; 41 - environment.systemPackages = with pkgs; [curl vim git]; 42 - systemd.tmpfiles.rules = let 43 - u = config.services.tangled-knot.gitUser; 44 - g = config.services.tangled-knot.gitUser; 45 - in [ 46 - "d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first 47 - "f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=168c426fa6d9829fcbe85c96bdf144e800fb9737d6ca87f21acc543b1aa3e440" 48 - ]; 49 - services.tangled-knot = { 50 - enable = true; 51 - server = { 52 - secretFile = "/var/lib/knot/secret"; 53 - hostname = "localhost:6000"; 54 - listenAddr = "0.0.0.0:6000"; 6 + }: let 7 + envVar = name: let 8 + var = builtins.getEnv name; 9 + in 10 + if var == "" 11 + then throw "\$${name} must be defined, see docs/hacking.md for more details" 12 + else var; 13 + in 14 + nixpkgs.lib.nixosSystem { 15 + inherit system; 16 + modules = [ 17 + self.nixosModules.knot 18 + self.nixosModules.spindle 19 + ({ 20 + lib, 21 + config, 22 + pkgs, 23 + ... 24 + }: { 25 + virtualisation.vmVariant.virtualisation = { 26 + host.pkgs = import nixpkgs {system = hostSystem;}; 27 + 28 + graphics = false; 29 + memorySize = 2048; 30 + diskSize = 10 * 1024; 31 + cores = 2; 32 + forwardPorts = [ 33 + # ssh 34 + { 35 + from = "host"; 36 + host.port = 2222; 37 + guest.port = 22; 38 + } 39 + # knot 40 + { 41 + from = "host"; 42 + host.port = 6000; 43 + guest.port = 6000; 44 + } 45 + # spindle 46 + { 47 + from = "host"; 48 + host.port = 6555; 49 + guest.port = 6555; 50 + } 51 + ]; 52 + sharedDirectories = { 53 + # We can't use the 9p mounts directly for most of these 54 + # as SQLite is incompatible with them. So instead we 55 + # mount the shared directories to a different location 56 + # and copy the contents around on service start/stop. 57 + knotData = { 58 + source = "$TANGLED_VM_DATA_DIR/knot"; 59 + target = "/mnt/knot-data"; 60 + }; 61 + spindleData = { 62 + source = "$TANGLED_VM_DATA_DIR/spindle"; 63 + target = "/mnt/spindle-data"; 64 + }; 65 + spindleLogs = { 66 + source = "$TANGLED_VM_DATA_DIR/spindle-logs"; 67 + target = "/var/log/spindle"; 68 + }; 69 + }; 55 70 }; 56 - }; 57 - services.tangled-spindle = { 58 - enable = true; 59 - server = { 60 - owner = "did:plc:qfpnj4og54vl56wngdriaxug"; 61 - hostname = "localhost:6555"; 62 - listenAddr = "0.0.0.0:6555"; 63 - dev = true; 71 + # This is fine because any and all ports that are forwarded to host are explicitly marked above, we don't need a separate guest firewall 72 + networking.firewall.enable = false; 73 + time.timeZone = "Europe/London"; 74 + services.getty.autologinUser = "root"; 75 + environment.systemPackages = with pkgs; [curl vim git sqlite litecli]; 76 + services.tangled-knot = { 77 + enable = true; 78 + motd = "Welcome to the development knot!\n"; 79 + server = { 80 + owner = envVar "TANGLED_VM_KNOT_OWNER"; 81 + hostname = "localhost:6000"; 82 + listenAddr = "0.0.0.0:6000"; 83 + }; 64 84 }; 65 - }; 66 - }) 67 - ]; 68 - } 85 + services.tangled-spindle = { 86 + enable = true; 87 + server = { 88 + owner = envVar "TANGLED_VM_SPINDLE_OWNER"; 89 + hostname = "localhost:6555"; 90 + listenAddr = "0.0.0.0:6555"; 91 + dev = true; 92 + secrets = { 93 + provider = "sqlite"; 94 + }; 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); 123 + }; 124 + }) 125 + ]; 126 + }
+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")
+11 -5
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 { ··· 23 24 } 24 25 25 26 type Secrets struct { 26 - Provider string `env:"PROVIDER, default=sqlite"` 27 + Provider string `env:"PROVIDER, default=sqlite"` 28 + OpenBao OpenBaoConfig `env:",prefix=OPENBAO_"` 27 29 } 28 30 29 - type Pipelines struct { 31 + type OpenBaoConfig struct { 32 + ProxyAddr string `env:"PROXY_ADDR, default=http://127.0.0.1:8200"` 33 + Mount string `env:"MOUNT, default=spindle"` 34 + } 35 + 36 + type NixeryPipelines struct { 30 37 Nixery string `env:"NIXERY, default=nixery.tangled.sh"` 31 38 WorkflowTimeout string `env:"WORKFLOW_TIMEOUT, default=5m"` 32 - LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 33 39 } 34 40 35 41 type Config struct { 36 - Server Server `env:",prefix=SPINDLE_SERVER_"` 37 - Pipelines Pipelines `env:",prefix=SPINDLE_PIPELINES_"` 42 + Server Server `env:",prefix=SPINDLE_SERVER_"` 43 + NixeryPipelines NixeryPipelines `env:",prefix=SPINDLE_NIXERY_PIPELINES_"` 38 44 } 39 45 40 46 func Load(ctx context.Context) (*Config, error) {
+29 -10
spindle/db/db.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "strings" 5 6 6 7 _ "github.com/mattn/go-sqlite3" 7 8 ) ··· 11 12 } 12 13 13 14 func Make(dbPath string) (*DB, error) { 14 - db, err := sql.Open("sqlite3", dbPath) 15 + // https://github.com/mattn/go-sqlite3#connection-string 16 + opts := []string{ 17 + "_foreign_keys=1", 18 + "_journal_mode=WAL", 19 + "_synchronous=NORMAL", 20 + "_auto_vacuum=incremental", 21 + } 22 + 23 + db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 15 24 if err != nil { 16 25 return nil, err 17 26 } 27 + 28 + // NOTE: If any other migration is added here, you MUST 29 + // copy the pattern in appview: use a single sql.Conn 30 + // for every migration. 18 31 19 32 _, err = db.Exec(` 20 - pragma journal_mode = WAL; 21 - pragma synchronous = normal; 22 - pragma foreign_keys = on; 23 - pragma temp_store = memory; 24 - pragma mmap_size = 30000000000; 25 - pragma page_size = 32768; 26 - pragma auto_vacuum = incremental; 27 - pragma busy_timeout = 5000; 28 - 29 33 create table if not exists _jetstream ( 30 34 id integer primary key autoincrement, 31 35 last_time_us integer not null ··· 43 47 addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 44 48 45 49 unique(owner, name) 50 + ); 51 + 52 + create table if not exists spindle_members ( 53 + -- identifiers for the record 54 + id integer primary key autoincrement, 55 + did text not null, 56 + rkey text not null, 57 + 58 + -- data 59 + instance text not null, 60 + subject text not null, 61 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 62 + 63 + -- constraints 64 + unique (did, instance, subject) 46 65 ); 47 66 48 67 -- status event for a single workflow
+59
spindle/db/member.go
··· 1 + package db 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type SpindleMember struct { 10 + Id int 11 + Did syntax.DID // owner of the record 12 + Rkey string // rkey of the record 13 + Instance string 14 + Subject syntax.DID // the member being added 15 + Created time.Time 16 + } 17 + 18 + func AddSpindleMember(db *DB, member SpindleMember) error { 19 + _, err := db.Exec( 20 + `insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`, 21 + member.Did, 22 + member.Rkey, 23 + member.Instance, 24 + member.Subject, 25 + ) 26 + return err 27 + } 28 + 29 + func RemoveSpindleMember(db *DB, owner_did, rkey string) error { 30 + _, err := db.Exec( 31 + "delete from spindle_members where did = ? and rkey = ?", 32 + owner_did, 33 + rkey, 34 + ) 35 + return err 36 + } 37 + 38 + func GetSpindleMember(db *DB, did, rkey string) (*SpindleMember, error) { 39 + query := 40 + `select id, did, rkey, instance, subject, created 41 + from spindle_members 42 + where did = ? and rkey = ?` 43 + 44 + var member SpindleMember 45 + var createdAt string 46 + err := db.QueryRow(query, did, rkey).Scan( 47 + &member.Id, 48 + &member.Did, 49 + &member.Rkey, 50 + &member.Instance, 51 + &member.Subject, 52 + &createdAt, 53 + ) 54 + if err != nil { 55 + return nil, err 56 + } 57 + 58 + return &member, nil 59 + }
-21
spindle/engine/ansi_stripper.go
··· 1 - package engine 2 - 3 - import ( 4 - "io" 5 - 6 - "regexp" 7 - ) 8 - 9 - // regex to match ANSI escape codes (e.g., color codes, cursor moves) 10 - const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" 11 - 12 - var re = regexp.MustCompile(ansi) 13 - 14 - type ansiStrippingWriter struct { 15 - underlying io.Writer 16 - } 17 - 18 - func (w *ansiStrippingWriter) Write(p []byte) (int, error) { 19 - clean := re.ReplaceAll(p, []byte{}) 20 - return w.underlying.Write(clean) 21 - }
+77 -401
spindle/engine/engine.go
··· 4 4 "context" 5 5 "errors" 6 6 "fmt" 7 - "io" 8 7 "log/slog" 9 - "os" 10 - "strings" 11 - "sync" 12 - "time" 13 8 14 - "github.com/docker/docker/api/types/container" 15 - "github.com/docker/docker/api/types/image" 16 - "github.com/docker/docker/api/types/mount" 17 - "github.com/docker/docker/api/types/network" 18 - "github.com/docker/docker/api/types/volume" 19 - "github.com/docker/docker/client" 20 - "github.com/docker/docker/pkg/stdcopy" 21 - "tangled.sh/tangled.sh/core/log" 9 + securejoin "github.com/cyphar/filepath-securejoin" 10 + "golang.org/x/sync/errgroup" 22 11 "tangled.sh/tangled.sh/core/notifier" 23 12 "tangled.sh/tangled.sh/core/spindle/config" 24 13 "tangled.sh/tangled.sh/core/spindle/db" 25 14 "tangled.sh/tangled.sh/core/spindle/models" 15 + "tangled.sh/tangled.sh/core/spindle/secrets" 26 16 ) 27 17 28 - const ( 29 - workspaceDir = "/tangled/workspace" 18 + var ( 19 + ErrTimedOut = errors.New("timed out") 20 + ErrWorkflowFailed = errors.New("workflow failed") 30 21 ) 31 22 32 - type cleanupFunc func(context.Context) error 33 - 34 - type Engine struct { 35 - docker client.APIClient 36 - l *slog.Logger 37 - db *db.DB 38 - n *notifier.Notifier 39 - cfg *config.Config 40 - 41 - cleanupMu sync.Mutex 42 - cleanup map[string][]cleanupFunc 43 - } 23 + func StartWorkflows(l *slog.Logger, vault secrets.Manager, cfg *config.Config, db *db.DB, n *notifier.Notifier, ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) { 24 + l.Info("starting all workflows in parallel", "pipeline", pipelineId) 44 25 45 - func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier) (*Engine, error) { 46 - dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 47 - if err != nil { 48 - return nil, err 49 - } 50 - 51 - l := log.FromContext(ctx).With("component", "spindle") 52 - 53 - e := &Engine{ 54 - docker: dcli, 55 - l: l, 56 - db: db, 57 - n: n, 58 - cfg: cfg, 26 + // extract secrets 27 + var allSecrets []secrets.UnlockedSecret 28 + if didSlashRepo, err := securejoin.SecureJoin(pipeline.RepoOwner, pipeline.RepoName); err == nil { 29 + if res, err := vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)); err == nil { 30 + allSecrets = res 31 + } 59 32 } 60 33 61 - e.cleanup = make(map[string][]cleanupFunc) 62 - 63 - return e, nil 64 - } 65 - 66 - func (e *Engine) StartWorkflows(ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) { 67 - e.l.Info("starting all workflows in parallel", "pipeline", pipelineId) 68 - 69 - wg := sync.WaitGroup{} 70 - for _, w := range pipeline.Workflows { 71 - wg.Add(1) 72 - go func() error { 73 - defer wg.Done() 74 - wid := models.WorkflowId{ 75 - PipelineId: pipelineId, 76 - Name: w.Name, 77 - } 78 - 79 - err := e.db.StatusRunning(wid, e.n) 80 - if err != nil { 81 - return err 82 - } 34 + eg, ctx := errgroup.WithContext(ctx) 35 + for eng, wfs := range pipeline.Workflows { 36 + workflowTimeout := eng.WorkflowTimeout() 37 + l.Info("using workflow timeout", "timeout", workflowTimeout) 83 38 84 - err = e.SetupWorkflow(ctx, wid) 85 - if err != nil { 86 - e.l.Error("setting up worklow", "wid", wid, "err", err) 87 - return err 88 - } 89 - defer e.DestroyWorkflow(ctx, wid) 90 - 91 - reader, err := e.docker.ImagePull(ctx, w.Image, image.PullOptions{}) 92 - if err != nil { 93 - e.l.Error("pipeline 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 + } 94 45 95 - err := e.db.StatusFailed(wid, err.Error(), -1, e.n) 46 + err := db.StatusRunning(wid, n) 96 47 if err != nil { 97 48 return err 98 49 } 99 50 100 - return fmt.Errorf("pulling image: %w", err) 101 - } 102 - defer reader.Close() 103 - io.Copy(os.Stdout, reader) 51 + err = eng.SetupWorkflow(ctx, wid, &w) 52 + if err != nil { 53 + // TODO(winter): Should this always set StatusFailed? 54 + // In the original, we only do in a subset of cases. 55 + l.Error("setting up worklow", "wid", wid, "err", err) 104 56 105 - workflowTimeoutStr := e.cfg.Pipelines.WorkflowTimeout 106 - workflowTimeout, err := time.ParseDuration(workflowTimeoutStr) 107 - if err != nil { 108 - e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr) 109 - workflowTimeout = 5 * time.Minute 110 - } 111 - e.l.Info("using workflow timeout", "timeout", workflowTimeout) 112 - ctx, cancel := context.WithTimeout(ctx, workflowTimeout) 113 - defer cancel() 57 + destroyErr := eng.DestroyWorkflow(ctx, wid) 58 + if destroyErr != nil { 59 + l.Error("failed to destroy workflow after setup failure", "error", destroyErr) 60 + } 114 61 115 - err = e.StartSteps(ctx, w.Steps, wid, w.Image) 116 - if err != nil { 117 - if errors.Is(err, ErrTimedOut) { 118 - dbErr := e.db.StatusTimeout(wid, e.n) 62 + dbErr := db.StatusFailed(wid, err.Error(), -1, n) 119 63 if dbErr != nil { 120 64 return dbErr 121 65 } 122 - } else { 123 - dbErr := e.db.StatusFailed(wid, err.Error(), -1, e.n) 124 - if dbErr != nil { 125 - return dbErr 126 - } 66 + return err 127 67 } 128 - 129 - return fmt.Errorf("starting steps image: %w", err) 130 - } 131 - 132 - err = e.db.StatusSuccess(wid, e.n) 133 - if err != nil { 134 - return err 135 - } 136 - 137 - return nil 138 - }() 139 - } 140 - 141 - wg.Wait() 142 - } 143 - 144 - // SetupWorkflow sets up a new network for the workflow and volumes for 145 - // the workspace and Nix store. These are persisted across steps and are 146 - // destroyed at the end of the workflow. 147 - func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId) error { 148 - e.l.Info("setting up workflow", "workflow", wid) 149 - 150 - _, err := e.docker.VolumeCreate(ctx, volume.CreateOptions{ 151 - Name: workspaceVolume(wid), 152 - Driver: "local", 153 - }) 154 - if err != nil { 155 - return err 156 - } 157 - e.registerCleanup(wid, func(ctx context.Context) error { 158 - return e.docker.VolumeRemove(ctx, workspaceVolume(wid), true) 159 - }) 160 - 161 - _, err = e.docker.VolumeCreate(ctx, volume.CreateOptions{ 162 - Name: nixVolume(wid), 163 - Driver: "local", 164 - }) 165 - if err != nil { 166 - return err 167 - } 168 - e.registerCleanup(wid, func(ctx context.Context) error { 169 - return e.docker.VolumeRemove(ctx, nixVolume(wid), true) 170 - }) 171 - 172 - _, err = e.docker.NetworkCreate(ctx, networkName(wid), network.CreateOptions{ 173 - Driver: "bridge", 174 - }) 175 - if err != nil { 176 - return err 177 - } 178 - e.registerCleanup(wid, func(ctx context.Context) error { 179 - return e.docker.NetworkRemove(ctx, networkName(wid)) 180 - }) 181 - 182 - return nil 183 - } 184 - 185 - // StartSteps starts all steps sequentially with the same base image. 186 - // ONLY marks pipeline as failed if container's exit code is non-zero. 187 - // All other errors are bubbled up. 188 - // Fixed version of the step execution logic 189 - func (e *Engine) StartSteps(ctx context.Context, steps []models.Step, wid models.WorkflowId, image string) error { 190 - 191 - for stepIdx, step := range steps { 192 - select { 193 - case <-ctx.Done(): 194 - return ctx.Err() 195 - default: 196 - } 197 - 198 - envs := ConstructEnvs(step.Environment) 199 - envs.AddEnv("HOME", workspaceDir) 200 - e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice()) 201 - 202 - hostConfig := hostConfig(wid) 203 - resp, err := e.docker.ContainerCreate(ctx, &container.Config{ 204 - Image: image, 205 - Cmd: []string{"bash", "-c", step.Command}, 206 - WorkingDir: workspaceDir, 207 - Tty: false, 208 - Hostname: "spindle", 209 - Env: envs.Slice(), 210 - }, hostConfig, nil, nil, "") 211 - defer e.DestroyStep(ctx, resp.ID) 212 - if err != nil { 213 - return fmt.Errorf("creating container: %w", err) 214 - } 215 - 216 - err = e.docker.NetworkConnect(ctx, networkName(wid), resp.ID, nil) 217 - if err != nil { 218 - return fmt.Errorf("connecting network: %w", err) 219 - } 220 - 221 - err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}) 222 - if err != nil { 223 - return err 224 - } 225 - e.l.Info("started container", "name", resp.ID, "step", step.Name) 226 - 227 - // start tailing logs in background 228 - tailDone := make(chan error, 1) 229 - go func() { 230 - tailDone <- e.TailStep(ctx, resp.ID, wid, stepIdx, step) 231 - }() 232 - 233 - // wait for container completion or timeout 234 - waitDone := make(chan struct{}) 235 - var state *container.State 236 - var waitErr error 237 - 238 - go func() { 239 - defer close(waitDone) 240 - state, waitErr = e.WaitStep(ctx, resp.ID) 241 - }() 242 - 243 - select { 244 - case <-waitDone: 245 - 246 - // wait for tailing to complete 247 - <-tailDone 248 - 249 - case <-ctx.Done(): 250 - e.l.Warn("step timed out; killing container", "container", resp.ID, "step", step.Name) 251 - err = e.DestroyStep(context.Background(), resp.ID) 252 - if err != nil { 253 - e.l.Error("failed to destroy step", "container", resp.ID, "error", err) 254 - } 68 + defer eng.DestroyWorkflow(ctx, wid) 255 69 256 - // wait for both goroutines to finish 257 - <-waitDone 258 - <-tailDone 259 - 260 - return ErrTimedOut 261 - } 70 + wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid) 71 + if err != nil { 72 + l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 73 + wfLogger = nil 74 + } else { 75 + defer wfLogger.Close() 76 + } 262 77 263 - select { 264 - case <-ctx.Done(): 265 - return ctx.Err() 266 - default: 267 - } 78 + ctx, cancel := context.WithTimeout(ctx, workflowTimeout) 79 + defer cancel() 268 80 269 - if waitErr != nil { 270 - return waitErr 271 - } 81 + for stepIdx, step := range w.Steps { 82 + if wfLogger != nil { 83 + ctl := wfLogger.ControlWriter(stepIdx, step) 84 + ctl.Write([]byte(step.Name())) 85 + } 272 86 273 - err = e.DestroyStep(ctx, resp.ID) 274 - if err != nil { 275 - return err 276 - } 87 + err = eng.RunStep(ctx, wid, &w, stepIdx, allSecrets, wfLogger) 88 + if err != nil { 89 + if errors.Is(err, ErrTimedOut) { 90 + dbErr := db.StatusTimeout(wid, n) 91 + if dbErr != nil { 92 + return dbErr 93 + } 94 + } else { 95 + dbErr := db.StatusFailed(wid, err.Error(), -1, n) 96 + if dbErr != nil { 97 + return dbErr 98 + } 99 + } 277 100 278 - if state.ExitCode != 0 { 279 - e.l.Error("workflow failed!", "workflow_id", wid.String(), "error", state.Error, "exit_code", state.ExitCode, "oom_killed", state.OOMKilled) 280 - if state.OOMKilled { 281 - return ErrOOMKilled 282 - } 283 - return ErrWorkflowFailed 284 - } 285 - } 101 + return fmt.Errorf("starting steps image: %w", err) 102 + } 103 + } 286 104 287 - return nil 288 - } 105 + err = db.StatusSuccess(wid, n) 106 + if err != nil { 107 + return err 108 + } 289 109 290 - func (e *Engine) WaitStep(ctx context.Context, containerID string) (*container.State, error) { 291 - wait, errCh := e.docker.ContainerWait(ctx, containerID, container.WaitConditionNotRunning) 292 - select { 293 - case err := <-errCh: 294 - if err != nil { 295 - return nil, err 110 + return nil 111 + }) 296 112 } 297 - case <-wait: 298 113 } 299 114 300 - e.l.Info("waited for container", "name", containerID) 301 - 302 - info, err := e.docker.ContainerInspect(ctx, containerID) 303 - if err != nil { 304 - return nil, err 305 - } 306 - 307 - return info.State, nil 308 - } 309 - 310 - func (e *Engine) TailStep(ctx context.Context, containerID string, wid models.WorkflowId, stepIdx int, step models.Step) error { 311 - wfLogger, err := NewWorkflowLogger(e.cfg.Pipelines.LogDir, wid) 312 - if err != nil { 313 - e.l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 314 - return err 115 + if err := eg.Wait(); err != nil { 116 + l.Error("failed to run one or more workflows", "err", err) 117 + } else { 118 + l.Error("successfully ran full pipeline") 315 119 } 316 - defer wfLogger.Close() 317 - 318 - ctl := wfLogger.ControlWriter(stepIdx, step) 319 - ctl.Write([]byte(step.Name)) 320 - 321 - logs, err := e.docker.ContainerLogs(ctx, containerID, container.LogsOptions{ 322 - Follow: true, 323 - ShowStdout: true, 324 - ShowStderr: true, 325 - Details: false, 326 - Timestamps: false, 327 - }) 328 - if err != nil { 329 - return err 330 - } 331 - 332 - _, err = stdcopy.StdCopy( 333 - wfLogger.DataWriter("stdout"), 334 - wfLogger.DataWriter("stderr"), 335 - logs, 336 - ) 337 - if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) { 338 - return fmt.Errorf("failed to copy logs: %w", err) 339 - } 340 - 341 - return nil 342 - } 343 - 344 - func (e *Engine) DestroyStep(ctx context.Context, containerID string) error { 345 - err := e.docker.ContainerKill(ctx, containerID, "9") // SIGKILL 346 - if err != nil && !isErrContainerNotFoundOrNotRunning(err) { 347 - return err 348 - } 349 - 350 - if err := e.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{ 351 - RemoveVolumes: true, 352 - RemoveLinks: false, 353 - Force: false, 354 - }); err != nil && !isErrContainerNotFoundOrNotRunning(err) { 355 - return err 356 - } 357 - 358 - return nil 359 - } 360 - 361 - func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error { 362 - e.cleanupMu.Lock() 363 - key := wid.String() 364 - 365 - fns := e.cleanup[key] 366 - delete(e.cleanup, key) 367 - e.cleanupMu.Unlock() 368 - 369 - for _, fn := range fns { 370 - if err := fn(ctx); err != nil { 371 - e.l.Error("failed to cleanup workflow resource", "workflowId", wid, "error", err) 372 - } 373 - } 374 - return nil 375 - } 376 - 377 - func (e *Engine) registerCleanup(wid models.WorkflowId, fn cleanupFunc) { 378 - e.cleanupMu.Lock() 379 - defer e.cleanupMu.Unlock() 380 - 381 - key := wid.String() 382 - e.cleanup[key] = append(e.cleanup[key], fn) 383 - } 384 - 385 - func workspaceVolume(wid models.WorkflowId) string { 386 - return fmt.Sprintf("workspace-%s", wid) 387 - } 388 - 389 - func nixVolume(wid models.WorkflowId) string { 390 - return fmt.Sprintf("nix-%s", wid) 391 - } 392 - 393 - func networkName(wid models.WorkflowId) string { 394 - return fmt.Sprintf("workflow-network-%s", wid) 395 - } 396 - 397 - func hostConfig(wid models.WorkflowId) *container.HostConfig { 398 - hostConfig := &container.HostConfig{ 399 - Mounts: []mount.Mount{ 400 - { 401 - Type: mount.TypeVolume, 402 - Source: workspaceVolume(wid), 403 - Target: workspaceDir, 404 - }, 405 - { 406 - Type: mount.TypeVolume, 407 - Source: nixVolume(wid), 408 - Target: "/nix", 409 - }, 410 - { 411 - Type: mount.TypeTmpfs, 412 - Target: "/tmp", 413 - ReadOnly: false, 414 - TmpfsOptions: &mount.TmpfsOptions{ 415 - Mode: 0o1777, // world-writeable sticky bit 416 - Options: [][]string{ 417 - {"exec"}, 418 - }, 419 - }, 420 - }, 421 - { 422 - Type: mount.TypeVolume, 423 - Source: "etc-nix-" + wid.String(), 424 - Target: "/etc/nix", 425 - }, 426 - }, 427 - ReadonlyRootfs: false, 428 - CapDrop: []string{"ALL"}, 429 - CapAdd: []string{"CAP_DAC_OVERRIDE"}, 430 - SecurityOpt: []string{"no-new-privileges"}, 431 - ExtraHosts: []string{"host.docker.internal:host-gateway"}, 432 - } 433 - 434 - return hostConfig 435 - } 436 - 437 - // thanks woodpecker 438 - func isErrContainerNotFoundOrNotRunning(err error) bool { 439 - // Error response from daemon: Cannot kill container: ...: No such container: ... 440 - // Error response from daemon: Cannot kill container: ...: Container ... is not running" 441 - // Error response from podman daemon: can only kill running containers. ... is in state exited 442 - // Error: No such container: ... 443 - return err != nil && (strings.Contains(err.Error(), "No such container") || strings.Contains(err.Error(), "is not running") || strings.Contains(err.Error(), "can only kill running containers")) 444 120 }
-28
spindle/engine/envs.go
··· 1 - package engine 2 - 3 - import ( 4 - "fmt" 5 - ) 6 - 7 - type EnvVars []string 8 - 9 - // ConstructEnvs converts a tangled.Pipeline_Step_Environment_Elem.{Key,Value} 10 - // representation into a docker-friendly []string{"KEY=value", ...} slice. 11 - func ConstructEnvs(envs map[string]string) EnvVars { 12 - var dockerEnvs EnvVars 13 - for k, v := range envs { 14 - ev := fmt.Sprintf("%s=%s", k, v) 15 - dockerEnvs = append(dockerEnvs, ev) 16 - } 17 - return dockerEnvs 18 - } 19 - 20 - // Slice returns the EnvVar as a []string slice. 21 - func (ev EnvVars) Slice() []string { 22 - return ev 23 - } 24 - 25 - // AddEnv adds a key=value string to the EnvVar. 26 - func (ev *EnvVars) AddEnv(key, value string) { 27 - *ev = append(*ev, fmt.Sprintf("%s=%s", key, value)) 28 - }
-48
spindle/engine/envs_test.go
··· 1 - package engine 2 - 3 - import ( 4 - "testing" 5 - 6 - "github.com/stretchr/testify/assert" 7 - ) 8 - 9 - func TestConstructEnvs(t *testing.T) { 10 - tests := []struct { 11 - name string 12 - in map[string]string 13 - want EnvVars 14 - }{ 15 - { 16 - name: "empty input", 17 - in: make(map[string]string), 18 - want: EnvVars{}, 19 - }, 20 - { 21 - name: "single env var", 22 - in: map[string]string{"FOO": "bar"}, 23 - want: EnvVars{"FOO=bar"}, 24 - }, 25 - { 26 - name: "multiple env vars", 27 - in: map[string]string{"FOO": "bar", "BAZ": "qux"}, 28 - want: EnvVars{"FOO=bar", "BAZ=qux"}, 29 - }, 30 - } 31 - for _, tt := range tests { 32 - t.Run(tt.name, func(t *testing.T) { 33 - got := ConstructEnvs(tt.in) 34 - if got == nil { 35 - got = EnvVars{} 36 - } 37 - assert.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 + }
+175 -9
spindle/ingester.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 8 + "time" 7 9 8 10 "tangled.sh/tangled.sh/core/api/tangled" 9 11 "tangled.sh/tangled.sh/core/eventconsumer" 12 + "tangled.sh/tangled.sh/core/idresolver" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + "tangled.sh/tangled.sh/core/spindle/db" 10 15 16 + comatproto "github.com/bluesky-social/indigo/api/atproto" 17 + "github.com/bluesky-social/indigo/atproto/identity" 18 + "github.com/bluesky-social/indigo/atproto/syntax" 19 + "github.com/bluesky-social/indigo/xrpc" 11 20 "github.com/bluesky-social/jetstream/pkg/models" 21 + securejoin "github.com/cyphar/filepath-securejoin" 12 22 ) 13 23 14 24 type Ingester func(ctx context.Context, e *models.Event) error ··· 30 40 31 41 switch e.Commit.Collection { 32 42 case tangled.SpindleMemberNSID: 33 - s.ingestMember(ctx, e) 43 + err = s.ingestMember(ctx, e) 34 44 case tangled.RepoNSID: 35 - s.ingestRepo(ctx, e) 45 + err = s.ingestRepo(ctx, e) 46 + case tangled.RepoCollaboratorNSID: 47 + err = s.ingestCollaborator(ctx, e) 48 + } 49 + 50 + if err != nil { 51 + s.l.Debug("failed to process message", "nsid", e.Commit.Collection, "err", err) 36 52 } 37 53 38 - return err 54 + return nil 39 55 } 40 56 } 41 57 42 58 func (s *Spindle) ingestMember(_ context.Context, e *models.Event) error { 43 - did := e.Did 44 59 var err error 60 + did := e.Did 61 + rkey := e.Commit.RKey 45 62 46 63 l := s.l.With("component", "ingester", "record", tangled.SpindleMemberNSID) 47 64 ··· 56 73 } 57 74 58 75 domain := s.cfg.Server.Hostname 59 - if s.cfg.Server.Dev { 60 - domain = s.cfg.Server.ListenAddr 61 - } 62 76 recordInstance := record.Instance 63 77 64 78 if recordInstance != domain { ··· 72 86 return fmt.Errorf("failed to enforce permissions: %w", err) 73 87 } 74 88 75 - if err := s.e.AddKnotMember(rbacDomain, record.Subject); err != nil { 89 + if err := db.AddSpindleMember(s.db, db.SpindleMember{ 90 + Did: syntax.DID(did), 91 + Rkey: rkey, 92 + Instance: recordInstance, 93 + Subject: syntax.DID(record.Subject), 94 + Created: time.Now(), 95 + }); err != nil { 96 + l.Error("failed to add member", "error", err) 97 + return fmt.Errorf("failed to add member: %w", err) 98 + } 99 + 100 + if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil { 76 101 l.Error("failed to add member", "error", err) 77 102 return fmt.Errorf("failed to add member: %w", err) 78 103 } ··· 86 111 87 112 return nil 88 113 114 + case models.CommitOperationDelete: 115 + record, err := db.GetSpindleMember(s.db, did, rkey) 116 + if err != nil { 117 + l.Error("failed to find member", "error", err) 118 + return fmt.Errorf("failed to find member: %w", err) 119 + } 120 + 121 + if err := db.RemoveSpindleMember(s.db, did, rkey); err != nil { 122 + l.Error("failed to remove member", "error", err) 123 + return fmt.Errorf("failed to remove member: %w", err) 124 + } 125 + 126 + if err := s.e.RemoveSpindleMember(rbacDomain, record.Subject.String()); err != nil { 127 + l.Error("failed to add member", "error", err) 128 + return fmt.Errorf("failed to add member: %w", err) 129 + } 130 + l.Info("added member from firehose", "member", record.Subject) 131 + 132 + if err := s.db.RemoveDid(record.Subject.String()); err != nil { 133 + l.Error("failed to add did", "error", err) 134 + return fmt.Errorf("failed to add did: %w", err) 135 + } 136 + s.jc.RemoveDid(record.Subject.String()) 137 + 89 138 } 90 139 return nil 91 140 } 92 141 93 - func (s *Spindle) ingestRepo(_ context.Context, e *models.Event) error { 142 + func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 94 143 var err error 144 + did := e.Did 145 + resolver := idresolver.DefaultResolver() 95 146 96 147 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 97 148 ··· 127 178 return fmt.Errorf("failed to add repo: %w", err) 128 179 } 129 180 181 + didSlashRepo, err := securejoin.SecureJoin(record.Owner, record.Name) 182 + if err != nil { 183 + return err 184 + } 185 + 186 + // add repo to rbac 187 + if err := s.e.AddRepo(record.Owner, rbac.ThisServer, didSlashRepo); err != nil { 188 + l.Error("failed to add repo to enforcer", "error", err) 189 + return fmt.Errorf("failed to add repo: %w", err) 190 + } 191 + 192 + // add collaborators to rbac 193 + owner, err := resolver.ResolveIdent(ctx, did) 194 + if err != nil || owner.Handle.IsInvalidHandle() { 195 + return err 196 + } 197 + if err := s.fetchAndAddCollaborators(ctx, owner, didSlashRepo); err != nil { 198 + return err 199 + } 200 + 130 201 // add this knot to the event consumer 131 202 src := eventconsumer.NewKnotSource(record.Knot) 132 203 s.ks.AddSource(context.Background(), src) ··· 136 207 } 137 208 return nil 138 209 } 210 + 211 + func (s *Spindle) ingestCollaborator(ctx context.Context, e *models.Event) error { 212 + var err error 213 + 214 + l := s.l.With("component", "ingester", "record", tangled.RepoCollaboratorNSID, "did", e.Did) 215 + 216 + l.Info("ingesting collaborator record") 217 + 218 + switch e.Commit.Operation { 219 + case models.CommitOperationCreate, models.CommitOperationUpdate: 220 + raw := e.Commit.Record 221 + record := tangled.RepoCollaborator{} 222 + err = json.Unmarshal(raw, &record) 223 + if err != nil { 224 + l.Error("invalid record", "error", err) 225 + return err 226 + } 227 + 228 + resolver := idresolver.DefaultResolver() 229 + 230 + subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 231 + if err != nil || subjectId.Handle.IsInvalidHandle() { 232 + return err 233 + } 234 + 235 + repoAt, err := syntax.ParseATURI(record.Repo) 236 + if err != nil { 237 + l.Info("rejecting record, invalid repoAt", "repoAt", record.Repo) 238 + return nil 239 + } 240 + 241 + // TODO: get rid of this entirely 242 + // resolve this aturi to extract the repo record 243 + owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 244 + if err != nil || owner.Handle.IsInvalidHandle() { 245 + return fmt.Errorf("failed to resolve handle: %w", err) 246 + } 247 + 248 + xrpcc := xrpc.Client{ 249 + Host: owner.PDSEndpoint(), 250 + } 251 + 252 + resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 253 + if err != nil { 254 + return err 255 + } 256 + 257 + repo := resp.Value.Val.(*tangled.Repo) 258 + didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 259 + 260 + // check perms for this user 261 + if ok, err := s.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil { 262 + return fmt.Errorf("insufficient permissions: %w", err) 263 + } 264 + 265 + // add collaborator to rbac 266 + if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 267 + l.Error("failed to add repo to enforcer", "error", err) 268 + return fmt.Errorf("failed to add repo: %w", err) 269 + } 270 + 271 + return nil 272 + } 273 + return nil 274 + } 275 + 276 + func (s *Spindle) fetchAndAddCollaborators(ctx context.Context, owner *identity.Identity, didSlashRepo string) error { 277 + l := s.l.With("component", "ingester", "handler", "fetchAndAddCollaborators") 278 + 279 + l.Info("fetching and adding existing collaborators") 280 + 281 + xrpcc := xrpc.Client{ 282 + Host: owner.PDSEndpoint(), 283 + } 284 + 285 + resp, err := comatproto.RepoListRecords(ctx, &xrpcc, tangled.RepoCollaboratorNSID, "", 50, owner.DID.String(), false) 286 + if err != nil { 287 + return err 288 + } 289 + 290 + var errs error 291 + for _, r := range resp.Records { 292 + if r == nil { 293 + continue 294 + } 295 + record := r.Value.Val.(*tangled.RepoCollaborator) 296 + 297 + if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 298 + l.Error("failed to add repo to enforcer", "error", err) 299 + errors.Join(errs, fmt.Errorf("failed to add repo: %w", err)) 300 + } 301 + } 302 + 303 + return errs 304 + }
+17
spindle/models/engine.go
··· 1 + package models 2 + 3 + import ( 4 + "context" 5 + "time" 6 + 7 + "tangled.sh/tangled.sh/core/api/tangled" 8 + "tangled.sh/tangled.sh/core/spindle/secrets" 9 + ) 10 + 11 + type Engine interface { 12 + InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*Workflow, error) 13 + SetupWorkflow(ctx context.Context, wid WorkflowId, wf *Workflow) error 14 + WorkflowTimeout() time.Duration 15 + DestroyWorkflow(ctx context.Context, wid WorkflowId) error 16 + RunStep(ctx context.Context, wid WorkflowId, w *Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *WorkflowLogger) error 17 + }
+82
spindle/models/logger.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "io" 7 + "os" 8 + "path/filepath" 9 + "strings" 10 + ) 11 + 12 + type WorkflowLogger struct { 13 + file *os.File 14 + encoder *json.Encoder 15 + } 16 + 17 + func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) { 18 + path := LogFilePath(baseDir, wid) 19 + 20 + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) 21 + if err != nil { 22 + return nil, fmt.Errorf("creating log file: %w", err) 23 + } 24 + 25 + return &WorkflowLogger{ 26 + file: file, 27 + encoder: json.NewEncoder(file), 28 + }, nil 29 + } 30 + 31 + func LogFilePath(baseDir string, workflowID WorkflowId) string { 32 + logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String())) 33 + return logFilePath 34 + } 35 + 36 + func (l *WorkflowLogger) Close() error { 37 + return l.file.Close() 38 + } 39 + 40 + func (l *WorkflowLogger) DataWriter(stream string) io.Writer { 41 + // TODO: emit stream 42 + return &dataWriter{ 43 + logger: l, 44 + stream: stream, 45 + } 46 + } 47 + 48 + func (l *WorkflowLogger) ControlWriter(idx int, step Step) io.Writer { 49 + return &controlWriter{ 50 + logger: l, 51 + idx: idx, 52 + step: step, 53 + } 54 + } 55 + 56 + type dataWriter struct { 57 + logger *WorkflowLogger 58 + stream string 59 + } 60 + 61 + func (w *dataWriter) Write(p []byte) (int, error) { 62 + line := strings.TrimRight(string(p), "\r\n") 63 + entry := NewDataLogLine(line, w.stream) 64 + if err := w.logger.encoder.Encode(entry); err != nil { 65 + return 0, err 66 + } 67 + return len(p), nil 68 + } 69 + 70 + type controlWriter struct { 71 + logger *WorkflowLogger 72 + idx int 73 + step Step 74 + } 75 + 76 + func (w *controlWriter) Write(_ []byte) (int, error) { 77 + entry := NewControlLogLine(w.idx, w.step) 78 + if err := w.logger.encoder.Encode(entry); err != nil { 79 + return 0, err 80 + } 81 + return len(w.step.Name()), nil 82 + }
+3 -3
spindle/models/models.go
··· 104 104 func NewControlLogLine(idx int, step Step) LogLine { 105 105 return LogLine{ 106 106 Kind: LogKindControl, 107 - Content: step.Name, 107 + Content: step.Name(), 108 108 StepId: idx, 109 - StepKind: step.Kind, 110 - StepCommand: step.Command, 109 + StepKind: step.Kind(), 110 + StepCommand: step.Command(), 111 111 } 112 112 }
+10 -108
spindle/models/pipeline.go
··· 1 1 package models 2 2 3 - import ( 4 - "path" 5 - 6 - "tangled.sh/tangled.sh/core/api/tangled" 7 - "tangled.sh/tangled.sh/core/spindle/config" 8 - ) 9 - 10 3 type Pipeline struct { 11 - Workflows []Workflow 4 + RepoOwner string 5 + RepoName string 6 + Workflows map[Engine][]Workflow 12 7 } 13 8 14 - type Step struct { 15 - Command string 16 - Name string 17 - Environment map[string]string 18 - Kind StepKind 9 + type Step interface { 10 + Name() string 11 + Command() string 12 + Kind() StepKind 19 13 } 20 14 21 15 type StepKind int ··· 28 22 ) 29 23 30 24 type Workflow struct { 31 - Steps []Step 32 - Environment map[string]string 33 - Name string 34 - Image string 35 - } 36 - 37 - // setupSteps get added to start of Steps 38 - type setupSteps []Step 39 - 40 - // addStep adds a step to the beginning of the workflow's steps. 41 - func (ss *setupSteps) addStep(step Step) { 42 - *ss = append(*ss, step) 43 - } 44 - 45 - // ToPipeline converts a tangled.Pipeline into a model.Pipeline. 46 - // In the process, dependencies are resolved: nixpkgs deps 47 - // are constructed atop nixery and set as the Workflow.Image, 48 - // and ones from custom registries 49 - func ToPipeline(pl tangled.Pipeline, cfg config.Config) *Pipeline { 50 - workflows := []Workflow{} 51 - 52 - for _, twf := range pl.Workflows { 53 - swf := &Workflow{} 54 - for _, tstep := range twf.Steps { 55 - sstep := Step{} 56 - sstep.Environment = stepEnvToMap(tstep.Environment) 57 - sstep.Command = tstep.Command 58 - sstep.Name = tstep.Name 59 - sstep.Kind = StepKindUser 60 - swf.Steps = append(swf.Steps, sstep) 61 - } 62 - swf.Name = twf.Name 63 - swf.Environment = workflowEnvToMap(twf.Environment) 64 - swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery) 65 - 66 - swf.addNixProfileToPath() 67 - swf.setGlobalEnvs() 68 - setup := &setupSteps{} 69 - 70 - setup.addStep(nixConfStep()) 71 - setup.addStep(cloneStep(*twf, *pl.TriggerMetadata, cfg.Server.Dev)) 72 - // this step could be empty 73 - if s := dependencyStep(*twf); s != nil { 74 - setup.addStep(*s) 75 - } 76 - 77 - // append setup steps in order to the start of workflow steps 78 - swf.Steps = append(*setup, swf.Steps...) 79 - 80 - workflows = append(workflows, *swf) 81 - } 82 - return &Pipeline{Workflows: workflows} 83 - } 84 - 85 - func workflowEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string { 86 - envMap := map[string]string{} 87 - for _, env := range envs { 88 - if env != nil { 89 - envMap[env.Key] = env.Value 90 - } 91 - } 92 - return envMap 93 - } 94 - 95 - func stepEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string { 96 - envMap := map[string]string{} 97 - for _, env := range envs { 98 - if env != nil { 99 - envMap[env.Key] = env.Value 100 - } 101 - } 102 - return envMap 103 - } 104 - 105 - func workflowImage(deps []*tangled.Pipeline_Dependency, nixery string) string { 106 - var dependencies string 107 - for _, d := range deps { 108 - if d.Registry == "nixpkgs" { 109 - dependencies = path.Join(d.Packages...) 110 - } 111 - } 112 - 113 - // load defaults from somewhere else 114 - dependencies = path.Join(dependencies, "bash", "git", "coreutils", "nix") 115 - 116 - return path.Join(nixery, dependencies) 117 - } 118 - 119 - func (wf *Workflow) addNixProfileToPath() { 120 - wf.Environment["PATH"] = "$PATH:/.nix-profile/bin" 121 - } 122 - 123 - func (wf *Workflow) setGlobalEnvs() { 124 - wf.Environment["NIX_CONFIG"] = "experimental-features = nix-command flakes" 125 - wf.Environment["HOME"] = "/tangled/workspace" 25 + Steps []Step 26 + Name string 27 + Data any 126 28 }
-125
spindle/models/setup_steps.go
··· 1 - package models 2 - 3 - import ( 4 - "fmt" 5 - "path" 6 - "strings" 7 - 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 - "tangled.sh/tangled.sh/core/workflow" 10 - ) 11 - 12 - func nixConfStep() Step { 13 - setupCmd := `echo 'extra-experimental-features = nix-command flakes' >> /etc/nix/nix.conf 14 - echo 'build-users-group = ' >> /etc/nix/nix.conf` 15 - return Step{ 16 - Command: setupCmd, 17 - Name: "Configure Nix", 18 - } 19 - } 20 - 21 - // cloneOptsAsSteps processes clone options and adds corresponding steps 22 - // to the beginning of the workflow's step list if cloning is not skipped. 23 - // 24 - // the steps to do here are: 25 - // - git init 26 - // - git remote add origin <url> 27 - // - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha> 28 - // - git checkout FETCH_HEAD 29 - func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step { 30 - if twf.Clone.Skip { 31 - return Step{} 32 - } 33 - 34 - var commands []string 35 - 36 - // initialize git repo in workspace 37 - commands = append(commands, "git init") 38 - 39 - // add repo as git remote 40 - scheme := "https://" 41 - if dev { 42 - scheme = "http://" 43 - tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal") 44 - } 45 - url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo) 46 - commands = append(commands, fmt.Sprintf("git remote add origin %s", url)) 47 - 48 - // run git fetch 49 - { 50 - var fetchArgs []string 51 - 52 - // default clone depth is 1 53 - depth := 1 54 - if twf.Clone.Depth > 1 { 55 - depth = int(twf.Clone.Depth) 56 - } 57 - fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth)) 58 - 59 - // optionally recurse submodules 60 - if twf.Clone.Submodules { 61 - fetchArgs = append(fetchArgs, "--recurse-submodules=yes") 62 - } 63 - 64 - // set remote to fetch from 65 - fetchArgs = append(fetchArgs, "origin") 66 - 67 - // set revision to checkout 68 - switch workflow.TriggerKind(tr.Kind) { 69 - case workflow.TriggerKindManual: 70 - // TODO: unimplemented 71 - case workflow.TriggerKindPush: 72 - fetchArgs = append(fetchArgs, tr.Push.NewSha) 73 - case workflow.TriggerKindPullRequest: 74 - fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha) 75 - } 76 - 77 - commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " "))) 78 - } 79 - 80 - // run git checkout 81 - commands = append(commands, "git checkout FETCH_HEAD") 82 - 83 - cloneStep := Step{ 84 - Command: strings.Join(commands, "\n"), 85 - Name: "Clone repository into workspace", 86 - } 87 - return cloneStep 88 - } 89 - 90 - // dependencyStep processes dependencies defined in the workflow. 91 - // For dependencies using a custom registry (i.e. not nixpkgs), it collects 92 - // all packages and adds a single 'nix profile install' step to the 93 - // beginning of the workflow's step list. 94 - func dependencyStep(twf tangled.Pipeline_Workflow) *Step { 95 - var customPackages []string 96 - 97 - for _, d := range twf.Dependencies { 98 - registry := d.Registry 99 - packages := d.Packages 100 - 101 - if registry == "nixpkgs" { 102 - continue 103 - } 104 - 105 - // collect packages from custom registries 106 - for _, pkg := range packages { 107 - customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg)) 108 - } 109 - } 110 - 111 - if len(customPackages) > 0 { 112 - installCmd := "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install" 113 - cmd := fmt.Sprintf("%s %s", installCmd, strings.Join(customPackages, " ")) 114 - installStep := Step{ 115 - Command: cmd, 116 - Name: "Install custom dependencies", 117 - Environment: map[string]string{ 118 - "NIX_NO_COLOR": "1", 119 - "NIX_SHOW_DOWNLOAD_PROGRESS": "0", 120 - }, 121 - } 122 - return &installStep 123 - } 124 - return nil 125 - }
+11 -4
spindle/secrets/manager.go
··· 1 1 package secrets 2 2 3 3 import ( 4 + "context" 4 5 "errors" 5 6 "regexp" 6 7 "time" ··· 26 27 type UnlockedSecret = Secret[string] 27 28 28 29 type Manager interface { 29 - AddSecret(secret UnlockedSecret) error 30 - RemoveSecret(secret Secret[any]) error 31 - GetSecretsLocked(repo DidSlashRepo) ([]LockedSecret, error) 32 - GetSecretsUnlocked(repo DidSlashRepo) ([]UnlockedSecret, error) 30 + AddSecret(ctx context.Context, secret UnlockedSecret) error 31 + RemoveSecret(ctx context.Context, secret Secret[any]) error 32 + GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) 33 + GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) 34 + } 35 + 36 + // stopper interface for managers that need cleanup 37 + type Stopper interface { 38 + Stop() 33 39 } 34 40 35 41 var ErrKeyAlreadyPresent = errors.New("key already present") ··· 40 46 var ( 41 47 _ = []Manager{ 42 48 &SqliteManager{}, 49 + &OpenBaoManager{}, 43 50 } 44 51 ) 45 52
+313
spindle/secrets/openbao.go
··· 1 + package secrets 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log/slog" 7 + "path" 8 + "strings" 9 + "time" 10 + 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + vault "github.com/openbao/openbao/api/v2" 13 + ) 14 + 15 + type OpenBaoManager struct { 16 + client *vault.Client 17 + mountPath string 18 + logger *slog.Logger 19 + } 20 + 21 + type OpenBaoManagerOpt func(*OpenBaoManager) 22 + 23 + func WithMountPath(mountPath string) OpenBaoManagerOpt { 24 + return func(v *OpenBaoManager) { 25 + v.mountPath = mountPath 26 + } 27 + } 28 + 29 + // NewOpenBaoManager creates a new OpenBao manager that connects to a Bao Proxy 30 + // The proxyAddress should point to the local Bao Proxy (e.g., "http://127.0.0.1:8200") 31 + // The proxy handles all authentication automatically via Auto-Auth 32 + func NewOpenBaoManager(proxyAddress string, logger *slog.Logger, opts ...OpenBaoManagerOpt) (*OpenBaoManager, error) { 33 + if proxyAddress == "" { 34 + return nil, fmt.Errorf("proxy address cannot be empty") 35 + } 36 + 37 + config := vault.DefaultConfig() 38 + config.Address = proxyAddress 39 + 40 + client, err := vault.NewClient(config) 41 + if err != nil { 42 + return nil, fmt.Errorf("failed to create openbao client: %w", err) 43 + } 44 + 45 + manager := &OpenBaoManager{ 46 + client: client, 47 + mountPath: "spindle", // default KV v2 mount path 48 + logger: logger, 49 + } 50 + 51 + for _, opt := range opts { 52 + opt(manager) 53 + } 54 + 55 + if err := manager.testConnection(); err != nil { 56 + return nil, fmt.Errorf("failed to connect to bao proxy: %w", err) 57 + } 58 + 59 + logger.Info("successfully connected to bao proxy", "address", proxyAddress) 60 + return manager, nil 61 + } 62 + 63 + // testConnection verifies that we can connect to the proxy 64 + func (v *OpenBaoManager) testConnection() error { 65 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 66 + defer cancel() 67 + 68 + // try token self-lookup as a quick way to verify proxy works 69 + // and is authenticated 70 + _, err := v.client.Auth().Token().LookupSelfWithContext(ctx) 71 + if err != nil { 72 + return fmt.Errorf("proxy connection test failed: %w", err) 73 + } 74 + 75 + return nil 76 + } 77 + 78 + func (v *OpenBaoManager) AddSecret(ctx context.Context, secret UnlockedSecret) error { 79 + if err := ValidateKey(secret.Key); err != nil { 80 + return err 81 + } 82 + 83 + secretPath := v.buildSecretPath(secret.Repo, secret.Key) 84 + v.logger.Debug("adding secret", "repo", secret.Repo, "key", secret.Key, "path", secretPath) 85 + 86 + // Check if secret already exists 87 + existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 88 + if err == nil && existing != nil { 89 + v.logger.Debug("secret already exists", "path", secretPath) 90 + return ErrKeyAlreadyPresent 91 + } 92 + 93 + secretData := map[string]interface{}{ 94 + "value": secret.Value, 95 + "repo": string(secret.Repo), 96 + "key": secret.Key, 97 + "created_at": secret.CreatedAt.Format(time.RFC3339), 98 + "created_by": secret.CreatedBy.String(), 99 + } 100 + 101 + v.logger.Debug("writing secret to openbao", "path", secretPath, "mount", v.mountPath) 102 + resp, err := v.client.KVv2(v.mountPath).Put(ctx, secretPath, secretData) 103 + if err != nil { 104 + v.logger.Error("failed to write secret", "path", secretPath, "error", err) 105 + return fmt.Errorf("failed to store secret in openbao: %w", err) 106 + } 107 + 108 + v.logger.Debug("secret write response", "version", resp.VersionMetadata.Version, "created_time", resp.VersionMetadata.CreatedTime) 109 + 110 + v.logger.Debug("verifying secret was written", "path", secretPath) 111 + readBack, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 112 + if err != nil { 113 + v.logger.Error("failed to verify secret after write", "path", secretPath, "error", err) 114 + return fmt.Errorf("secret not found after writing to %s/%s: %w", v.mountPath, secretPath, err) 115 + } 116 + 117 + if readBack == nil || readBack.Data == nil { 118 + v.logger.Error("secret verification returned empty data", "path", secretPath) 119 + return fmt.Errorf("secret verification failed: empty data returned for %s/%s", v.mountPath, secretPath) 120 + } 121 + 122 + v.logger.Info("secret added and verified successfully", "repo", secret.Repo, "key", secret.Key, "version", readBack.VersionMetadata.Version) 123 + return nil 124 + } 125 + 126 + func (v *OpenBaoManager) RemoveSecret(ctx context.Context, secret Secret[any]) error { 127 + secretPath := v.buildSecretPath(secret.Repo, secret.Key) 128 + 129 + // check if secret exists 130 + existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 131 + if err != nil || existing == nil { 132 + return ErrKeyNotFound 133 + } 134 + 135 + err = v.client.KVv2(v.mountPath).DeleteMetadata(ctx, secretPath) 136 + if err != nil { 137 + return fmt.Errorf("failed to delete secret from openbao: %w", err) 138 + } 139 + 140 + v.logger.Debug("secret removed successfully", "repo", secret.Repo, "key", secret.Key) 141 + return nil 142 + } 143 + 144 + func (v *OpenBaoManager) GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) { 145 + repoPath := v.buildRepoPath(repo) 146 + 147 + secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath)) 148 + if err != nil { 149 + if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") { 150 + return []LockedSecret{}, nil 151 + } 152 + return nil, fmt.Errorf("failed to list secrets: %w", err) 153 + } 154 + 155 + if secretsList == nil || secretsList.Data == nil { 156 + return []LockedSecret{}, nil 157 + } 158 + 159 + keys, ok := secretsList.Data["keys"].([]interface{}) 160 + if !ok { 161 + return []LockedSecret{}, nil 162 + } 163 + 164 + var secrets []LockedSecret 165 + 166 + for _, keyInterface := range keys { 167 + key, ok := keyInterface.(string) 168 + if !ok { 169 + continue 170 + } 171 + 172 + secretPath := fmt.Sprintf("%s/%s", repoPath, key) 173 + secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 174 + if err != nil { 175 + v.logger.Warn("failed to read secret metadata", "path", secretPath, "error", err) 176 + continue 177 + } 178 + 179 + if secretData == nil || secretData.Data == nil { 180 + continue 181 + } 182 + 183 + data := secretData.Data 184 + 185 + createdAtStr, ok := data["created_at"].(string) 186 + if !ok { 187 + createdAtStr = time.Now().Format(time.RFC3339) 188 + } 189 + 190 + createdAt, err := time.Parse(time.RFC3339, createdAtStr) 191 + if err != nil { 192 + createdAt = time.Now() 193 + } 194 + 195 + createdByStr, ok := data["created_by"].(string) 196 + if !ok { 197 + createdByStr = "" 198 + } 199 + 200 + keyStr, ok := data["key"].(string) 201 + if !ok { 202 + keyStr = key 203 + } 204 + 205 + secret := LockedSecret{ 206 + Key: keyStr, 207 + Repo: repo, 208 + CreatedAt: createdAt, 209 + CreatedBy: syntax.DID(createdByStr), 210 + } 211 + 212 + secrets = append(secrets, secret) 213 + } 214 + 215 + v.logger.Debug("retrieved locked secrets", "repo", repo, "count", len(secrets)) 216 + return secrets, nil 217 + } 218 + 219 + func (v *OpenBaoManager) GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) { 220 + repoPath := v.buildRepoPath(repo) 221 + 222 + secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath)) 223 + if err != nil { 224 + if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") { 225 + return []UnlockedSecret{}, nil 226 + } 227 + return nil, fmt.Errorf("failed to list secrets: %w", err) 228 + } 229 + 230 + if secretsList == nil || secretsList.Data == nil { 231 + return []UnlockedSecret{}, nil 232 + } 233 + 234 + keys, ok := secretsList.Data["keys"].([]interface{}) 235 + if !ok { 236 + return []UnlockedSecret{}, nil 237 + } 238 + 239 + var secrets []UnlockedSecret 240 + 241 + for _, keyInterface := range keys { 242 + key, ok := keyInterface.(string) 243 + if !ok { 244 + continue 245 + } 246 + 247 + secretPath := fmt.Sprintf("%s/%s", repoPath, key) 248 + secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 249 + if err != nil { 250 + v.logger.Warn("failed to read secret", "path", secretPath, "error", err) 251 + continue 252 + } 253 + 254 + if secretData == nil || secretData.Data == nil { 255 + continue 256 + } 257 + 258 + data := secretData.Data 259 + 260 + valueStr, ok := data["value"].(string) 261 + if !ok { 262 + v.logger.Warn("secret missing value", "path", secretPath) 263 + continue 264 + } 265 + 266 + createdAtStr, ok := data["created_at"].(string) 267 + if !ok { 268 + createdAtStr = time.Now().Format(time.RFC3339) 269 + } 270 + 271 + createdAt, err := time.Parse(time.RFC3339, createdAtStr) 272 + if err != nil { 273 + createdAt = time.Now() 274 + } 275 + 276 + createdByStr, ok := data["created_by"].(string) 277 + if !ok { 278 + createdByStr = "" 279 + } 280 + 281 + keyStr, ok := data["key"].(string) 282 + if !ok { 283 + keyStr = key 284 + } 285 + 286 + secret := UnlockedSecret{ 287 + Key: keyStr, 288 + Value: valueStr, 289 + Repo: repo, 290 + CreatedAt: createdAt, 291 + CreatedBy: syntax.DID(createdByStr), 292 + } 293 + 294 + secrets = append(secrets, secret) 295 + } 296 + 297 + v.logger.Debug("retrieved unlocked secrets", "repo", repo, "count", len(secrets)) 298 + return secrets, nil 299 + } 300 + 301 + // buildRepoPath creates a safe path for a repository 302 + func (v *OpenBaoManager) buildRepoPath(repo DidSlashRepo) string { 303 + // convert DidSlashRepo to a safe path by replacing special characters 304 + repoPath := strings.ReplaceAll(string(repo), "/", "_") 305 + repoPath = strings.ReplaceAll(repoPath, ":", "_") 306 + repoPath = strings.ReplaceAll(repoPath, ".", "_") 307 + return fmt.Sprintf("repos/%s", repoPath) 308 + } 309 + 310 + // buildSecretPath creates a path for a specific secret 311 + func (v *OpenBaoManager) buildSecretPath(repo DidSlashRepo, key string) string { 312 + return path.Join(v.buildRepoPath(repo), key) 313 + }
+605
spindle/secrets/openbao_test.go
··· 1 + package secrets 2 + 3 + import ( 4 + "context" 5 + "log/slog" 6 + "os" 7 + "testing" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/stretchr/testify/assert" 12 + ) 13 + 14 + // MockOpenBaoManager is a mock implementation of Manager interface for testing 15 + type MockOpenBaoManager struct { 16 + secrets map[string]UnlockedSecret // key: repo_key format 17 + shouldError bool 18 + errorToReturn error 19 + } 20 + 21 + func NewMockOpenBaoManager() *MockOpenBaoManager { 22 + return &MockOpenBaoManager{secrets: make(map[string]UnlockedSecret)} 23 + } 24 + 25 + func (m *MockOpenBaoManager) SetError(err error) { 26 + m.shouldError = true 27 + m.errorToReturn = err 28 + } 29 + 30 + func (m *MockOpenBaoManager) ClearError() { 31 + m.shouldError = false 32 + m.errorToReturn = nil 33 + } 34 + 35 + func (m *MockOpenBaoManager) buildKey(repo DidSlashRepo, key string) string { 36 + return string(repo) + "_" + key 37 + } 38 + 39 + func (m *MockOpenBaoManager) AddSecret(ctx context.Context, secret UnlockedSecret) error { 40 + if m.shouldError { 41 + return m.errorToReturn 42 + } 43 + 44 + key := m.buildKey(secret.Repo, secret.Key) 45 + if _, exists := m.secrets[key]; exists { 46 + return ErrKeyAlreadyPresent 47 + } 48 + 49 + m.secrets[key] = secret 50 + return nil 51 + } 52 + 53 + func (m *MockOpenBaoManager) RemoveSecret(ctx context.Context, secret Secret[any]) error { 54 + if m.shouldError { 55 + return m.errorToReturn 56 + } 57 + 58 + key := m.buildKey(secret.Repo, secret.Key) 59 + if _, exists := m.secrets[key]; !exists { 60 + return ErrKeyNotFound 61 + } 62 + 63 + delete(m.secrets, key) 64 + return nil 65 + } 66 + 67 + func (m *MockOpenBaoManager) GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) { 68 + if m.shouldError { 69 + return nil, m.errorToReturn 70 + } 71 + 72 + var result []LockedSecret 73 + for _, secret := range m.secrets { 74 + if secret.Repo == repo { 75 + result = append(result, LockedSecret{ 76 + Key: secret.Key, 77 + Repo: secret.Repo, 78 + CreatedAt: secret.CreatedAt, 79 + CreatedBy: secret.CreatedBy, 80 + }) 81 + } 82 + } 83 + 84 + return result, nil 85 + } 86 + 87 + func (m *MockOpenBaoManager) GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) { 88 + if m.shouldError { 89 + return nil, m.errorToReturn 90 + } 91 + 92 + var result []UnlockedSecret 93 + for _, secret := range m.secrets { 94 + if secret.Repo == repo { 95 + result = append(result, secret) 96 + } 97 + } 98 + 99 + return result, nil 100 + } 101 + 102 + func createTestSecretForOpenBao(repo, key, value, createdBy string) UnlockedSecret { 103 + return UnlockedSecret{ 104 + Key: key, 105 + Value: value, 106 + Repo: DidSlashRepo(repo), 107 + CreatedAt: time.Now(), 108 + CreatedBy: syntax.DID(createdBy), 109 + } 110 + } 111 + 112 + // Test MockOpenBaoManager interface compliance 113 + func TestMockOpenBaoManagerInterface(t *testing.T) { 114 + var _ Manager = (*MockOpenBaoManager)(nil) 115 + } 116 + 117 + func TestOpenBaoManagerInterface(t *testing.T) { 118 + var _ Manager = (*OpenBaoManager)(nil) 119 + } 120 + 121 + func TestNewOpenBaoManager(t *testing.T) { 122 + tests := []struct { 123 + name string 124 + proxyAddr string 125 + opts []OpenBaoManagerOpt 126 + expectError bool 127 + errorContains string 128 + }{ 129 + { 130 + name: "empty proxy address", 131 + proxyAddr: "", 132 + opts: nil, 133 + expectError: true, 134 + errorContains: "proxy address cannot be empty", 135 + }, 136 + { 137 + name: "valid proxy address", 138 + proxyAddr: "http://localhost:8200", 139 + opts: nil, 140 + expectError: true, // Will fail because no real proxy is running 141 + errorContains: "failed to connect to bao proxy", 142 + }, 143 + { 144 + name: "with mount path option", 145 + proxyAddr: "http://localhost:8200", 146 + opts: []OpenBaoManagerOpt{WithMountPath("custom-mount")}, 147 + expectError: true, // Will fail because no real proxy is running 148 + errorContains: "failed to connect to bao proxy", 149 + }, 150 + } 151 + 152 + for _, tt := range tests { 153 + t.Run(tt.name, func(t *testing.T) { 154 + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) 155 + manager, err := NewOpenBaoManager(tt.proxyAddr, logger, tt.opts...) 156 + 157 + if tt.expectError { 158 + assert.Error(t, err) 159 + assert.Nil(t, manager) 160 + assert.Contains(t, err.Error(), tt.errorContains) 161 + } else { 162 + assert.NoError(t, err) 163 + assert.NotNil(t, manager) 164 + } 165 + }) 166 + } 167 + } 168 + 169 + func TestOpenBaoManager_PathBuilding(t *testing.T) { 170 + manager := &OpenBaoManager{mountPath: "secret"} 171 + 172 + tests := []struct { 173 + name string 174 + repo DidSlashRepo 175 + key string 176 + expected string 177 + }{ 178 + { 179 + name: "simple repo path", 180 + repo: DidSlashRepo("did:plc:foo/repo"), 181 + key: "api_key", 182 + expected: "repos/did_plc_foo_repo/api_key", 183 + }, 184 + { 185 + name: "complex repo path with dots", 186 + repo: DidSlashRepo("did:web:example.com/my-repo"), 187 + key: "secret_key", 188 + expected: "repos/did_web_example_com_my-repo/secret_key", 189 + }, 190 + } 191 + 192 + for _, tt := range tests { 193 + t.Run(tt.name, func(t *testing.T) { 194 + result := manager.buildSecretPath(tt.repo, tt.key) 195 + assert.Equal(t, tt.expected, result) 196 + }) 197 + } 198 + } 199 + 200 + func TestOpenBaoManager_buildRepoPath(t *testing.T) { 201 + manager := &OpenBaoManager{mountPath: "test"} 202 + 203 + tests := []struct { 204 + name string 205 + repo DidSlashRepo 206 + expected string 207 + }{ 208 + { 209 + name: "simple repo", 210 + repo: "did:plc:test/myrepo", 211 + expected: "repos/did_plc_test_myrepo", 212 + }, 213 + { 214 + name: "repo with dots", 215 + repo: "did:plc:example.com/my.repo", 216 + expected: "repos/did_plc_example_com_my_repo", 217 + }, 218 + { 219 + name: "complex repo", 220 + repo: "did:web:example.com:8080/path/to/repo", 221 + expected: "repos/did_web_example_com_8080_path_to_repo", 222 + }, 223 + } 224 + 225 + for _, tt := range tests { 226 + t.Run(tt.name, func(t *testing.T) { 227 + result := manager.buildRepoPath(tt.repo) 228 + assert.Equal(t, tt.expected, result) 229 + }) 230 + } 231 + } 232 + 233 + func TestWithMountPath(t *testing.T) { 234 + manager := &OpenBaoManager{mountPath: "default"} 235 + 236 + opt := WithMountPath("custom-mount") 237 + opt(manager) 238 + 239 + assert.Equal(t, "custom-mount", manager.mountPath) 240 + } 241 + 242 + func TestMockOpenBaoManager_AddSecret(t *testing.T) { 243 + tests := []struct { 244 + name string 245 + secrets []UnlockedSecret 246 + expectError bool 247 + }{ 248 + { 249 + name: "add single secret", 250 + secrets: []UnlockedSecret{ 251 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"), 252 + }, 253 + expectError: false, 254 + }, 255 + { 256 + name: "add multiple secrets", 257 + secrets: []UnlockedSecret{ 258 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"), 259 + createTestSecretForOpenBao("did:plc:test/repo1", "DB_PASSWORD", "dbpass456", "did:plc:creator"), 260 + }, 261 + expectError: false, 262 + }, 263 + { 264 + name: "add duplicate secret", 265 + secrets: []UnlockedSecret{ 266 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"), 267 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "newsecret", "did:plc:creator"), 268 + }, 269 + expectError: true, 270 + }, 271 + } 272 + 273 + for _, tt := range tests { 274 + t.Run(tt.name, func(t *testing.T) { 275 + mock := NewMockOpenBaoManager() 276 + ctx := context.Background() 277 + var err error 278 + 279 + for i, secret := range tt.secrets { 280 + err = mock.AddSecret(ctx, secret) 281 + if tt.expectError && i == 1 { // Second secret should fail for duplicate test 282 + assert.Equal(t, ErrKeyAlreadyPresent, err) 283 + return 284 + } 285 + if !tt.expectError { 286 + assert.NoError(t, err) 287 + } 288 + } 289 + 290 + if !tt.expectError { 291 + assert.NoError(t, err) 292 + } 293 + }) 294 + } 295 + } 296 + 297 + func TestMockOpenBaoManager_RemoveSecret(t *testing.T) { 298 + tests := []struct { 299 + name string 300 + setupSecrets []UnlockedSecret 301 + removeSecret Secret[any] 302 + expectError bool 303 + }{ 304 + { 305 + name: "remove existing secret", 306 + setupSecrets: []UnlockedSecret{ 307 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"), 308 + }, 309 + removeSecret: Secret[any]{ 310 + Key: "API_KEY", 311 + Repo: DidSlashRepo("did:plc:test/repo1"), 312 + }, 313 + expectError: false, 314 + }, 315 + { 316 + name: "remove non-existent secret", 317 + setupSecrets: []UnlockedSecret{}, 318 + removeSecret: Secret[any]{ 319 + Key: "API_KEY", 320 + Repo: DidSlashRepo("did:plc:test/repo1"), 321 + }, 322 + expectError: true, 323 + }, 324 + } 325 + 326 + for _, tt := range tests { 327 + t.Run(tt.name, func(t *testing.T) { 328 + mock := NewMockOpenBaoManager() 329 + ctx := context.Background() 330 + 331 + // Setup secrets 332 + for _, secret := range tt.setupSecrets { 333 + err := mock.AddSecret(ctx, secret) 334 + assert.NoError(t, err) 335 + } 336 + 337 + // Remove secret 338 + err := mock.RemoveSecret(ctx, tt.removeSecret) 339 + 340 + if tt.expectError { 341 + assert.Equal(t, ErrKeyNotFound, err) 342 + } else { 343 + assert.NoError(t, err) 344 + } 345 + }) 346 + } 347 + } 348 + 349 + func TestMockOpenBaoManager_GetSecretsLocked(t *testing.T) { 350 + tests := []struct { 351 + name string 352 + setupSecrets []UnlockedSecret 353 + queryRepo DidSlashRepo 354 + expectedCount int 355 + expectedKeys []string 356 + expectError bool 357 + }{ 358 + { 359 + name: "get secrets from repo with secrets", 360 + setupSecrets: []UnlockedSecret{ 361 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"), 362 + createTestSecretForOpenBao("did:plc:test/repo1", "DB_PASSWORD", "dbpass456", "did:plc:creator"), 363 + createTestSecretForOpenBao("did:plc:test/repo2", "OTHER_KEY", "other789", "did:plc:creator"), 364 + }, 365 + queryRepo: DidSlashRepo("did:plc:test/repo1"), 366 + expectedCount: 2, 367 + expectedKeys: []string{"API_KEY", "DB_PASSWORD"}, 368 + expectError: false, 369 + }, 370 + { 371 + name: "get secrets from empty repo", 372 + setupSecrets: []UnlockedSecret{}, 373 + queryRepo: DidSlashRepo("did:plc:test/empty"), 374 + expectedCount: 0, 375 + expectedKeys: []string{}, 376 + expectError: false, 377 + }, 378 + } 379 + 380 + for _, tt := range tests { 381 + t.Run(tt.name, func(t *testing.T) { 382 + mock := NewMockOpenBaoManager() 383 + ctx := context.Background() 384 + 385 + // Setup 386 + for _, secret := range tt.setupSecrets { 387 + err := mock.AddSecret(ctx, secret) 388 + assert.NoError(t, err) 389 + } 390 + 391 + // Test 392 + secrets, err := mock.GetSecretsLocked(ctx, tt.queryRepo) 393 + 394 + if tt.expectError { 395 + assert.Error(t, err) 396 + } else { 397 + assert.NoError(t, err) 398 + assert.Len(t, secrets, tt.expectedCount) 399 + 400 + // Check keys 401 + actualKeys := make([]string, len(secrets)) 402 + for i, secret := range secrets { 403 + actualKeys[i] = secret.Key 404 + } 405 + 406 + for _, expectedKey := range tt.expectedKeys { 407 + assert.Contains(t, actualKeys, expectedKey) 408 + } 409 + } 410 + }) 411 + } 412 + } 413 + 414 + func TestMockOpenBaoManager_GetSecretsUnlocked(t *testing.T) { 415 + tests := []struct { 416 + name string 417 + setupSecrets []UnlockedSecret 418 + queryRepo DidSlashRepo 419 + expectedCount int 420 + expectedSecrets map[string]string // key -> value 421 + expectError bool 422 + }{ 423 + { 424 + name: "get unlocked secrets from repo", 425 + setupSecrets: []UnlockedSecret{ 426 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"), 427 + createTestSecretForOpenBao("did:plc:test/repo1", "DB_PASSWORD", "dbpass456", "did:plc:creator"), 428 + createTestSecretForOpenBao("did:plc:test/repo2", "OTHER_KEY", "other789", "did:plc:creator"), 429 + }, 430 + queryRepo: DidSlashRepo("did:plc:test/repo1"), 431 + expectedCount: 2, 432 + expectedSecrets: map[string]string{ 433 + "API_KEY": "secret123", 434 + "DB_PASSWORD": "dbpass456", 435 + }, 436 + expectError: false, 437 + }, 438 + { 439 + name: "get secrets from empty repo", 440 + setupSecrets: []UnlockedSecret{}, 441 + queryRepo: DidSlashRepo("did:plc:test/empty"), 442 + expectedCount: 0, 443 + expectedSecrets: map[string]string{}, 444 + expectError: false, 445 + }, 446 + } 447 + 448 + for _, tt := range tests { 449 + t.Run(tt.name, func(t *testing.T) { 450 + mock := NewMockOpenBaoManager() 451 + ctx := context.Background() 452 + 453 + // Setup 454 + for _, secret := range tt.setupSecrets { 455 + err := mock.AddSecret(ctx, secret) 456 + assert.NoError(t, err) 457 + } 458 + 459 + // Test 460 + secrets, err := mock.GetSecretsUnlocked(ctx, tt.queryRepo) 461 + 462 + if tt.expectError { 463 + assert.Error(t, err) 464 + } else { 465 + assert.NoError(t, err) 466 + assert.Len(t, secrets, tt.expectedCount) 467 + 468 + // Check key-value pairs 469 + actualSecrets := make(map[string]string) 470 + for _, secret := range secrets { 471 + actualSecrets[secret.Key] = secret.Value 472 + } 473 + 474 + for expectedKey, expectedValue := range tt.expectedSecrets { 475 + actualValue, exists := actualSecrets[expectedKey] 476 + assert.True(t, exists, "Expected key %s not found", expectedKey) 477 + assert.Equal(t, expectedValue, actualValue) 478 + } 479 + } 480 + }) 481 + } 482 + } 483 + 484 + func TestMockOpenBaoManager_ErrorHandling(t *testing.T) { 485 + mock := NewMockOpenBaoManager() 486 + ctx := context.Background() 487 + testError := assert.AnError 488 + 489 + // Test error injection 490 + mock.SetError(testError) 491 + 492 + secret := createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator") 493 + 494 + // All operations should return the injected error 495 + err := mock.AddSecret(ctx, secret) 496 + assert.Equal(t, testError, err) 497 + 498 + _, err = mock.GetSecretsLocked(ctx, "did:plc:test/repo1") 499 + assert.Equal(t, testError, err) 500 + 501 + _, err = mock.GetSecretsUnlocked(ctx, "did:plc:test/repo1") 502 + assert.Equal(t, testError, err) 503 + 504 + err = mock.RemoveSecret(ctx, Secret[any]{Key: "API_KEY", Repo: "did:plc:test/repo1"}) 505 + assert.Equal(t, testError, err) 506 + 507 + // Clear error and test normal operation 508 + mock.ClearError() 509 + err = mock.AddSecret(ctx, secret) 510 + assert.NoError(t, err) 511 + } 512 + 513 + func TestMockOpenBaoManager_Integration(t *testing.T) { 514 + tests := []struct { 515 + name string 516 + scenario func(t *testing.T, mock *MockOpenBaoManager) 517 + }{ 518 + { 519 + name: "complete workflow", 520 + scenario: func(t *testing.T, mock *MockOpenBaoManager) { 521 + ctx := context.Background() 522 + repo := DidSlashRepo("did:plc:test/integration") 523 + 524 + // Start with empty repo 525 + secrets, err := mock.GetSecretsLocked(ctx, repo) 526 + assert.NoError(t, err) 527 + assert.Empty(t, secrets) 528 + 529 + // Add some secrets 530 + secret1 := createTestSecretForOpenBao(string(repo), "API_KEY", "secret123", "did:plc:creator") 531 + secret2 := createTestSecretForOpenBao(string(repo), "DB_PASSWORD", "dbpass456", "did:plc:creator") 532 + 533 + err = mock.AddSecret(ctx, secret1) 534 + assert.NoError(t, err) 535 + 536 + err = mock.AddSecret(ctx, secret2) 537 + assert.NoError(t, err) 538 + 539 + // Verify secrets exist 540 + secrets, err = mock.GetSecretsLocked(ctx, repo) 541 + assert.NoError(t, err) 542 + assert.Len(t, secrets, 2) 543 + 544 + unlockedSecrets, err := mock.GetSecretsUnlocked(ctx, repo) 545 + assert.NoError(t, err) 546 + assert.Len(t, unlockedSecrets, 2) 547 + 548 + // Remove one secret 549 + err = mock.RemoveSecret(ctx, Secret[any]{Key: "API_KEY", Repo: repo}) 550 + assert.NoError(t, err) 551 + 552 + // Verify only one secret remains 553 + secrets, err = mock.GetSecretsLocked(ctx, repo) 554 + assert.NoError(t, err) 555 + assert.Len(t, secrets, 1) 556 + assert.Equal(t, "DB_PASSWORD", secrets[0].Key) 557 + }, 558 + }, 559 + } 560 + 561 + for _, tt := range tests { 562 + t.Run(tt.name, func(t *testing.T) { 563 + mock := NewMockOpenBaoManager() 564 + tt.scenario(t, mock) 565 + }) 566 + } 567 + } 568 + 569 + func TestOpenBaoManager_ProxyConfiguration(t *testing.T) { 570 + tests := []struct { 571 + name string 572 + proxyAddr string 573 + description string 574 + }{ 575 + { 576 + name: "default_localhost", 577 + proxyAddr: "http://127.0.0.1:8200", 578 + description: "Should connect to default localhost proxy", 579 + }, 580 + { 581 + name: "custom_host", 582 + proxyAddr: "http://bao-proxy:8200", 583 + description: "Should connect to custom proxy host", 584 + }, 585 + { 586 + name: "https_proxy", 587 + proxyAddr: "https://127.0.0.1:8200", 588 + description: "Should connect to HTTPS proxy", 589 + }, 590 + } 591 + 592 + for _, tt := range tests { 593 + t.Run(tt.name, func(t *testing.T) { 594 + t.Log("Testing scenario:", tt.description) 595 + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) 596 + 597 + // All these will fail because no real proxy is running 598 + // but we can test that the configuration is properly accepted 599 + manager, err := NewOpenBaoManager(tt.proxyAddr, logger) 600 + assert.Error(t, err) // Expected because no real proxy 601 + assert.Nil(t, manager) 602 + assert.Contains(t, err.Error(), "failed to connect to bao proxy") 603 + }) 604 + } 605 + }
+22
spindle/secrets/policy.hcl
··· 1 + # Allow full access to the spindle KV mount 2 + path "spindle/*" { 3 + capabilities = ["create", "read", "update", "delete", "list"] 4 + } 5 + 6 + path "spindle/data/*" { 7 + capabilities = ["create", "read", "update", "delete"] 8 + } 9 + 10 + path "spindle/metadata/*" { 11 + capabilities = ["list", "read", "delete"] 12 + } 13 + 14 + # Allow listing mounts (for connection testing) 15 + path "sys/mounts" { 16 + capabilities = ["read"] 17 + } 18 + 19 + # Allow token self-lookup (for health checks) 20 + path "auth/token/lookup-self" { 21 + capabilities = ["read"] 22 + }
+10 -9
spindle/secrets/sqlite.go
··· 2 2 package secrets 3 3 4 4 import ( 5 + "context" 5 6 "database/sql" 6 7 "fmt" 7 8 "time" ··· 23 24 } 24 25 25 26 func NewSQLiteManager(dbPath string, opts ...SqliteManagerOpt) (*SqliteManager, error) { 26 - db, err := sql.Open("sqlite3", dbPath) 27 + db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1") 27 28 if err != nil { 28 29 return nil, fmt.Errorf("failed to open sqlite database: %w", err) 29 30 } ··· 61 62 return err 62 63 } 63 64 64 - func (s *SqliteManager) AddSecret(secret UnlockedSecret) error { 65 + func (s *SqliteManager) AddSecret(ctx context.Context, secret UnlockedSecret) error { 65 66 query := fmt.Sprintf(` 66 67 insert or ignore into %s (repo, key, value, created_by) 67 68 values (?, ?, ?, ?); 68 69 `, s.tableName) 69 70 70 - res, err := s.db.Exec(query, secret.Repo, secret.Key, secret.Value, secret.CreatedBy) 71 + res, err := s.db.ExecContext(ctx, query, secret.Repo, secret.Key, secret.Value, secret.CreatedBy) 71 72 if err != nil { 72 73 return err 73 74 } ··· 84 85 return nil 85 86 } 86 87 87 - func (s *SqliteManager) RemoveSecret(secret Secret[any]) error { 88 + func (s *SqliteManager) RemoveSecret(ctx context.Context, secret Secret[any]) error { 88 89 query := fmt.Sprintf(` 89 90 delete from %s where repo = ? and key = ?; 90 91 `, s.tableName) 91 92 92 - res, err := s.db.Exec(query, secret.Repo, secret.Key) 93 + res, err := s.db.ExecContext(ctx, query, secret.Repo, secret.Key) 93 94 if err != nil { 94 95 return err 95 96 } ··· 106 107 return nil 107 108 } 108 109 109 - func (s *SqliteManager) GetSecretsLocked(didSlashRepo DidSlashRepo) ([]LockedSecret, error) { 110 + func (s *SqliteManager) GetSecretsLocked(ctx context.Context, didSlashRepo DidSlashRepo) ([]LockedSecret, error) { 110 111 query := fmt.Sprintf(` 111 112 select repo, key, created_at, created_by from %s where repo = ?; 112 113 `, s.tableName) 113 114 114 - rows, err := s.db.Query(query, didSlashRepo) 115 + rows, err := s.db.QueryContext(ctx, query, didSlashRepo) 115 116 if err != nil { 116 117 return nil, err 117 118 } ··· 138 139 return ls, nil 139 140 } 140 141 141 - func (s *SqliteManager) GetSecretsUnlocked(didSlashRepo DidSlashRepo) ([]UnlockedSecret, error) { 142 + func (s *SqliteManager) GetSecretsUnlocked(ctx context.Context, didSlashRepo DidSlashRepo) ([]UnlockedSecret, error) { 142 143 query := fmt.Sprintf(` 143 144 select repo, key, value, created_at, created_by from %s where repo = ?; 144 145 `, s.tableName) 145 146 146 - rows, err := s.db.Query(query, didSlashRepo) 147 + rows, err := s.db.QueryContext(ctx, query, didSlashRepo) 147 148 if err != nil { 148 149 return nil, err 149 150 }
+31 -21
spindle/secrets/sqlite_test.go
··· 1 1 package secrets 2 2 3 3 import ( 4 + "context" 4 5 "testing" 5 6 "time" 6 7 8 + "github.com/alecthomas/assert/v2" 7 9 "github.com/bluesky-social/indigo/atproto/syntax" 8 10 ) 9 11 ··· 122 124 defer manager.db.Close() 123 125 124 126 for i, secret := range tt.secrets { 125 - err := manager.AddSecret(secret) 127 + err := manager.AddSecret(context.Background(), secret) 126 128 if err != tt.expectError[i] { 127 129 t.Errorf("Secret %d: expected error %v, got %v", i, tt.expectError[i], err) 128 130 } ··· 189 191 190 192 // Setup secrets 191 193 for _, secret := range tt.setupSecrets { 192 - if err := manager.AddSecret(secret); err != nil { 194 + if err := manager.AddSecret(context.Background(), secret); err != nil { 193 195 t.Fatalf("Failed to setup secret: %v", err) 194 196 } 195 197 } 196 198 197 199 // Test removal 198 - err := manager.RemoveSecret(tt.removeSecret) 200 + err := manager.RemoveSecret(context.Background(), tt.removeSecret) 199 201 if err != tt.expectError { 200 202 t.Errorf("Expected error %v, got %v", tt.expectError, err) 201 203 } ··· 262 264 263 265 // Setup secrets 264 266 for _, secret := range tt.setupSecrets { 265 - if err := manager.AddSecret(secret); err != nil { 267 + if err := manager.AddSecret(context.Background(), secret); err != nil { 266 268 t.Fatalf("Failed to setup secret: %v", err) 267 269 } 268 270 } 269 271 270 272 // Test getting locked secrets 271 - lockedSecrets, err := manager.GetSecretsLocked(tt.queryRepo) 273 + lockedSecrets, err := manager.GetSecretsLocked(context.Background(), tt.queryRepo) 272 274 if tt.expectError && err == nil { 273 275 t.Error("Expected error but got none") 274 276 return ··· 369 371 370 372 // Setup secrets 371 373 for _, secret := range tt.setupSecrets { 372 - if err := manager.AddSecret(secret); err != nil { 374 + if err := manager.AddSecret(context.Background(), secret); err != nil { 373 375 t.Fatalf("Failed to setup secret: %v", err) 374 376 } 375 377 } 376 378 377 379 // Test getting unlocked secrets 378 - unlockedSecrets, err := manager.GetSecretsUnlocked(tt.queryRepo) 380 + unlockedSecrets, err := manager.GetSecretsUnlocked(context.Background(), tt.queryRepo) 379 381 if tt.expectError && err == nil { 380 382 t.Error("Expected error but got none") 381 383 return ··· 424 426 operations: []func(Manager) error{ 425 427 func(m Manager) error { 426 428 secret := createTestSecret("interface.test/repo", "test_key", "test_value", "did:plc:user") 427 - return m.AddSecret(secret) 429 + return m.AddSecret(context.Background(), secret) 428 430 }, 429 431 func(m Manager) error { 430 - _, err := m.GetSecretsLocked(DidSlashRepo("interface.test/repo")) 432 + _, err := m.GetSecretsLocked(context.Background(), DidSlashRepo("interface.test/repo")) 431 433 return err 432 434 }, 433 435 func(m Manager) error { 434 - _, err := m.GetSecretsUnlocked(DidSlashRepo("interface.test/repo")) 436 + _, err := m.GetSecretsUnlocked(context.Background(), DidSlashRepo("interface.test/repo")) 435 437 return err 436 438 }, 437 439 func(m Manager) error { ··· 439 441 Key: "test_key", 440 442 Repo: DidSlashRepo("interface.test/repo"), 441 443 } 442 - return m.RemoveSecret(secret) 444 + return m.RemoveSecret(context.Background(), secret) 443 445 }, 444 446 }, 445 447 expectError: false, ··· 449 451 operations: []func(Manager) error{ 450 452 func(m Manager) error { 451 453 secret := createTestSecret("interface.test/repo", "dup_key", "value1", "did:plc:user") 452 - return m.AddSecret(secret) 454 + return m.AddSecret(context.Background(), secret) 453 455 }, 454 456 func(m Manager) error { 455 457 secret := createTestSecret("interface.test/repo", "dup_key", "value2", "did:plc:user") 456 - return m.AddSecret(secret) // Should return ErrKeyAlreadyPresent 458 + return m.AddSecret(context.Background(), secret) // Should return ErrKeyAlreadyPresent 457 459 }, 458 460 }, 459 461 expectError: true, ··· 507 509 508 510 // Add all secrets 509 511 for _, secret := range secrets { 510 - if err := manager.AddSecret(secret); err != nil { 512 + if err := manager.AddSecret(context.Background(), secret); err != nil { 511 513 t.Fatalf("Failed to add secret %s: %v", secret.Key, err) 512 514 } 513 515 } 514 516 515 517 // Verify counts 516 - locked1, _ := manager.GetSecretsLocked(repo1) 517 - locked2, _ := manager.GetSecretsLocked(repo2) 518 + locked1, _ := manager.GetSecretsLocked(context.Background(), repo1) 519 + locked2, _ := manager.GetSecretsLocked(context.Background(), repo2) 518 520 519 521 if len(locked1) != 2 { 520 522 t.Errorf("Expected 2 secrets for repo1, got %d", len(locked1)) ··· 525 527 526 528 // Remove and verify 527 529 secretToRemove := Secret[any]{Key: "db_password", Repo: repo1} 528 - if err := manager.RemoveSecret(secretToRemove); err != nil { 530 + if err := manager.RemoveSecret(context.Background(), secretToRemove); err != nil { 529 531 t.Fatalf("Failed to remove secret: %v", err) 530 532 } 531 533 532 - locked1After, _ := manager.GetSecretsLocked(repo1) 534 + locked1After, _ := manager.GetSecretsLocked(context.Background(), repo1) 533 535 if len(locked1After) != 1 { 534 536 t.Errorf("Expected 1 secret for repo1 after removal, got %d", len(locked1After)) 535 537 } ··· 544 546 repo := DidSlashRepo("empty.test/repo") 545 547 546 548 // Operations on empty database should not error 547 - locked, err := manager.GetSecretsLocked(repo) 549 + locked, err := manager.GetSecretsLocked(context.Background(), repo) 548 550 if err != nil { 549 551 t.Errorf("GetSecretsLocked on empty DB failed: %v", err) 550 552 } ··· 552 554 t.Errorf("Expected 0 secrets, got %d", len(locked)) 553 555 } 554 556 555 - unlocked, err := manager.GetSecretsUnlocked(repo) 557 + unlocked, err := manager.GetSecretsUnlocked(context.Background(), repo) 556 558 if err != nil { 557 559 t.Errorf("GetSecretsUnlocked on empty DB failed: %v", err) 558 560 } ··· 562 564 563 565 // Remove from empty should return ErrKeyNotFound 564 566 nonExistent := Secret[any]{Key: "none", Repo: repo} 565 - err = manager.RemoveSecret(nonExistent) 567 + err = manager.RemoveSecret(context.Background(), nonExistent) 566 568 if err != ErrKeyNotFound { 567 569 t.Errorf("Expected ErrKeyNotFound, got %v", err) 568 570 } ··· 578 580 }) 579 581 } 580 582 } 583 + 584 + func TestSqliteManager_StopperInterface(t *testing.T) { 585 + manager := &SqliteManager{} 586 + 587 + // Verify that SqliteManager does NOT implement the Stopper interface 588 + _, ok := interface{}(manager).(Stopper) 589 + assert.False(t, ok, "SqliteManager should NOT implement Stopper interface") 590 + }
+93 -15
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 ··· 68 70 69 71 n := notifier.New() 70 72 71 - eng, err := engine.New(ctx, cfg, d, &n) 73 + var vault secrets.Manager 74 + switch cfg.Server.Secrets.Provider { 75 + case "openbao": 76 + if cfg.Server.Secrets.OpenBao.ProxyAddr == "" { 77 + return fmt.Errorf("openbao proxy address is required when using openbao secrets provider") 78 + } 79 + vault, err = secrets.NewOpenBaoManager( 80 + cfg.Server.Secrets.OpenBao.ProxyAddr, 81 + logger, 82 + secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount), 83 + ) 84 + if err != nil { 85 + return fmt.Errorf("failed to setup openbao secrets provider: %w", err) 86 + } 87 + logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount) 88 + case "sqlite", "": 89 + vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 90 + if err != nil { 91 + return fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 92 + } 93 + logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath) 94 + default: 95 + return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 96 + } 97 + 98 + nixeryEng, err := nixery.New(ctx, cfg) 72 99 if err != nil { 73 100 return err 74 101 } 75 102 76 - jq := queue.NewQueue(100, 2) 103 + jq := queue.NewQueue(100, 5) 77 104 78 105 collections := []string{ 79 106 tangled.SpindleMemberNSID, 80 107 tangled.RepoNSID, 108 + tangled.RepoCollaboratorNSID, 81 109 } 82 110 jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true) 83 111 if err != nil { ··· 85 113 } 86 114 jc.AddDid(cfg.Server.Owner) 87 115 116 + // Check if the spindle knows about any Dids; 117 + dids, err := d.GetAllDids() 118 + if err != nil { 119 + return fmt.Errorf("failed to get all dids: %w", err) 120 + } 121 + for _, d := range dids { 122 + jc.AddDid(d) 123 + } 124 + 88 125 resolver := idresolver.DefaultResolver() 89 126 90 127 spindle := Spindle{ ··· 93 130 db: d, 94 131 l: logger, 95 132 n: &n, 96 - eng: eng, 133 + engs: map[string]models.Engine{"nixery": nixeryEng}, 97 134 jq: jq, 98 135 cfg: cfg, 99 136 res: resolver, ··· 113 150 // starts a job queue runner in the background 114 151 jq.Start() 115 152 defer jq.Stop() 153 + 154 + // Stop vault token renewal if it implements Stopper 155 + if stopper, ok := vault.(secrets.Stopper); ok { 156 + defer stopper.Stop() 157 + } 116 158 117 159 cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath) 118 160 if err != nil { ··· 172 214 func (s *Spindle) XrpcRouter() http.Handler { 173 215 logger := s.l.With("route", "xrpc") 174 216 217 + serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String()) 218 + 175 219 x := xrpc.Xrpc{ 176 - Logger: logger, 177 - Db: s.db, 178 - Enforcer: s.e, 179 - Engine: s.eng, 180 - Config: s.cfg, 181 - Resolver: s.res, 182 - 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, 183 228 } 184 229 185 230 return x.Router() ··· 202 247 return fmt.Errorf("no repo data found") 203 248 } 204 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 + 205 254 // filter by repos 206 255 _, err = s.db.GetRepo( 207 256 tpl.TriggerMetadata.Repo.Knot, ··· 217 266 Rkey: msg.Rkey, 218 267 } 219 268 269 + workflows := make(map[models.Engine][]models.Workflow) 270 + 220 271 for _, w := range tpl.Workflows { 221 272 if w != nil { 222 - 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{ 223 299 PipelineId: pipelineId, 224 300 Name: w.Name, 225 301 }, s.n) ··· 228 304 } 229 305 } 230 306 } 231 - 232 - spl := models.ToPipeline(tpl, *s.cfg) 233 307 234 308 ok := s.jq.Enqueue(queue.Job{ 235 309 Run: func() error { 236 - 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) 237 315 return nil 238 316 }, 239 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,
+92
spindle/xrpc/add_secret.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/bluesky-social/indigo/xrpc" 12 + securejoin "github.com/cyphar/filepath-securejoin" 13 + "tangled.sh/tangled.sh/core/api/tangled" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + "tangled.sh/tangled.sh/core/spindle/secrets" 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 + ) 18 + 19 + func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) { 20 + l := x.Logger 21 + fail := func(e xrpcerr.XrpcError) { 22 + l.Error("failed", "kind", e.Tag, "error", e.Message) 23 + writeError(w, e, http.StatusBadRequest) 24 + } 25 + 26 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 + if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 + return 30 + } 31 + 32 + var data tangled.RepoAddSecret_Input 33 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 + fail(xrpcerr.GenericError(err)) 35 + return 36 + } 37 + 38 + if err := secrets.ValidateKey(data.Key); err != nil { 39 + fail(xrpcerr.GenericError(err)) 40 + return 41 + } 42 + 43 + // unfortunately we have to resolve repo-at here 44 + repoAt, err := syntax.ParseATURI(data.Repo) 45 + if err != nil { 46 + fail(xrpcerr.InvalidRepoError(data.Repo)) 47 + return 48 + } 49 + 50 + // resolve this aturi to extract the repo record 51 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 52 + if err != nil || ident.Handle.IsInvalidHandle() { 53 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 54 + return 55 + } 56 + 57 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 58 + resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 59 + if err != nil { 60 + fail(xrpcerr.GenericError(err)) 61 + return 62 + } 63 + 64 + repo := resp.Value.Val.(*tangled.Repo) 65 + didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 66 + if err != nil { 67 + fail(xrpcerr.GenericError(err)) 68 + return 69 + } 70 + 71 + if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 72 + l.Error("insufficent permissions", "did", actorDid.String()) 73 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 74 + return 75 + } 76 + 77 + secret := secrets.UnlockedSecret{ 78 + Repo: secrets.DidSlashRepo(didPath), 79 + Key: data.Key, 80 + Value: data.Value, 81 + CreatedAt: time.Now(), 82 + CreatedBy: actorDid, 83 + } 84 + err = x.Vault.AddSecret(r.Context(), secret) 85 + if err != nil { 86 + l.Error("failed to add secret to vault", "did", actorDid.String(), "err", err) 87 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 88 + return 89 + } 90 + 91 + w.WriteHeader(http.StatusOK) 92 + }
+92
spindle/xrpc/list_secrets.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/bluesky-social/indigo/xrpc" 12 + securejoin "github.com/cyphar/filepath-securejoin" 13 + "tangled.sh/tangled.sh/core/api/tangled" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + "tangled.sh/tangled.sh/core/spindle/secrets" 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 + ) 18 + 19 + func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) { 20 + l := x.Logger 21 + fail := func(e xrpcerr.XrpcError) { 22 + l.Error("failed", "kind", e.Tag, "error", e.Message) 23 + writeError(w, e, http.StatusBadRequest) 24 + } 25 + 26 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 + if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 + return 30 + } 31 + 32 + repoParam := r.URL.Query().Get("repo") 33 + if repoParam == "" { 34 + fail(xrpcerr.GenericError(fmt.Errorf("empty params"))) 35 + return 36 + } 37 + 38 + // unfortunately we have to resolve repo-at here 39 + repoAt, err := syntax.ParseATURI(repoParam) 40 + if err != nil { 41 + fail(xrpcerr.InvalidRepoError(repoParam)) 42 + return 43 + } 44 + 45 + // resolve this aturi to extract the repo record 46 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 47 + if err != nil || ident.Handle.IsInvalidHandle() { 48 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 49 + return 50 + } 51 + 52 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 53 + resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 54 + if err != nil { 55 + fail(xrpcerr.GenericError(err)) 56 + return 57 + } 58 + 59 + repo := resp.Value.Val.(*tangled.Repo) 60 + didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 61 + if err != nil { 62 + fail(xrpcerr.GenericError(err)) 63 + return 64 + } 65 + 66 + if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 67 + l.Error("insufficent permissions", "did", actorDid.String()) 68 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 69 + return 70 + } 71 + 72 + ls, err := x.Vault.GetSecretsLocked(r.Context(), secrets.DidSlashRepo(didPath)) 73 + if err != nil { 74 + l.Error("failed to get secret from vault", "did", actorDid.String(), "err", err) 75 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 76 + return 77 + } 78 + 79 + var out tangled.RepoListSecrets_Output 80 + for _, l := range ls { 81 + out.Secrets = append(out.Secrets, &tangled.RepoListSecrets_Secret{ 82 + Repo: repoAt.String(), 83 + Key: l.Key, 84 + CreatedAt: l.CreatedAt.Format(time.RFC3339), 85 + CreatedBy: l.CreatedBy.String(), 86 + }) 87 + } 88 + 89 + w.Header().Set("Content-Type", "application/json") 90 + w.WriteHeader(http.StatusOK) 91 + json.NewEncoder(w).Encode(out) 92 + }
+83
spindle/xrpc/remove_secret.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/bluesky-social/indigo/xrpc" 11 + securejoin "github.com/cyphar/filepath-securejoin" 12 + "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + "tangled.sh/tangled.sh/core/spindle/secrets" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + ) 17 + 18 + func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) { 19 + l := x.Logger 20 + fail := func(e xrpcerr.XrpcError) { 21 + l.Error("failed", "kind", e.Tag, "error", e.Message) 22 + writeError(w, e, http.StatusBadRequest) 23 + } 24 + 25 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 + if !ok { 27 + fail(xrpcerr.MissingActorDidError) 28 + return 29 + } 30 + 31 + var data tangled.RepoRemoveSecret_Input 32 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 + fail(xrpcerr.GenericError(err)) 34 + return 35 + } 36 + 37 + // unfortunately we have to resolve repo-at here 38 + repoAt, err := syntax.ParseATURI(data.Repo) 39 + if err != nil { 40 + fail(xrpcerr.InvalidRepoError(data.Repo)) 41 + return 42 + } 43 + 44 + // resolve this aturi to extract the repo record 45 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 46 + if err != nil || ident.Handle.IsInvalidHandle() { 47 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 48 + return 49 + } 50 + 51 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 52 + resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 53 + if err != nil { 54 + fail(xrpcerr.GenericError(err)) 55 + return 56 + } 57 + 58 + repo := resp.Value.Val.(*tangled.Repo) 59 + didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 60 + if err != nil { 61 + fail(xrpcerr.GenericError(err)) 62 + return 63 + } 64 + 65 + if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 66 + l.Error("insufficent permissions", "did", actorDid.String()) 67 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 68 + return 69 + } 70 + 71 + secret := secrets.Secret[any]{ 72 + Repo: secrets.DidSlashRepo(didPath), 73 + Key: data.Key, 74 + } 75 + err = x.Vault.RemoveSecret(r.Context(), secret) 76 + if err != nil { 77 + l.Error("failed to remove secret from vault", "did", actorDid.String(), "err", err) 78 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 79 + return 80 + } 81 + 82 + w.WriteHeader(http.StatusOK) 83 + }
+52
spindle/xrpc/xrpc.go
··· 1 + package xrpc 2 + 3 + import ( 4 + _ "embed" 5 + "encoding/json" 6 + "log/slog" 7 + "net/http" 8 + 9 + "github.com/go-chi/chi/v5" 10 + 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/idresolver" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + "tangled.sh/tangled.sh/core/spindle/config" 15 + "tangled.sh/tangled.sh/core/spindle/db" 16 + "tangled.sh/tangled.sh/core/spindle/models" 17 + "tangled.sh/tangled.sh/core/spindle/secrets" 18 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 19 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 20 + ) 21 + 22 + const ActorDid string = "ActorDid" 23 + 24 + type Xrpc struct { 25 + Logger *slog.Logger 26 + Db *db.DB 27 + Enforcer *rbac.Enforcer 28 + Engines map[string]models.Engine 29 + Config *config.Config 30 + Resolver *idresolver.Resolver 31 + Vault secrets.Manager 32 + ServiceAuth *serviceauth.ServiceAuth 33 + } 34 + 35 + func (x *Xrpc) Router() http.Handler { 36 + r := chi.NewRouter() 37 + 38 + r.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) 41 + 42 + return r 43 + } 44 + 45 + // this is slightly different from http_util::write_error to follow the spec: 46 + // 47 + // the json object returned must include an "error" and a "message" 48 + func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 49 + w.Header().Set("Content-Type", "application/json") 50 + w.WriteHeader(status) 51 + json.NewEncoder(w).Encode(e) 52 + }
+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 + }