+1
-1
.air/appview.toml
+1
-1
.air/appview.toml
+4
.gitignore
+4
.gitignore
+12
.prettierrc.json
+12
.prettierrc.json
+2
.tangled/workflows/build.yml
+2
.tangled/workflows/build.yml
+3
-12
.tangled/workflows/fmt.yml
+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
.tangled/workflows/test.yml
-16
.zed/settings.json
-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
+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
+
}
+31
api/tangled/repoaddSecret.go
+31
api/tangled/repoaddSecret.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.addSecret
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoAddSecretNSID = "sh.tangled.repo.addSecret"
15
+
)
16
+
17
+
// RepoAddSecret_Input is the input argument to a sh.tangled.repo.addSecret call.
18
+
type RepoAddSecret_Input struct {
19
+
Key string `json:"key" cborgen:"key"`
20
+
Repo string `json:"repo" cborgen:"repo"`
21
+
Value string `json:"value" cborgen:"value"`
22
+
}
23
+
24
+
// RepoAddSecret calls the XRPC method "sh.tangled.repo.addSecret".
25
+
func RepoAddSecret(ctx context.Context, c util.LexClient, input *RepoAddSecret_Input) error {
26
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.addSecret", nil, input, nil); err != nil {
27
+
return err
28
+
}
29
+
30
+
return nil
31
+
}
+25
api/tangled/repocollaborator.go
+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
+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
+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
+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
+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
+
}
+41
api/tangled/repolistSecrets.go
+41
api/tangled/repolistSecrets.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.listSecrets
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoListSecretsNSID = "sh.tangled.repo.listSecrets"
15
+
)
16
+
17
+
// RepoListSecrets_Output is the output of a sh.tangled.repo.listSecrets call.
18
+
type RepoListSecrets_Output struct {
19
+
Secrets []*RepoListSecrets_Secret `json:"secrets" cborgen:"secrets"`
20
+
}
21
+
22
+
// RepoListSecrets_Secret is a "secret" in the sh.tangled.repo.listSecrets schema.
23
+
type RepoListSecrets_Secret struct {
24
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
25
+
CreatedBy string `json:"createdBy" cborgen:"createdBy"`
26
+
Key string `json:"key" cborgen:"key"`
27
+
Repo string `json:"repo" cborgen:"repo"`
28
+
}
29
+
30
+
// RepoListSecrets calls the XRPC method "sh.tangled.repo.listSecrets".
31
+
func RepoListSecrets(ctx context.Context, c util.LexClient, repo string) (*RepoListSecrets_Output, error) {
32
+
var out RepoListSecrets_Output
33
+
34
+
params := map[string]interface{}{}
35
+
params["repo"] = repo
36
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.listSecrets", params, nil, &out); err != nil {
37
+
return nil, err
38
+
}
39
+
40
+
return &out, nil
41
+
}
+44
api/tangled/repomerge.go
+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
+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
+
}
+30
api/tangled/reporemoveSecret.go
+30
api/tangled/reporemoveSecret.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.removeSecret
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoRemoveSecretNSID = "sh.tangled.repo.removeSecret"
15
+
)
16
+
17
+
// RepoRemoveSecret_Input is the input argument to a sh.tangled.repo.removeSecret call.
18
+
type RepoRemoveSecret_Input struct {
19
+
Key string `json:"key" cborgen:"key"`
20
+
Repo string `json:"repo" cborgen:"repo"`
21
+
}
22
+
23
+
// RepoRemoveSecret calls the XRPC method "sh.tangled.repo.removeSecret".
24
+
func RepoRemoveSecret(ctx context.Context, c util.LexClient, input *RepoRemoveSecret_Input) error {
25
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.removeSecret", nil, input, nil); err != nil {
26
+
return err
27
+
}
28
+
29
+
return nil
30
+
}
+22
api/tangled/tangledknot.go
+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
+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
+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
+1
appview/cache/session/store.go
+21
-5
appview/config/config.go
+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
+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
+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
+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
+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
+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
-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
-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
+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
+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
+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
+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(®istration.Id, ®istration.Domain, ®istration.ByDid, &createdAt, ®isteredAt)
81
+
var createdAt string
82
+
var registeredAt sql.Null[string]
83
+
var readOnly int
84
+
var reg Registration
52
85
86
+
err = rows.Scan(®.Id, ®.Domain, ®.ByDid, &createdAt, ®isteredAt, &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(®istration.Id, ®istration.Domain, ®istration.ByDid, &createdAt, ®isteredAt)
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 ®istration, 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
+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
+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
+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
+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
+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
+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
+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
+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
+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: ®istration,
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
+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
+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
}
+88
-2
appview/oauth/oauth.go
+88
-2
appview/oauth/oauth.go
···
7
7
"net/url"
8
8
"time"
9
9
10
+
indigo_xrpc "github.com/bluesky-social/indigo/xrpc"
10
11
"github.com/gorilla/sessions"
11
12
oauth "tangled.sh/icyphox.sh/atproto-oauth"
12
13
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
···
102
103
if err != nil {
103
104
return nil, false, fmt.Errorf("error parsing expiry time: %w", err)
104
105
}
105
-
if expiry.Sub(time.Now()) <= 5*time.Minute {
106
+
if time.Until(expiry) <= 5*time.Minute {
106
107
privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk))
107
108
if err != nil {
108
109
return nil, false, err
···
206
207
return xrpcClient, nil
207
208
}
208
209
210
+
// use this to create a client to communicate with knots or spindles
211
+
//
212
+
// this is a higher level abstraction on ServerGetServiceAuth
213
+
type ServiceClientOpts struct {
214
+
service string
215
+
exp int64
216
+
lxm string
217
+
dev bool
218
+
}
219
+
220
+
type ServiceClientOpt func(*ServiceClientOpts)
221
+
222
+
func WithService(service string) ServiceClientOpt {
223
+
return func(s *ServiceClientOpts) {
224
+
s.service = service
225
+
}
226
+
}
227
+
228
+
// Specify the Duration in seconds for the expiry of this token
229
+
//
230
+
// The time of expiry is calculated as time.Now().Unix() + exp
231
+
func WithExp(exp int64) ServiceClientOpt {
232
+
return func(s *ServiceClientOpts) {
233
+
s.exp = time.Now().Unix() + exp
234
+
}
235
+
}
236
+
237
+
func WithLxm(lxm string) ServiceClientOpt {
238
+
return func(s *ServiceClientOpts) {
239
+
s.lxm = lxm
240
+
}
241
+
}
242
+
243
+
func WithDev(dev bool) ServiceClientOpt {
244
+
return func(s *ServiceClientOpts) {
245
+
s.dev = dev
246
+
}
247
+
}
248
+
249
+
func (s *ServiceClientOpts) Audience() string {
250
+
return fmt.Sprintf("did:web:%s", s.service)
251
+
}
252
+
253
+
func (s *ServiceClientOpts) Host() string {
254
+
scheme := "https://"
255
+
if s.dev {
256
+
scheme = "http://"
257
+
}
258
+
259
+
return scheme + s.service
260
+
}
261
+
262
+
func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) {
263
+
opts := ServiceClientOpts{}
264
+
for _, o := range os {
265
+
o(&opts)
266
+
}
267
+
268
+
authorizedClient, err := o.AuthorizedClient(r)
269
+
if err != nil {
270
+
return nil, err
271
+
}
272
+
273
+
// force expiry to atleast 60 seconds in the future
274
+
sixty := time.Now().Unix() + 60
275
+
if opts.exp < sixty {
276
+
opts.exp = sixty
277
+
}
278
+
279
+
resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm)
280
+
if err != nil {
281
+
return nil, err
282
+
}
283
+
284
+
return &indigo_xrpc.Client{
285
+
Auth: &indigo_xrpc.AuthInfo{
286
+
AccessJwt: resp.Token,
287
+
},
288
+
Host: opts.Host(),
289
+
Client: &http.Client{
290
+
Timeout: time.Second * 5,
291
+
},
292
+
}, nil
293
+
}
294
+
209
295
type ClientMetadata struct {
210
296
ClientID string `json:"client_id"`
211
297
ClientName string `json:"client_name"`
···
232
318
redirectURIs := makeRedirectURIs(clientURI)
233
319
234
320
if o.config.Core.Dev {
235
-
clientURI = fmt.Sprintf("http://127.0.0.1:3000")
321
+
clientURI = "http://127.0.0.1:3000"
236
322
redirectURIs = makeRedirectURIs(clientURI)
237
323
238
324
query := url.Values{}
+42
-6
appview/pages/funcmap.go
+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
+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
+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
+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
-85
appview/pages/pages.go
+281
-85
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"
36
-
"github.com/microcosm-cc/bluemonday"
37
39
)
38
40
39
41
//go:embed templates/* static
···
44
46
t map[string]*template.Template
45
47
46
48
avatar config.AvatarConfig
49
+
resolver *idresolver.Resolver
47
50
dev bool
48
51
embedFS embed.FS
49
52
templateDir string // Path to templates on disk for dev mode
50
53
rctx *markup.RenderContext
51
54
}
52
55
53
-
func NewPages(config *config.Config) *Pages {
56
+
func NewPages(config *config.Config, res *idresolver.Resolver) *Pages {
54
57
// initialized with safe defaults, can be overriden per use
55
58
rctx := &markup.RenderContext{
56
59
IsDev: config.Core.Dev,
57
60
CamoUrl: config.Camo.Host,
58
61
CamoSecret: config.Camo.SharedSecret,
62
+
Sanitizer: markup.NewSanitizer(),
59
63
}
60
64
61
65
p := &Pages{
···
65
69
avatar: config.Avatar,
66
70
embedFS: Files,
67
71
rctx: rctx,
72
+
resolver: res,
68
73
templateDir: "appview/pages",
69
74
}
70
75
···
255
260
return p.executeOrReload(name, w, "layouts/repobase", params)
256
261
}
257
262
263
+
func (p *Pages) Favicon(w io.Writer) error {
264
+
return p.executePlain("favicon", w, nil)
265
+
}
266
+
258
267
type LoginParams struct {
268
+
ReturnUrl string
259
269
}
260
270
261
271
func (p *Pages) Login(w io.Writer, params LoginParams) error {
262
272
return p.executePlain("user/login", w, params)
263
273
}
264
274
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
+
265
299
type TimelineParams struct {
266
300
LoggedInUser *oauth.User
267
301
Timeline []db.TimelineEvent
268
-
DidHandleMap map[string]string
302
+
Repos []db.Repo
269
303
}
270
304
271
305
func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
272
-
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)
273
317
}
274
318
275
-
type SettingsParams struct {
319
+
type UserKeysSettingsParams struct {
276
320
LoggedInUser *oauth.User
277
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
278
332
Emails []db.Email
333
+
Tabs []map[string]any
334
+
Tab string
279
335
}
280
336
281
-
func (p *Pages) Settings(w io.Writer, params SettingsParams) error {
282
-
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)
283
347
}
284
348
285
349
type KnotsParams struct {
···
293
357
294
358
type KnotParams struct {
295
359
LoggedInUser *oauth.User
296
-
DidHandleMap map[string]string
297
360
Registration *db.Registration
298
361
Members []string
299
362
Repos map[string][]db.Repo
···
305
368
}
306
369
307
370
type KnotListingParams struct {
308
-
db.Registration
371
+
*db.Registration
309
372
}
310
373
311
374
func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error {
312
375
return p.executePlain("knots/fragments/knotListing", w, params)
313
376
}
314
377
315
-
type KnotListingFullParams struct {
316
-
Registrations []db.Registration
317
-
}
318
-
319
-
func (p *Pages) KnotListingFull(w io.Writer, params KnotListingFullParams) error {
320
-
return p.executePlain("knots/fragments/knotListingFull", w, params)
321
-
}
322
-
323
-
type KnotSecretParams struct {
324
-
Secret string
325
-
}
326
-
327
-
func (p *Pages) KnotSecret(w io.Writer, params KnotSecretParams) error {
328
-
return p.executePlain("knots/fragments/secret", w, params)
329
-
}
330
-
331
378
type SpindlesParams struct {
332
379
LoggedInUser *oauth.User
333
380
Spindles []db.Spindle
···
350
397
Spindle db.Spindle
351
398
Members []string
352
399
Repos map[string][]db.Repo
353
-
DidHandleMap map[string]string
354
400
}
355
401
356
402
func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error {
···
376
422
return p.execute("repo/fork", w, params)
377
423
}
378
424
379
-
type ProfilePageParams struct {
425
+
type ProfileHomePageParams struct {
380
426
LoggedInUser *oauth.User
381
427
Repos []db.Repo
382
428
CollaboratingRepos []db.Repo
383
429
ProfileTimeline *db.ProfileTimeline
384
430
Card ProfileCard
385
431
Punchcard db.Punchcard
386
-
387
-
DidHandleMap map[string]string
388
432
}
389
433
390
434
type ProfileCard struct {
391
-
UserDid string
392
-
UserHandle string
393
-
FollowStatus db.FollowStatus
394
-
AvatarUri string
395
-
Followers int
396
-
Following int
435
+
UserDid string
436
+
UserHandle string
437
+
FollowStatus db.FollowStatus
438
+
FollowersCount int
439
+
FollowingCount int
397
440
398
441
Profile *db.Profile
399
442
}
400
443
401
-
func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error {
444
+
func (p *Pages) ProfileHomePage(w io.Writer, params ProfileHomePageParams) error {
402
445
return p.execute("user/profile", w, params)
403
446
}
404
447
···
406
449
LoggedInUser *oauth.User
407
450
Repos []db.Repo
408
451
Card ProfileCard
409
-
410
-
DidHandleMap map[string]string
411
452
}
412
453
413
454
func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error {
414
455
return p.execute("user/repos", w, params)
415
456
}
416
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
+
417
486
type FollowFragmentParams struct {
418
487
UserDid string
419
488
FollowStatus db.FollowStatus
···
436
505
LoggedInUser *oauth.User
437
506
Profile *db.Profile
438
507
AllRepos []PinnedRepo
439
-
DidHandleMap map[string]string
440
508
}
441
509
442
510
type PinnedRepo struct {
···
471
539
}
472
540
473
541
type RepoIndexParams struct {
474
-
LoggedInUser *oauth.User
475
-
RepoInfo repoinfo.RepoInfo
476
-
Active string
477
-
TagMap map[string][]string
478
-
CommitsTrunc []*object.Commit
479
-
TagsTrunc []*types.TagReference
480
-
BranchesTrunc []types.Branch
481
-
ForkInfo *types.ForkInfo
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
482
550
HTMLReadme template.HTML
483
551
Raw bool
484
552
EmailToDidOrHandle map[string]string
···
495
563
}
496
564
497
565
p.rctx.RepoInfo = params.RepoInfo
566
+
p.rctx.RepoInfo.Ref = params.Ref
498
567
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
499
568
500
569
if params.ReadmeFileName != "" {
501
-
var htmlString string
502
570
ext := filepath.Ext(params.ReadmeFileName)
503
571
switch ext {
504
572
case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
505
-
htmlString = p.rctx.RenderMarkdown(params.Readme)
506
573
params.Raw = false
507
-
params.HTMLReadme = template.HTML(p.rctx.Sanitize(htmlString))
574
+
htmlString := p.rctx.RenderMarkdown(params.Readme)
575
+
sanitized := p.rctx.SanitizeDefault(htmlString)
576
+
params.HTMLReadme = template.HTML(sanitized)
508
577
default:
509
-
htmlString = string(params.Readme)
510
578
params.Raw = true
511
-
params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString))
512
579
}
513
580
}
514
581
···
625
692
LoggedInUser *oauth.User
626
693
RepoInfo repoinfo.RepoInfo
627
694
Active string
695
+
Unsupported bool
696
+
IsImage bool
697
+
IsVideo bool
698
+
ContentSrc string
628
699
BreadCrumbs [][]string
629
700
ShowRendered bool
630
701
RenderToggle bool
···
641
712
p.rctx.RepoInfo = params.RepoInfo
642
713
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
643
714
htmlString := p.rctx.RenderMarkdown(params.Contents)
644
-
params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString))
715
+
sanitized := p.rctx.SanitizeDefault(htmlString)
716
+
params.RenderedContents = template.HTML(sanitized)
645
717
}
646
718
}
647
719
648
-
if params.Lines < 5000 {
649
-
c := params.Contents
650
-
formatter := chromahtml.New(
651
-
chromahtml.InlineCode(false),
652
-
chromahtml.WithLineNumbers(true),
653
-
chromahtml.WithLinkableLineNumbers(true, "L"),
654
-
chromahtml.Standalone(false),
655
-
chromahtml.WithClasses(true),
656
-
)
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
+
)
657
728
658
-
lexer := lexers.Get(filepath.Base(params.Path))
659
-
if lexer == nil {
660
-
lexer = lexers.Fallback
661
-
}
662
-
663
-
iterator, err := lexer.Tokenise(nil, c)
664
-
if err != nil {
665
-
return fmt.Errorf("chroma tokenize: %w", err)
666
-
}
729
+
lexer := lexers.Get(filepath.Base(params.Path))
730
+
if lexer == nil {
731
+
lexer = lexers.Fallback
732
+
}
667
733
668
-
var code bytes.Buffer
669
-
err = formatter.Format(&code, style, iterator)
670
-
if err != nil {
671
-
return fmt.Errorf("chroma format: %w", err)
672
-
}
734
+
iterator, err := lexer.Tokenise(nil, c)
735
+
if err != nil {
736
+
return fmt.Errorf("chroma tokenize: %w", err)
737
+
}
673
738
674
-
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)
675
743
}
676
744
745
+
params.Contents = code.String()
677
746
params.Active = "overview"
678
747
return p.executeRepo("repo/blob", w, params)
679
748
}
···
692
761
Branches []types.Branch
693
762
Spindles []string
694
763
CurrentSpindle string
764
+
Secrets []*tangled.RepoListSecrets_Secret
765
+
695
766
// TODO: use repoinfo.roles
696
767
IsCollaboratorInviteAllowed bool
697
768
}
···
701
772
return p.executeRepo("repo/settings", w, params)
702
773
}
703
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
+
704
819
type RepoIssuesParams struct {
705
820
LoggedInUser *oauth.User
706
821
RepoInfo repoinfo.RepoInfo
707
822
Active string
708
823
Issues []db.Issue
709
-
DidHandleMap map[string]string
710
824
Page pagination.Page
711
825
FilteringByOpen bool
712
826
}
···
720
834
LoggedInUser *oauth.User
721
835
RepoInfo repoinfo.RepoInfo
722
836
Active string
723
-
Issue db.Issue
837
+
Issue *db.Issue
724
838
Comments []db.Comment
725
839
IssueOwnerHandle string
726
-
DidHandleMap map[string]string
727
840
728
841
OrderedReactionKinds []db.ReactionKind
729
842
Reactions map[db.ReactionKind]int
···
777
890
778
891
type SingleIssueCommentParams struct {
779
892
LoggedInUser *oauth.User
780
-
DidHandleMap map[string]string
781
893
RepoInfo repoinfo.RepoInfo
782
894
Issue *db.Issue
783
895
Comment *db.Comment
···
809
921
RepoInfo repoinfo.RepoInfo
810
922
Pulls []*db.Pull
811
923
Active string
812
-
DidHandleMap map[string]string
813
924
FilteringBy db.PullState
814
925
Stacks map[string]db.Stack
926
+
Pipelines map[string]db.Pipeline
815
927
}
816
928
817
929
func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
···
841
953
LoggedInUser *oauth.User
842
954
RepoInfo repoinfo.RepoInfo
843
955
Active string
844
-
DidHandleMap map[string]string
845
956
Pull *db.Pull
846
957
Stack db.Stack
847
958
AbandonedPulls []*db.Pull
···
861
972
862
973
type RepoPullPatchParams struct {
863
974
LoggedInUser *oauth.User
864
-
DidHandleMap map[string]string
865
975
RepoInfo repoinfo.RepoInfo
866
976
Pull *db.Pull
867
977
Stack db.Stack
···
879
989
880
990
type RepoPullInterdiffParams struct {
881
991
LoggedInUser *oauth.User
882
-
DidHandleMap map[string]string
883
992
RepoInfo repoinfo.RepoInfo
884
993
Pull *db.Pull
885
994
Round int
···
1070
1179
return p.executeRepo("repo/pipelines/workflow", w, params)
1071
1180
}
1072
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
+
1073
1265
func (p *Pages) Static() http.Handler {
1074
1266
if p.dev {
1075
1267
return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static")))
···
1120
1312
1121
1313
func (p *Pages) Error404(w io.Writer) error {
1122
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)
1123
1319
}
1124
1320
1125
1321
func (p *Pages) Error503(w io.Writer) error {
+24
-4
appview/pages/templates/errors/404.html
+24
-4
appview/pages/templates/errors/404.html
···
1
1
{{ define "title" }}404 · tangled{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<h1>404 — 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 — 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
+36
-3
appview/pages/templates/errors/500.html
···
1
1
{{ define "title" }}500 · tangled{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<h1>500 — 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 — 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
+28
-5
appview/pages/templates/errors/503.html
···
1
1
{{ define "title" }}503 · tangled{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<h1>503 — 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 — 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
+28
appview/pages/templates/errors/knot404.html
···
1
+
{{ define "title" }}404 · 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 — 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
+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
+96
-32
appview/pages/templates/knots/dashboard.html
···
1
-
{{ define "title" }}{{ .Registration.Domain }}{{ end }}
1
+
{{ define "title" }}{{ .Registration.Domain }} · 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
+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 }}
+57
-25
appview/pages/templates/knots/fragments/knotListing.html
+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
-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
-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
+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
+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 }}
+16
-21
appview/pages/templates/layouts/repobase.html
+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
+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
+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
+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
+19
-6
appview/pages/templates/repo/blob.html
···
5
5
6
6
{{ $title := printf "%s at %s · %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
+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
+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
+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
+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
+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
-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
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
+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
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
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 }}
+101
-131
appview/pages/templates/repo/index.html
+101
-131
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">
···
358
322
{{ end }}
359
323
360
324
{{ define "repoAfter" }}
361
-
{{- if .HTMLReadme -}}
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
-
{{- .HTMLReadme -}}
371
-
</pre>
372
-
{{- else -}}
373
-
{{ .HTMLReadme }}
374
-
{{- end -}}</article>
375
-
</section>
325
+
{{- if or .HTMLReadme .Readme -}}
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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+110
appview/pages/templates/repo/settings/access.html
···
1
+
{{ define "title" }}{{ .Tab }} settings · {{ .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
+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 }}
+70
appview/pages/templates/repo/settings/general.html
+70
appview/pages/templates/repo/settings/general.html
···
1
+
{{ define "title" }}{{ .Tab }} settings · {{ .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
+145
appview/pages/templates/repo/settings/pipelines.html
···
1
+
{{ define "title" }}{{ .Tab }} settings · {{ .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
-138
appview/pages/templates/repo/settings.html
···
1
-
{{ define "title" }}settings · {{ .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 }}
+5
-4
appview/pages/templates/repo/tree.html
+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
-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
+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
+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
+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
+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
+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
+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
+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
+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
+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
-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
+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 · 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
+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
+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
+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
+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
+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
+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
+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
+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
+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 · 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
+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
+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
+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
+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
+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 }}
+101
appview/pages/templates/user/settings/keys.html
+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
+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
+
<div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
34
+
<span>Handle</span>
35
+
</div>
36
+
{{ if .LoggedInUser.Handle }}
37
+
<span class="font-bold">
38
+
@{{ .LoggedInUser.Handle }}
39
+
</span>
40
+
{{ end }}
41
+
</div>
42
+
</div>
43
+
<div class="flex items-center justify-between p-4">
44
+
<div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]">
45
+
<div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
46
+
<span>Decentralized Identifier (DID)</span>
47
+
</div>
48
+
<span class="font-mono font-bold">
49
+
{{ .LoggedInUser.Did }}
50
+
</span>
51
+
</div>
52
+
</div>
53
+
<div class="flex items-center justify-between p-4">
54
+
<div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]">
55
+
<div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
56
+
<span>Personal Data Server (PDS)</span>
57
+
</div>
58
+
<span class="font-bold">
59
+
{{ .LoggedInUser.Pds }}
60
+
</span>
61
+
</div>
62
+
</div>
63
+
</div>
64
+
{{ end }}
+53
appview/pages/templates/user/signup.html
+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 · 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
+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
+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
+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
+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
+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—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
+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
+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
+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
+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
+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
+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
+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
-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
+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
+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
+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
+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
+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—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
+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
+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
+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
+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
+4
cmd/genjwks/main.go
+1
-1
cmd/punchcardPopulate/main.go
+1
-1
cmd/punchcardPopulate/main.go
+14
-15
docs/contributing.md
+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.
+22
-15
docs/hacking.md
+22
-15
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` in the browser and
59
-
generate a knot secret. Replace the existing secret in
60
-
`flake.nix` with the newly generated 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
61
62
-
You can now start a lightweight NixOS VM using
63
-
`nixos-shell` like so:
62
+
If you don't want to [set up a spindle](#running-a-spindle),
63
+
you can use any placeholder value.
64
+
65
+
You can now start a lightweight NixOS VM like so:
64
66
65
67
```bash
66
-
nix run .#vm
67
-
# or nixos-shell --flake .#vm
68
+
nix run --impure .#vm
68
69
69
-
# hit Ctrl-a + c + q to exit the VM
70
+
# type `poweroff` at the shell to exit the VM
70
71
```
71
72
72
73
This starts a knot on port 6000, a spindle on port 6555
73
-
with `ssh` exposed on port 2222. You can push repositories
74
-
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:
75
84
76
85
```bash
77
86
Host nixos-shell
···
91
100
## running a spindle
92
101
93
102
The above VM should already be running a spindle on
94
-
`localhost:6555`. You can head to the spindle dashboard on
95
-
`http://localhost:3000/spindles`, and register a spindle
96
-
with hostname `localhost:6555`. It should instantly be
97
-
verified. You can then configure each repository to use this
98
-
spindle and run CI jobs.
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.
99
106
100
107
Of interest when debugging spindles:
101
108
+27
-7
docs/knot-hosting.md
+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.
+35
docs/migrations/knot-1.7.0.md
+35
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 to knots are managed via [Inter-Service
6
+
Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
7
+
Knots will be read-only until upgraded.
8
+
9
+
Upgrading is quite easy, in essence:
10
+
11
+
- `KNOT_SERVER_SECRET` is no more, you can remove this
12
+
environment variable entirely
13
+
- `KNOT_SERVER_OWNER` is now required on boot, set this to
14
+
your DID. You can find your DID in the
15
+
[settings](https://tangled.sh/settings) page.
16
+
- Restart your knot once you have replaced the environment
17
+
variable
18
+
- Head to the [knot dashboard](https://tangled.sh/knots) and
19
+
hit the "retry" button to verify your knot. This simply
20
+
writes a `sh.tangled.knot` record to your PDS.
21
+
22
+
## Nix
23
+
24
+
If you use the nix module, simply bump the flake to the
25
+
latest revision, and change your config block like so:
26
+
27
+
```diff
28
+
services.tangled-knot = {
29
+
enable = true;
30
+
server = {
31
+
- secretFile = /path/to/secret;
32
+
+ owner = "did:plc:foo";
33
+
};
34
+
};
35
+
```
+4
-3
docs/spindle/architecture.md
+4
-3
docs/spindle/architecture.md
···
13
13
14
14
### the engine
15
15
16
-
At present, the only supported backend is Docker. Spindle executes each step in
17
-
the pipeline in a fresh container, with state persisted across steps within the
18
-
`/tangled/workspace` directory.
16
+
At present, the only supported backend is Docker (and Podman, if Docker
17
+
compatibility is enabled, so that `/run/docker.sock` is created). Spindle
18
+
executes each step in the pipeline in a fresh container, with state persisted
19
+
across steps within the `/tangled/workspace` directory.
19
20
20
21
The base image for the container is constructed on the fly using
21
22
[Nixery](https://nixery.dev), which is handy for caching layers for frequently
+285
docs/spindle/openbao.md
+285
docs/spindle/openbao.md
···
1
+
# spindle secrets with openbao
2
+
3
+
This document covers setting up Spindle to use OpenBao for secrets
4
+
management via OpenBao Proxy instead of the default SQLite backend.
5
+
6
+
## overview
7
+
8
+
Spindle now uses OpenBao Proxy for secrets management. The proxy handles
9
+
authentication automatically using AppRole credentials, while Spindle
10
+
connects to the local proxy instead of directly to the OpenBao server.
11
+
12
+
This approach provides better security, automatic token renewal, and
13
+
simplified application code.
14
+
15
+
## installation
16
+
17
+
Install OpenBao from nixpkgs:
18
+
19
+
```bash
20
+
nix shell nixpkgs#openbao # for a local server
21
+
```
22
+
23
+
## setup
24
+
25
+
The setup process can is documented for both local development and production.
26
+
27
+
### local development
28
+
29
+
Start OpenBao in dev mode:
30
+
31
+
```bash
32
+
bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201
33
+
```
34
+
35
+
This starts OpenBao on `http://localhost:8201` with a root token.
36
+
37
+
Set up environment for bao CLI:
38
+
39
+
```bash
40
+
export BAO_ADDR=http://localhost:8200
41
+
export BAO_TOKEN=root
42
+
```
43
+
44
+
### production
45
+
46
+
You would typically use a systemd service with a configuration file. Refer to
47
+
[@tangled.sh/infra](https://tangled.sh/@tangled.sh/infra) for how this can be
48
+
achieved using Nix.
49
+
50
+
Then, initialize the bao server:
51
+
```bash
52
+
bao operator init -key-shares=1 -key-threshold=1
53
+
```
54
+
55
+
This will print out an unseal key and a root key. Save them somewhere (like a password manager). Then unseal the vault to begin setting it up:
56
+
```bash
57
+
bao operator unseal <unseal_key>
58
+
```
59
+
60
+
All steps below remain the same across both dev and production setups.
61
+
62
+
### configure openbao server
63
+
64
+
Create the spindle KV mount:
65
+
66
+
```bash
67
+
bao secrets enable -path=spindle -version=2 kv
68
+
```
69
+
70
+
Set up AppRole authentication and policy:
71
+
72
+
Create a policy file `spindle-policy.hcl`:
73
+
74
+
```hcl
75
+
# Full access to spindle KV v2 data
76
+
path "spindle/data/*" {
77
+
capabilities = ["create", "read", "update", "delete"]
78
+
}
79
+
80
+
# Access to metadata for listing and management
81
+
path "spindle/metadata/*" {
82
+
capabilities = ["list", "read", "delete", "update"]
83
+
}
84
+
85
+
# Allow listing at root level
86
+
path "spindle/" {
87
+
capabilities = ["list"]
88
+
}
89
+
90
+
# Required for connection testing and health checks
91
+
path "auth/token/lookup-self" {
92
+
capabilities = ["read"]
93
+
}
94
+
```
95
+
96
+
Apply the policy and create an AppRole:
97
+
98
+
```bash
99
+
bao policy write spindle-policy spindle-policy.hcl
100
+
bao auth enable approle
101
+
bao write auth/approle/role/spindle \
102
+
token_policies="spindle-policy" \
103
+
token_ttl=1h \
104
+
token_max_ttl=4h \
105
+
bind_secret_id=true \
106
+
secret_id_ttl=0 \
107
+
secret_id_num_uses=0
108
+
```
109
+
110
+
Get the credentials:
111
+
112
+
```bash
113
+
# Get role ID (static)
114
+
ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id)
115
+
116
+
# Generate secret ID
117
+
SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id)
118
+
119
+
echo "Role ID: $ROLE_ID"
120
+
echo "Secret ID: $SECRET_ID"
121
+
```
122
+
123
+
### create proxy configuration
124
+
125
+
Create the credential files:
126
+
127
+
```bash
128
+
# Create directory for OpenBao files
129
+
mkdir -p /tmp/openbao
130
+
131
+
# Save credentials
132
+
echo "$ROLE_ID" > /tmp/openbao/role-id
133
+
echo "$SECRET_ID" > /tmp/openbao/secret-id
134
+
chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id
135
+
```
136
+
137
+
Create a proxy configuration file `/tmp/openbao/proxy.hcl`:
138
+
139
+
```hcl
140
+
# OpenBao server connection
141
+
vault {
142
+
address = "http://localhost:8200"
143
+
}
144
+
145
+
# Auto-Auth using AppRole
146
+
auto_auth {
147
+
method "approle" {
148
+
mount_path = "auth/approle"
149
+
config = {
150
+
role_id_file_path = "/tmp/openbao/role-id"
151
+
secret_id_file_path = "/tmp/openbao/secret-id"
152
+
}
153
+
}
154
+
155
+
# Optional: write token to file for debugging
156
+
sink "file" {
157
+
config = {
158
+
path = "/tmp/openbao/token"
159
+
mode = 0640
160
+
}
161
+
}
162
+
}
163
+
164
+
# Proxy listener for Spindle
165
+
listener "tcp" {
166
+
address = "127.0.0.1:8201"
167
+
tls_disable = true
168
+
}
169
+
170
+
# Enable API proxy with auto-auth token
171
+
api_proxy {
172
+
use_auto_auth_token = true
173
+
}
174
+
175
+
# Enable response caching
176
+
cache {
177
+
use_auto_auth_token = true
178
+
}
179
+
180
+
# Logging
181
+
log_level = "info"
182
+
```
183
+
184
+
### start the proxy
185
+
186
+
Start OpenBao Proxy:
187
+
188
+
```bash
189
+
bao proxy -config=/tmp/openbao/proxy.hcl
190
+
```
191
+
192
+
The proxy will authenticate with OpenBao and start listening on
193
+
`127.0.0.1:8201`.
194
+
195
+
### configure spindle
196
+
197
+
Set these environment variables for Spindle:
198
+
199
+
```bash
200
+
export SPINDLE_SERVER_SECRETS_PROVIDER=openbao
201
+
export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201
202
+
export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle
203
+
```
204
+
205
+
Start Spindle:
206
+
207
+
Spindle will now connect to the local proxy, which handles all
208
+
authentication automatically.
209
+
210
+
## production setup for proxy
211
+
212
+
For production, you'll want to run the proxy as a service:
213
+
214
+
Place your production configuration in `/etc/openbao/proxy.hcl` with
215
+
proper TLS settings for the vault connection.
216
+
217
+
## verifying setup
218
+
219
+
Test the proxy directly:
220
+
221
+
```bash
222
+
# Check proxy health
223
+
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health
224
+
225
+
# Test token lookup through proxy
226
+
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self
227
+
```
228
+
229
+
Test OpenBao operations through the server:
230
+
231
+
```bash
232
+
# List all secrets
233
+
bao kv list spindle/
234
+
235
+
# Add a test secret via Spindle API, then check it exists
236
+
bao kv list spindle/repos/
237
+
238
+
# Get a specific secret
239
+
bao kv get spindle/repos/your_repo_path/SECRET_NAME
240
+
```
241
+
242
+
## how it works
243
+
244
+
- Spindle connects to OpenBao Proxy on localhost (typically port 8200 or 8201)
245
+
- The proxy authenticates with OpenBao using AppRole credentials
246
+
- All Spindle requests go through the proxy, which injects authentication tokens
247
+
- Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}`
248
+
- Repository paths like `did:plc:alice/myrepo` become `did_plc_alice_myrepo`
249
+
- The proxy handles all token renewal automatically
250
+
- Spindle no longer manages tokens or authentication directly
251
+
252
+
## troubleshooting
253
+
254
+
**Connection refused**: Check that the OpenBao Proxy is running and
255
+
listening on the configured address.
256
+
257
+
**403 errors**: Verify the AppRole credentials are correct and the policy
258
+
has the necessary permissions.
259
+
260
+
**404 route errors**: The spindle KV mount probably doesn't exist - run
261
+
the mount creation step again.
262
+
263
+
**Proxy authentication failures**: Check the proxy logs and verify the
264
+
role-id and secret-id files are readable and contain valid credentials.
265
+
266
+
**Secret not found after writing**: This can indicate policy permission
267
+
issues. Verify the policy includes both `spindle/data/*` and
268
+
`spindle/metadata/*` paths with appropriate capabilities.
269
+
270
+
Check proxy logs:
271
+
272
+
```bash
273
+
# If running as systemd service
274
+
journalctl -u openbao-proxy -f
275
+
276
+
# If running directly, check the console output
277
+
```
278
+
279
+
Test AppRole authentication manually:
280
+
281
+
```bash
282
+
bao write auth/approle/login \
283
+
role_id="$(cat /tmp/openbao/role-id)" \
284
+
secret_id="$(cat /tmp/openbao/secret-id)"
285
+
```
+33
-3
docs/spindle/pipeline.md
+33
-3
docs/spindle/pipeline.md
···
4
4
repo. Generally:
5
5
6
6
* Pipelines are defined in YAML.
7
-
* Dependencies can be specified from
8
-
[Nixpkgs](https://search.nixos.org) or custom registries.
9
-
* Environment variables can be set globally or per-step.
7
+
* Workflows can run using different *engines*.
8
+
9
+
The most barebones workflow looks like this:
10
+
11
+
```yaml
12
+
when:
13
+
- event: ["push"]
14
+
branch: ["main"]
15
+
16
+
engine: "nixery"
17
+
18
+
# optional
19
+
clone:
20
+
skip: false
21
+
depth: 50
22
+
submodules: true
23
+
```
24
+
25
+
The `when` and `engine` fields are required, while every other aspect
26
+
of how the definition is parsed is up to the engine. Currently, a spindle
27
+
provides at least one of these built-in engines:
28
+
29
+
## `nixery`
30
+
31
+
The Nixery engine uses an instance of [Nixery](https://nixery.dev) to run
32
+
steps that use dependencies from [Nixpkgs](https://github.com/NixOS/nixpkgs).
10
33
11
34
Here's an example that uses all fields:
12
35
···
57
80
depth: 50
58
81
submodules: true
59
82
```
83
+
84
+
## git push options
85
+
86
+
These are push options that can be used with the `--push-option (-o)` flag of git push:
87
+
88
+
- `verbose-ci`, `ci-verbose`: enables diagnostics reporting for the CI pipeline, allowing you to see any issues when you push.
89
+
- `skip-ci`, `ci-skip`: skips triggering the CI pipeline.
+1
-1
eventconsumer/cursor/sqlite.go
+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
+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
+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
+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
+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
+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
+24
hook/hook.go
+24
hook/hook.go
···
3
3
import (
4
4
"bufio"
5
5
"context"
6
+
"encoding/json"
6
7
"fmt"
7
8
"net/http"
8
9
"os"
···
10
11
11
12
"github.com/urfave/cli/v3"
12
13
)
14
+
15
+
type HookResponse struct {
16
+
Messages []string `json:"messages"`
17
+
}
13
18
14
19
// The hook command is nested like so:
15
20
//
···
36
41
Usage: "endpoint for the internal API",
37
42
Value: "http://localhost:5444",
38
43
},
44
+
&cli.StringSliceFlag{
45
+
Name: "push-option",
46
+
Usage: "any push option from git",
47
+
},
39
48
},
40
49
Commands: []*cli.Command{
41
50
{
···
52
61
userDid := cmd.String("user-did")
53
62
userHandle := cmd.String("user-handle")
54
63
endpoint := cmd.String("internal-api")
64
+
pushOptions := cmd.StringSlice("push-option")
55
65
56
66
payloadReader := bufio.NewReader(os.Stdin)
57
67
payload, _ := payloadReader.ReadString('\n')
···
67
77
req.Header.Set("X-Git-Dir", gitDir)
68
78
req.Header.Set("X-Git-User-Did", userDid)
69
79
req.Header.Set("X-Git-User-Handle", userHandle)
80
+
if pushOptions != nil {
81
+
for _, option := range pushOptions {
82
+
req.Header.Add("X-Git-Push-Option", option)
83
+
}
84
+
}
70
85
71
86
resp, err := client.Do(req)
72
87
if err != nil {
···
76
91
77
92
if resp.StatusCode != http.StatusOK {
78
93
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
94
+
}
95
+
96
+
var data HookResponse
97
+
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
98
+
return fmt.Errorf("failed to decode response: %w", err)
99
+
}
100
+
101
+
for _, message := range data.Messages {
102
+
fmt.Println(message)
79
103
}
80
104
81
105
return nil
+6
-1
hook/setup.go
+6
-1
hook/setup.go
···
133
133
134
134
hookContent := fmt.Sprintf(`#!/usr/bin/env bash
135
135
# AUTO GENERATED BY KNOT, DO NOT MODIFY
136
-
%s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" post-recieve
136
+
push_options=()
137
+
for ((i=0; i<GIT_PUSH_OPTION_COUNT; i++)); do
138
+
option_var="GIT_PUSH_OPTION_$i"
139
+
push_options+=(-push-option "${!option_var}")
140
+
done
141
+
%s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-recieve
137
142
`, executablePath, config.internalApi)
138
143
139
144
return os.WriteFile(hookPath, []byte(hookContent), 0755)
+84
-8
input.css
+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
+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
-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
+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
+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
+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
-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
+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
+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
+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
+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
-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
+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
}
+55
-17
knotserver/internal.go
+55
-17
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"
···
13
14
"github.com/go-chi/chi/v5"
14
15
"github.com/go-chi/chi/v5/middleware"
15
16
"tangled.sh/tangled.sh/core/api/tangled"
17
+
"tangled.sh/tangled.sh/core/hook"
16
18
"tangled.sh/tangled.sh/core/knotserver/config"
17
19
"tangled.sh/tangled.sh/core/knotserver/db"
18
20
"tangled.sh/tangled.sh/core/knotserver/git"
···
45
47
}
46
48
47
49
w.WriteHeader(http.StatusNoContent)
48
-
return
49
50
}
50
51
51
52
func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) {
···
61
62
data = append(data, j)
62
63
}
63
64
writeJSON(w, data)
64
-
return
65
+
}
66
+
67
+
type PushOptions struct {
68
+
skipCi bool
69
+
verboseCi bool
65
70
}
66
71
67
72
func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) {
···
90
95
// non-fatal
91
96
}
92
97
98
+
// extract any push options
99
+
pushOptionsRaw := r.Header.Values("X-Git-Push-Option")
100
+
pushOptions := PushOptions{}
101
+
for _, option := range pushOptionsRaw {
102
+
if option == "skip-ci" || option == "ci-skip" {
103
+
pushOptions.skipCi = true
104
+
}
105
+
if option == "verbose-ci" || option == "ci-verbose" {
106
+
pushOptions.verboseCi = true
107
+
}
108
+
}
109
+
110
+
resp := hook.HookResponse{
111
+
Messages: make([]string, 0),
112
+
}
113
+
93
114
for _, line := range lines {
94
115
err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName)
95
116
if err != nil {
···
97
118
// non-fatal
98
119
}
99
120
100
-
err = h.triggerPipeline(line, gitUserDid, repoDid, repoName)
121
+
err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions)
101
122
if err != nil {
102
123
l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
103
124
// non-fatal
104
125
}
105
126
}
127
+
128
+
writeJSON(w, resp)
106
129
}
107
130
108
131
func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
···
121
144
return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err)
122
145
}
123
146
124
-
meta := gr.RefUpdateMeta(line)
147
+
var errs error
148
+
meta, err := gr.RefUpdateMeta(line)
149
+
errors.Join(errs, err)
125
150
126
151
metaRecord := meta.AsRecord()
127
152
···
145
170
EventJson: string(eventJson),
146
171
}
147
172
148
-
return h.db.InsertEvent(event, h.n)
173
+
return errors.Join(errs, h.db.InsertEvent(event, h.n))
149
174
}
150
175
151
-
func (h *InternalHandle) triggerPipeline(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
176
+
func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error {
177
+
if pushOptions.skipCi {
178
+
return nil
179
+
}
180
+
152
181
didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
153
182
if err != nil {
154
183
return err
···
169
198
return err
170
199
}
171
200
172
-
var pipeline workflow.Pipeline
201
+
var pipeline workflow.RawPipeline
173
202
for _, e := range workflowDir {
174
203
if !e.IsFile {
175
204
continue
···
181
210
continue
182
211
}
183
212
184
-
wf, err := workflow.FromFile(e.Name, contents)
185
-
if err != nil {
186
-
// TODO: log here, respond to client that is pushing
187
-
h.l.Error("failed to parse workflow", "err", err, "path", fpath)
188
-
continue
189
-
}
190
-
191
-
pipeline = append(pipeline, wf)
213
+
pipeline = append(pipeline, workflow.RawWorkflow{
214
+
Name: e.Name,
215
+
Contents: contents,
216
+
})
192
217
}
193
218
194
219
trigger := tangled.Pipeline_PushTriggerData{
···
209
234
},
210
235
}
211
236
212
-
// TODO: send the diagnostics back to the user here via stderr
213
-
cp := compiler.Compile(pipeline)
237
+
cp := compiler.Compile(compiler.Parse(pipeline))
214
238
eventJson, err := json.Marshal(cp)
215
239
if err != nil {
216
240
return err
241
+
}
242
+
243
+
for _, e := range compiler.Diagnostics.Errors {
244
+
*clientMsgs = append(*clientMsgs, e.String())
245
+
}
246
+
247
+
if pushOptions.verboseCi {
248
+
if compiler.Diagnostics.IsEmpty() {
249
+
*clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics")
250
+
}
251
+
252
+
for _, w := range compiler.Diagnostics.Warnings {
253
+
*clientMsgs = append(*clientMsgs, w.String())
254
+
}
217
255
}
218
256
219
257
// do not run empty pipelines
-53
knotserver/middleware.go
-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
+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
+1
knotserver/server.go
+156
knotserver/xrpc/create_repo.go
+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
+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
+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
+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
+
}
+112
knotserver/xrpc/merge.go
+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
+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
-149
knotserver/xrpc/router.go
···
1
-
package xrpc
2
-
3
-
import (
4
-
"context"
5
-
"encoding/json"
6
-
"fmt"
7
-
"log/slog"
8
-
"net/http"
9
-
"strings"
10
-
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/idresolver"
13
-
"tangled.sh/tangled.sh/core/jetstream"
14
-
"tangled.sh/tangled.sh/core/knotserver/config"
15
-
"tangled.sh/tangled.sh/core/knotserver/db"
16
-
"tangled.sh/tangled.sh/core/notifier"
17
-
"tangled.sh/tangled.sh/core/rbac"
18
-
19
-
"github.com/bluesky-social/indigo/atproto/auth"
20
-
"github.com/go-chi/chi/v5"
21
-
)
22
-
23
-
type Xrpc struct {
24
-
Config *config.Config
25
-
Db *db.DB
26
-
Ingester *jetstream.JetstreamClient
27
-
Enforcer *rbac.Enforcer
28
-
Logger *slog.Logger
29
-
Notifier *notifier.Notifier
30
-
Resolver *idresolver.Resolver
31
-
}
32
-
33
-
func (x *Xrpc) Router() http.Handler {
34
-
r := chi.NewRouter()
35
-
36
-
r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch)
37
-
38
-
return r
39
-
}
40
-
41
-
func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler {
42
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
43
-
l := x.Logger.With("url", r.URL)
44
-
45
-
token := r.Header.Get("Authorization")
46
-
token = strings.TrimPrefix(token, "Bearer ")
47
-
48
-
s := auth.ServiceAuthValidator{
49
-
Audience: x.Config.Server.Did().String(),
50
-
Dir: x.Resolver.Directory(),
51
-
}
52
-
53
-
did, err := s.Validate(r.Context(), token, nil)
54
-
if err != nil {
55
-
l.Error("signature verification failed", "err", err)
56
-
writeError(w, AuthError(err), http.StatusForbidden)
57
-
return
58
-
}
59
-
60
-
r = r.WithContext(
61
-
context.WithValue(r.Context(), ActorDid, did),
62
-
)
63
-
64
-
next.ServeHTTP(w, r)
65
-
})
66
-
}
67
-
68
-
type XrpcError struct {
69
-
Tag string `json:"error"`
70
-
Message string `json:"message"`
71
-
}
72
-
73
-
func NewXrpcError(opts ...ErrOpt) XrpcError {
74
-
x := XrpcError{}
75
-
for _, o := range opts {
76
-
o(&x)
77
-
}
78
-
79
-
return x
80
-
}
81
-
82
-
type ErrOpt = func(xerr *XrpcError)
83
-
84
-
func WithTag(tag string) ErrOpt {
85
-
return func(xerr *XrpcError) {
86
-
xerr.Tag = tag
87
-
}
88
-
}
89
-
90
-
func WithMessage[S ~string](s S) ErrOpt {
91
-
return func(xerr *XrpcError) {
92
-
xerr.Message = string(s)
93
-
}
94
-
}
95
-
96
-
func WithError(e error) ErrOpt {
97
-
return func(xerr *XrpcError) {
98
-
xerr.Message = e.Error()
99
-
}
100
-
}
101
-
102
-
var MissingActorDidError = NewXrpcError(
103
-
WithTag("MissingActorDid"),
104
-
WithMessage("actor DID not supplied"),
105
-
)
106
-
107
-
var AuthError = func(err error) XrpcError {
108
-
return NewXrpcError(
109
-
WithTag("Auth"),
110
-
WithError(fmt.Errorf("signature verification failed: %w", err)),
111
-
)
112
-
}
113
-
114
-
var InvalidRepoError = func(r string) XrpcError {
115
-
return NewXrpcError(
116
-
WithTag("InvalidRepo"),
117
-
WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)),
118
-
)
119
-
}
120
-
121
-
var AccessControlError = func(d string) XrpcError {
122
-
return NewXrpcError(
123
-
WithTag("AccessControl"),
124
-
WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)),
125
-
)
126
-
}
127
-
128
-
var GitError = func(e error) XrpcError {
129
-
return NewXrpcError(
130
-
WithTag("Git"),
131
-
WithError(fmt.Errorf("git error: %w", e)),
132
-
)
133
-
}
134
-
135
-
func GenericError(err error) XrpcError {
136
-
return NewXrpcError(
137
-
WithTag("Generic"),
138
-
WithError(err),
139
-
)
140
-
}
141
-
142
-
// this is slightly different from http_util::write_error to follow the spec:
143
-
//
144
-
// the json object returned must include an "error" and a "message"
145
-
func writeError(w http.ResponseWriter, e XrpcError, status int) {
146
-
w.Header().Set("Content-Type", "application/json")
147
-
w.WriteHeader(status)
148
-
json.NewEncoder(w).Encode(e)
149
-
}
+12
-10
knotserver/xrpc/set_default_branch.go
+12
-10
knotserver/xrpc/set_default_branch.go
···
12
12
"tangled.sh/tangled.sh/core/api/tangled"
13
13
"tangled.sh/tangled.sh/core/knotserver/git"
14
14
"tangled.sh/tangled.sh/core/rbac"
15
+
16
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
15
17
)
16
18
17
19
const ActorDid string = "ActorDid"
18
20
19
21
func (x *Xrpc) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
20
22
l := x.Logger
21
-
fail := func(e XrpcError) {
23
+
fail := func(e xrpcerr.XrpcError) {
22
24
l.Error("failed", "kind", e.Tag, "error", e.Message)
23
25
writeError(w, e, http.StatusBadRequest)
24
26
}
25
27
26
28
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
27
29
if !ok {
28
-
fail(MissingActorDidError)
30
+
fail(xrpcerr.MissingActorDidError)
29
31
return
30
32
}
31
33
32
34
var data tangled.RepoSetDefaultBranch_Input
33
35
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
34
-
fail(GenericError(err))
36
+
fail(xrpcerr.GenericError(err))
35
37
return
36
38
}
37
39
38
40
// unfortunately we have to resolve repo-at here
39
41
repoAt, err := syntax.ParseATURI(data.Repo)
40
42
if err != nil {
41
-
fail(InvalidRepoError(data.Repo))
43
+
fail(xrpcerr.InvalidRepoError(data.Repo))
42
44
return
43
45
}
44
46
45
47
// resolve this aturi to extract the repo record
46
48
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
47
49
if err != nil || ident.Handle.IsInvalidHandle() {
48
-
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
50
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
49
51
return
50
52
}
51
53
52
54
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
53
55
resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
54
56
if err != nil {
55
-
fail(GenericError(err))
57
+
fail(xrpcerr.GenericError(err))
56
58
return
57
59
}
58
60
59
61
repo := resp.Value.Val.(*tangled.Repo)
60
62
didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name)
61
63
if err != nil {
62
-
fail(GenericError(err))
64
+
fail(xrpcerr.GenericError(err))
63
65
return
64
66
}
65
67
66
68
if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
67
69
l.Error("insufficent permissions", "did", actorDid.String())
68
-
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
70
+
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
69
71
return
70
72
}
71
73
72
74
path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath)
73
75
gr, err := git.PlainOpen(path)
74
76
if err != nil {
75
-
fail(InvalidRepoError(data.Repo))
77
+
fail(xrpcerr.GenericError(err))
76
78
return
77
79
}
78
80
79
81
err = gr.SetDefaultBranch(data.DefaultBranch)
80
82
if err != nil {
81
83
l.Error("setting default branch", "error", err.Error())
82
-
writeError(w, GitError(err), http.StatusInternalServerError)
84
+
writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError)
83
85
return
84
86
}
85
87
+60
knotserver/xrpc/xrpc.go
+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
+
}
-52
lexicons/artifact.json
-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
-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
+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
+
}
+207
lexicons/pipeline/pipeline.json
+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
-263
lexicons/pipeline.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.pipeline",
4
-
"needsCbor": true,
5
-
"needsType": true,
6
-
"defs": {
7
-
"main": {
8
-
"type": "record",
9
-
"key": "tid",
10
-
"record": {
11
-
"type": "object",
12
-
"required": [
13
-
"triggerMetadata",
14
-
"workflows"
15
-
],
16
-
"properties": {
17
-
"triggerMetadata": {
18
-
"type": "ref",
19
-
"ref": "#triggerMetadata"
20
-
},
21
-
"workflows": {
22
-
"type": "array",
23
-
"items": {
24
-
"type": "ref",
25
-
"ref": "#workflow"
26
-
}
27
-
}
28
-
}
29
-
}
30
-
},
31
-
"triggerMetadata": {
32
-
"type": "object",
33
-
"required": [
34
-
"kind",
35
-
"repo"
36
-
],
37
-
"properties": {
38
-
"kind": {
39
-
"type": "string",
40
-
"enum": [
41
-
"push",
42
-
"pull_request",
43
-
"manual"
44
-
]
45
-
},
46
-
"repo": {
47
-
"type": "ref",
48
-
"ref": "#triggerRepo"
49
-
},
50
-
"push": {
51
-
"type": "ref",
52
-
"ref": "#pushTriggerData"
53
-
},
54
-
"pullRequest": {
55
-
"type": "ref",
56
-
"ref": "#pullRequestTriggerData"
57
-
},
58
-
"manual": {
59
-
"type": "ref",
60
-
"ref": "#manualTriggerData"
61
-
}
62
-
}
63
-
},
64
-
"triggerRepo": {
65
-
"type": "object",
66
-
"required": [
67
-
"knot",
68
-
"did",
69
-
"repo",
70
-
"defaultBranch"
71
-
],
72
-
"properties": {
73
-
"knot": {
74
-
"type": "string"
75
-
},
76
-
"did": {
77
-
"type": "string",
78
-
"format": "did"
79
-
},
80
-
"repo": {
81
-
"type": "string"
82
-
},
83
-
"defaultBranch": {
84
-
"type": "string"
85
-
}
86
-
}
87
-
},
88
-
"pushTriggerData": {
89
-
"type": "object",
90
-
"required": [
91
-
"ref",
92
-
"newSha",
93
-
"oldSha"
94
-
],
95
-
"properties": {
96
-
"ref": {
97
-
"type": "string"
98
-
},
99
-
"newSha": {
100
-
"type": "string",
101
-
"minLength": 40,
102
-
"maxLength": 40
103
-
},
104
-
"oldSha": {
105
-
"type": "string",
106
-
"minLength": 40,
107
-
"maxLength": 40
108
-
}
109
-
}
110
-
},
111
-
"pullRequestTriggerData": {
112
-
"type": "object",
113
-
"required": [
114
-
"sourceBranch",
115
-
"targetBranch",
116
-
"sourceSha",
117
-
"action"
118
-
],
119
-
"properties": {
120
-
"sourceBranch": {
121
-
"type": "string"
122
-
},
123
-
"targetBranch": {
124
-
"type": "string"
125
-
},
126
-
"sourceSha": {
127
-
"type": "string",
128
-
"minLength": 40,
129
-
"maxLength": 40
130
-
},
131
-
"action": {
132
-
"type": "string"
133
-
}
134
-
}
135
-
},
136
-
"manualTriggerData": {
137
-
"type": "object",
138
-
"properties": {
139
-
"inputs": {
140
-
"type": "array",
141
-
"items": {
142
-
"type": "ref",
143
-
"ref": "#pair"
144
-
}
145
-
}
146
-
}
147
-
},
148
-
"workflow": {
149
-
"type": "object",
150
-
"required": [
151
-
"name",
152
-
"dependencies",
153
-
"steps",
154
-
"environment",
155
-
"clone"
156
-
],
157
-
"properties": {
158
-
"name": {
159
-
"type": "string"
160
-
},
161
-
"dependencies": {
162
-
"type": "array",
163
-
"items": {
164
-
"type": "ref",
165
-
"ref": "#dependency"
166
-
}
167
-
},
168
-
"steps": {
169
-
"type": "array",
170
-
"items": {
171
-
"type": "ref",
172
-
"ref": "#step"
173
-
}
174
-
},
175
-
"environment": {
176
-
"type": "array",
177
-
"items": {
178
-
"type": "ref",
179
-
"ref": "#pair"
180
-
}
181
-
},
182
-
"clone": {
183
-
"type": "ref",
184
-
"ref": "#cloneOpts"
185
-
}
186
-
}
187
-
},
188
-
"dependency": {
189
-
"type": "object",
190
-
"required": [
191
-
"registry",
192
-
"packages"
193
-
],
194
-
"properties": {
195
-
"registry": {
196
-
"type": "string"
197
-
},
198
-
"packages": {
199
-
"type": "array",
200
-
"items": {
201
-
"type": "string"
202
-
}
203
-
}
204
-
}
205
-
},
206
-
"cloneOpts": {
207
-
"type": "object",
208
-
"required": [
209
-
"skip",
210
-
"depth",
211
-
"submodules"
212
-
],
213
-
"properties": {
214
-
"skip": {
215
-
"type": "boolean"
216
-
},
217
-
"depth": {
218
-
"type": "integer"
219
-
},
220
-
"submodules": {
221
-
"type": "boolean"
222
-
}
223
-
}
224
-
},
225
-
"step": {
226
-
"type": "object",
227
-
"required": [
228
-
"name",
229
-
"command"
230
-
],
231
-
"properties": {
232
-
"name": {
233
-
"type": "string"
234
-
},
235
-
"command": {
236
-
"type": "string"
237
-
},
238
-
"environment": {
239
-
"type": "array",
240
-
"items": {
241
-
"type": "ref",
242
-
"ref": "#pair"
243
-
}
244
-
}
245
-
}
246
-
},
247
-
"pair": {
248
-
"type": "object",
249
-
"required": [
250
-
"key",
251
-
"value"
252
-
],
253
-
"properties": {
254
-
"key": {
255
-
"type": "string"
256
-
},
257
-
"value": {
258
-
"type": "string"
259
-
}
260
-
}
261
-
}
262
-
}
263
-
}
+37
lexicons/repo/addSecret.json
+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
+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
+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
+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
+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
+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
+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
+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
+
}
+67
lexicons/repo/listSecrets.json
+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
+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
+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
+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
+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
-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
+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
-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
+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
+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
+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
+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
-18
nix/modules/knot.nix
+54
-18
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
-
EOF
117
-
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
118
-
'';
119
-
120
129
users.users.${cfg.gitUser} = {
121
130
isSystemUser = true;
122
131
useDefaultShell = true;
···
152
161
description = "knot service";
153
162
after = ["network.target" "sshd.service"];
154
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
+
155
190
serviceConfig = {
156
191
User = cfg.gitUser;
192
+
PermissionsStartOnly = true;
157
193
WorkingDirectory = cfg.stateDir;
158
194
Environment = [
159
195
"KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}"
···
163
199
"KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
164
200
"KNOT_SERVER_DB_PATH=${cfg.server.dbPath}"
165
201
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
202
+
"KNOT_SERVER_OWNER=${cfg.server.owner}"
166
203
];
167
-
EnvironmentFile = cfg.server.secretFile;
168
204
ExecStart = "${cfg.package}/bin/knot server";
169
205
Restart = "always";
170
206
};
+24
-2
nix/modules/spindle.nix
+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
+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
+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
+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
-3
nix/pkgs/knot-unwrapped.nix
+1
-1
nix/pkgs/lexgen.nix
+1
-1
nix/pkgs/lexgen.nix
+2
-3
nix/pkgs/spindle.nix
+2
-3
nix/pkgs/spindle.nix
+121
-63
nix/vm.nix
+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
+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
+1
-1
rbac/rbac_test.go
+27
-10
spindle/config/config.go
+27
-10
spindle/config/config.go
···
2
2
3
3
import (
4
4
"context"
5
+
"fmt"
5
6
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
6
8
"github.com/sethvargo/go-envconfig"
7
9
)
8
10
9
11
type Server struct {
10
-
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"`
11
-
DBPath string `env:"DB_PATH, default=spindle.db"`
12
-
Hostname string `env:"HOSTNAME, required"`
13
-
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
14
-
Dev bool `env:"DEV, default=false"`
15
-
Owner string `env:"OWNER, required"`
12
+
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"`
13
+
DBPath string `env:"DB_PATH, default=spindle.db"`
14
+
Hostname string `env:"HOSTNAME, required"`
15
+
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
16
+
Dev bool `env:"DEV, default=false"`
17
+
Owner string `env:"OWNER, required"`
18
+
Secrets Secrets `env:",prefix=SECRETS_"`
19
+
LogDir string `env:"LOG_DIR, default=/var/log/spindle"`
20
+
}
21
+
22
+
func (s Server) Did() syntax.DID {
23
+
return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname))
24
+
}
25
+
26
+
type Secrets struct {
27
+
Provider string `env:"PROVIDER, default=sqlite"`
28
+
OpenBao OpenBaoConfig `env:",prefix=OPENBAO_"`
16
29
}
17
30
18
-
type Pipelines struct {
31
+
type OpenBaoConfig struct {
32
+
ProxyAddr string `env:"PROXY_ADDR, default=http://127.0.0.1:8200"`
33
+
Mount string `env:"MOUNT, default=spindle"`
34
+
}
35
+
36
+
type NixeryPipelines struct {
19
37
Nixery string `env:"NIXERY, default=nixery.tangled.sh"`
20
38
WorkflowTimeout string `env:"WORKFLOW_TIMEOUT, default=5m"`
21
-
LogDir string `env:"LOG_DIR, default=/var/log/spindle"`
22
39
}
23
40
24
41
type Config struct {
25
-
Server Server `env:",prefix=SPINDLE_SERVER_"`
26
-
Pipelines Pipelines `env:",prefix=SPINDLE_PIPELINES_"`
42
+
Server Server `env:",prefix=SPINDLE_SERVER_"`
43
+
NixeryPipelines NixeryPipelines `env:",prefix=SPINDLE_NIXERY_PIPELINES_"`
27
44
}
28
45
29
46
func Load(ctx context.Context) (*Config, error) {
+29
-10
spindle/db/db.go
+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
+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
-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
+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
-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
-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
-9
spindle/engine/errors.go
-84
spindle/engine/logger.go
-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
+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
+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
+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
+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
+7
spindle/engines/nixery/errors.go
+126
spindle/engines/nixery/setup_steps.go
+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
+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
+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
+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
+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
+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
-125
spindle/models/setup_steps.go
···
1
-
package models
2
-
3
-
import (
4
-
"fmt"
5
-
"path"
6
-
"strings"
7
-
8
-
"tangled.sh/tangled.sh/core/api/tangled"
9
-
"tangled.sh/tangled.sh/core/workflow"
10
-
)
11
-
12
-
func nixConfStep() Step {
13
-
setupCmd := `echo 'extra-experimental-features = nix-command flakes' >> /etc/nix/nix.conf
14
-
echo 'build-users-group = ' >> /etc/nix/nix.conf`
15
-
return Step{
16
-
Command: setupCmd,
17
-
Name: "Configure Nix",
18
-
}
19
-
}
20
-
21
-
// cloneOptsAsSteps processes clone options and adds corresponding steps
22
-
// to the beginning of the workflow's step list if cloning is not skipped.
23
-
//
24
-
// the steps to do here are:
25
-
// - git init
26
-
// - git remote add origin <url>
27
-
// - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha>
28
-
// - git checkout FETCH_HEAD
29
-
func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step {
30
-
if twf.Clone.Skip {
31
-
return Step{}
32
-
}
33
-
34
-
var commands []string
35
-
36
-
// initialize git repo in workspace
37
-
commands = append(commands, "git init")
38
-
39
-
// add repo as git remote
40
-
scheme := "https://"
41
-
if dev {
42
-
scheme = "http://"
43
-
tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal")
44
-
}
45
-
url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo)
46
-
commands = append(commands, fmt.Sprintf("git remote add origin %s", url))
47
-
48
-
// run git fetch
49
-
{
50
-
var fetchArgs []string
51
-
52
-
// default clone depth is 1
53
-
depth := 1
54
-
if twf.Clone.Depth > 1 {
55
-
depth = int(twf.Clone.Depth)
56
-
}
57
-
fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth))
58
-
59
-
// optionally recurse submodules
60
-
if twf.Clone.Submodules {
61
-
fetchArgs = append(fetchArgs, "--recurse-submodules=yes")
62
-
}
63
-
64
-
// set remote to fetch from
65
-
fetchArgs = append(fetchArgs, "origin")
66
-
67
-
// set revision to checkout
68
-
switch workflow.TriggerKind(tr.Kind) {
69
-
case workflow.TriggerKindManual:
70
-
// TODO: unimplemented
71
-
case workflow.TriggerKindPush:
72
-
fetchArgs = append(fetchArgs, tr.Push.NewSha)
73
-
case workflow.TriggerKindPullRequest:
74
-
fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha)
75
-
}
76
-
77
-
commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " ")))
78
-
}
79
-
80
-
// run git checkout
81
-
commands = append(commands, "git checkout FETCH_HEAD")
82
-
83
-
cloneStep := Step{
84
-
Command: strings.Join(commands, "\n"),
85
-
Name: "Clone repository into workspace",
86
-
}
87
-
return cloneStep
88
-
}
89
-
90
-
// dependencyStep processes dependencies defined in the workflow.
91
-
// For dependencies using a custom registry (i.e. not nixpkgs), it collects
92
-
// all packages and adds a single 'nix profile install' step to the
93
-
// beginning of the workflow's step list.
94
-
func dependencyStep(twf tangled.Pipeline_Workflow) *Step {
95
-
var customPackages []string
96
-
97
-
for _, d := range twf.Dependencies {
98
-
registry := d.Registry
99
-
packages := d.Packages
100
-
101
-
if registry == "nixpkgs" {
102
-
continue
103
-
}
104
-
105
-
// collect packages from custom registries
106
-
for _, pkg := range packages {
107
-
customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg))
108
-
}
109
-
}
110
-
111
-
if len(customPackages) > 0 {
112
-
installCmd := "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install"
113
-
cmd := fmt.Sprintf("%s %s", installCmd, strings.Join(customPackages, " "))
114
-
installStep := Step{
115
-
Command: cmd,
116
-
Name: "Install custom dependencies",
117
-
Environment: map[string]string{
118
-
"NIX_NO_COLOR": "1",
119
-
"NIX_SHOW_DOWNLOAD_PROGRESS": "0",
120
-
},
121
-
}
122
-
return &installStep
123
-
}
124
-
return nil
125
-
}
+25
spindle/motd
+25
spindle/motd
···
1
+
****
2
+
*** ***
3
+
*** ** ****** **
4
+
** * *****
5
+
* ** **
6
+
* * * ***************
7
+
** ** *# **
8
+
* ** ** *** **
9
+
* * ** ** * ******
10
+
* ** ** * ** * *
11
+
** ** *** ** ** *
12
+
** ** * ** * *
13
+
** **** ** * *
14
+
** *** ** ** **
15
+
*** ** *****
16
+
********************
17
+
**
18
+
*
19
+
#**************
20
+
**
21
+
********
22
+
23
+
This is a spindle server. More info at https://tangled.sh/@tangled.sh/core/tree/master/docs/spindle
24
+
25
+
Most API routes are under /xrpc/
+70
spindle/secrets/manager.go
+70
spindle/secrets/manager.go
···
1
+
package secrets
2
+
3
+
import (
4
+
"context"
5
+
"errors"
6
+
"regexp"
7
+
"time"
8
+
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
)
11
+
12
+
type DidSlashRepo string
13
+
14
+
type Secret[T any] struct {
15
+
Key string
16
+
Value T
17
+
Repo DidSlashRepo
18
+
CreatedAt time.Time
19
+
CreatedBy syntax.DID
20
+
}
21
+
22
+
// the secret is not present
23
+
type LockedSecret = Secret[struct{}]
24
+
25
+
// the secret is present in plaintext, never expose this publicly,
26
+
// only use in the workflow engine
27
+
type UnlockedSecret = Secret[string]
28
+
29
+
type Manager interface {
30
+
AddSecret(ctx context.Context, secret UnlockedSecret) error
31
+
RemoveSecret(ctx context.Context, secret Secret[any]) error
32
+
GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error)
33
+
GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error)
34
+
}
35
+
36
+
// stopper interface for managers that need cleanup
37
+
type Stopper interface {
38
+
Stop()
39
+
}
40
+
41
+
var ErrKeyAlreadyPresent = errors.New("key already present")
42
+
var ErrInvalidKeyIdent = errors.New("key is not a valid identifier")
43
+
var ErrKeyNotFound = errors.New("key not found")
44
+
45
+
// ensure that we are satisfying the interface
46
+
var (
47
+
_ = []Manager{
48
+
&SqliteManager{},
49
+
&OpenBaoManager{},
50
+
}
51
+
)
52
+
53
+
var (
54
+
// bash identifier syntax
55
+
keyIdent = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
56
+
)
57
+
58
+
func isValidKey(key string) bool {
59
+
if key == "" {
60
+
return false
61
+
}
62
+
return keyIdent.MatchString(key)
63
+
}
64
+
65
+
func ValidateKey(key string) error {
66
+
if !isValidKey(key) {
67
+
return ErrInvalidKeyIdent
68
+
}
69
+
return nil
70
+
}
+313
spindle/secrets/openbao.go
+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
+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
+22
spindle/secrets/policy.hcl
···
1
+
# Allow full access to the spindle KV mount
2
+
path "spindle/*" {
3
+
capabilities = ["create", "read", "update", "delete", "list"]
4
+
}
5
+
6
+
path "spindle/data/*" {
7
+
capabilities = ["create", "read", "update", "delete"]
8
+
}
9
+
10
+
path "spindle/metadata/*" {
11
+
capabilities = ["list", "read", "delete"]
12
+
}
13
+
14
+
# Allow listing mounts (for connection testing)
15
+
path "sys/mounts" {
16
+
capabilities = ["read"]
17
+
}
18
+
19
+
# Allow token self-lookup (for health checks)
20
+
path "auth/token/lookup-self" {
21
+
capabilities = ["read"]
22
+
}
+172
spindle/secrets/sqlite.go
+172
spindle/secrets/sqlite.go
···
1
+
// an sqlite3 backed secret manager
2
+
package secrets
3
+
4
+
import (
5
+
"context"
6
+
"database/sql"
7
+
"fmt"
8
+
"time"
9
+
10
+
_ "github.com/mattn/go-sqlite3"
11
+
)
12
+
13
+
type SqliteManager struct {
14
+
db *sql.DB
15
+
tableName string
16
+
}
17
+
18
+
type SqliteManagerOpt func(*SqliteManager)
19
+
20
+
func WithTableName(name string) SqliteManagerOpt {
21
+
return func(s *SqliteManager) {
22
+
s.tableName = name
23
+
}
24
+
}
25
+
26
+
func NewSQLiteManager(dbPath string, opts ...SqliteManagerOpt) (*SqliteManager, error) {
27
+
db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1")
28
+
if err != nil {
29
+
return nil, fmt.Errorf("failed to open sqlite database: %w", err)
30
+
}
31
+
32
+
manager := &SqliteManager{
33
+
db: db,
34
+
tableName: "secrets",
35
+
}
36
+
37
+
for _, o := range opts {
38
+
o(manager)
39
+
}
40
+
41
+
if err := manager.init(); err != nil {
42
+
return nil, err
43
+
}
44
+
45
+
return manager, nil
46
+
}
47
+
48
+
// creates a table and sets up the schema, migrations if any can go here
49
+
func (s *SqliteManager) init() error {
50
+
createTable :=
51
+
`create table if not exists ` + s.tableName + `(
52
+
id integer primary key autoincrement,
53
+
repo text not null,
54
+
key text not null,
55
+
value text not null,
56
+
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
57
+
created_by text not null,
58
+
59
+
unique(repo, key)
60
+
);`
61
+
_, err := s.db.Exec(createTable)
62
+
return err
63
+
}
64
+
65
+
func (s *SqliteManager) AddSecret(ctx context.Context, secret UnlockedSecret) error {
66
+
query := fmt.Sprintf(`
67
+
insert or ignore into %s (repo, key, value, created_by)
68
+
values (?, ?, ?, ?);
69
+
`, s.tableName)
70
+
71
+
res, err := s.db.ExecContext(ctx, query, secret.Repo, secret.Key, secret.Value, secret.CreatedBy)
72
+
if err != nil {
73
+
return err
74
+
}
75
+
76
+
num, err := res.RowsAffected()
77
+
if err != nil {
78
+
return err
79
+
}
80
+
81
+
if num == 0 {
82
+
return ErrKeyAlreadyPresent
83
+
}
84
+
85
+
return nil
86
+
}
87
+
88
+
func (s *SqliteManager) RemoveSecret(ctx context.Context, secret Secret[any]) error {
89
+
query := fmt.Sprintf(`
90
+
delete from %s where repo = ? and key = ?;
91
+
`, s.tableName)
92
+
93
+
res, err := s.db.ExecContext(ctx, query, secret.Repo, secret.Key)
94
+
if err != nil {
95
+
return err
96
+
}
97
+
98
+
num, err := res.RowsAffected()
99
+
if err != nil {
100
+
return err
101
+
}
102
+
103
+
if num == 0 {
104
+
return ErrKeyNotFound
105
+
}
106
+
107
+
return nil
108
+
}
109
+
110
+
func (s *SqliteManager) GetSecretsLocked(ctx context.Context, didSlashRepo DidSlashRepo) ([]LockedSecret, error) {
111
+
query := fmt.Sprintf(`
112
+
select repo, key, created_at, created_by from %s where repo = ?;
113
+
`, s.tableName)
114
+
115
+
rows, err := s.db.QueryContext(ctx, query, didSlashRepo)
116
+
if err != nil {
117
+
return nil, err
118
+
}
119
+
120
+
var ls []LockedSecret
121
+
for rows.Next() {
122
+
var l LockedSecret
123
+
var createdAt string
124
+
if err = rows.Scan(&l.Repo, &l.Key, &createdAt, &l.CreatedBy); err != nil {
125
+
return nil, err
126
+
}
127
+
128
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
129
+
l.CreatedAt = t
130
+
}
131
+
132
+
ls = append(ls, l)
133
+
}
134
+
135
+
if err = rows.Err(); err != nil {
136
+
return nil, err
137
+
}
138
+
139
+
return ls, nil
140
+
}
141
+
142
+
func (s *SqliteManager) GetSecretsUnlocked(ctx context.Context, didSlashRepo DidSlashRepo) ([]UnlockedSecret, error) {
143
+
query := fmt.Sprintf(`
144
+
select repo, key, value, created_at, created_by from %s where repo = ?;
145
+
`, s.tableName)
146
+
147
+
rows, err := s.db.QueryContext(ctx, query, didSlashRepo)
148
+
if err != nil {
149
+
return nil, err
150
+
}
151
+
152
+
var ls []UnlockedSecret
153
+
for rows.Next() {
154
+
var l UnlockedSecret
155
+
var createdAt string
156
+
if err = rows.Scan(&l.Repo, &l.Key, &l.Value, &createdAt, &l.CreatedBy); err != nil {
157
+
return nil, err
158
+
}
159
+
160
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
161
+
l.CreatedAt = t
162
+
}
163
+
164
+
ls = append(ls, l)
165
+
}
166
+
167
+
if err = rows.Err(); err != nil {
168
+
return nil, err
169
+
}
170
+
171
+
return ls, nil
172
+
}
+590
spindle/secrets/sqlite_test.go
+590
spindle/secrets/sqlite_test.go
···
1
+
package secrets
2
+
3
+
import (
4
+
"context"
5
+
"testing"
6
+
"time"
7
+
8
+
"github.com/alecthomas/assert/v2"
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
)
11
+
12
+
func createInMemoryDB(t *testing.T) *SqliteManager {
13
+
t.Helper()
14
+
manager, err := NewSQLiteManager(":memory:")
15
+
if err != nil {
16
+
t.Fatalf("Failed to create in-memory manager: %v", err)
17
+
}
18
+
return manager
19
+
}
20
+
21
+
func createTestSecret(repo, key, value, createdBy string) UnlockedSecret {
22
+
return UnlockedSecret{
23
+
Key: key,
24
+
Value: value,
25
+
Repo: DidSlashRepo(repo),
26
+
CreatedAt: time.Now(),
27
+
CreatedBy: syntax.DID(createdBy),
28
+
}
29
+
}
30
+
31
+
// ensure that interface is satisfied
32
+
func TestManagerInterface(t *testing.T) {
33
+
var _ Manager = (*SqliteManager)(nil)
34
+
}
35
+
36
+
func TestNewSQLiteManager(t *testing.T) {
37
+
tests := []struct {
38
+
name string
39
+
dbPath string
40
+
opts []SqliteManagerOpt
41
+
expectError bool
42
+
expectTable string
43
+
}{
44
+
{
45
+
name: "default table name",
46
+
dbPath: ":memory:",
47
+
opts: nil,
48
+
expectError: false,
49
+
expectTable: "secrets",
50
+
},
51
+
{
52
+
name: "custom table name",
53
+
dbPath: ":memory:",
54
+
opts: []SqliteManagerOpt{WithTableName("custom_secrets")},
55
+
expectError: false,
56
+
expectTable: "custom_secrets",
57
+
},
58
+
{
59
+
name: "invalid database path",
60
+
dbPath: "/invalid/path/to/database.db",
61
+
opts: nil,
62
+
expectError: true,
63
+
expectTable: "",
64
+
},
65
+
}
66
+
67
+
for _, tt := range tests {
68
+
t.Run(tt.name, func(t *testing.T) {
69
+
manager, err := NewSQLiteManager(tt.dbPath, tt.opts...)
70
+
if tt.expectError {
71
+
if err == nil {
72
+
t.Error("Expected error but got none")
73
+
}
74
+
return
75
+
}
76
+
77
+
if err != nil {
78
+
t.Fatalf("Unexpected error: %v", err)
79
+
}
80
+
defer manager.db.Close()
81
+
82
+
if manager.tableName != tt.expectTable {
83
+
t.Errorf("Expected table name %s, got %s", tt.expectTable, manager.tableName)
84
+
}
85
+
})
86
+
}
87
+
}
88
+
89
+
func TestSqliteManager_AddSecret(t *testing.T) {
90
+
tests := []struct {
91
+
name string
92
+
secrets []UnlockedSecret
93
+
expectError []error
94
+
}{
95
+
{
96
+
name: "add single secret",
97
+
secrets: []UnlockedSecret{
98
+
createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"),
99
+
},
100
+
expectError: []error{nil},
101
+
},
102
+
{
103
+
name: "add multiple unique secrets",
104
+
secrets: []UnlockedSecret{
105
+
createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"),
106
+
createTestSecret("did:plc:foo/repo", "db_password", "password_456", "did:plc:example123"),
107
+
createTestSecret("other.com/repo", "api_key", "other_secret", "did:plc:other"),
108
+
},
109
+
expectError: []error{nil, nil, nil},
110
+
},
111
+
{
112
+
name: "add duplicate secret",
113
+
secrets: []UnlockedSecret{
114
+
createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"),
115
+
createTestSecret("did:plc:foo/repo", "api_key", "different_value", "did:plc:example123"),
116
+
},
117
+
expectError: []error{nil, ErrKeyAlreadyPresent},
118
+
},
119
+
}
120
+
121
+
for _, tt := range tests {
122
+
t.Run(tt.name, func(t *testing.T) {
123
+
manager := createInMemoryDB(t)
124
+
defer manager.db.Close()
125
+
126
+
for i, secret := range tt.secrets {
127
+
err := manager.AddSecret(context.Background(), secret)
128
+
if err != tt.expectError[i] {
129
+
t.Errorf("Secret %d: expected error %v, got %v", i, tt.expectError[i], err)
130
+
}
131
+
}
132
+
})
133
+
}
134
+
}
135
+
136
+
func TestSqliteManager_RemoveSecret(t *testing.T) {
137
+
tests := []struct {
138
+
name string
139
+
setupSecrets []UnlockedSecret
140
+
removeSecret Secret[any]
141
+
expectError error
142
+
}{
143
+
{
144
+
name: "remove existing secret",
145
+
setupSecrets: []UnlockedSecret{
146
+
createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"),
147
+
},
148
+
removeSecret: Secret[any]{
149
+
Key: "api_key",
150
+
Repo: DidSlashRepo("did:plc:foo/repo"),
151
+
},
152
+
expectError: nil,
153
+
},
154
+
{
155
+
name: "remove non-existent secret",
156
+
setupSecrets: []UnlockedSecret{
157
+
createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"),
158
+
},
159
+
removeSecret: Secret[any]{
160
+
Key: "non_existent_key",
161
+
Repo: DidSlashRepo("did:plc:foo/repo"),
162
+
},
163
+
expectError: ErrKeyNotFound,
164
+
},
165
+
{
166
+
name: "remove from empty database",
167
+
setupSecrets: []UnlockedSecret{},
168
+
removeSecret: Secret[any]{
169
+
Key: "any_key",
170
+
Repo: DidSlashRepo("did:plc:foo/repo"),
171
+
},
172
+
expectError: ErrKeyNotFound,
173
+
},
174
+
{
175
+
name: "remove secret from wrong repo",
176
+
setupSecrets: []UnlockedSecret{
177
+
createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"),
178
+
},
179
+
removeSecret: Secret[any]{
180
+
Key: "api_key",
181
+
Repo: DidSlashRepo("other.com/repo"),
182
+
},
183
+
expectError: ErrKeyNotFound,
184
+
},
185
+
}
186
+
187
+
for _, tt := range tests {
188
+
t.Run(tt.name, func(t *testing.T) {
189
+
manager := createInMemoryDB(t)
190
+
defer manager.db.Close()
191
+
192
+
// Setup secrets
193
+
for _, secret := range tt.setupSecrets {
194
+
if err := manager.AddSecret(context.Background(), secret); err != nil {
195
+
t.Fatalf("Failed to setup secret: %v", err)
196
+
}
197
+
}
198
+
199
+
// Test removal
200
+
err := manager.RemoveSecret(context.Background(), tt.removeSecret)
201
+
if err != tt.expectError {
202
+
t.Errorf("Expected error %v, got %v", tt.expectError, err)
203
+
}
204
+
})
205
+
}
206
+
}
207
+
208
+
func TestSqliteManager_GetSecretsLocked(t *testing.T) {
209
+
tests := []struct {
210
+
name string
211
+
setupSecrets []UnlockedSecret
212
+
queryRepo DidSlashRepo
213
+
expectedCount int
214
+
expectedKeys []string
215
+
expectError bool
216
+
}{
217
+
{
218
+
name: "get secrets for repo with multiple secrets",
219
+
setupSecrets: []UnlockedSecret{
220
+
createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"),
221
+
createTestSecret("did:plc:foo/repo", "key2", "value2", "did:plc:user2"),
222
+
createTestSecret("other.com/repo", "key3", "value3", "did:plc:user3"),
223
+
},
224
+
queryRepo: DidSlashRepo("did:plc:foo/repo"),
225
+
expectedCount: 2,
226
+
expectedKeys: []string{"key1", "key2"},
227
+
expectError: false,
228
+
},
229
+
{
230
+
name: "get secrets for repo with single secret",
231
+
setupSecrets: []UnlockedSecret{
232
+
createTestSecret("did:plc:foo/repo", "single_key", "single_value", "did:plc:user1"),
233
+
createTestSecret("other.com/repo", "other_key", "other_value", "did:plc:user2"),
234
+
},
235
+
queryRepo: DidSlashRepo("did:plc:foo/repo"),
236
+
expectedCount: 1,
237
+
expectedKeys: []string{"single_key"},
238
+
expectError: false,
239
+
},
240
+
{
241
+
name: "get secrets for non-existent repo",
242
+
setupSecrets: []UnlockedSecret{
243
+
createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"),
244
+
},
245
+
queryRepo: DidSlashRepo("nonexistent.com/repo"),
246
+
expectedCount: 0,
247
+
expectedKeys: []string{},
248
+
expectError: false,
249
+
},
250
+
{
251
+
name: "get secrets from empty database",
252
+
setupSecrets: []UnlockedSecret{},
253
+
queryRepo: DidSlashRepo("did:plc:foo/repo"),
254
+
expectedCount: 0,
255
+
expectedKeys: []string{},
256
+
expectError: false,
257
+
},
258
+
}
259
+
260
+
for _, tt := range tests {
261
+
t.Run(tt.name, func(t *testing.T) {
262
+
manager := createInMemoryDB(t)
263
+
defer manager.db.Close()
264
+
265
+
// Setup secrets
266
+
for _, secret := range tt.setupSecrets {
267
+
if err := manager.AddSecret(context.Background(), secret); err != nil {
268
+
t.Fatalf("Failed to setup secret: %v", err)
269
+
}
270
+
}
271
+
272
+
// Test getting locked secrets
273
+
lockedSecrets, err := manager.GetSecretsLocked(context.Background(), tt.queryRepo)
274
+
if tt.expectError && err == nil {
275
+
t.Error("Expected error but got none")
276
+
return
277
+
}
278
+
if !tt.expectError && err != nil {
279
+
t.Fatalf("Unexpected error: %v", err)
280
+
}
281
+
282
+
if len(lockedSecrets) != tt.expectedCount {
283
+
t.Errorf("Expected %d secrets, got %d", tt.expectedCount, len(lockedSecrets))
284
+
}
285
+
286
+
// Verify keys and that values are not present (locked)
287
+
foundKeys := make(map[string]bool)
288
+
for _, ls := range lockedSecrets {
289
+
foundKeys[ls.Key] = true
290
+
if ls.Repo != tt.queryRepo {
291
+
t.Errorf("Expected repo %s, got %s", tt.queryRepo, ls.Repo)
292
+
}
293
+
if ls.CreatedBy == "" {
294
+
t.Error("Expected CreatedBy to be present")
295
+
}
296
+
if ls.CreatedAt.IsZero() {
297
+
t.Error("Expected CreatedAt to be set")
298
+
}
299
+
}
300
+
301
+
for _, expectedKey := range tt.expectedKeys {
302
+
if !foundKeys[expectedKey] {
303
+
t.Errorf("Expected key %s not found", expectedKey)
304
+
}
305
+
}
306
+
})
307
+
}
308
+
}
309
+
310
+
func TestSqliteManager_GetSecretsUnlocked(t *testing.T) {
311
+
tests := []struct {
312
+
name string
313
+
setupSecrets []UnlockedSecret
314
+
queryRepo DidSlashRepo
315
+
expectedCount int
316
+
expectedSecrets map[string]string // key -> value
317
+
expectError bool
318
+
}{
319
+
{
320
+
name: "get unlocked secrets for repo with multiple secrets",
321
+
setupSecrets: []UnlockedSecret{
322
+
createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"),
323
+
createTestSecret("did:plc:foo/repo", "key2", "value2", "did:plc:user2"),
324
+
createTestSecret("other.com/repo", "key3", "value3", "did:plc:user3"),
325
+
},
326
+
queryRepo: DidSlashRepo("did:plc:foo/repo"),
327
+
expectedCount: 2,
328
+
expectedSecrets: map[string]string{
329
+
"key1": "value1",
330
+
"key2": "value2",
331
+
},
332
+
expectError: false,
333
+
},
334
+
{
335
+
name: "get unlocked secrets for repo with single secret",
336
+
setupSecrets: []UnlockedSecret{
337
+
createTestSecret("did:plc:foo/repo", "single_key", "single_value", "did:plc:user1"),
338
+
createTestSecret("other.com/repo", "other_key", "other_value", "did:plc:user2"),
339
+
},
340
+
queryRepo: DidSlashRepo("did:plc:foo/repo"),
341
+
expectedCount: 1,
342
+
expectedSecrets: map[string]string{
343
+
"single_key": "single_value",
344
+
},
345
+
expectError: false,
346
+
},
347
+
{
348
+
name: "get unlocked secrets for non-existent repo",
349
+
setupSecrets: []UnlockedSecret{
350
+
createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"),
351
+
},
352
+
queryRepo: DidSlashRepo("nonexistent.com/repo"),
353
+
expectedCount: 0,
354
+
expectedSecrets: map[string]string{},
355
+
expectError: false,
356
+
},
357
+
{
358
+
name: "get unlocked secrets from empty database",
359
+
setupSecrets: []UnlockedSecret{},
360
+
queryRepo: DidSlashRepo("did:plc:foo/repo"),
361
+
expectedCount: 0,
362
+
expectedSecrets: map[string]string{},
363
+
expectError: false,
364
+
},
365
+
}
366
+
367
+
for _, tt := range tests {
368
+
t.Run(tt.name, func(t *testing.T) {
369
+
manager := createInMemoryDB(t)
370
+
defer manager.db.Close()
371
+
372
+
// Setup secrets
373
+
for _, secret := range tt.setupSecrets {
374
+
if err := manager.AddSecret(context.Background(), secret); err != nil {
375
+
t.Fatalf("Failed to setup secret: %v", err)
376
+
}
377
+
}
378
+
379
+
// Test getting unlocked secrets
380
+
unlockedSecrets, err := manager.GetSecretsUnlocked(context.Background(), tt.queryRepo)
381
+
if tt.expectError && err == nil {
382
+
t.Error("Expected error but got none")
383
+
return
384
+
}
385
+
if !tt.expectError && err != nil {
386
+
t.Fatalf("Unexpected error: %v", err)
387
+
}
388
+
389
+
if len(unlockedSecrets) != tt.expectedCount {
390
+
t.Errorf("Expected %d secrets, got %d", tt.expectedCount, len(unlockedSecrets))
391
+
}
392
+
393
+
// Verify keys, values, and metadata
394
+
for _, us := range unlockedSecrets {
395
+
expectedValue, exists := tt.expectedSecrets[us.Key]
396
+
if !exists {
397
+
t.Errorf("Unexpected key: %s", us.Key)
398
+
continue
399
+
}
400
+
if us.Value != expectedValue {
401
+
t.Errorf("Expected value %s for key %s, got %s", expectedValue, us.Key, us.Value)
402
+
}
403
+
if us.Repo != tt.queryRepo {
404
+
t.Errorf("Expected repo %s, got %s", tt.queryRepo, us.Repo)
405
+
}
406
+
if us.CreatedBy == "" {
407
+
t.Error("Expected CreatedBy to be present")
408
+
}
409
+
if us.CreatedAt.IsZero() {
410
+
t.Error("Expected CreatedAt to be set")
411
+
}
412
+
}
413
+
})
414
+
}
415
+
}
416
+
417
+
// Test that demonstrates interface usage with table-driven tests
418
+
func TestManagerInterface_Usage(t *testing.T) {
419
+
tests := []struct {
420
+
name string
421
+
operations []func(Manager) error
422
+
expectError bool
423
+
}{
424
+
{
425
+
name: "successful workflow",
426
+
operations: []func(Manager) error{
427
+
func(m Manager) error {
428
+
secret := createTestSecret("interface.test/repo", "test_key", "test_value", "did:plc:user")
429
+
return m.AddSecret(context.Background(), secret)
430
+
},
431
+
func(m Manager) error {
432
+
_, err := m.GetSecretsLocked(context.Background(), DidSlashRepo("interface.test/repo"))
433
+
return err
434
+
},
435
+
func(m Manager) error {
436
+
_, err := m.GetSecretsUnlocked(context.Background(), DidSlashRepo("interface.test/repo"))
437
+
return err
438
+
},
439
+
func(m Manager) error {
440
+
secret := Secret[any]{
441
+
Key: "test_key",
442
+
Repo: DidSlashRepo("interface.test/repo"),
443
+
}
444
+
return m.RemoveSecret(context.Background(), secret)
445
+
},
446
+
},
447
+
expectError: false,
448
+
},
449
+
{
450
+
name: "error on duplicate key",
451
+
operations: []func(Manager) error{
452
+
func(m Manager) error {
453
+
secret := createTestSecret("interface.test/repo", "dup_key", "value1", "did:plc:user")
454
+
return m.AddSecret(context.Background(), secret)
455
+
},
456
+
func(m Manager) error {
457
+
secret := createTestSecret("interface.test/repo", "dup_key", "value2", "did:plc:user")
458
+
return m.AddSecret(context.Background(), secret) // Should return ErrKeyAlreadyPresent
459
+
},
460
+
},
461
+
expectError: true,
462
+
},
463
+
}
464
+
465
+
for _, tt := range tests {
466
+
t.Run(tt.name, func(t *testing.T) {
467
+
var manager Manager = createInMemoryDB(t)
468
+
defer func() {
469
+
if sqliteManager, ok := manager.(*SqliteManager); ok {
470
+
sqliteManager.db.Close()
471
+
}
472
+
}()
473
+
474
+
var finalErr error
475
+
for i, operation := range tt.operations {
476
+
if err := operation(manager); err != nil {
477
+
finalErr = err
478
+
t.Logf("Operation %d returned error: %v", i, err)
479
+
}
480
+
}
481
+
482
+
if tt.expectError && finalErr == nil {
483
+
t.Error("Expected error but got none")
484
+
}
485
+
if !tt.expectError && finalErr != nil {
486
+
t.Errorf("Unexpected error: %v", finalErr)
487
+
}
488
+
})
489
+
}
490
+
}
491
+
492
+
// Integration test with table-driven scenarios
493
+
func TestSqliteManager_Integration(t *testing.T) {
494
+
tests := []struct {
495
+
name string
496
+
scenario func(*testing.T, *SqliteManager)
497
+
}{
498
+
{
499
+
name: "multi-repo secret management",
500
+
scenario: func(t *testing.T, manager *SqliteManager) {
501
+
repo1 := DidSlashRepo("example1.com/repo")
502
+
repo2 := DidSlashRepo("example2.com/repo")
503
+
504
+
secrets := []UnlockedSecret{
505
+
createTestSecret(string(repo1), "db_password", "super_secret_123", "did:plc:admin"),
506
+
createTestSecret(string(repo1), "api_key", "api_key_456", "did:plc:user1"),
507
+
createTestSecret(string(repo2), "token", "bearer_token_789", "did:plc:user2"),
508
+
}
509
+
510
+
// Add all secrets
511
+
for _, secret := range secrets {
512
+
if err := manager.AddSecret(context.Background(), secret); err != nil {
513
+
t.Fatalf("Failed to add secret %s: %v", secret.Key, err)
514
+
}
515
+
}
516
+
517
+
// Verify counts
518
+
locked1, _ := manager.GetSecretsLocked(context.Background(), repo1)
519
+
locked2, _ := manager.GetSecretsLocked(context.Background(), repo2)
520
+
521
+
if len(locked1) != 2 {
522
+
t.Errorf("Expected 2 secrets for repo1, got %d", len(locked1))
523
+
}
524
+
if len(locked2) != 1 {
525
+
t.Errorf("Expected 1 secret for repo2, got %d", len(locked2))
526
+
}
527
+
528
+
// Remove and verify
529
+
secretToRemove := Secret[any]{Key: "db_password", Repo: repo1}
530
+
if err := manager.RemoveSecret(context.Background(), secretToRemove); err != nil {
531
+
t.Fatalf("Failed to remove secret: %v", err)
532
+
}
533
+
534
+
locked1After, _ := manager.GetSecretsLocked(context.Background(), repo1)
535
+
if len(locked1After) != 1 {
536
+
t.Errorf("Expected 1 secret for repo1 after removal, got %d", len(locked1After))
537
+
}
538
+
if locked1After[0].Key != "api_key" {
539
+
t.Errorf("Expected remaining secret to be 'api_key', got %s", locked1After[0].Key)
540
+
}
541
+
},
542
+
},
543
+
{
544
+
name: "empty database operations",
545
+
scenario: func(t *testing.T, manager *SqliteManager) {
546
+
repo := DidSlashRepo("empty.test/repo")
547
+
548
+
// Operations on empty database should not error
549
+
locked, err := manager.GetSecretsLocked(context.Background(), repo)
550
+
if err != nil {
551
+
t.Errorf("GetSecretsLocked on empty DB failed: %v", err)
552
+
}
553
+
if len(locked) != 0 {
554
+
t.Errorf("Expected 0 secrets, got %d", len(locked))
555
+
}
556
+
557
+
unlocked, err := manager.GetSecretsUnlocked(context.Background(), repo)
558
+
if err != nil {
559
+
t.Errorf("GetSecretsUnlocked on empty DB failed: %v", err)
560
+
}
561
+
if len(unlocked) != 0 {
562
+
t.Errorf("Expected 0 secrets, got %d", len(unlocked))
563
+
}
564
+
565
+
// Remove from empty should return ErrKeyNotFound
566
+
nonExistent := Secret[any]{Key: "none", Repo: repo}
567
+
err = manager.RemoveSecret(context.Background(), nonExistent)
568
+
if err != ErrKeyNotFound {
569
+
t.Errorf("Expected ErrKeyNotFound, got %v", err)
570
+
}
571
+
},
572
+
},
573
+
}
574
+
575
+
for _, tt := range tests {
576
+
t.Run(tt.name, func(t *testing.T) {
577
+
manager := createInMemoryDB(t)
578
+
defer manager.db.Close()
579
+
tt.scenario(t, manager)
580
+
})
581
+
}
582
+
}
583
+
584
+
func TestSqliteManager_StopperInterface(t *testing.T) {
585
+
manager := &SqliteManager{}
586
+
587
+
// Verify that SqliteManager does NOT implement the Stopper interface
588
+
_, ok := interface{}(manager).(Stopper)
589
+
assert.False(t, ok, "SqliteManager should NOT implement Stopper interface")
590
+
}
+133
-47
spindle/server.go
+133
-47
spindle/server.go
···
2
2
3
3
import (
4
4
"context"
5
+
_ "embed"
5
6
"encoding/json"
6
7
"fmt"
7
8
"log/slog"
···
11
12
"tangled.sh/tangled.sh/core/api/tangled"
12
13
"tangled.sh/tangled.sh/core/eventconsumer"
13
14
"tangled.sh/tangled.sh/core/eventconsumer/cursor"
15
+
"tangled.sh/tangled.sh/core/idresolver"
14
16
"tangled.sh/tangled.sh/core/jetstream"
15
17
"tangled.sh/tangled.sh/core/log"
16
18
"tangled.sh/tangled.sh/core/notifier"
···
18
20
"tangled.sh/tangled.sh/core/spindle/config"
19
21
"tangled.sh/tangled.sh/core/spindle/db"
20
22
"tangled.sh/tangled.sh/core/spindle/engine"
23
+
"tangled.sh/tangled.sh/core/spindle/engines/nixery"
21
24
"tangled.sh/tangled.sh/core/spindle/models"
22
25
"tangled.sh/tangled.sh/core/spindle/queue"
26
+
"tangled.sh/tangled.sh/core/spindle/secrets"
27
+
"tangled.sh/tangled.sh/core/spindle/xrpc"
28
+
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
23
29
)
24
30
31
+
//go:embed motd
32
+
var motd []byte
33
+
25
34
const (
26
35
rbacDomain = "thisserver"
27
36
)
28
37
29
38
type Spindle struct {
30
-
jc *jetstream.JetstreamClient
31
-
db *db.DB
32
-
e *rbac.Enforcer
33
-
l *slog.Logger
34
-
n *notifier.Notifier
35
-
eng *engine.Engine
36
-
jq *queue.Queue
37
-
cfg *config.Config
38
-
ks *eventconsumer.Consumer
39
+
jc *jetstream.JetstreamClient
40
+
db *db.DB
41
+
e *rbac.Enforcer
42
+
l *slog.Logger
43
+
n *notifier.Notifier
44
+
engs map[string]models.Engine
45
+
jq *queue.Queue
46
+
cfg *config.Config
47
+
ks *eventconsumer.Consumer
48
+
res *idresolver.Resolver
49
+
vault secrets.Manager
39
50
}
40
51
41
52
func Run(ctx context.Context) error {
···
59
70
60
71
n := notifier.New()
61
72
62
-
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)
63
99
if err != nil {
64
100
return err
65
101
}
66
102
67
-
jq := queue.NewQueue(100, 2)
103
+
jq := queue.NewQueue(100, 5)
68
104
69
105
collections := []string{
70
106
tangled.SpindleMemberNSID,
71
107
tangled.RepoNSID,
108
+
tangled.RepoCollaboratorNSID,
72
109
}
73
110
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true)
74
111
if err != nil {
···
76
113
}
77
114
jc.AddDid(cfg.Server.Owner)
78
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
+
125
+
resolver := idresolver.DefaultResolver()
126
+
79
127
spindle := Spindle{
80
-
jc: jc,
81
-
e: e,
82
-
db: d,
83
-
l: logger,
84
-
n: &n,
85
-
eng: eng,
86
-
jq: jq,
87
-
cfg: cfg,
128
+
jc: jc,
129
+
e: e,
130
+
db: d,
131
+
l: logger,
132
+
n: &n,
133
+
engs: map[string]models.Engine{"nixery": nixeryEng},
134
+
jq: jq,
135
+
cfg: cfg,
136
+
res: resolver,
137
+
vault: vault,
88
138
}
89
139
90
140
err = e.AddSpindle(rbacDomain)
···
101
151
jq.Start()
102
152
defer jq.Stop()
103
153
154
+
// Stop vault token renewal if it implements Stopper
155
+
if stopper, ok := vault.(secrets.Stopper); ok {
156
+
defer stopper.Stop()
157
+
}
158
+
104
159
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
105
160
if err != nil {
106
161
return fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
···
144
199
mux := chi.NewRouter()
145
200
146
201
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
147
-
w.Write([]byte(
148
-
` ****
149
-
*** ***
150
-
*** ** ****** **
151
-
** * *****
152
-
* ** **
153
-
* * * ***************
154
-
** ** *# **
155
-
* ** ** *** **
156
-
* * ** ** * ******
157
-
* ** ** * ** * *
158
-
** ** *** ** ** *
159
-
** ** * ** * *
160
-
** **** ** * *
161
-
** *** ** ** **
162
-
*** ** *****
163
-
********************
164
-
**
165
-
*
166
-
#**************
167
-
**
168
-
********
169
-
170
-
This is a spindle server. More info at https://tangled.sh/@tangled.sh/core/tree/master/docs/spindle`))
202
+
w.Write(motd)
171
203
})
172
204
mux.HandleFunc("/events", s.Events)
173
205
mux.HandleFunc("/owner", func(w http.ResponseWriter, r *http.Request) {
174
206
w.Write([]byte(s.cfg.Server.Owner))
175
207
})
176
208
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
209
+
210
+
mux.Mount("/xrpc", s.XrpcRouter())
177
211
return mux
178
212
}
179
213
214
+
func (s *Spindle) XrpcRouter() http.Handler {
215
+
logger := s.l.With("route", "xrpc")
216
+
217
+
serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String())
218
+
219
+
x := xrpc.Xrpc{
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,
228
+
}
229
+
230
+
return x.Router()
231
+
}
232
+
180
233
func (s *Spindle) processPipeline(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error {
181
234
if msg.Nsid == tangled.PipelineNSID {
182
235
tpl := tangled.Pipeline{}
···
194
247
return fmt.Errorf("no repo data found")
195
248
}
196
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
+
197
254
// filter by repos
198
255
_, err = s.db.GetRepo(
199
256
tpl.TriggerMetadata.Repo.Knot,
···
209
266
Rkey: msg.Rkey,
210
267
}
211
268
269
+
workflows := make(map[models.Engine][]models.Workflow)
270
+
212
271
for _, w := range tpl.Workflows {
213
272
if w != nil {
214
-
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{
215
299
PipelineId: pipelineId,
216
300
Name: w.Name,
217
301
}, s.n)
···
221
305
}
222
306
}
223
307
224
-
spl := models.ToPipeline(tpl, *s.cfg)
225
-
226
308
ok := s.jq.Enqueue(queue.Job{
227
309
Run: func() error {
228
-
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)
229
315
return nil
230
316
},
231
317
OnFail: func(jobError error) {
+32
-2
spindle/stream.go
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+
}