+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
-11
.tangled/workflows/fmt.yml
+3
-11
.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"
12
-
command: |
13
-
alejandra -c nix/**/*.nix flake.nix
14
-
15
-
- name: "go fmt"
8
+
- name: "Check formatting"
16
9
command: |
17
-
gofmt -l .
18
-
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
+
}
+30
api/tangled/reposetDefaultBranch.go
+30
api/tangled/reposetDefaultBranch.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.setDefaultBranch
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoSetDefaultBranchNSID = "sh.tangled.repo.setDefaultBranch"
15
+
)
16
+
17
+
// RepoSetDefaultBranch_Input is the input argument to a sh.tangled.repo.setDefaultBranch call.
18
+
type RepoSetDefaultBranch_Input struct {
19
+
DefaultBranch string `json:"defaultBranch" cborgen:"defaultBranch"`
20
+
Repo string `json:"repo" cborgen:"repo"`
21
+
}
22
+
23
+
// RepoSetDefaultBranch calls the XRPC method "sh.tangled.repo.setDefaultBranch".
24
+
func RepoSetDefaultBranch(ctx context.Context, c util.LexClient, input *RepoSetDefaultBranch_Input) error {
25
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.setDefaultBranch", nil, input, nil); err != nil {
26
+
return err
27
+
}
28
+
29
+
return nil
30
+
}
+3
-1
api/tangled/stateclosed.go
+3
-1
api/tangled/stateclosed.go
+3
-1
api/tangled/stateopen.go
+3
-1
api/tangled/stateopen.go
+3
-1
api/tangled/statusclosed.go
+3
-1
api/tangled/statusclosed.go
+3
-1
api/tangled/statusmerged.go
+3
-1
api/tangled/statusmerged.go
+3
-1
api/tangled/statusopen.go
+3
-1
api/tangled/statusopen.go
+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
}
+146
-43
appview/db/follow.go
+146
-43
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
···
12
14
Rkey string
13
15
}
14
16
15
-
func AddFollow(e Execer, userDid, subjectDid, rkey string) error {
17
+
func AddFollow(e Execer, follow *Follow) error {
16
18
query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)`
17
-
_, err := e.Exec(query, userDid, subjectDid, rkey)
19
+
_, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey)
18
20
return err
19
21
}
20
22
···
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
120
+
}
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
+
}
87
133
}
88
-
}
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
+
}
131
202
132
-
if err := rows.Err(); err != nil {
133
-
return nil, err
203
+
func GetFollowers(e Execer, did string) ([]Follow, error) {
204
+
return GetFollows(e, 0, FilterEq("subject_did", did))
205
+
}
206
+
207
+
func GetFollowing(e Execer, did string) ([]Follow, error) {
208
+
return GetFollows(e, 0, FilterEq("user_did", did))
209
+
}
210
+
211
+
type FollowStatus int
212
+
213
+
const (
214
+
IsNotFollowing FollowStatus = iota
215
+
IsFollowing
216
+
IsSelf
217
+
)
218
+
219
+
func (s FollowStatus) String() string {
220
+
switch s {
221
+
case IsNotFollowing:
222
+
return "IsNotFollowing"
223
+
case IsFollowing:
224
+
return "IsFollowing"
225
+
case IsSelf:
226
+
return "IsSelf"
227
+
default:
228
+
return "IsNotFollowing"
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
}
+115
-24
appview/db/issues.go
+115
-24
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
11
14
type Issue struct {
15
+
ID int64
12
16
RepoAt syntax.ATURI
13
17
OwnerDid string
14
18
IssueId int
15
-
IssueAt string
19
+
Rkey string
16
20
Created time.Time
17
21
Title string
18
22
Body string
···
41
45
Edited *time.Time
42
46
}
43
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
+
44
52
func NewIssue(tx *sql.Tx, issue *Issue) error {
45
53
defer tx.Rollback()
46
54
···
65
73
66
74
issue.IssueId = nextId
67
75
68
-
_, err = tx.Exec(`
69
-
insert into issues (repo_at, owner_did, issue_id, title, body)
70
-
values (?, ?, ?, ?, ?)
71
-
`, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body)
76
+
res, err := tx.Exec(`
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)
80
+
if err != nil {
81
+
return err
82
+
}
83
+
84
+
lastID, err := res.LastInsertId()
72
85
if err != nil {
73
86
return err
74
87
}
88
+
issue.ID = lastID
75
89
76
90
if err := tx.Commit(); err != nil {
77
91
return err
···
80
94
return nil
81
95
}
82
96
83
-
func SetIssueAt(e Execer, repoAt syntax.ATURI, issueId int, issueAt string) error {
84
-
_, err := e.Exec(`update issues set issue_at = ? where repo_at = ? and issue_id = ?`, issueAt, repoAt, issueId)
85
-
return err
86
-
}
87
-
88
97
func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
89
98
var issueAt string
90
99
err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt)
91
100
return issueAt, err
92
-
}
93
-
94
-
func GetIssueId(e Execer, repoAt syntax.ATURI) (int, error) {
95
-
var issueId int
96
-
err := e.QueryRow(`select next_issue_id from repo_issue_seqs where repo_at = ?`, repoAt).Scan(&issueId)
97
-
return issueId - 1, err
98
101
}
99
102
100
103
func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
···
103
106
return ownerDid, err
104
107
}
105
108
106
-
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) {
107
110
var issues []Issue
108
111
openValue := 0
109
112
if isOpen {
···
114
117
`
115
118
with numbered_issue as (
116
119
select
120
+
i.id,
117
121
i.owner_did,
122
+
i.rkey,
118
123
i.issue_id,
119
124
i.created,
120
125
i.title,
···
132
137
i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open
133
138
)
134
139
select
140
+
id,
135
141
owner_did,
142
+
rkey,
136
143
issue_id,
137
144
created,
138
145
title,
139
146
body,
140
147
open,
141
148
comment_count
142
-
from
149
+
from
143
150
numbered_issue
144
-
where
151
+
where
145
152
row_num between ? and ?`,
146
153
repoAt, openValue, page.Offset+1, page.Offset+page.Limit)
147
154
if err != nil {
···
153
160
var issue Issue
154
161
var createdAt string
155
162
var metadata IssueMetadata
156
-
err := rows.Scan(&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)
157
164
if err != nil {
158
165
return nil, err
159
166
}
···
175
182
return issues, nil
176
183
}
177
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
+
178
265
// timeframe here is directly passed into the sql query filter, and any
179
266
// timeframe in the past should be negative; e.g.: "-3 months"
180
267
func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) {
···
182
269
183
270
rows, err := e.Query(
184
271
`select
272
+
i.id,
185
273
i.owner_did,
274
+
i.rkey,
186
275
i.repo_at,
187
276
i.issue_id,
188
277
i.created,
···
213
302
var issueCreatedAt, repoCreatedAt string
214
303
var repo Repo
215
304
err := rows.Scan(
305
+
&issue.ID,
216
306
&issue.OwnerDid,
307
+
&issue.Rkey,
217
308
&issue.RepoAt,
218
309
&issue.IssueId,
219
310
&issueCreatedAt,
···
257
348
}
258
349
259
350
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) {
260
-
query := `select 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 = ?`
261
352
row := e.QueryRow(query, repoAt, issueId)
262
353
263
354
var issue Issue
264
355
var createdAt string
265
-
err := row.Scan(&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)
266
357
if err != nil {
267
358
return nil, err
268
359
}
···
277
368
}
278
369
279
370
func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) {
280
-
query := `select 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 = ?`
281
372
row := e.QueryRow(query, repoAt, issueId)
282
373
283
374
var issue Issue
284
375
var createdAt string
285
-
err := row.Scan(&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)
286
377
if err != nil {
287
378
return nil, nil, err
288
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
+
}
+80
-8
appview/db/star.go
+80
-8
appview/db/star.go
···
33
33
return nil
34
34
}
35
35
36
-
func AddStar(e Execer, starredByDid string, repoAt syntax.ATURI, rkey string) error {
36
+
func AddStar(e Execer, star *Star) error {
37
37
query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)`
38
-
_, err := e.Exec(query, starredByDid, repoAt, rkey)
38
+
_, err := e.Exec(
39
+
query,
40
+
star.StarredByDid,
41
+
star.RepoAt.String(),
42
+
star.Rkey,
43
+
)
39
44
return err
40
45
}
41
46
42
47
// Get a star record
43
48
func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) {
44
49
query := `
45
-
select starred_by_did, repo_at, created, rkey
50
+
select starred_by_did, repo_at, created, rkey
46
51
from stars
47
52
where starred_by_did = ? and repo_at = ?`
48
53
row := e.QueryRow(query, starredByDid, repoAt)
···
114
119
}
115
120
116
121
repoQuery := fmt.Sprintf(
117
-
`select starred_by_did, repo_at, created, rkey
122
+
`select starred_by_did, repo_at, created, rkey
118
123
from stars
119
124
%s
120
125
order by created desc
···
182
187
var stars []Star
183
188
184
189
rows, err := e.Query(`
185
-
select
190
+
select
186
191
s.starred_by_did,
187
192
s.repo_at,
188
193
s.rkey,
···
191
196
r.name,
192
197
r.knot,
193
198
r.rkey,
194
-
r.created,
195
-
r.at_uri
199
+
r.created
196
200
from stars s
197
201
join repos r on s.repo_at = r.at_uri
198
202
`)
···
217
221
&repo.Knot,
218
222
&repo.Rkey,
219
223
&repoCreatedAt,
220
-
&repo.AtUri,
221
224
); err != nil {
222
225
return nil, err
223
226
}
···
241
244
242
245
return stars, nil
243
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
+
}
-113
appview/idresolver/resolver.go
-113
appview/idresolver/resolver.go
···
1
-
package idresolver
2
-
3
-
import (
4
-
"context"
5
-
"net"
6
-
"net/http"
7
-
"sync"
8
-
"time"
9
-
10
-
"github.com/bluesky-social/indigo/atproto/identity"
11
-
"github.com/bluesky-social/indigo/atproto/identity/redisdir"
12
-
"github.com/bluesky-social/indigo/atproto/syntax"
13
-
"github.com/carlmjohnson/versioninfo"
14
-
"tangled.sh/tangled.sh/core/appview/config"
15
-
)
16
-
17
-
type Resolver struct {
18
-
directory identity.Directory
19
-
}
20
-
21
-
func BaseDirectory() identity.Directory {
22
-
base := identity.BaseDirectory{
23
-
PLCURL: identity.DefaultPLCURL,
24
-
HTTPClient: http.Client{
25
-
Timeout: time.Second * 10,
26
-
Transport: &http.Transport{
27
-
// would want this around 100ms for services doing lots of handle resolution. Impacts PLC connections as well, but not too bad.
28
-
IdleConnTimeout: time.Millisecond * 1000,
29
-
MaxIdleConns: 100,
30
-
},
31
-
},
32
-
Resolver: net.Resolver{
33
-
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
34
-
d := net.Dialer{Timeout: time.Second * 3}
35
-
return d.DialContext(ctx, network, address)
36
-
},
37
-
},
38
-
TryAuthoritativeDNS: true,
39
-
// primary Bluesky PDS instance only supports HTTP resolution method
40
-
SkipDNSDomainSuffixes: []string{".bsky.social"},
41
-
UserAgent: "indigo-identity/" + versioninfo.Short(),
42
-
}
43
-
return &base
44
-
}
45
-
46
-
func RedisDirectory(url string) (identity.Directory, error) {
47
-
hitTTL := time.Hour * 24
48
-
errTTL := time.Second * 30
49
-
invalidHandleTTL := time.Minute * 5
50
-
return redisdir.NewRedisDirectory(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000)
51
-
}
52
-
53
-
func DefaultResolver() *Resolver {
54
-
return &Resolver{
55
-
directory: identity.DefaultDirectory(),
56
-
}
57
-
}
58
-
59
-
func RedisResolver(config config.RedisConfig) (*Resolver, error) {
60
-
directory, err := RedisDirectory(config.ToURL())
61
-
if err != nil {
62
-
return nil, err
63
-
}
64
-
return &Resolver{
65
-
directory: directory,
66
-
}, nil
67
-
}
68
-
69
-
func (r *Resolver) ResolveIdent(ctx context.Context, arg string) (*identity.Identity, error) {
70
-
id, err := syntax.ParseAtIdentifier(arg)
71
-
if err != nil {
72
-
return nil, err
73
-
}
74
-
75
-
return r.directory.Lookup(ctx, *id)
76
-
}
77
-
78
-
func (r *Resolver) ResolveIdents(ctx context.Context, idents []string) []*identity.Identity {
79
-
results := make([]*identity.Identity, len(idents))
80
-
var wg sync.WaitGroup
81
-
82
-
done := make(chan struct{})
83
-
defer close(done)
84
-
85
-
for idx, ident := range idents {
86
-
wg.Add(1)
87
-
go func(index int, id string) {
88
-
defer wg.Done()
89
-
90
-
select {
91
-
case <-ctx.Done():
92
-
results[index] = nil
93
-
case <-done:
94
-
results[index] = nil
95
-
default:
96
-
identity, _ := r.ResolveIdent(ctx, id)
97
-
results[index] = identity
98
-
}
99
-
}(idx, ident)
100
-
}
101
-
102
-
wg.Wait()
103
-
return results
104
-
}
105
-
106
-
func (r *Resolver) InvalidateIdent(ctx context.Context, arg string) error {
107
-
id, err := syntax.ParseAtIdentifier(arg)
108
-
if err != nil {
109
-
return err
110
-
}
111
-
112
-
return r.directory.Purge(ctx, *id)
113
-
}
+246
-9
appview/ingester.go
+246
-9
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/idresolver"
18
-
"tangled.sh/tangled.sh/core/appview/spindleverify"
17
+
"tangled.sh/tangled.sh/core/appview/serververify"
18
+
"tangled.sh/tangled.sh/core/idresolver"
19
19
"tangled.sh/tangled.sh/core/rbac"
20
20
)
21
21
···
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
···
100
106
l.Error("invalid record", "err", err)
101
107
return err
102
108
}
103
-
err = db.AddStar(i.Db, did, subjectUri, e.Commit.RKey)
109
+
err = db.AddStar(i.Db, &db.Star{
110
+
StarredByDid: did,
111
+
RepoAt: subjectUri,
112
+
Rkey: e.Commit.RKey,
113
+
})
104
114
case models.CommitOperationDelete:
105
115
err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey)
106
116
}
···
129
139
return err
130
140
}
131
141
132
-
subjectDid := record.Subject
133
-
err = db.AddFollow(i.Db, did, subjectDid, e.Commit.RKey)
142
+
err = db.AddFollow(i.Db, &db.Follow{
143
+
UserDid: did,
144
+
SubjectDid: record.Subject,
145
+
Rkey: e.Commit.RKey,
146
+
})
134
147
case models.CommitOperationDelete:
135
148
err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey)
136
149
}
···
378
391
if err != nil {
379
392
return fmt.Errorf("failed to update ACLs: %w", err)
380
393
}
394
+
395
+
l.Info("added spindle member")
381
396
case models.CommitOperationDelete:
382
397
rkey := e.Commit.RKey
383
398
···
424
439
if err = i.Enforcer.E.SavePolicy(); err != nil {
425
440
return fmt.Errorf("failed to save ACLs: %w", err)
426
441
}
442
+
443
+
l.Info("removed spindle member")
427
444
}
428
445
429
446
return nil
···
462
479
return err
463
480
}
464
481
465
-
err = spindleverify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev)
482
+
err = serververify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev)
466
483
if err != nil {
467
484
l.Error("failed to add spindle to db", "err", err, "instance", instance)
468
485
return err
469
486
}
470
487
471
-
_, err = spindleverify.MarkVerified(ddb, i.Enforcer, instance, did)
488
+
_, err = serververify.MarkSpindleVerified(ddb, i.Enforcer, instance, did)
472
489
if err != nil {
473
490
return fmt.Errorf("failed to mark verified: %w", err)
474
491
}
···
503
520
i.Enforcer.E.LoadPolicy()
504
521
}()
505
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
+
506
533
err = db.DeleteSpindle(
507
534
tx,
508
535
db.FilterEq("owner", did),
···
532
559
533
560
return nil
534
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
+
}
+53
-117
appview/issues/issues.go
+53
-117
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
-
"github.com/posthog/posthog-go"
18
17
19
18
"tangled.sh/tangled.sh/core/api/tangled"
20
-
"tangled.sh/tangled.sh/core/appview"
21
19
"tangled.sh/tangled.sh/core/appview/config"
22
20
"tangled.sh/tangled.sh/core/appview/db"
23
-
"tangled.sh/tangled.sh/core/appview/idresolver"
21
+
"tangled.sh/tangled.sh/core/appview/notify"
24
22
"tangled.sh/tangled.sh/core/appview/oauth"
25
23
"tangled.sh/tangled.sh/core/appview/pages"
24
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
26
25
"tangled.sh/tangled.sh/core/appview/pagination"
27
26
"tangled.sh/tangled.sh/core/appview/reporesolver"
27
+
"tangled.sh/tangled.sh/core/idresolver"
28
+
"tangled.sh/tangled.sh/core/tid"
28
29
)
29
30
30
31
type Issues struct {
···
34
35
idResolver *idresolver.Resolver
35
36
db *db.DB
36
37
config *config.Config
37
-
posthog posthog.Client
38
+
notifier notify.Notifier
38
39
}
39
40
40
41
func New(
···
44
45
idResolver *idresolver.Resolver,
45
46
db *db.DB,
46
47
config *config.Config,
47
-
posthog posthog.Client,
48
+
notifier notify.Notifier,
48
49
) *Issues {
49
50
return &Issues{
50
51
oauth: oauth,
···
53
54
idResolver: idResolver,
54
55
db: db,
55
56
config: config,
56
-
posthog: posthog,
57
+
notifier: notifier,
57
58
}
58
59
}
59
60
···
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
-
Reactions: reactionCountMap,
124
-
UserReacted: userReactions,
109
+
Reactions: reactionCountMap,
110
+
UserReacted: userReactions,
125
111
})
126
112
127
113
}
···
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.")
···
171
157
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
172
158
Collection: tangled.RepoIssueStateNSID,
173
159
Repo: user.Did,
174
-
Rkey: appview.TID(),
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.")
···
275
261
}
276
262
277
263
commentId := mathrand.IntN(1000000)
278
-
rkey := appview.TID()
264
+
rkey := tid.TID()
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")
703
660
return
704
661
}
705
662
706
-
err = db.NewIssue(tx, &db.Issue{
707
-
RepoAt: f.RepoAt,
663
+
issue := &db.Issue{
664
+
RepoAt: f.RepoAt(),
665
+
Rkey: tid.TID(),
708
666
Title: title,
709
667
Body: body,
710
668
OwnerDid: user.Did,
711
-
})
669
+
}
670
+
err = db.NewIssue(tx, issue)
712
671
if err != nil {
713
672
log.Println("failed to create issue", err)
714
673
rp.pages.Notice(w, "issues", "Failed to create issue.")
715
674
return
716
675
}
717
676
718
-
issueId, err := db.GetIssueId(rp.db, f.RepoAt)
719
-
if err != nil {
720
-
log.Println("failed to get issue id", err)
721
-
rp.pages.Notice(w, "issues", "Failed to create issue.")
722
-
return
723
-
}
724
-
725
677
client, err := rp.oauth.AuthorizedClient(r)
726
678
if err != nil {
727
679
log.Println("failed to get authorized client", err)
728
680
rp.pages.Notice(w, "issues", "Failed to create issue.")
729
681
return
730
682
}
731
-
atUri := f.RepoAt.String()
732
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
683
+
atUri := f.RepoAt().String()
684
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
733
685
Collection: tangled.RepoIssueNSID,
734
686
Repo: user.Did,
735
-
Rkey: appview.TID(),
687
+
Rkey: issue.Rkey,
736
688
Record: &lexutil.LexiconTypeDecoder{
737
689
Val: &tangled.RepoIssue{
738
690
Repo: atUri,
739
691
Title: title,
740
692
Body: &body,
741
693
Owner: user.Did,
742
-
IssueId: int64(issueId),
694
+
IssueId: int64(issue.IssueId),
743
695
},
744
696
},
745
697
})
···
749
701
return
750
702
}
751
703
752
-
err = db.SetIssueAt(rp.db, f.RepoAt, issueId, resp.Uri)
753
-
if err != nil {
754
-
log.Println("failed to set issue at", err)
755
-
rp.pages.Notice(w, "issues", "Failed to create issue.")
756
-
return
757
-
}
704
+
rp.notifier.NewIssue(r.Context(), issue)
758
705
759
-
if !rp.config.Core.Dev {
760
-
err = rp.posthog.Enqueue(posthog.Capture{
761
-
DistinctId: user.Did,
762
-
Event: "new_issue",
763
-
Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "issue_id": issueId},
764
-
})
765
-
if err != nil {
766
-
log.Println("failed to enqueue posthog event:", err)
767
-
}
768
-
}
769
-
770
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
706
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
771
707
return
772
708
}
773
709
}
+445
-235
appview/knots/knots.go
+445
-235
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"
15
13
"tangled.sh/tangled.sh/core/api/tangled"
16
-
"tangled.sh/tangled.sh/core/appview"
17
14
"tangled.sh/tangled.sh/core/appview/config"
18
15
"tangled.sh/tangled.sh/core/appview/db"
19
-
"tangled.sh/tangled.sh/core/appview/idresolver"
20
16
"tangled.sh/tangled.sh/core/appview/middleware"
21
17
"tangled.sh/tangled.sh/core/appview/oauth"
22
18
"tangled.sh/tangled.sh/core/appview/pages"
19
+
"tangled.sh/tangled.sh/core/appview/serververify"
23
20
"tangled.sh/tangled.sh/core/eventconsumer"
24
-
"tangled.sh/tangled.sh/core/knotclient"
21
+
"tangled.sh/tangled.sh/core/idresolver"
25
22
"tangled.sh/tangled.sh/core/rbac"
23
+
"tangled.sh/tangled.sh/core/tid"
26
24
27
25
comatproto "github.com/bluesky-social/indigo/api/atproto"
28
26
lexutil "github.com/bluesky-social/indigo/lex/util"
···
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
97
+
}
98
+
if len(registrations) != 1 {
99
+
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
100
+
return
100
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")
198
256
199
-
if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) {
200
-
k.Pages.Notice(w, noticeId, "Response signature mismatch, consider regenerating the secret and retrying.")
201
-
l.Error("signature mismatch", "bytes", signatureBytes)
202
-
return
257
+
noticeId := "operation-error"
258
+
defaultErr := "Failed to delete knot. Try again later."
259
+
fail := func() {
260
+
k.Pages.Notice(w, noticeId, defaultErr)
203
261
}
204
262
205
-
tx, err := k.Db.BeginTx(r.Context(), nil)
206
-
if err != nil {
207
-
l.Error("failed to start tx", "err", err)
263
+
domain := chi.URLParam(r, "domain")
264
+
if domain == "" {
265
+
l.Error("empty domain")
208
266
fail()
209
267
return
210
268
}
211
-
defer func() {
212
-
tx.Rollback()
213
-
err = k.Enforcer.E.LoadPolicy()
214
-
if err != nil {
215
-
l.Error("rollback failed", "err", err)
216
-
}
217
-
}()
218
269
219
-
// mark as registered
220
-
err = db.Register(tx, domain)
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
+
)
221
276
if err != nil {
222
-
l.Error("failed to register domain", "err", err)
277
+
l.Error("failed to get registration", "err", err)
223
278
fail()
224
279
return
225
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]
226
287
227
-
// set permissions for this did as owner
228
-
reg, err := db.RegistrationByDomain(tx, domain)
288
+
tx, err := k.Db.Begin()
229
289
if err != nil {
230
-
l.Error("failed get registration by domain", "err", err)
290
+
l.Error("failed to start txn", "err", err)
231
291
fail()
232
292
return
233
293
}
294
+
defer func() {
295
+
tx.Rollback()
296
+
k.Enforcer.E.LoadPolicy()
297
+
}()
234
298
235
-
// add basic acls for this domain
236
-
err = k.Enforcer.AddKnot(domain)
299
+
err = db.DeleteKnot(
300
+
tx,
301
+
db.FilterEq("did", user.Did),
302
+
db.FilterEq("domain", domain),
303
+
)
237
304
if err != nil {
238
-
l.Error("failed to add knot to enforcer", "err", err)
305
+
l.Error("failed to delete registration", "err", err)
239
306
fail()
240
307
return
241
308
}
242
309
243
-
// add this did as owner of this domain
244
-
err = k.Enforcer.AddKnotOwner(domain, reg.ByDid)
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
+
}
318
+
}
319
+
320
+
client, err := k.OAuth.AuthorizedClient(r)
245
321
if err != nil {
246
-
l.Error("failed to add knot owner to enforcer", "err", err)
322
+
l.Error("failed to authorize client", "err", err)
247
323
fail()
248
324
return
249
325
}
250
326
327
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
328
+
Collection: tangled.KnotNSID,
329
+
Repo: user.Did,
330
+
Rkey: domain,
331
+
})
332
+
if err != nil {
333
+
// non-fatal
334
+
l.Error("failed to delete record", "err", err)
335
+
}
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
}
318
-
}
319
434
320
-
repos, err := db.GetRepos(
321
-
k.Db,
322
-
0,
323
-
db.FilterEq("knot", domain),
324
-
db.FilterIn("did", members),
325
-
)
326
-
if err != nil {
327
-
l.Error("failed to get repos list", "err", err)
328
-
fail()
329
-
return
330
-
}
331
-
// convert to map
332
-
repoByMember := make(map[string][]db.Repo)
333
-
for _, r := range repos {
334
-
repoByMember[r.Did] = append(repoByMember[r.Did], r)
335
-
}
435
+
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain)
436
+
var exCid *string
437
+
if ex != nil {
438
+
exCid = ex.Cid
439
+
}
336
440
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()
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)
349
455
}
350
456
}
351
457
352
-
k.Pages.Knot(w, pages.KnotParams{
353
-
LoggedInUser: user,
354
-
DidHandleMap: didHandleMap,
355
-
Registration: reg,
356
-
Members: members,
357
-
Repos: repoByMember,
358
-
IsOwner: true,
359
-
})
360
-
}
458
+
// add this knot to knotstream
459
+
go k.Knotstream.AddSource(
460
+
r.Context(),
461
+
eventconsumer.NewKnotSource(domain),
462
+
)
361
463
362
-
// list members of domain, requires auth and requires owner status
363
-
func (k *Knots) members(w http.ResponseWriter, r *http.Request) {
364
-
l := k.Logger.With("handler", "members")
365
-
366
-
domain := chi.URLParam(r, "domain")
367
-
if domain == "" {
368
-
http.Error(w, "malformed url", http.StatusBadRequest)
464
+
shouldRefresh := r.Header.Get("shouldRefresh")
465
+
if shouldRefresh == "true" {
466
+
k.Pages.HxRefresh(w)
369
467
return
370
468
}
371
-
l = l.With("domain", domain)
372
469
373
-
// list all members for this domain
374
-
memberDids, err := k.Enforcer.GetUserByRole("server:member", domain)
470
+
// Get updated registration to show
471
+
registrations, err = db.GetRegistrations(
472
+
k.Db,
473
+
db.FilterEq("did", user.Did),
474
+
db.FilterEq("domain", domain),
475
+
)
375
476
if err != nil {
376
-
w.Write([]byte("failed to fetch member list"))
477
+
l.Error("failed to get registration", "err", err)
478
+
fail()
479
+
return
480
+
}
481
+
if len(registrations) != 1 {
482
+
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
483
+
fail()
377
484
return
378
485
}
486
+
updatedRegistration := registrations[0]
487
+
488
+
log.Println(updatedRegistration)
379
489
380
-
w.Write([]byte(strings.Join(memberDids, "\n")))
381
-
return
490
+
w.Header().Set("HX-Reswap", "outerHTML")
491
+
k.Pages.KnotListing(w, pages.KnotListingParams{
492
+
Registration: &updatedRegistration,
493
+
})
382
494
}
383
495
384
-
// add member to domain, requires auth and requires invite access
385
496
func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) {
386
-
l := k.Logger.With("handler", "members")
497
+
user := k.OAuth.GetUser(r)
498
+
l := k.Logger.With("handler", "addMember")
387
499
388
500
domain := chi.URLParam(r, "domain")
389
501
if domain == "" {
390
-
http.Error(w, "malformed url", http.StatusBadRequest)
502
+
l.Error("empty domain")
503
+
http.Error(w, "Not found", http.StatusNotFound)
391
504
return
392
505
}
393
506
l = l.With("domain", domain)
507
+
l = l.With("user", user.Did)
394
508
395
-
reg, err := db.RegistrationByDomain(k.Db, 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
+
)
396
515
if err != nil {
397
-
l.Error("failed to get registration by domain", "err", err)
398
-
http.Error(w, "malformed url", http.StatusBadRequest)
516
+
l.Error("failed to get registration", "err", err)
517
+
return
518
+
}
519
+
if len(registrations) != 1 {
520
+
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
399
521
return
400
522
}
523
+
registration := registrations[0]
401
524
402
-
noticeId := fmt.Sprintf("add-member-error-%d", reg.Id)
403
-
l = l.With("notice-id", noticeId)
525
+
noticeId := fmt.Sprintf("add-member-error-%d", registration.Id)
404
526
defaultErr := "Failed to add member. Try again later."
405
527
fail := func() {
406
528
k.Pages.Notice(w, noticeId, defaultErr)
407
529
}
408
530
409
-
subjectIdentifier := r.FormValue("subject")
410
-
if subjectIdentifier == "" {
411
-
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.")
412
535
return
413
536
}
414
-
l = l.With("subjectIdentifier", subjectIdentifier)
537
+
l = l.With("member", member)
415
538
416
-
subjectIdentity, err := k.IdResolver.ResolveIdent(r.Context(), subjectIdentifier)
539
+
memberId, err := k.IdResolver.ResolveIdent(r.Context(), member)
417
540
if err != nil {
418
-
l.Error("failed to resolve identity", "err", err)
541
+
l.Error("failed to resolve member identity to handle", "err", err)
542
+
k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
543
+
return
544
+
}
545
+
if memberId.Handle.IsInvalidHandle() {
546
+
l.Error("failed to resolve member identity to handle")
419
547
k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
420
548
return
421
549
}
422
-
l = l.With("subjectDid", subjectIdentity.DID)
423
-
424
-
l.Info("adding member to knot")
425
550
426
-
// announce this relation into the firehose, store into owners' pds
551
+
// write to pds
427
552
client, err := k.OAuth.AuthorizedClient(r)
428
553
if err != nil {
429
-
l.Error("failed to create client", "err", err)
554
+
l.Error("failed to authorize client", "err", err)
430
555
fail()
431
556
return
432
557
}
433
558
434
-
currentUser := k.OAuth.GetUser(r)
435
-
createdAt := time.Now().Format(time.RFC3339)
436
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
559
+
rkey := tid.TID()
560
+
561
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
437
562
Collection: tangled.KnotMemberNSID,
438
-
Repo: currentUser.Did,
439
-
Rkey: appview.TID(),
563
+
Repo: user.Did,
564
+
Rkey: rkey,
440
565
Record: &lexutil.LexiconTypeDecoder{
441
566
Val: &tangled.KnotMember{
442
-
Subject: subjectIdentity.DID.String(),
567
+
CreatedAt: time.Now().Format(time.RFC3339),
443
568
Domain: domain,
444
-
CreatedAt: createdAt,
445
-
}},
569
+
Subject: memberId.DID.String(),
570
+
},
571
+
},
446
572
})
447
-
// invalid record
448
573
if err != nil {
449
-
l.Error("failed to write to PDS", "err", err)
450
-
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.")
451
576
return
452
577
}
453
-
l = l.With("at-uri", resp.Uri)
454
-
l.Info("wrote record to PDS")
455
578
456
-
secret, err := db.GetRegistrationKey(k.Db, domain)
579
+
err = k.Enforcer.AddKnotMember(domain, memberId.DID.String())
457
580
if err != nil {
458
-
l.Error("failed to get registration key", "err", err)
581
+
l.Error("failed to add member to ACLs", "err", err)
459
582
fail()
460
583
return
461
584
}
462
585
463
-
ksClient, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev)
586
+
err = k.Enforcer.E.SavePolicy()
464
587
if err != nil {
465
-
l.Error("failed to create client", "err", err)
588
+
l.Error("failed to save ACL policy", "err", err)
466
589
fail()
467
590
return
468
591
}
469
592
470
-
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
+
)
471
622
if err != nil {
472
-
l.Error("failed to reach knotserver", "err", err)
473
-
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)
628
+
return
629
+
}
630
+
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.")
635
+
return
636
+
}
637
+
l = l.With("member", member)
638
+
639
+
memberId, err := k.IdResolver.ResolveIdent(r.Context(), member)
640
+
if err != nil {
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)
655
+
fail()
474
656
return
475
657
}
476
658
477
-
if ksResp.StatusCode != http.StatusNoContent {
478
-
l.Error("status mismatch", "got", ksResp.StatusCode, "expected", http.StatusNoContent)
479
-
k.Pages.Notice(w, noticeId, fmt.Sprintf("Unexpected status code from knotserver %d, expected %d", ksResp.StatusCode, http.StatusNoContent))
659
+
client, err := k.OAuth.AuthorizedClient(r)
660
+
if err != nil {
661
+
l.Error("failed to authorize client", "err", err)
662
+
fail()
480
663
return
481
664
}
482
665
483
-
err = k.Enforcer.AddKnotMember(domain, subjectIdentity.DID.String())
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()
484
672
if err != nil {
485
-
l.Error("failed to add member to enforcer", "err", err)
673
+
l.Error("failed to save ACLs", "err", err)
486
674
fail()
487
675
return
488
676
}
489
677
490
-
// success
491
-
k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain))
678
+
// ok
679
+
k.Pages.HxRefresh(w)
492
680
}
493
681
494
-
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
+
})
495
705
}
+20
-25
appview/middleware/middleware.go
+20
-25
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"
15
15
"tangled.sh/tangled.sh/core/appview/db"
16
-
"tangled.sh/tangled.sh/core/appview/idresolver"
17
16
"tangled.sh/tangled.sh/core/appview/oauth"
18
17
"tangled.sh/tangled.sh/core/appview/pages"
19
18
"tangled.sh/tangled.sh/core/appview/pagination"
20
19
"tangled.sh/tangled.sh/core/appview/reporesolver"
20
+
"tangled.sh/tangled.sh/core/idresolver"
21
21
"tangled.sh/tangled.sh/core/rbac"
22
22
)
23
23
···
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" {
+68
appview/notify/merged_notifier.go
+68
appview/notify/merged_notifier.go
···
1
+
package notify
2
+
3
+
import (
4
+
"context"
5
+
6
+
"tangled.sh/tangled.sh/core/appview/db"
7
+
)
8
+
9
+
type mergedNotifier struct {
10
+
notifiers []Notifier
11
+
}
12
+
13
+
func NewMergedNotifier(notifiers ...Notifier) Notifier {
14
+
return &mergedNotifier{notifiers}
15
+
}
16
+
17
+
var _ Notifier = &mergedNotifier{}
18
+
19
+
func (m *mergedNotifier) NewRepo(ctx context.Context, repo *db.Repo) {
20
+
for _, notifier := range m.notifiers {
21
+
notifier.NewRepo(ctx, repo)
22
+
}
23
+
}
24
+
25
+
func (m *mergedNotifier) NewStar(ctx context.Context, star *db.Star) {
26
+
for _, notifier := range m.notifiers {
27
+
notifier.NewStar(ctx, star)
28
+
}
29
+
}
30
+
func (m *mergedNotifier) DeleteStar(ctx context.Context, star *db.Star) {
31
+
for _, notifier := range m.notifiers {
32
+
notifier.DeleteStar(ctx, star)
33
+
}
34
+
}
35
+
36
+
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *db.Issue) {
37
+
for _, notifier := range m.notifiers {
38
+
notifier.NewIssue(ctx, issue)
39
+
}
40
+
}
41
+
42
+
func (m *mergedNotifier) NewFollow(ctx context.Context, follow *db.Follow) {
43
+
for _, notifier := range m.notifiers {
44
+
notifier.NewFollow(ctx, follow)
45
+
}
46
+
}
47
+
func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {
48
+
for _, notifier := range m.notifiers {
49
+
notifier.DeleteFollow(ctx, follow)
50
+
}
51
+
}
52
+
53
+
func (m *mergedNotifier) NewPull(ctx context.Context, pull *db.Pull) {
54
+
for _, notifier := range m.notifiers {
55
+
notifier.NewPull(ctx, pull)
56
+
}
57
+
}
58
+
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {
59
+
for _, notifier := range m.notifiers {
60
+
notifier.NewPullComment(ctx, comment)
61
+
}
62
+
}
63
+
64
+
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {
65
+
for _, notifier := range m.notifiers {
66
+
notifier.UpdateProfile(ctx, profile)
67
+
}
68
+
}
+44
appview/notify/notifier.go
+44
appview/notify/notifier.go
···
1
+
package notify
2
+
3
+
import (
4
+
"context"
5
+
6
+
"tangled.sh/tangled.sh/core/appview/db"
7
+
)
8
+
9
+
type Notifier interface {
10
+
NewRepo(ctx context.Context, repo *db.Repo)
11
+
12
+
NewStar(ctx context.Context, star *db.Star)
13
+
DeleteStar(ctx context.Context, star *db.Star)
14
+
15
+
NewIssue(ctx context.Context, issue *db.Issue)
16
+
17
+
NewFollow(ctx context.Context, follow *db.Follow)
18
+
DeleteFollow(ctx context.Context, follow *db.Follow)
19
+
20
+
NewPull(ctx context.Context, pull *db.Pull)
21
+
NewPullComment(ctx context.Context, comment *db.PullComment)
22
+
23
+
UpdateProfile(ctx context.Context, profile *db.Profile)
24
+
}
25
+
26
+
// BaseNotifier is a listener that does nothing
27
+
type BaseNotifier struct{}
28
+
29
+
var _ Notifier = &BaseNotifier{}
30
+
31
+
func (m *BaseNotifier) NewRepo(ctx context.Context, repo *db.Repo) {}
32
+
33
+
func (m *BaseNotifier) NewStar(ctx context.Context, star *db.Star) {}
34
+
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *db.Star) {}
35
+
36
+
func (m *BaseNotifier) NewIssue(ctx context.Context, issue *db.Issue) {}
37
+
38
+
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *db.Follow) {}
39
+
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {}
40
+
41
+
func (m *BaseNotifier) NewPull(ctx context.Context, pull *db.Pull) {}
42
+
func (m *BaseNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {}
43
+
44
+
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {}
+190
-18
appview/oauth/handler/handler.go
+190
-18
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"
19
-
"tangled.sh/tangled.sh/core/appview/idresolver"
20
24
"tangled.sh/tangled.sh/core/appview/middleware"
21
25
"tangled.sh/tangled.sh/core/appview/oauth"
22
26
"tangled.sh/tangled.sh/core/appview/oauth/client"
23
27
"tangled.sh/tangled.sh/core/appview/pages"
24
-
"tangled.sh/tangled.sh/core/knotclient"
28
+
"tangled.sh/tangled.sh/core/idresolver"
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)
342
373
return
343
374
}
344
-
err = o.enforcer.E.SavePolicy()
375
+
376
+
if len(spindleMembers) != 0 {
377
+
log.Printf("did %s is already a member of the default spindle", did)
378
+
return
379
+
}
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
+
}
+284
-88
appview/pages/pages.go
+284
-88
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)
273
307
}
274
308
275
-
type SettingsParams struct {
309
+
type UserProfileSettingsParams struct {
310
+
LoggedInUser *oauth.User
311
+
Tabs []map[string]any
312
+
Tab string
313
+
}
314
+
315
+
func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error {
316
+
return p.execute("user/settings/profile", w, params)
317
+
}
318
+
319
+
type UserKeysSettingsParams struct {
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 {
···
448
516
return p.executePlain("user/fragments/editPins", w, params)
449
517
}
450
518
451
-
type RepoActionsFragmentParams struct {
519
+
type RepoStarFragmentParams struct {
452
520
IsStarred bool
453
521
RepoAt syntax.ATURI
454
522
Stats db.RepoStats
455
523
}
456
524
457
-
func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error {
458
-
return p.executePlain("repo/fragments/repoActions", w, params)
525
+
func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error {
526
+
return p.executePlain("repo/fragments/repoStar", w, params)
459
527
}
460
528
461
529
type RepoDescriptionParams 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
-
}
729
+
lexer := lexers.Get(filepath.Base(params.Path))
730
+
if lexer == nil {
731
+
lexer = lexers.Fallback
732
+
}
662
733
663
-
iterator, err := lexer.Tokenise(nil, c)
664
-
if err != nil {
665
-
return fmt.Errorf("chroma tokenize: %w", err)
666
-
}
734
+
iterator, err := lexer.Tokenise(nil, c)
735
+
if err != nil {
736
+
return fmt.Errorf("chroma tokenize: %w", err)
737
+
}
667
738
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
-
}
673
-
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 }}
+19
-2
appview/pages/templates/layouts/repobase.html
+19
-2
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>
···
19
19
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
20
20
</div>
21
21
22
-
{{ template "repo/fragments/repoActions" .RepoInfo }}
22
+
<div class="flex items-center gap-2 z-auto">
23
+
<a
24
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
25
+
href="/{{ .RepoInfo.FullName }}/feed.atom"
26
+
>
27
+
{{ i "rss" "size-4" }}
28
+
</a>
29
+
{{ template "repo/fragments/repoStar" .RepoInfo }}
30
+
<a
31
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
32
+
hx-boost="true"
33
+
href="/{{ .RepoInfo.FullName }}/fork"
34
+
>
35
+
{{ i "git-fork" "w-4 h-4" }}
36
+
fork
37
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
38
+
</a>
39
+
</div>
23
40
</div>
24
41
{{ template "repo/fragments/repoDescription" . }}
25
42
</section>
+47
-19
appview/pages/templates/layouts/topbar.html
+47
-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
>
···
45
60
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
46
61
>
47
62
<a href="/{{ $user }}">profile</a>
63
+
<a href="/{{ $user }}?tab=repos">repositories</a>
64
+
<a href="/strings/{{ $user }}">strings</a>
48
65
<a href="/knots">knots</a>
49
66
<a href="/spindles">spindles</a>
50
67
<a href="/settings">settings</a>
···
56
73
</a>
57
74
</div>
58
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>
59
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}}
+17
-7
appview/pages/templates/repo/empty.html
+17
-7
appview/pages/templates/repo/empty.html
···
23
23
{{ end }}
24
24
</div>
25
25
</div>
26
+
{{ else if (and .LoggedInUser (eq .LoggedInUser.Did .RepoInfo.OwnerDid)) }}
27
+
{{ $knot := .RepoInfo.Knot }}
28
+
{{ if eq $knot "knot1.tangled.sh" }}
29
+
{{ $knot = "tangled.sh" }}
30
+
{{ end }}
31
+
<div class="w-full flex place-content-center">
32
+
<div class="py-6 w-fit flex flex-col gap-4">
33
+
<p>This is an empty repository. To get started:</p>
34
+
{{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }}
35
+
36
+
<p><span class="{{$bullet}}">1</span>First, generate a new <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Generating-Your-SSH-Public-Key" class="underline">SSH key pair</a>.</p>
37
+
<p><span class="{{$bullet}}">2</span>Then add the public key to your account from the <a href="/settings" class="underline">settings</a> page.</p>
38
+
<p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p>
39
+
<p><span class="{{$bullet}}">4</span>Push!</p>
40
+
</div>
41
+
</div>
26
42
{{ else }}
27
-
<p class="text-center pt-5 text-gray-400 dark:text-gray-500">
28
-
This is an empty repository. Push some commits here.
29
-
</p>
43
+
<p class="text-gray-400 dark:text-gray-500 py-6 text-center">This is an empty repository.</p>
30
44
{{ end }}
31
45
</main>
32
46
{{ end }}
33
-
34
-
{{ define "repoAfter" }}
35
-
{{ template "repo/fragments/cloneInstructions" . }}
36
-
{{ 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>
-48
appview/pages/templates/repo/fragments/repoActions.html
-48
appview/pages/templates/repo/fragments/repoActions.html
···
1
-
{{ define "repo/fragments/repoActions" }}
2
-
<div class="flex items-center gap-2 z-auto">
3
-
<button
4
-
id="starBtn"
5
-
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
6
-
{{ if .IsStarred }}
7
-
hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
8
-
{{ else }}
9
-
hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
10
-
{{ end }}
11
-
12
-
hx-trigger="click"
13
-
hx-target="#starBtn"
14
-
hx-swap="outerHTML"
15
-
hx-disabled-elt="#starBtn"
16
-
>
17
-
{{ if .IsStarred }}
18
-
{{ i "star" "w-4 h-4 fill-current" }}
19
-
{{ else }}
20
-
{{ i "star" "w-4 h-4" }}
21
-
{{ end }}
22
-
<span class="text-sm">
23
-
{{ .Stats.StarCount }}
24
-
</span>
25
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
26
-
</button>
27
-
{{ if .DisableFork }}
28
-
<button
29
-
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
30
-
disabled
31
-
title="Empty repositories cannot be forked"
32
-
>
33
-
{{ i "git-fork" "w-4 h-4" }}
34
-
fork
35
-
</button>
36
-
{{ else }}
37
-
<a
38
-
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
39
-
hx-boost="true"
40
-
href="/{{ .FullName }}/fork"
41
-
>
42
-
{{ i "git-fork" "w-4 h-4" }}
43
-
fork
44
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
45
-
</a>
46
-
{{ end }}
47
-
</div>
48
-
{{ end }}
+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 }}
+26
appview/pages/templates/repo/fragments/repoStar.html
+26
appview/pages/templates/repo/fragments/repoStar.html
···
1
+
{{ define "repo/fragments/repoStar" }}
2
+
<button
3
+
id="starBtn"
4
+
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
5
+
{{ if .IsStarred }}
6
+
hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
7
+
{{ else }}
8
+
hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
9
+
{{ end }}
10
+
11
+
hx-trigger="click"
12
+
hx-target="this"
13
+
hx-swap="outerHTML"
14
+
hx-disabled-elt="#starBtn"
15
+
>
16
+
{{ if .IsStarred }}
17
+
{{ i "star" "w-4 h-4 fill-current" }}
18
+
{{ else }}
19
+
{{ i "star" "w-4 h-4" }}
20
+
{{ end }}
21
+
<span class="text-sm">
22
+
{{ .Stats.StarCount }}
23
+
</span>
24
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
25
+
</button>
26
+
{{ 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-['ยท']">
+72
-75
appview/pages/templates/repo/log.html
+72
-75
appview/pages/templates/repo/log.html
···
14
14
</h2>
15
15
16
16
<!-- desktop view (hidden on small screens) -->
17
-
<table class="w-full border-collapse hidden md:table">
18
-
<thead>
19
-
<tr>
20
-
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Author</th>
21
-
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Commit</th>
22
-
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Message</th>
23
-
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold"></th>
24
-
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Date</th>
25
-
</tr>
26
-
</thead>
27
-
<tbody>
28
-
{{ range $index, $commit := .Commits }}
29
-
{{ $messageParts := splitN $commit.Message "\n\n" 2 }}
30
-
<tr class="{{ if ne $index (sub (len $.Commits) 1) }}border-b border-gray-200 dark:border-gray-700{{ end }}">
31
-
<td class=" py-3 align-top">
32
-
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
33
-
{{ if $didOrHandle }}
34
-
{{ template "user/fragments/picHandleLink" $didOrHandle }}
35
-
{{ else }}
36
-
<a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a>
37
-
{{ end }}
38
-
</td>
39
-
<td class="py-3 align-top font-mono flex items-center">
40
-
{{ $verified := $.VerifiedCommits.IsVerified $commit.Hash.String }}
41
-
{{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }}
42
-
{{ if $verified }}
43
-
{{ $hashStyle = "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 rounded" }}
44
-
{{ end }}
45
-
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="no-underline hover:underline {{ $hashStyle }} px-2 py-1/2 rounded flex items-center gap-2">
46
-
{{ slice $commit.Hash.String 0 8 }}
47
-
{{ if $verified }}
48
-
{{ i "shield-check" "w-4 h-4" }}
49
-
{{ end }}
50
-
</a>
51
-
<div class="{{ if not $verified }} ml-6 {{ end }}inline-flex">
52
-
<button class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
53
-
title="Copy SHA"
54
-
onclick="navigator.clipboard.writeText('{{ $commit.Hash.String }}'); this.innerHTML=`{{ i "copy-check" "w-4 h-4" }}`; setTimeout(() => this.innerHTML=`{{ i "copy" "w-4 h-4" }}`, 1500)">
55
-
{{ i "copy" "w-4 h-4" }}
56
-
</button>
57
-
<a href="/{{ $.RepoInfo.FullName }}/tree/{{ $commit.Hash.String }}" class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" title="Browse repository at this commit">
58
-
{{ i "folder-code" "w-4 h-4" }}
59
-
</a>
60
-
</div>
17
+
<div class="hidden md:flex md:flex-col divide-y divide-gray-200 dark:divide-gray-700">
18
+
{{ $grid := "grid grid-cols-14 gap-4" }}
19
+
<div class="{{ $grid }}">
20
+
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2">Author</div>
21
+
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-3">Commit</div>
22
+
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-6">Message</div>
23
+
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-1"></div>
24
+
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2 justify-self-end">Date</div>
25
+
</div>
26
+
{{ range $index, $commit := .Commits }}
27
+
{{ $messageParts := splitN $commit.Message "\n\n" 2 }}
28
+
<div class="{{ $grid }} py-3">
29
+
<div class="align-top truncate col-span-2">
30
+
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
31
+
{{ if $didOrHandle }}
32
+
{{ template "user/fragments/picHandleLink" $didOrHandle }}
33
+
{{ else }}
34
+
<a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a>
35
+
{{ end }}
36
+
</div>
37
+
<div class="align-top font-mono flex items-start col-span-3">
38
+
{{ $verified := $.VerifiedCommits.IsVerified $commit.Hash.String }}
39
+
{{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }}
40
+
{{ if $verified }}
41
+
{{ $hashStyle = "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 rounded" }}
42
+
{{ end }}
43
+
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="no-underline hover:underline {{ $hashStyle }} px-2 py-1/2 rounded flex items-center gap-2">
44
+
{{ slice $commit.Hash.String 0 8 }}
45
+
{{ if $verified }}
46
+
{{ i "shield-check" "w-4 h-4" }}
47
+
{{ end }}
48
+
</a>
49
+
<div class="{{ if not $verified }} ml-6 {{ end }}inline-flex">
50
+
<button class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
51
+
title="Copy SHA"
52
+
onclick="navigator.clipboard.writeText('{{ $commit.Hash.String }}'); this.innerHTML=`{{ i "copy-check" "w-4 h-4" }}`; setTimeout(() => this.innerHTML=`{{ i "copy" "w-4 h-4" }}`, 1500)">
53
+
{{ i "copy" "w-4 h-4" }}
54
+
</button>
55
+
<a href="/{{ $.RepoInfo.FullName }}/tree/{{ $commit.Hash.String }}" class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" title="Browse repository at this commit">
56
+
{{ i "folder-code" "w-4 h-4" }}
57
+
</a>
58
+
</div>
61
59
62
-
</td>
63
-
<td class=" py-3 align-top">
64
-
<div class="flex items-center justify-start gap-2">
65
-
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white no-underline hover:underline">{{ index $messageParts 0 }}</a>
66
-
{{ if gt (len $messageParts) 1 }}
67
-
<button class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 dark:bg-gray-700 dark:hover:bg-gray-600 rounded" hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">{{ i "ellipsis" "w-3 h-3" }}</button>
68
-
{{ end }}
60
+
</div>
61
+
<div class="align-top col-span-6">
62
+
<div>
63
+
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white no-underline hover:underline">{{ index $messageParts 0 }}</a>
64
+
{{ if gt (len $messageParts) 1 }}
65
+
<button class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 dark:bg-gray-700 dark:hover:bg-gray-600 rounded" hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">{{ i "ellipsis" "w-3 h-3" }}</button>
66
+
{{ end }}
69
67
70
-
{{ if index $.TagMap $commit.Hash.String }}
71
-
{{ range $tag := index $.TagMap $commit.Hash.String }}
72
-
<span class="ml-2 text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 inline-flex items-center">
73
-
{{ $tag }}
74
-
</span>
75
-
{{ end }}
76
-
{{ end }}
77
-
</div>
68
+
{{ if index $.TagMap $commit.Hash.String }}
69
+
{{ range $tag := index $.TagMap $commit.Hash.String }}
70
+
<span class="ml-2 text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 inline-flex items-center">
71
+
{{ $tag }}
72
+
</span>
73
+
{{ end }}
74
+
{{ end }}
75
+
</div>
78
76
79
-
{{ if gt (len $messageParts) 1 }}
80
-
<p class="hidden mt-1 text-sm text-gray-600 dark:text-gray-400">{{ nl2br (index $messageParts 1) }}</p>
81
-
{{ end }}
82
-
</td>
83
-
<td class="py-3 align-top">
84
-
<!-- ci status -->
85
-
{{ $pipeline := index $.Pipelines .Hash.String }}
86
-
{{ if and $pipeline (gt (len $pipeline.Statuses) 0) }}
87
-
{{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }}
88
-
{{ end }}
89
-
</td>
90
-
<td class=" py-3 align-top text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" $commit.Committer.When }}</td>
91
-
</tr>
92
-
{{ end }}
93
-
</tbody>
94
-
</table>
77
+
{{ if gt (len $messageParts) 1 }}
78
+
<p class="hidden mt-1 text-sm text-gray-600 dark:text-gray-400">{{ nl2br (index $messageParts 1) }}</p>
79
+
{{ end }}
80
+
</div>
81
+
<div class="align-top col-span-1">
82
+
<!-- ci status -->
83
+
{{ $pipeline := index $.Pipelines .Hash.String }}
84
+
{{ if and $pipeline (gt (len $pipeline.Statuses) 0) }}
85
+
{{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }}
86
+
{{ end }}
87
+
</div>
88
+
<div class="align-top justify-self-end text-gray-500 dark:text-gray-400 col-span-2">{{ template "repo/fragments/shortTimeAgo" $commit.Committer.When }}</div>
89
+
</div>
90
+
{{ end }}
91
+
</div>
95
92
96
93
<!-- mobile view (visible only on small screens) -->
97
94
<div class="md:hidden">
+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
+
{{ if .LoggedInUser.Handle }}
34
+
<span class="font-bold">
35
+
@{{ .LoggedInUser.Handle }}
36
+
</span>
37
+
<div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
38
+
<span>Handle</span>
39
+
</div>
40
+
{{ end }}
41
+
</div>
42
+
</div>
43
+
<div class="flex items-center justify-between p-4">
44
+
<div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]">
45
+
<span class="font-mono text-xs">
46
+
{{ .LoggedInUser.Did }}
47
+
</span>
48
+
<div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
49
+
<span>Decentralized Identifier (DID)</span>
50
+
</div>
51
+
</div>
52
+
</div>
53
+
<div class="flex items-center justify-between p-4">
54
+
<div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]">
55
+
<span class="font-bold">
56
+
{{ .LoggedInUser.Pds }}
57
+
</span>
58
+
<div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
59
+
<span>Personal Data Server (PDS)</span>
60
+
</div>
61
+
</div>
62
+
</div>
63
+
</div>
64
+
{{ end }}
+53
appview/pages/templates/user/signup.html
+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
+
+1
-5
appview/pipelines/pipelines.go
+1
-5
appview/pipelines/pipelines.go
···
11
11
12
12
"tangled.sh/tangled.sh/core/appview/config"
13
13
"tangled.sh/tangled.sh/core/appview/db"
14
-
"tangled.sh/tangled.sh/core/appview/idresolver"
15
14
"tangled.sh/tangled.sh/core/appview/oauth"
16
15
"tangled.sh/tangled.sh/core/appview/pages"
17
16
"tangled.sh/tangled.sh/core/appview/reporesolver"
18
17
"tangled.sh/tangled.sh/core/eventconsumer"
18
+
"tangled.sh/tangled.sh/core/idresolver"
19
19
"tangled.sh/tangled.sh/core/log"
20
20
"tangled.sh/tangled.sh/core/rbac"
21
21
spindlemodel "tangled.sh/tangled.sh/core/spindle/models"
22
22
23
23
"github.com/go-chi/chi/v5"
24
24
"github.com/gorilla/websocket"
25
-
"github.com/posthog/posthog-go"
26
25
)
27
26
28
27
type Pipelines struct {
···
34
33
spindlestream *eventconsumer.Consumer
35
34
db *db.DB
36
35
enforcer *rbac.Enforcer
37
-
posthog posthog.Client
38
36
logger *slog.Logger
39
37
}
40
38
···
46
44
idResolver *idresolver.Resolver,
47
45
db *db.DB,
48
46
config *config.Config,
49
-
posthog posthog.Client,
50
47
enforcer *rbac.Enforcer,
51
48
) *Pipelines {
52
49
logger := log.New("pipelines")
···
58
55
config: config,
59
56
spindlestream: spindlestream,
60
57
db: db,
61
-
posthog: posthog,
62
58
enforcer: enforcer,
63
59
logger: logger,
64
60
}
+131
appview/posthog/notifier.go
+131
appview/posthog/notifier.go
···
1
+
package posthog_service
2
+
3
+
import (
4
+
"context"
5
+
"log"
6
+
7
+
"github.com/posthog/posthog-go"
8
+
"tangled.sh/tangled.sh/core/appview/db"
9
+
"tangled.sh/tangled.sh/core/appview/notify"
10
+
)
11
+
12
+
type posthogNotifier struct {
13
+
client posthog.Client
14
+
notify.BaseNotifier
15
+
}
16
+
17
+
func NewPosthogNotifier(client posthog.Client) notify.Notifier {
18
+
return &posthogNotifier{
19
+
client,
20
+
notify.BaseNotifier{},
21
+
}
22
+
}
23
+
24
+
var _ notify.Notifier = &posthogNotifier{}
25
+
26
+
func (n *posthogNotifier) NewRepo(ctx context.Context, repo *db.Repo) {
27
+
err := n.client.Enqueue(posthog.Capture{
28
+
DistinctId: repo.Did,
29
+
Event: "new_repo",
30
+
Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()},
31
+
})
32
+
if err != nil {
33
+
log.Println("failed to enqueue posthog event:", err)
34
+
}
35
+
}
36
+
37
+
func (n *posthogNotifier) NewStar(ctx context.Context, star *db.Star) {
38
+
err := n.client.Enqueue(posthog.Capture{
39
+
DistinctId: star.StarredByDid,
40
+
Event: "star",
41
+
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
42
+
})
43
+
if err != nil {
44
+
log.Println("failed to enqueue posthog event:", err)
45
+
}
46
+
}
47
+
48
+
func (n *posthogNotifier) DeleteStar(ctx context.Context, star *db.Star) {
49
+
err := n.client.Enqueue(posthog.Capture{
50
+
DistinctId: star.StarredByDid,
51
+
Event: "unstar",
52
+
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
53
+
})
54
+
if err != nil {
55
+
log.Println("failed to enqueue posthog event:", err)
56
+
}
57
+
}
58
+
59
+
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *db.Issue) {
60
+
err := n.client.Enqueue(posthog.Capture{
61
+
DistinctId: issue.OwnerDid,
62
+
Event: "new_issue",
63
+
Properties: posthog.Properties{
64
+
"repo_at": issue.RepoAt.String(),
65
+
"issue_id": issue.IssueId,
66
+
},
67
+
})
68
+
if err != nil {
69
+
log.Println("failed to enqueue posthog event:", err)
70
+
}
71
+
}
72
+
73
+
func (n *posthogNotifier) NewPull(ctx context.Context, pull *db.Pull) {
74
+
err := n.client.Enqueue(posthog.Capture{
75
+
DistinctId: pull.OwnerDid,
76
+
Event: "new_pull",
77
+
Properties: posthog.Properties{
78
+
"repo_at": pull.RepoAt,
79
+
"pull_id": pull.PullId,
80
+
},
81
+
})
82
+
if err != nil {
83
+
log.Println("failed to enqueue posthog event:", err)
84
+
}
85
+
}
86
+
87
+
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {
88
+
err := n.client.Enqueue(posthog.Capture{
89
+
DistinctId: comment.OwnerDid,
90
+
Event: "new_pull_comment",
91
+
Properties: posthog.Properties{
92
+
"repo_at": comment.RepoAt,
93
+
"pull_id": comment.PullId,
94
+
},
95
+
})
96
+
if err != nil {
97
+
log.Println("failed to enqueue posthog event:", err)
98
+
}
99
+
}
100
+
101
+
func (n *posthogNotifier) NewFollow(ctx context.Context, follow *db.Follow) {
102
+
err := n.client.Enqueue(posthog.Capture{
103
+
DistinctId: follow.UserDid,
104
+
Event: "follow",
105
+
Properties: posthog.Properties{"subject": follow.SubjectDid},
106
+
})
107
+
if err != nil {
108
+
log.Println("failed to enqueue posthog event:", err)
109
+
}
110
+
}
111
+
112
+
func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {
113
+
err := n.client.Enqueue(posthog.Capture{
114
+
DistinctId: follow.UserDid,
115
+
Event: "unfollow",
116
+
Properties: posthog.Properties{"subject": follow.SubjectDid},
117
+
})
118
+
if err != nil {
119
+
log.Println("failed to enqueue posthog event:", err)
120
+
}
121
+
}
122
+
123
+
func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {
124
+
err := n.client.Enqueue(posthog.Capture{
125
+
DistinctId: profile.Did,
126
+
Event: "edit_profile",
127
+
})
128
+
if err != nil {
129
+
log.Println("failed to enqueue posthog event:", err)
130
+
}
131
+
}
+192
-218
appview/pulls/pulls.go
+192
-218
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"
···
14
12
"time"
15
13
16
14
"tangled.sh/tangled.sh/core/api/tangled"
17
-
"tangled.sh/tangled.sh/core/appview"
18
15
"tangled.sh/tangled.sh/core/appview/config"
19
16
"tangled.sh/tangled.sh/core/appview/db"
20
-
"tangled.sh/tangled.sh/core/appview/idresolver"
17
+
"tangled.sh/tangled.sh/core/appview/notify"
21
18
"tangled.sh/tangled.sh/core/appview/oauth"
22
19
"tangled.sh/tangled.sh/core/appview/pages"
20
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
23
21
"tangled.sh/tangled.sh/core/appview/reporesolver"
22
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
23
+
"tangled.sh/tangled.sh/core/idresolver"
24
24
"tangled.sh/tangled.sh/core/knotclient"
25
25
"tangled.sh/tangled.sh/core/patchutil"
26
+
"tangled.sh/tangled.sh/core/tid"
26
27
"tangled.sh/tangled.sh/core/types"
27
28
28
29
"github.com/bluekeyes/go-gitdiff/gitdiff"
29
30
comatproto "github.com/bluesky-social/indigo/api/atproto"
30
-
"github.com/bluesky-social/indigo/atproto/syntax"
31
31
lexutil "github.com/bluesky-social/indigo/lex/util"
32
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
32
33
"github.com/go-chi/chi/v5"
33
34
"github.com/google/uuid"
34
-
"github.com/posthog/posthog-go"
35
35
)
36
36
37
37
type Pulls struct {
···
41
41
idResolver *idresolver.Resolver
42
42
db *db.DB
43
43
config *config.Config
44
-
posthog posthog.Client
44
+
notifier notify.Notifier
45
45
}
46
46
47
47
func New(
···
51
51
resolver *idresolver.Resolver,
52
52
db *db.DB,
53
53
config *config.Config,
54
-
posthog posthog.Client,
54
+
notifier notify.Notifier,
55
55
) *Pulls {
56
56
return &Pulls{
57
57
oauth: oauth,
···
60
60
idResolver: resolver,
61
61
db: db,
62
62
config: config,
63
-
posthog: posthog,
63
+
notifier: notifier,
64
64
}
65
65
}
66
66
···
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
-
return
601
557
}
602
558
603
559
func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
···
651
607
createdAt := time.Now().Format(time.RFC3339)
652
608
ownerDid := user.Did
653
609
654
-
pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId)
610
+
pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId)
655
611
if err != nil {
656
612
log.Println("failed to get pull at", err)
657
613
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
658
614
return
659
615
}
660
616
661
-
atUri := f.RepoAt.String()
617
+
atUri := f.RepoAt().String()
662
618
client, err := s.oauth.AuthorizedClient(r)
663
619
if err != nil {
664
620
log.Println("failed to get authorized client", err)
···
668
624
atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
669
625
Collection: tangled.RepoPullCommentNSID,
670
626
Repo: user.Did,
671
-
Rkey: appview.TID(),
627
+
Rkey: tid.TID(),
672
628
Record: &lexutil.LexiconTypeDecoder{
673
629
Val: &tangled.RepoPullComment{
674
630
Repo: &atUri,
···
685
641
return
686
642
}
687
643
688
-
// Create the pull comment in the database with the commentAt field
689
-
commentId, err := db.NewPullComment(tx, &db.PullComment{
644
+
comment := &db.PullComment{
690
645
OwnerDid: user.Did,
691
-
RepoAt: f.RepoAt.String(),
646
+
RepoAt: f.RepoAt().String(),
692
647
PullId: pull.PullId,
693
648
Body: body,
694
649
CommentAt: atResp.Uri,
695
650
SubmissionId: pull.Submissions[roundNumber].ID,
696
-
})
651
+
}
652
+
653
+
// Create the pull comment in the database with the commentAt field
654
+
commentId, err := db.NewPullComment(tx, comment)
697
655
if err != nil {
698
656
log.Println("failed to create pull comment", err)
699
657
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
···
707
665
return
708
666
}
709
667
710
-
if !s.config.Core.Dev {
711
-
err = s.posthog.Enqueue(posthog.Capture{
712
-
DistinctId: user.Did,
713
-
Event: "new_pull_comment",
714
-
Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pull.PullId},
715
-
})
716
-
if err != nil {
717
-
log.Println("failed to enqueue posthog event:", err)
718
-
}
719
-
}
668
+
s.notifier.NewPullComment(r.Context(), comment)
720
669
721
670
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
722
671
return
···
740
689
return
741
690
}
742
691
743
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
692
+
result, err := us.Branches(f.OwnerDid(), f.Name)
744
693
if err != nil {
745
694
log.Println("failed to fetch branches", err)
746
695
return
···
788
737
s.pages.Notice(w, "pull", "Title is required for git-diff patches.")
789
738
return
790
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
+
}
791
745
}
792
746
793
747
// Validate we have at least one valid PR creation method
···
864
818
return
865
819
}
866
820
867
-
comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
821
+
comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, targetBranch, sourceBranch)
868
822
if err != nil {
869
823
log.Println("failed to compare", err)
870
824
s.pages.Notice(w, "pull", err.Error())
···
910
864
return
911
865
}
912
866
913
-
secret, err := db.GetRegistrationKey(s.db, fork.Knot)
914
-
if err != nil {
915
-
log.Println("failed to fetch registration key:", err)
916
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
917
-
return
918
-
}
919
-
920
-
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
+
)
921
873
if err != nil {
922
-
log.Println("failed to create signed client:", err)
874
+
log.Printf("failed to connect to knot server: %v", err)
923
875
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
924
876
return
925
877
}
···
931
883
return
932
884
}
933
885
934
-
resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch)
935
-
if err != nil {
936
-
log.Println("failed to create hidden ref:", err, resp.StatusCode)
937
-
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())
938
897
return
939
898
}
940
899
941
-
switch resp.StatusCode {
942
-
case 404:
943
-
case 400:
944
-
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)
945
906
return
946
907
}
947
908
···
966
927
return
967
928
}
968
929
969
-
forkAtUri, err := syntax.ParseATURI(fork.AtUri)
970
-
if err != nil {
971
-
log.Println("failed to parse fork AT URI", err)
972
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
973
-
return
974
-
}
930
+
forkAtUri := fork.RepoAt()
931
+
forkAtUriStr := forkAtUri.String()
975
932
976
933
pullSource := &db.PullSource{
977
934
Branch: sourceBranch,
···
979
936
}
980
937
recordPullSource := &tangled.RepoPull_Source{
981
938
Branch: sourceBranch,
982
-
Repo: &fork.AtUri,
939
+
Repo: &forkAtUriStr,
983
940
Sha: sourceRev,
984
941
}
985
942
···
1045
1002
body = formatPatches[0].Body
1046
1003
}
1047
1004
1048
-
rkey := appview.TID()
1005
+
rkey := tid.TID()
1049
1006
initialSubmission := db.PullSubmission{
1050
1007
Patch: patch,
1051
1008
SourceRev: sourceRev,
1052
1009
}
1053
-
err = db.NewPull(tx, &db.Pull{
1010
+
pull := &db.Pull{
1054
1011
Title: title,
1055
1012
Body: body,
1056
1013
TargetBranch: targetBranch,
1057
1014
OwnerDid: user.Did,
1058
-
RepoAt: f.RepoAt,
1015
+
RepoAt: f.RepoAt(),
1059
1016
Rkey: rkey,
1060
1017
Submissions: []*db.PullSubmission{
1061
1018
&initialSubmission,
1062
1019
},
1063
1020
PullSource: pullSource,
1064
-
})
1021
+
}
1022
+
err = db.NewPull(tx, pull)
1065
1023
if err != nil {
1066
1024
log.Println("failed to create pull request", err)
1067
1025
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1068
1026
return
1069
1027
}
1070
-
pullId, err := db.NextPullId(tx, f.RepoAt)
1028
+
pullId, err := db.NextPullId(tx, f.RepoAt())
1071
1029
if err != nil {
1072
1030
log.Println("failed to get pull id", err)
1073
1031
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
···
1082
1040
Val: &tangled.RepoPull{
1083
1041
Title: title,
1084
1042
PullId: int64(pullId),
1085
-
TargetRepo: string(f.RepoAt),
1043
+
TargetRepo: string(f.RepoAt()),
1086
1044
TargetBranch: targetBranch,
1087
1045
Patch: patch,
1088
1046
Source: recordPullSource,
···
1101
1059
return
1102
1060
}
1103
1061
1104
-
if !s.config.Core.Dev {
1105
-
err = s.posthog.Enqueue(posthog.Capture{
1106
-
DistinctId: user.Did,
1107
-
Event: "new_pull",
1108
-
Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pullId},
1109
-
})
1110
-
if err != nil {
1111
-
log.Println("failed to enqueue posthog event:", err)
1112
-
}
1113
-
}
1062
+
s.notifier.NewPull(r.Context(), pull)
1114
1063
1115
1064
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
1116
1065
}
···
1269
1218
return
1270
1219
}
1271
1220
1272
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
1221
+
result, err := us.Branches(f.OwnerDid(), f.Name)
1273
1222
if err != nil {
1274
1223
log.Println("failed to reach knotserver", err)
1275
1224
return
···
1353
1302
return
1354
1303
}
1355
1304
1356
-
targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName)
1305
+
targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.Name)
1357
1306
if err != nil {
1358
1307
log.Println("failed to reach knotserver for target branches", err)
1359
1308
return
···
1469
1418
return
1470
1419
}
1471
1420
1472
-
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)
1473
1422
if err != nil {
1474
1423
log.Printf("compare request failed: %s", err)
1475
1424
s.pages.Notice(w, "resubmit-error", err.Error())
···
1519
1468
return
1520
1469
}
1521
1470
1522
-
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
+
)
1523
1478
if err != nil {
1524
-
log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err)
1525
-
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)
1526
1480
return
1527
1481
}
1528
1482
1529
-
// update the hidden tracking branch to latest
1530
-
signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev)
1531
-
if err != nil {
1532
-
log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err)
1533
-
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())
1534
1494
return
1535
1495
}
1536
-
1537
-
resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch)
1538
-
if err != nil || resp.StatusCode != http.StatusNoContent {
1539
-
log.Printf("failed to update tracking branch: %s", err)
1540
-
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.")
1541
1499
return
1542
1500
}
1543
1501
···
1653
1611
Val: &tangled.RepoPull{
1654
1612
Title: pull.Title,
1655
1613
PullId: int64(pull.PullId),
1656
-
TargetRepo: string(f.RepoAt),
1614
+
TargetRepo: string(f.RepoAt()),
1657
1615
TargetBranch: pull.TargetBranch,
1658
1616
Patch: patch, // new patch
1659
1617
Source: recordPullSource,
···
1673
1631
}
1674
1632
1675
1633
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1676
-
return
1677
1634
}
1678
1635
1679
1636
func (s *Pulls) resubmitStackedPullHelper(
···
1770
1727
1771
1728
// deleted pulls are marked as deleted in the DB
1772
1729
for _, p := range deletions {
1730
+
// do not do delete already merged PRs
1731
+
if p.State == db.PullMerged {
1732
+
continue
1733
+
}
1734
+
1773
1735
err := db.DeletePull(tx, p.RepoAt, p.PullId)
1774
1736
if err != nil {
1775
1737
log.Println("failed to delete pull", err, p.PullId)
···
1810
1772
op, _ := origById[id]
1811
1773
np, _ := newById[id]
1812
1774
1775
+
// do not update already merged PRs
1776
+
if op.State == db.PullMerged {
1777
+
continue
1778
+
}
1779
+
1813
1780
submission := np.Submissions[np.LastRoundNumber()]
1814
1781
1815
1782
// resubmit the old pull
···
1917
1884
}
1918
1885
1919
1886
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1920
-
return
1921
1887
}
1922
1888
1923
1889
func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
···
1955
1921
1956
1922
patch := pullsToMerge.CombinedPatch()
1957
1923
1958
-
secret, err := db.GetRegistrationKey(s.db, f.Knot)
1959
-
if err != nil {
1960
-
log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
1961
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1962
-
return
1963
-
}
1964
-
1965
1924
ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid)
1966
1925
if err != nil {
1967
1926
log.Printf("resolving identity: %s", err)
···
1974
1933
log.Printf("failed to get primary email: %s", err)
1975
1934
}
1976
1935
1977
-
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
1978
-
if err != nil {
1979
-
log.Printf("failed to create signed client for %s: %s", f.Knot, err)
1980
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1981
-
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,
1982
1944
}
1983
1945
1984
-
// Merge the pull request
1985
-
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
+
)
1986
1960
if err != nil {
1987
-
log.Printf("failed to merge pull request: %s", err)
1961
+
log.Printf("failed to connect to knot server: %v", err)
1988
1962
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1989
1963
return
1990
1964
}
1991
1965
1992
-
if resp.StatusCode != http.StatusOK {
1993
-
log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
1994
-
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())
1995
1969
return
1996
1970
}
1997
1971
···
2004
1978
defer tx.Rollback()
2005
1979
2006
1980
for _, p := range pullsToMerge {
2007
-
err := db.MergePull(tx, f.RepoAt, p.PullId)
1981
+
err := db.MergePull(tx, f.RepoAt(), p.PullId)
2008
1982
if err != nil {
2009
1983
log.Printf("failed to update pull request status in database: %s", err)
2010
1984
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
···
2020
1994
return
2021
1995
}
2022
1996
2023
-
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))
2024
1998
}
2025
1999
2026
2000
func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) {
···
2041
2015
2042
2016
// auth filter: only owner or collaborators can close
2043
2017
roles := f.RolesInRepo(user)
2018
+
isOwner := roles.IsOwner()
2044
2019
isCollaborator := roles.IsCollaborator()
2045
2020
isPullAuthor := user.Did == pull.OwnerDid
2046
-
isCloseAllowed := isCollaborator || isPullAuthor
2021
+
isCloseAllowed := isOwner || isCollaborator || isPullAuthor
2047
2022
if !isCloseAllowed {
2048
2023
log.Println("failed to close pull")
2049
2024
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
···
2071
2046
2072
2047
for _, p := range pullsToClose {
2073
2048
// Close the pull in the database
2074
-
err = db.ClosePull(tx, f.RepoAt, p.PullId)
2049
+
err = db.ClosePull(tx, f.RepoAt(), p.PullId)
2075
2050
if err != nil {
2076
2051
log.Println("failed to close pull", err)
2077
2052
s.pages.Notice(w, "pull-close", "Failed to close pull.")
···
2087
2062
}
2088
2063
2089
2064
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2090
-
return
2091
2065
}
2092
2066
2093
2067
func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) {
···
2109
2083
2110
2084
// auth filter: only owner or collaborators can close
2111
2085
roles := f.RolesInRepo(user)
2086
+
isOwner := roles.IsOwner()
2112
2087
isCollaborator := roles.IsCollaborator()
2113
2088
isPullAuthor := user.Did == pull.OwnerDid
2114
-
isCloseAllowed := isCollaborator || isPullAuthor
2089
+
isCloseAllowed := isOwner || isCollaborator || isPullAuthor
2115
2090
if !isCloseAllowed {
2116
2091
log.Println("failed to close pull")
2117
2092
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
···
2139
2114
2140
2115
for _, p := range pullsToReopen {
2141
2116
// Close the pull in the database
2142
-
err = db.ReopenPull(tx, f.RepoAt, p.PullId)
2117
+
err = db.ReopenPull(tx, f.RepoAt(), p.PullId)
2143
2118
if err != nil {
2144
2119
log.Println("failed to close pull", err)
2145
2120
s.pages.Notice(w, "pull-close", "Failed to close pull.")
···
2155
2130
}
2156
2131
2157
2132
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2158
-
return
2159
2133
}
2160
2134
2161
2135
func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) {
···
2181
2155
2182
2156
title := fp.Title
2183
2157
body := fp.Body
2184
-
rkey := appview.TID()
2158
+
rkey := tid.TID()
2185
2159
2186
2160
initialSubmission := db.PullSubmission{
2187
2161
Patch: fp.Raw,
···
2192
2166
Body: body,
2193
2167
TargetBranch: targetBranch,
2194
2168
OwnerDid: user.Did,
2195
-
RepoAt: f.RepoAt,
2169
+
RepoAt: f.RepoAt(),
2196
2170
Rkey: rkey,
2197
2171
Submissions: []*db.PullSubmission{
2198
2172
&initialSubmission,
+2
appview/pulls/router.go
+2
appview/pulls/router.go
+8
-8
appview/repo/artifact.go
+8
-8
appview/repo/artifact.go
···
14
14
"github.com/go-git/go-git/v5/plumbing"
15
15
"github.com/ipfs/go-cid"
16
16
"tangled.sh/tangled.sh/core/api/tangled"
17
-
"tangled.sh/tangled.sh/core/appview"
18
17
"tangled.sh/tangled.sh/core/appview/db"
19
18
"tangled.sh/tangled.sh/core/appview/pages"
20
19
"tangled.sh/tangled.sh/core/appview/reporesolver"
21
20
"tangled.sh/tangled.sh/core/knotclient"
21
+
"tangled.sh/tangled.sh/core/tid"
22
22
"tangled.sh/tangled.sh/core/types"
23
23
)
24
24
···
64
64
65
65
log.Println("uploaded blob", humanize.Bytes(uint64(uploadBlobResp.Blob.Size)), uploadBlobResp.Blob.Ref.String())
66
66
67
-
rkey := appview.TID()
67
+
rkey := tid.TID()
68
68
createdAt := time.Now()
69
69
70
70
putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
···
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
-
}
+641
-259
appview/repo/repo.go
+641
-259
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
-
"tangled.sh/tangled.sh/core/appview"
20
23
"tangled.sh/tangled.sh/core/appview/commitverify"
21
24
"tangled.sh/tangled.sh/core/appview/config"
22
25
"tangled.sh/tangled.sh/core/appview/db"
23
-
"tangled.sh/tangled.sh/core/appview/idresolver"
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
29
"tangled.sh/tangled.sh/core/appview/pages/markup"
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"
33
+
"tangled.sh/tangled.sh/core/idresolver"
29
34
"tangled.sh/tangled.sh/core/knotclient"
30
35
"tangled.sh/tangled.sh/core/patchutil"
31
36
"tangled.sh/tangled.sh/core/rbac"
37
+
"tangled.sh/tangled.sh/core/tid"
32
38
"tangled.sh/tangled.sh/core/types"
39
+
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
33
40
34
41
securejoin "github.com/cyphar/filepath-securejoin"
35
42
"github.com/go-chi/chi/v5"
36
43
"github.com/go-git/go-git/v5/plumbing"
37
-
"github.com/posthog/posthog-go"
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 {
···
49
54
spindlestream *eventconsumer.Consumer
50
55
db *db.DB
51
56
enforcer *rbac.Enforcer
52
-
posthog posthog.Client
57
+
notifier notify.Notifier
58
+
logger *slog.Logger
59
+
serviceAuth *serviceauth.ServiceAuth
53
60
}
54
61
55
62
func New(
···
60
67
idResolver *idresolver.Resolver,
61
68
db *db.DB,
62
69
config *config.Config,
63
-
posthog posthog.Client,
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,
···
70
78
config: config,
71
79
spindlestream: spindlestream,
72
80
db: db,
73
-
posthog: posthog,
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
}
···
177
208
rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
178
209
RepoInfo: f.RepoInfo(user),
179
210
})
180
-
return
181
211
}
182
212
183
213
func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) {
···
188
218
return
189
219
}
190
220
191
-
repoAt := f.RepoAt
221
+
repoAt := f.RepoAt()
192
222
rkey := repoAt.RecordKey().String()
193
223
if rkey == "" {
194
224
log.Println("invalid aturi for repo", err)
···
238
268
Record: &lexutil.LexiconTypeDecoder{
239
269
Val: &tangled.Repo{
240
270
Knot: f.Knot,
241
-
Name: f.RepoName,
271
+
Name: f.Name,
242
272
Owner: user.Did,
243
-
CreatedAt: f.CreatedAt,
273
+
CreatedAt: f.Created.Format(time.RFC3339),
244
274
Description: &newDescription,
245
275
Spindle: &f.Spindle,
246
276
},
···
286
316
return
287
317
}
288
318
289
-
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))
290
320
if err != nil {
321
+
rp.pages.Error503(w)
291
322
log.Println("failed to reach knotserver", err)
292
323
return
293
324
}
···
351
382
if !rp.config.Core.Dev {
352
383
protocol = "https"
353
384
}
354
-
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))
355
391
if err != nil {
392
+
rp.pages.Error503(w)
356
393
log.Println("failed to reach knotserver", err)
357
394
return
358
395
}
359
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
+
360
405
body, err := io.ReadAll(resp.Body)
361
406
if err != nil {
362
407
log.Printf("Error reading response body: %v", err)
···
381
426
user := rp.oauth.GetUser(r)
382
427
383
428
var breadcrumbs [][]string
384
-
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)})
385
430
if treePath != "" {
386
431
for idx, elem := range strings.Split(treePath, "/") {
387
432
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
···
412
457
return
413
458
}
414
459
415
-
result, err := us.Tags(f.OwnerDid(), f.RepoName)
460
+
result, err := us.Tags(f.OwnerDid(), f.Name)
416
461
if err != nil {
462
+
rp.pages.Error503(w)
417
463
log.Println("failed to reach knotserver", err)
418
464
return
419
465
}
420
466
421
-
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()))
422
468
if err != nil {
423
469
log.Println("failed grab artifacts", err)
424
470
return
···
454
500
ArtifactMap: artifactMap,
455
501
DanglingArtifacts: danglingArtifacts,
456
502
})
457
-
return
458
503
}
459
504
460
505
func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) {
···
470
515
return
471
516
}
472
517
473
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
518
+
result, err := us.Branches(f.OwnerDid(), f.Name)
474
519
if err != nil {
520
+
rp.pages.Error503(w)
475
521
log.Println("failed to reach knotserver", err)
476
522
return
477
523
}
···
499
545
if !rp.config.Core.Dev {
500
546
protocol = "https"
501
547
}
502
-
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))
503
549
if err != nil {
550
+
rp.pages.Error503(w)
504
551
log.Println("failed to reach knotserver", err)
552
+
return
553
+
}
554
+
555
+
if resp.StatusCode == http.StatusNotFound {
556
+
rp.pages.Error404(w)
505
557
return
506
558
}
507
559
···
519
571
}
520
572
521
573
var breadcrumbs [][]string
522
-
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)})
523
575
if filePath != "" {
524
576
for idx, elem := range strings.Split(filePath, "/") {
525
577
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
···
534
586
showRendered = r.URL.Query().Get("code") != "true"
535
587
}
536
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
+
}
612
+
}
613
+
537
614
user := rp.oauth.GetUser(r)
538
615
rp.pages.RepoBlob(w, pages.RepoBlobParams{
539
616
LoggedInUser: user,
···
542
619
BreadCrumbs: breadcrumbs,
543
620
ShowRendered: showRendered,
544
621
RenderToggle: renderToggle,
622
+
Unsupported: unsupported,
623
+
IsImage: isImage,
624
+
IsVideo: isVideo,
625
+
ContentSrc: contentSrc,
545
626
})
546
-
return
547
627
}
548
628
549
629
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
550
630
f, err := rp.repoResolver.Resolve(r)
551
631
if err != nil {
552
632
log.Println("failed to get repo and knot", err)
633
+
w.WriteHeader(http.StatusBadRequest)
553
634
return
554
635
}
555
636
···
560
641
if !rp.config.Core.Dev {
561
642
protocol = "https"
562
643
}
563
-
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)
564
648
if err != nil {
565
-
log.Println("failed to reach knotserver", err)
649
+
log.Println("failed to create request", err)
566
650
return
567
651
}
568
652
569
-
body, err := io.ReadAll(resp.Body)
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)
570
660
if err != nil {
571
-
log.Printf("Error reading response body: %v", err)
661
+
log.Println("failed to reach knotserver", err)
662
+
rp.pages.Error503(w)
572
663
return
573
664
}
665
+
defer resp.Body.Close()
574
666
575
-
var result types.RepoBlobResponse
576
-
err = json.Unmarshal(body, &result)
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)
677
+
return
678
+
}
679
+
680
+
contentType := resp.Header.Get("Content-Type")
681
+
body, err := io.ReadAll(resp.Body)
577
682
if err != nil {
578
-
log.Println("failed to parse response:", err)
683
+
log.Printf("error reading response body from knotserver: %v", err)
684
+
w.WriteHeader(http.StatusInternalServerError)
579
685
return
580
686
}
581
687
582
-
if result.IsBinary {
583
-
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")
584
690
w.Write(body)
691
+
} else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
692
+
w.Header().Set("Content-Type", contentType)
693
+
w.Write(body)
694
+
} else {
695
+
w.WriteHeader(http.StatusUnsupportedMediaType)
696
+
w.Write([]byte("unsupported content type"))
585
697
return
586
698
}
587
-
588
-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
589
-
w.Write([]byte(result.Contents))
590
-
return
591
699
}
592
700
593
701
// modify the spindle configured for this repo
594
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
+
595
714
f, err := rp.repoResolver.Resolve(r)
596
715
if err != nil {
597
-
log.Println("failed to get repo and knot", err)
598
-
w.WriteHeader(http.StatusBadRequest)
716
+
fail("Failed to resolve repo. Try again later", err)
599
717
return
600
718
}
601
719
602
-
repoAt := f.RepoAt
720
+
repoAt := f.RepoAt()
603
721
rkey := repoAt.RecordKey().String()
604
722
if rkey == "" {
605
-
log.Println("invalid aturi for repo", err)
606
-
w.WriteHeader(http.StatusInternalServerError)
723
+
fail("Failed to resolve repo. Try again later", err)
607
724
return
608
725
}
609
726
610
-
user := rp.oauth.GetUser(r)
611
-
612
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
613
729
client, err := rp.oauth.AuthorizedClient(r)
614
730
if err != nil {
615
-
log.Println("failed to get client")
616
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
731
+
fail("Failed to authorize. Try again later.", err)
617
732
return
618
733
}
619
734
620
-
// ensure that this is a valid spindle for this user
621
-
validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
622
-
if err != nil {
623
-
log.Println("failed to get valid spindles")
624
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
625
-
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
+
}
626
747
}
627
748
628
-
if !slices.Contains(validSpindles, newSpindle) {
629
-
log.Println("newSpindle not present in validSpindles", "newSpindle", newSpindle, "validSpindles", validSpindles)
630
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
631
-
return
749
+
spindlePtr := &newSpindle
750
+
if removingSpindle {
751
+
spindlePtr = nil
632
752
}
633
753
634
754
// optimistic update
635
-
err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle)
755
+
err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr)
636
756
if err != nil {
637
-
log.Println("failed to perform update-spindle query", err)
638
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
757
+
fail("Failed to update spindle. Try again later.", err)
639
758
return
640
759
}
641
760
642
761
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
643
762
if err != nil {
644
-
// failed to get record
645
-
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)
646
764
return
647
765
}
648
766
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
···
653
771
Record: &lexutil.LexiconTypeDecoder{
654
772
Val: &tangled.Repo{
655
773
Knot: f.Knot,
656
-
Name: f.RepoName,
774
+
Name: f.Name,
657
775
Owner: user.Did,
658
-
CreatedAt: f.CreatedAt,
776
+
CreatedAt: f.Created.Format(time.RFC3339),
659
777
Description: &f.Description,
660
-
Spindle: &newSpindle,
778
+
Spindle: spindlePtr,
661
779
},
662
780
},
663
781
})
664
782
665
783
if err != nil {
666
-
log.Println("failed to perform update-spindle query", err)
667
-
// failed to get record
668
-
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)
669
785
return
670
786
}
671
787
672
-
// add this spindle to spindle stream
673
-
rp.spindlestream.AddSource(
674
-
context.Background(),
675
-
eventconsumer.NewSpindleSource(newSpindle),
676
-
)
788
+
if !removingSpindle {
789
+
// add this spindle to spindle stream
790
+
rp.spindlestream.AddSource(
791
+
context.Background(),
792
+
eventconsumer.NewSpindleSource(newSpindle),
793
+
)
794
+
}
677
795
678
-
w.Write(fmt.Append(nil, "spindle set to: ", newSpindle))
796
+
rp.pages.HxRefresh(w)
679
797
}
680
798
681
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
+
682
805
f, err := rp.repoResolver.Resolve(r)
683
806
if err != nil {
684
-
log.Println("failed to get repo and knot", err)
807
+
l.Error("failed to get repo and knot", "err", err)
685
808
return
686
809
}
687
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)
815
+
}
816
+
688
817
collaborator := r.FormValue("collaborator")
689
818
if collaborator == "" {
690
-
http.Error(w, "malformed form", http.StatusBadRequest)
819
+
fail("Invalid form.", nil)
691
820
return
692
821
}
693
822
823
+
// remove a single leading `@`, to make @handle work with ResolveIdent
824
+
collaborator = strings.TrimPrefix(collaborator, "@")
825
+
694
826
collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator)
695
827
if err != nil {
696
-
w.Write([]byte("failed to resolve collaborator did to a handle"))
828
+
fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err)
697
829
return
698
830
}
699
-
log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot)
700
-
701
-
// TODO: create an atproto record for this
702
831
703
-
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
704
-
if err != nil {
705
-
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)
706
834
return
707
835
}
836
+
l = l.With("collaborator", collaboratorIdent.Handle)
837
+
l = l.With("knot", f.Knot)
708
838
709
-
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)
710
841
if err != nil {
711
-
log.Println("failed to create client to ", f.Knot)
842
+
fail("Failed to write to PDS.", err)
712
843
return
713
844
}
714
845
715
-
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
716
862
if err != nil {
717
-
log.Printf("failed to make request to %s: %s", f.Knot, err)
863
+
fail("Failed to write record to PDS.", err)
718
864
return
719
865
}
720
866
721
-
if ksResp.StatusCode != http.StatusNoContent {
722
-
w.Write(fmt.Append(nil, "knotserver failed to add collaborator: ", err))
723
-
return
724
-
}
867
+
aturi := resp.Uri
868
+
l = l.With("at-uri", aturi)
869
+
l.Info("wrote record to PDS")
725
870
726
871
tx, err := rp.db.BeginTx(r.Context(), nil)
727
872
if err != nil {
728
-
log.Println("failed to start tx")
729
-
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
873
+
fail("Failed to add collaborator.", err)
730
874
return
731
875
}
732
-
defer func() {
733
-
tx.Rollback()
734
-
err = rp.enforcer.E.LoadPolicy()
735
-
if err != nil {
736
-
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
737
885
}
738
-
}()
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()
739
893
740
894
err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
741
895
if err != nil {
742
-
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
896
+
fail("Failed to add collaborator permissions.", err)
743
897
return
744
898
}
745
899
746
-
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
+
})
747
907
if err != nil {
748
-
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
908
+
fail("Failed to add collaborator.", err)
749
909
return
750
910
}
751
911
752
912
err = tx.Commit()
753
913
if err != nil {
754
-
log.Println("failed to commit changes", err)
755
-
http.Error(w, err.Error(), http.StatusInternalServerError)
914
+
fail("Failed to add collaborator.", err)
756
915
return
757
916
}
758
917
759
918
err = rp.enforcer.E.SavePolicy()
760
919
if err != nil {
761
-
log.Println("failed to update ACLs", err)
762
-
http.Error(w, err.Error(), http.StatusInternalServerError)
920
+
fail("Failed to update collaborator permissions.", err)
763
921
return
764
922
}
765
923
766
-
w.Write(fmt.Append(nil, "added collaborator: ", collaboratorIdent.Handle.String()))
924
+
// clear aturi to when everything is successful
925
+
aturi = ""
767
926
927
+
rp.pages.HxRefresh(w)
768
928
}
769
929
770
930
func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
771
931
user := rp.oauth.GetUser(r)
772
932
933
+
noticeId := "operation-error"
773
934
f, err := rp.repoResolver.Resolve(r)
774
935
if err != nil {
775
936
log.Println("failed to get repo and knot", err)
···
782
943
log.Println("failed to get authorized client", err)
783
944
return
784
945
}
785
-
repoRkey := f.RepoAt.RecordKey().String()
786
946
_, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
787
947
Collection: tangled.RepoNSID,
788
948
Repo: user.Did,
789
-
Rkey: repoRkey,
949
+
Rkey: f.Rkey,
790
950
})
791
951
if err != nil {
792
952
log.Printf("failed to delete record: %s", err)
793
-
rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.")
794
-
return
795
-
}
796
-
log.Println("removed repo record ", f.RepoAt.String())
797
-
798
-
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
799
-
if err != nil {
800
-
log.Printf("no key found for domain %s: %s\n", f.Knot, err)
953
+
rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.")
801
954
return
802
955
}
956
+
log.Println("removed repo record ", f.RepoAt().String())
803
957
804
-
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
958
+
client, err := rp.oauth.ServiceClient(
959
+
r,
960
+
oauth.WithService(f.Knot),
961
+
oauth.WithLxm(tangled.RepoDeleteNSID),
962
+
oauth.WithDev(rp.config.Core.Dev),
963
+
)
805
964
if err != nil {
806
-
log.Println("failed to create client to ", f.Knot)
965
+
log.Println("failed to connect to knot server:", err)
807
966
return
808
967
}
809
968
810
-
ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName)
811
-
if err != nil {
812
-
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())
813
980
return
814
981
}
815
-
816
-
if ksResp.StatusCode != http.StatusNoContent {
817
-
log.Println("failed to remove repo from knot, continuing anyway ", f.Knot)
818
-
} else {
819
-
log.Println("removed repo from knot ", f.Knot)
820
-
}
982
+
log.Println("deleted repo from knot")
821
983
822
984
tx, err := rp.db.BeginTx(r.Context(), nil)
823
985
if err != nil {
···
836
998
// remove collaborator RBAC
837
999
repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
838
1000
if err != nil {
839
-
rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators")
1001
+
rp.pages.Notice(w, noticeId, "Failed to remove collaborators")
840
1002
return
841
1003
}
842
1004
for _, c := range repoCollaborators {
···
848
1010
// remove repo RBAC
849
1011
err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
850
1012
if err != nil {
851
-
rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules")
1013
+
rp.pages.Notice(w, noticeId, "Failed to update RBAC rules")
852
1014
return
853
1015
}
854
1016
855
1017
// remove repo from db
856
-
err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName)
1018
+
err = db.RemoveRepo(tx, f.OwnerDid(), f.Name)
857
1019
if err != nil {
858
-
rp.pages.Notice(w, "settings-delete", "Failed to update appview")
1020
+
rp.pages.Notice(w, noticeId, "Failed to update appview")
859
1021
return
860
1022
}
861
1023
log.Println("removed repo from db")
···
884
1046
return
885
1047
}
886
1048
1049
+
noticeId := "operation-error"
887
1050
branch := r.FormValue("branch")
888
1051
if branch == "" {
889
1052
http.Error(w, "malformed form", http.StatusBadRequest)
890
1053
return
891
1054
}
892
1055
893
-
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
+
)
894
1062
if err != nil {
895
-
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.")
896
1065
return
897
1066
}
898
1067
899
-
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
900
-
if err != nil {
901
-
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())
902
1079
return
903
1080
}
904
1081
905
-
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)
906
1092
if err != nil {
907
-
log.Printf("failed to make request to %s: %s", f.Knot, err)
1093
+
log.Println("failed to get repo and knot", err)
908
1094
return
909
1095
}
910
1096
911
-
if ksResp.StatusCode != http.StatusNoContent {
912
-
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)
913
1099
return
914
1100
}
915
1101
916
-
w.Write(fmt.Append(nil, "default branch set to: ", branch))
917
-
}
1102
+
lxm := tangled.RepoAddSecretNSID
1103
+
if r.Method == http.MethodDelete {
1104
+
lxm = tangled.RepoRemoveSecretNSID
1105
+
}
918
1106
919
-
func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
920
-
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
+
)
921
1114
if err != nil {
922
-
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)
923
1122
return
924
1123
}
925
1124
926
1125
switch r.Method {
927
-
case http.MethodGet:
928
-
// for now, this is just pubkeys
929
-
user := rp.oauth.GetUser(r)
930
-
repoCollaborators, err := f.Collaborators(r.Context())
931
-
if err != nil {
932
-
log.Println("failed to get collaborators", err)
933
-
}
1126
+
case http.MethodPut:
1127
+
errorId := "add-secret-error"
934
1128
935
-
isCollaboratorInviteAllowed := false
936
-
if user != nil {
937
-
ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo())
938
-
if err == nil && ok {
939
-
isCollaboratorInviteAllowed = true
940
-
}
1129
+
value := r.FormValue("value")
1130
+
if value == "" {
1131
+
w.WriteHeader(http.StatusBadRequest)
1132
+
return
941
1133
}
942
1134
943
-
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
+
)
944
1144
if err != nil {
945
-
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.")
946
1147
return
947
1148
}
948
1149
949
-
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
+
)
950
1161
if err != nil {
951
-
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.")
952
1164
return
953
1165
}
1166
+
}
1167
+
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)
954
1191
955
-
// all spindles that this user is a member of
956
-
spindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
957
-
if err != nil {
958
-
log.Println("failed to fetch spindles", err)
959
-
return
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
960
1269
}
1270
+
}
961
1271
962
-
rp.pages.RepoSettings(w, pages.RepoSettingsParams{
963
-
LoggedInUser: user,
964
-
RepoInfo: f.RepoInfo(user),
965
-
Collaborators: repoCollaborators,
966
-
IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
967
-
Branches: result.Branches,
968
-
Spindles: spindles,
969
-
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(),
970
1291
})
971
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
+
})
972
1303
}
973
1304
974
1305
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
1306
+
ref := chi.URLParam(r, "ref")
1307
+
975
1308
user := rp.oauth.GetUser(r)
976
1309
f, err := rp.repoResolver.Resolve(r)
977
1310
if err != nil {
···
981
1314
982
1315
switch r.Method {
983
1316
case http.MethodPost:
984
-
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
+
)
985
1323
if err != nil {
986
-
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.")
987
1325
return
988
1326
}
989
1327
990
-
client, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
991
-
if err != nil {
992
-
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.")
993
1331
return
994
1332
}
995
1333
996
-
var uri string
997
-
if rp.config.Core.Dev {
998
-
uri = "http"
999
-
} else {
1000
-
uri = "https"
1001
-
}
1002
-
forkName := fmt.Sprintf("%s", f.RepoName)
1003
-
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1004
-
1005
-
_, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref)
1006
-
if err != nil {
1007
-
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())
1008
1346
return
1009
1347
}
1010
1348
···
1037
1375
})
1038
1376
1039
1377
case http.MethodPost:
1378
+
l := rp.logger.With("handler", "ForkRepo")
1040
1379
1041
-
knot := r.FormValue("knot")
1042
-
if knot == "" {
1380
+
targetKnot := r.FormValue("knot")
1381
+
if targetKnot == "" {
1043
1382
rp.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
1044
1383
return
1045
1384
}
1385
+
l = l.With("targetKnot", targetKnot)
1046
1386
1047
-
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")
1048
1388
if err != nil || !ok {
1049
1389
rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
1050
1390
return
1051
1391
}
1052
1392
1053
-
forkName := fmt.Sprintf("%s", f.RepoName)
1054
-
1393
+
// choose a name for a fork
1394
+
forkName := f.Name
1055
1395
// this check is *only* to see if the forked repo name already exists
1056
1396
// in the user's account.
1057
-
existingRepo, err := db.GetRepo(rp.db, user.Did, f.RepoName)
1397
+
existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name)
1058
1398
if err != nil {
1059
1399
if errors.Is(err, sql.ErrNoRows) {
1060
1400
// no existing repo with this name found, we can use the name as is
···
1067
1407
// repo with this name already exists, append random string
1068
1408
forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1069
1409
}
1070
-
secret, err := db.GetRegistrationKey(rp.db, knot)
1071
-
if err != nil {
1072
-
rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot))
1073
-
return
1074
-
}
1075
-
1076
-
client, err := knotclient.NewSignedClient(knot, secret, rp.config.Core.Dev)
1077
-
if err != nil {
1078
-
rp.pages.Notice(w, "repo", "Failed to reach knot server.")
1079
-
return
1080
-
}
1410
+
l = l.With("forkName", forkName)
1081
1411
1082
-
var uri string
1412
+
uri := "https"
1083
1413
if rp.config.Core.Dev {
1084
1414
uri = "http"
1085
-
} else {
1086
-
uri = "https"
1087
1415
}
1088
-
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1089
-
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)
1090
1419
1091
-
rkey := appview.TID()
1420
+
sourceAt := f.RepoAt().String()
1421
+
1422
+
// create an atproto record for this fork
1423
+
rkey := tid.TID()
1092
1424
repo := &db.Repo{
1093
1425
Did: user.Did,
1094
1426
Name: forkName,
1095
-
Knot: knot,
1427
+
Knot: targetKnot,
1096
1428
Rkey: rkey,
1097
1429
Source: sourceAt,
1098
1430
}
1099
1431
1100
-
tx, err := rp.db.BeginTx(r.Context(), nil)
1101
-
if err != nil {
1102
-
log.Println(err)
1103
-
rp.pages.Notice(w, "repo", "Failed to save repository information.")
1104
-
return
1105
-
}
1106
-
defer func() {
1107
-
tx.Rollback()
1108
-
err = rp.enforcer.E.LoadPolicy()
1109
-
if err != nil {
1110
-
log.Println("failed to rollback policies")
1111
-
}
1112
-
}()
1113
-
1114
-
resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName)
1115
-
if err != nil {
1116
-
rp.pages.Notice(w, "repo", "Failed to create repository on knot server.")
1117
-
return
1118
-
}
1119
-
1120
-
switch resp.StatusCode {
1121
-
case http.StatusConflict:
1122
-
rp.pages.Notice(w, "repo", "A repository with that name already exists.")
1123
-
return
1124
-
case http.StatusInternalServerError:
1125
-
rp.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
1126
-
case http.StatusNoContent:
1127
-
// continue
1128
-
}
1129
-
1130
1432
xrpcClient, err := rp.oauth.AuthorizedClient(r)
1131
1433
if err != nil {
1132
-
log.Println("failed to get authorized client", err)
1133
-
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.")
1134
1436
return
1135
1437
}
1136
1438
···
1149
1451
}},
1150
1452
})
1151
1453
if err != nil {
1152
-
log.Printf("failed to create record: %s", err)
1454
+
l.Error("failed to write to PDS", "err", err)
1153
1455
rp.pages.Notice(w, "repo", "Failed to announce repository creation.")
1154
1456
return
1155
1457
}
1156
-
log.Println("created repo record: ", atresp.Uri)
1458
+
1459
+
aturi := atresp.Uri
1460
+
l = l.With("aturi", aturi)
1461
+
l.Info("wrote to PDS")
1157
1462
1158
-
repo.AtUri = atresp.Uri
1463
+
tx, err := rp.db.BeginTx(r.Context(), nil)
1464
+
if err != nil {
1465
+
l.Info("txn failed", "err", err)
1466
+
rp.pages.Notice(w, "repo", "Failed to save repository information.")
1467
+
return
1468
+
}
1469
+
1470
+
// The rollback function reverts a few things on failure:
1471
+
// - the pending txn
1472
+
// - the ACLs
1473
+
// - the atproto record created
1474
+
rollback := func() {
1475
+
err1 := tx.Rollback()
1476
+
err2 := rp.enforcer.E.LoadPolicy()
1477
+
err3 := rollbackRecord(context.Background(), aturi, xrpcClient)
1478
+
1479
+
// ignore txn complete errors, this is okay
1480
+
if errors.Is(err1, sql.ErrTxDone) {
1481
+
err1 = nil
1482
+
}
1483
+
1484
+
if errs := errors.Join(err1, err2, err3); errs != nil {
1485
+
l.Error("failed to rollback changes", "errs", errs)
1486
+
return
1487
+
}
1488
+
}
1489
+
defer rollback()
1490
+
1491
+
client, err := rp.oauth.ServiceClient(
1492
+
r,
1493
+
oauth.WithService(targetKnot),
1494
+
oauth.WithLxm(tangled.RepoCreateNSID),
1495
+
oauth.WithDev(rp.config.Core.Dev),
1496
+
)
1497
+
if err != nil {
1498
+
l.Error("could not create service client", "err", err)
1499
+
rp.pages.Notice(w, "repo", "Failed to connect to knot server.")
1500
+
return
1501
+
}
1502
+
1503
+
err = tangled.RepoCreate(
1504
+
r.Context(),
1505
+
client,
1506
+
&tangled.RepoCreate_Input{
1507
+
Rkey: rkey,
1508
+
Source: &forkSourceUrl,
1509
+
},
1510
+
)
1511
+
if err := xrpcclient.HandleXrpcErr(err); err != nil {
1512
+
rp.pages.Notice(w, "repo", err.Error())
1513
+
return
1514
+
}
1515
+
1159
1516
err = db.AddRepo(tx, repo)
1160
1517
if err != nil {
1161
1518
log.Println(err)
···
1165
1522
1166
1523
// acls
1167
1524
p, _ := securejoin.SecureJoin(user.Did, forkName)
1168
-
err = rp.enforcer.AddRepo(user.Did, knot, p)
1525
+
err = rp.enforcer.AddRepo(user.Did, targetKnot, p)
1169
1526
if err != nil {
1170
1527
log.Println(err)
1171
1528
rp.pages.Notice(w, "repo", "Failed to set up repository permissions.")
···
1186
1543
return
1187
1544
}
1188
1545
1546
+
// reset the ATURI because the transaction completed successfully
1547
+
aturi = ""
1548
+
1549
+
rp.notifier.NewRepo(r.Context(), repo)
1189
1550
rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
1190
-
return
1191
1551
}
1192
1552
}
1193
1553
1554
+
// this is used to rollback changes made to the PDS
1555
+
//
1556
+
// it is a no-op if the provided ATURI is empty
1557
+
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
1558
+
if aturi == "" {
1559
+
return nil
1560
+
}
1561
+
1562
+
parsed := syntax.ATURI(aturi)
1563
+
1564
+
collection := parsed.Collection().String()
1565
+
repo := parsed.Authority().String()
1566
+
rkey := parsed.RecordKey().String()
1567
+
1568
+
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
1569
+
Collection: collection,
1570
+
Repo: repo,
1571
+
Rkey: rkey,
1572
+
})
1573
+
return err
1574
+
}
1575
+
1194
1576
func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
1195
1577
user := rp.oauth.GetUser(r)
1196
1578
f, err := rp.repoResolver.Resolve(r)
···
1206
1588
return
1207
1589
}
1208
1590
1209
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
1591
+
result, err := us.Branches(f.OwnerDid(), f.Name)
1210
1592
if err != nil {
1211
1593
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1212
1594
log.Println("failed to reach knotserver", err)
···
1236
1618
head = queryHead
1237
1619
}
1238
1620
1239
-
tags, err := us.Tags(f.OwnerDid(), f.RepoName)
1621
+
tags, err := us.Tags(f.OwnerDid(), f.Name)
1240
1622
if err != nil {
1241
1623
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1242
1624
log.Println("failed to reach knotserver", err)
···
1298
1680
return
1299
1681
}
1300
1682
1301
-
branches, err := us.Branches(f.OwnerDid(), f.RepoName)
1683
+
branches, err := us.Branches(f.OwnerDid(), f.Name)
1302
1684
if err != nil {
1303
1685
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1304
1686
log.Println("failed to reach knotserver", err)
1305
1687
return
1306
1688
}
1307
1689
1308
-
tags, err := us.Tags(f.OwnerDid(), f.RepoName)
1690
+
tags, err := us.Tags(f.OwnerDid(), f.Name)
1309
1691
if err != nil {
1310
1692
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1311
1693
log.Println("failed to reach knotserver", err)
1312
1694
return
1313
1695
}
1314
1696
1315
-
formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head)
1697
+
formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head)
1316
1698
if err != nil {
1317
1699
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1318
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
+42
-108
appview/reporesolver/resolver.go
+42
-108
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"
19
18
"tangled.sh/tangled.sh/core/appview/db"
20
-
"tangled.sh/tangled.sh/core/appview/idresolver"
21
19
"tangled.sh/tangled.sh/core/appview/oauth"
22
20
"tangled.sh/tangled.sh/core/appview/pages"
23
21
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
24
-
"tangled.sh/tangled.sh/core/knotclient"
22
+
"tangled.sh/tangled.sh/core/idresolver"
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
+
}
+46
-11
appview/settings/settings.go
+46
-11
appview/settings/settings.go
···
12
12
13
13
"github.com/go-chi/chi/v5"
14
14
"tangled.sh/tangled.sh/core/api/tangled"
15
-
"tangled.sh/tangled.sh/core/appview"
16
15
"tangled.sh/tangled.sh/core/appview/config"
17
16
"tangled.sh/tangled.sh/core/appview/db"
18
17
"tangled.sh/tangled.sh/core/appview/email"
19
18
"tangled.sh/tangled.sh/core/appview/middleware"
20
19
"tangled.sh/tangled.sh/core/appview/oauth"
21
20
"tangled.sh/tangled.sh/core/appview/pages"
21
+
"tangled.sh/tangled.sh/core/tid"
22
22
23
23
comatproto "github.com/bluesky-social/indigo/api/atproto"
24
24
lexutil "github.com/bluesky-social/indigo/lex/util"
···
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) {
···
366
401
return
367
402
}
368
403
369
-
rkey := appview.TID()
404
+
rkey := tid.TID()
370
405
371
406
tx, err := s.Db.Begin()
372
407
if err != nil {
···
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
+
}
+27
-32
appview/spindles/spindles.go
+27
-32
appview/spindles/spindles.go
···
10
10
11
11
"github.com/go-chi/chi/v5"
12
12
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview"
14
13
"tangled.sh/tangled.sh/core/appview/config"
15
14
"tangled.sh/tangled.sh/core/appview/db"
16
-
"tangled.sh/tangled.sh/core/appview/idresolver"
17
15
"tangled.sh/tangled.sh/core/appview/middleware"
18
16
"tangled.sh/tangled.sh/core/appview/oauth"
19
17
"tangled.sh/tangled.sh/core/appview/pages"
20
-
verify "tangled.sh/tangled.sh/core/appview/spindleverify"
18
+
"tangled.sh/tangled.sh/core/appview/serververify"
19
+
"tangled.sh/tangled.sh/core/idresolver"
21
20
"tangled.sh/tangled.sh/core/rbac"
21
+
"tangled.sh/tangled.sh/core/tid"
22
22
23
23
comatproto "github.com/bluesky-social/indigo/api/atproto"
24
24
"github.com/bluesky-social/indigo/atproto/syntax"
···
113
113
return
114
114
}
115
115
116
-
identsToResolve := make([]string, len(members))
117
-
for i, member := range members {
118
-
identsToResolve[i] = member
119
-
}
120
-
resolvedIds := s.IdResolver.ResolveIdents(r.Context(), identsToResolve)
121
-
didHandleMap := make(map[string]string)
122
-
for _, identity := range resolvedIds {
123
-
if !identity.Handle.IsInvalidHandle() {
124
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
125
-
} else {
126
-
didHandleMap[identity.DID.String()] = identity.DID.String()
127
-
}
128
-
}
129
-
130
116
// organize repos by did
131
117
repoMap := make(map[string][]db.Repo)
132
118
for _, r := range repos {
···
138
124
Spindle: spindle,
139
125
Members: members,
140
126
Repos: repoMap,
141
-
DidHandleMap: didHandleMap,
142
127
})
143
128
}
144
129
···
242
227
}
243
228
244
229
// begin verification
245
-
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)
246
231
if err != nil {
247
232
l.Error("verification failed", "err", err)
248
233
s.Pages.HxRefresh(w)
249
234
return
250
235
}
251
236
252
-
_, err = verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did)
237
+
_, err = serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did)
253
238
if err != nil {
254
239
l.Error("failed to mark verified", "err", err)
255
240
s.Pages.HxRefresh(w)
···
258
243
259
244
// ok
260
245
s.Pages.HxRefresh(w)
261
-
return
262
246
}
263
247
264
248
func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) {
···
306
290
s.Enforcer.E.LoadPolicy()
307
291
}()
308
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
+
309
305
err = db.DeleteSpindle(
310
306
tx,
311
307
db.FilterEq("owner", user.Did),
···
404
400
}
405
401
406
402
// begin verification
407
-
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)
408
404
if err != nil {
409
405
l.Error("verification failed", "err", err)
410
406
411
-
if errors.Is(err, verify.FetchError) {
412
-
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.")
413
409
return
414
410
}
415
411
416
-
if e, ok := err.(*verify.OwnerMismatch); ok {
412
+
if e, ok := err.(*serververify.OwnerMismatch); ok {
417
413
s.Pages.Notice(w, noticeId, e.Error())
418
414
return
419
415
}
···
422
418
return
423
419
}
424
420
425
-
rowId, err := verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did)
421
+
rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did)
426
422
if err != nil {
427
423
l.Error("failed to mark verified", "err", err)
428
424
s.Pages.Notice(w, noticeId, err.Error())
···
524
520
s.Enforcer.E.LoadPolicy()
525
521
}()
526
522
527
-
rkey := appview.TID()
523
+
rkey := tid.TID()
528
524
529
525
// add member to db
530
526
if err = db.AddSpindleMember(tx, db.SpindleMember{
···
610
606
611
607
if string(spindles[0].Owner) != user.Did {
612
608
l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner)
613
-
s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.")
609
+
s.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.")
614
610
return
615
611
}
616
612
617
613
member := r.FormValue("member")
618
614
if member == "" {
619
615
l.Error("empty member")
620
-
s.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
616
+
s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
621
617
return
622
618
}
623
619
l = l.With("member", member)
···
625
621
memberId, err := s.IdResolver.ResolveIdent(r.Context(), member)
626
622
if err != nil {
627
623
l.Error("failed to resolve member identity to handle", "err", err)
628
-
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.")
629
625
return
630
626
}
631
627
if memberId.Handle.IsInvalidHandle() {
632
628
l.Error("failed to resolve member identity to handle")
633
-
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.")
634
630
return
635
631
}
636
632
···
711
707
712
708
// ok
713
709
s.Pages.HxRefresh(w)
714
-
return
715
710
}
-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
-
}
+13
-26
appview/state/follow.go
+13
-26
appview/state/follow.go
···
7
7
8
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
9
lexutil "github.com/bluesky-social/indigo/lex/util"
10
-
"github.com/posthog/posthog-go"
11
10
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/appview"
13
11
"tangled.sh/tangled.sh/core/appview/db"
14
12
"tangled.sh/tangled.sh/core/appview/pages"
13
+
"tangled.sh/tangled.sh/core/tid"
15
14
)
16
15
17
16
func (s *State) Follow(w http.ResponseWriter, r *http.Request) {
···
42
41
switch r.Method {
43
42
case http.MethodPost:
44
43
createdAt := time.Now().Format(time.RFC3339)
45
-
rkey := appview.TID()
44
+
rkey := tid.TID()
46
45
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
47
46
Collection: tangled.GraphFollowNSID,
48
47
Repo: currentUser.Did,
···
58
57
return
59
58
}
60
59
61
-
err = db.AddFollow(s.db, currentUser.Did, subjectIdent.DID.String(), rkey)
60
+
log.Println("created atproto record: ", resp.Uri)
61
+
62
+
follow := &db.Follow{
63
+
UserDid: currentUser.Did,
64
+
SubjectDid: subjectIdent.DID.String(),
65
+
Rkey: rkey,
66
+
}
67
+
68
+
err = db.AddFollow(s.db, follow)
62
69
if err != nil {
63
70
log.Println("failed to follow", err)
64
71
return
65
72
}
66
73
67
-
log.Println("created atproto record: ", resp.Uri)
74
+
s.notifier.NewFollow(r.Context(), follow)
68
75
69
76
s.pages.FollowFragment(w, pages.FollowFragmentParams{
70
77
UserDid: subjectIdent.DID.String(),
71
78
FollowStatus: db.IsFollowing,
72
79
})
73
80
74
-
if !s.config.Core.Dev {
75
-
err = s.posthog.Enqueue(posthog.Capture{
76
-
DistinctId: currentUser.Did,
77
-
Event: "follow",
78
-
Properties: posthog.Properties{"subject": subjectIdent.DID.String()},
79
-
})
80
-
if err != nil {
81
-
log.Println("failed to enqueue posthog event:", err)
82
-
}
83
-
}
84
-
85
81
return
86
82
case http.MethodDelete:
87
83
// find the record in the db
···
113
109
FollowStatus: db.IsNotFollowing,
114
110
})
115
111
116
-
if !s.config.Core.Dev {
117
-
err = s.posthog.Enqueue(posthog.Capture{
118
-
DistinctId: currentUser.Did,
119
-
Event: "unfollow",
120
-
Properties: posthog.Properties{"subject": subjectIdent.DID.String()},
121
-
})
122
-
if err != nil {
123
-
log.Println("failed to enqueue posthog event:", err)
124
-
}
125
-
}
112
+
s.notifier.DeleteFollow(r.Context(), follow)
126
113
127
114
return
128
115
}
+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
+338
-118
appview/state/profile.go
+338
-118
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"
19
-
"github.com/posthog/posthog-go"
17
+
"github.com/gorilla/feeds"
20
18
"tangled.sh/tangled.sh/core/api/tangled"
21
19
"tangled.sh/tangled.sh/core/appview/db"
20
+
"tangled.sh/tangled.sh/core/appview/oauth"
22
21
"tangled.sh/tangled.sh/core/appview/pages"
23
22
)
24
23
···
26
25
tabVal := r.URL.Query().Get("tab")
27
26
switch tabVal {
28
27
case "":
29
-
s.profilePage(w, r)
28
+
s.profileHomePage(w, r)
30
29
case "repos":
31
30
s.reposPage(w, r)
31
+
case "followers":
32
+
s.followersPage(w, r)
33
+
case "following":
34
+
s.followingPage(w, r)
32
35
}
33
36
}
34
37
35
-
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 {
36
45
didOrHandle := chi.URLParam(r, "user")
37
46
if didOrHandle == "" {
38
-
http.Error(w, "Bad request", http.StatusBadRequest)
39
-
return
47
+
http.Error(w, "bad request", http.StatusBadRequest)
48
+
return nil
40
49
}
41
50
42
51
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
43
52
if !ok {
44
-
s.pages.Error404(w)
45
-
return
53
+
log.Printf("malformed middleware")
54
+
w.WriteHeader(http.StatusInternalServerError)
55
+
return nil
56
+
}
57
+
did := ident.DID.String()
58
+
59
+
profile, err := db.GetProfile(s.db, did)
60
+
if err != nil {
61
+
log.Printf("getting profile data for %s: %s", did, err)
62
+
s.pages.Error500(w)
63
+
return nil
46
64
}
47
65
48
-
profile, err := db.GetProfile(s.db, ident.DID.String())
66
+
followStats, err := db.GetFollowerFollowingCount(s.db, did)
49
67
if err != nil {
50
-
log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
68
+
log.Printf("getting follow stats for %s: %s", did, err)
51
69
}
52
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
+
},
88
+
}
89
+
}
90
+
91
+
func (s *State) profileHomePage(w http.ResponseWriter, r *http.Request) {
92
+
pageWithProfile := s.profilePage(w, r)
93
+
if pageWithProfile == nil {
94
+
return
95
+
}
96
+
97
+
id := pageWithProfile.Id
53
98
repos, err := db.GetRepos(
54
99
s.db,
55
100
0,
56
-
db.FilterEq("did", ident.DID.String()),
101
+
db.FilterEq("did", id.DID),
57
102
)
58
103
if err != nil {
59
-
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
104
+
log.Printf("getting repos for %s: %s", id.DID, err)
60
105
}
61
106
107
+
profile := pageWithProfile.Card.Profile
62
108
// filter out ones that are pinned
63
109
pinnedRepos := []db.Repo{}
64
110
for i, r := range repos {
···
73
119
}
74
120
}
75
121
76
-
collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String())
122
+
collaboratingRepos, err := db.CollaboratingIn(s.db, id.DID.String())
77
123
if err != nil {
78
-
log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err)
124
+
log.Printf("getting collaborating repos for %s: %s", id.DID, err)
79
125
}
80
126
81
127
pinnedCollaboratingRepos := []db.Repo{}
···
86
132
}
87
133
}
88
134
89
-
timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String())
135
+
timeline, err := db.MakeProfileTimeline(s.db, id.DID.String())
90
136
if err != nil {
91
-
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)
92
138
}
93
139
94
140
var didsToResolve []string
···
110
156
}
111
157
}
112
158
113
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve)
114
-
didHandleMap := make(map[string]string)
115
-
for _, identity := range resolvedIds {
116
-
if !identity.Handle.IsInvalidHandle() {
117
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
118
-
} else {
119
-
didHandleMap[identity.DID.String()] = identity.DID.String()
120
-
}
121
-
}
122
-
123
-
followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String())
124
-
if err != nil {
125
-
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
126
-
}
127
-
128
-
loggedInUser := s.oauth.GetUser(r)
129
-
followStatus := db.IsNotFollowing
130
-
if loggedInUser != nil {
131
-
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
132
-
}
133
-
134
159
now := time.Now()
135
160
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
136
161
punchcard, err := db.MakePunchcard(
137
162
s.db,
138
-
db.FilterEq("did", ident.DID.String()),
163
+
db.FilterEq("did", id.DID),
139
164
db.FilterGte("date", startOfYear.Format(time.DateOnly)),
140
165
db.FilterLte("date", now.Format(time.DateOnly)),
141
166
)
142
167
if err != nil {
143
-
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)
144
169
}
145
170
146
-
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
147
-
s.pages.ProfilePage(w, pages.ProfilePageParams{
148
-
LoggedInUser: loggedInUser,
171
+
s.pages.ProfileHomePage(w, pages.ProfileHomePageParams{
172
+
LoggedInUser: pageWithProfile.LoggedInUser,
149
173
Repos: pinnedRepos,
150
174
CollaboratingRepos: pinnedCollaboratingRepos,
151
-
DidHandleMap: didHandleMap,
152
-
Card: pages.ProfileCard{
153
-
UserDid: ident.DID.String(),
154
-
UserHandle: ident.Handle.String(),
155
-
AvatarUri: profileAvatarUri,
156
-
Profile: profile,
157
-
FollowStatus: followStatus,
158
-
Followers: followers,
159
-
Following: following,
160
-
},
161
-
Punchcard: punchcard,
162
-
ProfileTimeline: timeline,
175
+
Card: pageWithProfile.Card,
176
+
Punchcard: punchcard,
177
+
ProfileTimeline: timeline,
163
178
})
164
179
}
165
180
166
181
func (s *State) reposPage(w http.ResponseWriter, r *http.Request) {
167
-
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
168
-
if !ok {
169
-
s.pages.Error404(w)
182
+
pageWithProfile := s.profilePage(w, r)
183
+
if pageWithProfile == nil {
170
184
return
171
185
}
172
186
173
-
profile, err := db.GetProfile(s.db, ident.DID.String())
174
-
if err != nil {
175
-
log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
176
-
}
177
-
187
+
id := pageWithProfile.Id
178
188
repos, err := db.GetRepos(
179
189
s.db,
180
190
0,
181
-
db.FilterEq("did", ident.DID.String()),
191
+
db.FilterEq("did", id.DID),
182
192
)
183
193
if err != nil {
184
-
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
194
+
log.Printf("getting repos for %s: %s", id.DID, err)
185
195
}
186
196
187
-
loggedInUser := s.oauth.GetUser(r)
188
-
followStatus := db.IsNotFollowing
189
-
if loggedInUser != nil {
190
-
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
191
214
}
192
215
193
-
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())
194
220
if err != nil {
195
-
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
196
223
}
197
224
198
-
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
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)
247
+
}
248
+
249
+
var loggedInUserFollowing map[string]struct{}
250
+
if loggedInUser != nil {
251
+
following, err := db.GetFollowing(s.db, loggedInUser.Did)
252
+
if err != nil {
253
+
return FollowsPageParams{}, err
254
+
}
255
+
if len(following) > 0 {
256
+
loggedInUserFollowing = make(map[string]struct{}, len(following))
257
+
for _, follow := range following {
258
+
loggedInUserFollowing[follow.SubjectDid] = struct{}{}
259
+
}
260
+
}
261
+
}
199
262
200
-
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{
201
294
LoggedInUser: loggedInUser,
202
-
Repos: repos,
203
-
DidHandleMap: map[string]string{ident.DID.String(): ident.Handle.String()},
204
-
Card: pages.ProfileCard{
205
-
UserDid: ident.DID.String(),
206
-
UserHandle: ident.Handle.String(),
207
-
AvatarUri: profileAvatarUri,
208
-
Profile: profile,
209
-
FollowStatus: followStatus,
210
-
Followers: followers,
211
-
Following: following,
212
-
},
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,
213
311
})
214
312
}
215
313
216
-
func (s *State) GetAvatarUri(handle string) string {
217
-
secret := s.config.Avatar.SharedSecret
218
-
h := hmac.New(sha256.New, []byte(secret))
219
-
h.Write([]byte(handle))
220
-
signature := hex.EncodeToString(h.Sum(nil))
221
-
return fmt.Sprintf("%s/%s/%s", s.config.Avatar.Host, signature, handle)
314
+
func (s *State) followingPage(w http.ResponseWriter, r *http.Request) {
315
+
followPage, err := s.followPage(w, r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid })
316
+
if err != nil {
317
+
s.pages.Notice(w, "all-following", "Failed to load following")
318
+
return
319
+
}
320
+
321
+
s.pages.FollowingPage(w, pages.FollowingPageParams{
322
+
LoggedInUser: followPage.LoggedInUser,
323
+
Following: followPage.Follows,
324
+
Card: followPage.Card,
325
+
})
326
+
}
327
+
328
+
func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) {
329
+
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
330
+
if !ok {
331
+
s.pages.Error404(w)
332
+
return
333
+
}
334
+
335
+
feed, err := s.getProfileFeed(r.Context(), &ident)
336
+
if err != nil {
337
+
s.pages.Error500(w)
338
+
return
339
+
}
340
+
341
+
if feed == nil {
342
+
return
343
+
}
344
+
345
+
atom, err := feed.ToAtom()
346
+
if err != nil {
347
+
s.pages.Error500(w)
348
+
return
349
+
}
350
+
351
+
w.Header().Set("content-type", "application/atom+xml")
352
+
w.Write([]byte(atom))
353
+
}
354
+
355
+
func (s *State) getProfileFeed(ctx context.Context, id *identity.Identity) (*feeds.Feed, error) {
356
+
timeline, err := db.MakeProfileTimeline(s.db, id.DID.String())
357
+
if err != nil {
358
+
return nil, err
359
+
}
360
+
361
+
author := &feeds.Author{
362
+
Name: fmt.Sprintf("@%s", id.Handle),
363
+
}
364
+
365
+
feed := feeds.Feed{
366
+
Title: fmt.Sprintf("%s's timeline", author.Name),
367
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, id.Handle), Type: "text/html", Rel: "alternate"},
368
+
Items: make([]*feeds.Item, 0),
369
+
Updated: time.UnixMilli(0),
370
+
Author: author,
371
+
}
372
+
373
+
for _, byMonth := range timeline.ByMonth {
374
+
if err := s.addPullRequestItems(ctx, &feed, byMonth.PullEvents.Items, author); err != nil {
375
+
return nil, err
376
+
}
377
+
if err := s.addIssueItems(ctx, &feed, byMonth.IssueEvents.Items, author); err != nil {
378
+
return nil, err
379
+
}
380
+
if err := s.addRepoItems(ctx, &feed, byMonth.RepoEvents, author); err != nil {
381
+
return nil, err
382
+
}
383
+
}
384
+
385
+
slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int {
386
+
return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli())
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
222
468
}
223
469
224
470
func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
···
266
512
}
267
513
268
514
s.updateProfile(profile, w, r)
269
-
return
270
515
}
271
516
272
517
func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) {
···
306
551
profile.PinnedRepos = pinnedRepos
307
552
308
553
s.updateProfile(profile, w, r)
309
-
return
310
554
}
311
555
312
556
func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) {
···
371
615
return
372
616
}
373
617
374
-
if !s.config.Core.Dev {
375
-
err = s.posthog.Enqueue(posthog.Capture{
376
-
DistinctId: user.Did,
377
-
Event: "edit_profile",
378
-
})
379
-
if err != nil {
380
-
log.Println("failed to enqueue posthog event:", err)
381
-
}
382
-
}
618
+
s.notifier.UpdateProfile(r.Context(), profile)
383
619
384
620
s.pages.HxRedirect(w, "/"+user.Did)
385
-
return
386
621
}
387
622
388
623
func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) {
···
434
669
})
435
670
}
436
671
437
-
var didsToResolve []string
438
-
for _, r := range allRepos {
439
-
didsToResolve = append(didsToResolve, r.Did)
440
-
}
441
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve)
442
-
didHandleMap := make(map[string]string)
443
-
for _, identity := range resolvedIds {
444
-
if !identity.Handle.IsInvalidHandle() {
445
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
446
-
} else {
447
-
didHandleMap[identity.DID.String()] = identity.DID.String()
448
-
}
449
-
}
450
-
451
672
s.pages.EditPinsFragment(w, pages.EditPinsParams{
452
673
LoggedInUser: user,
453
674
Profile: profile,
454
675
AllRepos: allRepos,
455
-
DidHandleMap: didHandleMap,
456
676
})
457
677
}
+8
-8
appview/state/reaction.go
+8
-8
appview/state/reaction.go
···
10
10
11
11
lexutil "github.com/bluesky-social/indigo/lex/util"
12
12
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview"
14
13
"tangled.sh/tangled.sh/core/appview/db"
15
14
"tangled.sh/tangled.sh/core/appview/pages"
15
+
"tangled.sh/tangled.sh/core/tid"
16
16
)
17
17
18
18
func (s *State) React(w http.ResponseWriter, r *http.Request) {
···
45
45
switch r.Method {
46
46
case http.MethodPost:
47
47
createdAt := time.Now().Format(time.RFC3339)
48
-
rkey := appview.TID()
48
+
rkey := tid.TID()
49
49
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
50
50
Collection: tangled.FeedReactionNSID,
51
51
Repo: currentUser.Did,
···
77
77
log.Println("created atproto record: ", resp.Uri)
78
78
79
79
s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{
80
-
ThreadAt: subjectUri,
81
-
Kind: reactionKind,
82
-
Count: count,
80
+
ThreadAt: subjectUri,
81
+
Kind: reactionKind,
82
+
Count: count,
83
83
IsReacted: true,
84
84
})
85
85
···
115
115
}
116
116
117
117
s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{
118
-
ThreadAt: subjectUri,
119
-
Kind: reactionKind,
120
-
Count: count,
118
+
ThreadAt: subjectUri,
119
+
Kind: reactionKind,
120
+
Count: count,
121
121
IsReacted: false,
122
122
})
123
123
+53
-13
appview/state/router.go
+53
-13
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
···
30
32
s.pages,
31
33
)
32
34
35
+
router.Get("/favicon.svg", s.Favicon)
36
+
router.Get("/favicon.ico", s.Favicon)
37
+
38
+
userRouter := s.UserRouter(&middleware)
39
+
standardRouter := s.StandardRouter(&middleware)
40
+
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
···
66
74
func (s *State) UserRouter(mw *middleware.Middleware) http.Handler {
67
75
r := chi.NewRouter()
68
76
69
-
// strip @ from user
70
-
r.Use(middleware.StripLeadingAt)
71
-
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 {
201
-
issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.posthog)
233
+
issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier)
202
234
return issues.Router(mw)
203
235
}
204
236
205
237
func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler {
206
-
pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.posthog)
238
+
pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier)
207
239
return pulls.Router(mw)
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.posthog, 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
215
248
func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler {
216
-
pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.posthog, s.enforcer)
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
+
}
+15
-29
appview/state/star.go
+15
-29
appview/state/star.go
···
8
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
10
lexutil "github.com/bluesky-social/indigo/lex/util"
11
-
"github.com/posthog/posthog-go"
12
11
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview"
14
12
"tangled.sh/tangled.sh/core/appview/db"
15
13
"tangled.sh/tangled.sh/core/appview/pages"
14
+
"tangled.sh/tangled.sh/core/tid"
16
15
)
17
16
18
17
func (s *State) Star(w http.ResponseWriter, r *http.Request) {
···
39
38
switch r.Method {
40
39
case http.MethodPost:
41
40
createdAt := time.Now().Format(time.RFC3339)
42
-
rkey := appview.TID()
41
+
rkey := tid.TID()
43
42
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
44
43
Collection: tangled.FeedStarNSID,
45
44
Repo: currentUser.Did,
···
54
53
log.Println("failed to create atproto record", err)
55
54
return
56
55
}
56
+
log.Println("created atproto record: ", resp.Uri)
57
57
58
-
err = db.AddStar(s.db, currentUser.Did, subjectUri, rkey)
58
+
star := &db.Star{
59
+
StarredByDid: currentUser.Did,
60
+
RepoAt: subjectUri,
61
+
Rkey: rkey,
62
+
}
63
+
64
+
err = db.AddStar(s.db, star)
59
65
if err != nil {
60
66
log.Println("failed to star", err)
61
67
return
···
66
72
log.Println("failed to get star count for ", subjectUri)
67
73
}
68
74
69
-
log.Println("created atproto record: ", resp.Uri)
75
+
s.notifier.NewStar(r.Context(), star)
70
76
71
-
s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{
77
+
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
72
78
IsStarred: true,
73
79
RepoAt: subjectUri,
74
80
Stats: db.RepoStats{
···
76
82
},
77
83
})
78
84
79
-
if !s.config.Core.Dev {
80
-
err = s.posthog.Enqueue(posthog.Capture{
81
-
DistinctId: currentUser.Did,
82
-
Event: "star",
83
-
Properties: posthog.Properties{"repo_at": subjectUri.String()},
84
-
})
85
-
if err != nil {
86
-
log.Println("failed to enqueue posthog event:", err)
87
-
}
88
-
}
89
-
90
85
return
91
86
case http.MethodDelete:
92
87
// find the record in the db
···
119
114
return
120
115
}
121
116
122
-
s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{
117
+
s.notifier.DeleteStar(r.Context(), star)
118
+
119
+
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
123
120
IsStarred: false,
124
121
RepoAt: subjectUri,
125
122
Stats: db.RepoStats{
126
123
StarCount: starCount,
127
124
},
128
125
})
129
-
130
-
if !s.config.Core.Dev {
131
-
err = s.posthog.Enqueue(posthog.Capture{
132
-
DistinctId: currentUser.Did,
133
-
Event: "unstar",
134
-
Properties: posthog.Properties{"repo_at": subjectUri.String()},
135
-
})
136
-
if err != nil {
137
-
log.Println("failed to enqueue posthog event:", err)
138
-
}
139
-
}
140
126
141
127
return
142
128
}
+147
-422
appview/state/state.go
+147
-422
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"
···
21
23
"tangled.sh/tangled.sh/core/appview/cache/session"
22
24
"tangled.sh/tangled.sh/core/appview/config"
23
25
"tangled.sh/tangled.sh/core/appview/db"
24
-
"tangled.sh/tangled.sh/core/appview/idresolver"
26
+
"tangled.sh/tangled.sh/core/appview/notify"
25
27
"tangled.sh/tangled.sh/core/appview/oauth"
26
28
"tangled.sh/tangled.sh/core/appview/pages"
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"
33
+
"tangled.sh/tangled.sh/core/idresolver"
29
34
"tangled.sh/tangled.sh/core/jetstream"
30
-
"tangled.sh/tangled.sh/core/knotclient"
31
35
tlog "tangled.sh/tangled.sh/core/log"
32
36
"tangled.sh/tangled.sh/core/rbac"
37
+
"tangled.sh/tangled.sh/core/tid"
38
+
// xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
33
39
)
34
40
35
41
type State struct {
36
42
db *db.DB
43
+
notifier notify.Notifier
37
44
oauth *oauth.OAuth
38
45
enforcer *rbac.Enforcer
39
-
tidClock syntax.TIDClock
40
46
pages *pages.Pages
41
47
sess *session.SessionStore
42
48
idResolver *idresolver.Resolver
···
46
52
repoResolver *reporesolver.RepoResolver
47
53
knotstream *eventconsumer.Consumer
48
54
spindlestream *eventconsumer.Consumer
55
+
logger *slog.Logger
49
56
}
50
57
51
58
func Make(ctx context.Context, config *config.Config) (*State, error) {
···
59
66
return nil, fmt.Errorf("failed to create enforcer: %w", err)
60
67
}
61
68
62
-
clock := syntax.NewTIDClock(0)
63
-
64
-
pgs := pages.NewPages(config)
65
-
66
-
res, err := idresolver.RedisResolver(config.Redis)
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(),
···
131
137
}
132
138
spindlestream.Start(ctx)
133
139
140
+
var notifiers []notify.Notifier
141
+
if !config.Core.Dev {
142
+
notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog))
143
+
}
144
+
notifier := notify.NewMergedNotifier(notifiers...)
145
+
134
146
state := &State{
135
147
d,
148
+
notifier,
136
149
oauth,
137
150
enforcer,
138
-
clock,
139
151
pgs,
140
152
sess,
141
153
res,
···
145
157
repoResolver,
146
158
knotstream,
147
159
spindlestream,
160
+
slog.Default(),
148
161
}
149
162
150
163
return state, nil
151
164
}
152
165
153
-
func TID(c *syntax.TIDClock) string {
154
-
return c.Next().String()
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
+
})
155
191
}
156
192
157
193
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
···
163
199
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
164
200
}
165
201
166
-
var didsToResolve []string
167
-
for _, ev := range timeline {
168
-
if ev.Repo != nil {
169
-
didsToResolve = append(didsToResolve, ev.Repo.Did)
170
-
if ev.Source != nil {
171
-
didsToResolve = append(didsToResolve, ev.Source.Did)
172
-
}
173
-
}
174
-
if ev.Follow != nil {
175
-
didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid)
176
-
}
177
-
if ev.Star != nil {
178
-
didsToResolve = append(didsToResolve, ev.Star.StarredByDid, ev.Star.Repo.Did)
179
-
}
180
-
}
181
-
182
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve)
183
-
didHandleMap := make(map[string]string)
184
-
for _, identity := range resolvedIds {
185
-
if !identity.Handle.IsInvalidHandle() {
186
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
187
-
} else {
188
-
didHandleMap[identity.DID.String()] = identity.DID.String()
189
-
}
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
190
207
}
191
208
192
209
s.pages.Timeline(w, pages.TimelineParams{
193
210
LoggedInUser: user,
194
211
Timeline: timeline,
195
-
DidHandleMap: didHandleMap,
212
+
Repos: repos,
196
213
})
197
-
198
-
return
199
214
}
200
215
201
-
// requires auth
202
-
// func (s *State) RegistrationKey(w http.ResponseWriter, r *http.Request) {
203
-
// switch r.Method {
204
-
// case http.MethodGet:
205
-
// // list open registrations under this did
206
-
//
207
-
// return
208
-
// case http.MethodPost:
209
-
// session, err := s.oauth.Stores().Get(r, oauth.SessionName)
210
-
// if err != nil || session.IsNew {
211
-
// log.Println("unauthorized attempt to generate registration key")
212
-
// http.Error(w, "Forbidden", http.StatusUnauthorized)
213
-
// return
214
-
// }
215
-
//
216
-
// did := session.Values[oauth.SessionDid].(string)
217
-
//
218
-
// // check if domain is valid url, and strip extra bits down to just host
219
-
// domain := r.FormValue("domain")
220
-
// if domain == "" {
221
-
// http.Error(w, "Invalid form", http.StatusBadRequest)
222
-
// return
223
-
// }
224
-
//
225
-
// key, err := db.GenerateRegistrationKey(s.db, domain, did)
226
-
//
227
-
// if err != nil {
228
-
// log.Println(err)
229
-
// http.Error(w, "unable to register this domain", http.StatusNotAcceptable)
230
-
// return
231
-
// }
232
-
//
233
-
// w.Write([]byte(key))
234
-
// }
235
-
// }
236
-
237
216
func (s *State) Keys(w http.ResponseWriter, r *http.Request) {
238
217
user := chi.URLParam(r, "user")
239
218
user = strings.TrimPrefix(user, "@")
···
266
245
}
267
246
}
268
247
269
-
// create a signed request and check if a node responds to that
270
-
// func (s *State) InitKnotServer(w http.ResponseWriter, r *http.Request) {
271
-
// user := s.oauth.GetUser(r)
272
-
//
273
-
// noticeId := "operation-error"
274
-
// defaultErr := "Failed to register spindle. Try again later."
275
-
// fail := func() {
276
-
// s.pages.Notice(w, noticeId, defaultErr)
277
-
// }
278
-
//
279
-
// domain := chi.URLParam(r, "domain")
280
-
// if domain == "" {
281
-
// http.Error(w, "malformed url", http.StatusBadRequest)
282
-
// return
283
-
// }
284
-
// log.Println("checking ", domain)
285
-
//
286
-
// secret, err := db.GetRegistrationKey(s.db, domain)
287
-
// if err != nil {
288
-
// log.Printf("no key found for domain %s: %s\n", domain, err)
289
-
// return
290
-
// }
291
-
//
292
-
// client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev)
293
-
// if err != nil {
294
-
// log.Println("failed to create client to ", domain)
295
-
// }
296
-
//
297
-
// resp, err := client.Init(user.Did)
298
-
// if err != nil {
299
-
// w.Write([]byte("no dice"))
300
-
// log.Println("domain was unreachable after 5 seconds")
301
-
// return
302
-
// }
303
-
//
304
-
// if resp.StatusCode == http.StatusConflict {
305
-
// log.Println("status conflict", resp.StatusCode)
306
-
// w.Write([]byte("already registered, sorry!"))
307
-
// return
308
-
// }
309
-
//
310
-
// if resp.StatusCode != http.StatusNoContent {
311
-
// log.Println("status nok", resp.StatusCode)
312
-
// w.Write([]byte("no dice"))
313
-
// return
314
-
// }
315
-
//
316
-
// // verify response mac
317
-
// signature := resp.Header.Get("X-Signature")
318
-
// signatureBytes, err := hex.DecodeString(signature)
319
-
// if err != nil {
320
-
// return
321
-
// }
322
-
//
323
-
// expectedMac := hmac.New(sha256.New, []byte(secret))
324
-
// expectedMac.Write([]byte("ok"))
325
-
//
326
-
// if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) {
327
-
// log.Printf("response body signature mismatch: %x\n", signatureBytes)
328
-
// return
329
-
// }
330
-
//
331
-
// tx, err := s.db.BeginTx(r.Context(), nil)
332
-
// if err != nil {
333
-
// log.Println("failed to start tx", err)
334
-
// http.Error(w, err.Error(), http.StatusInternalServerError)
335
-
// return
336
-
// }
337
-
// defer func() {
338
-
// tx.Rollback()
339
-
// err = s.enforcer.E.LoadPolicy()
340
-
// if err != nil {
341
-
// log.Println("failed to rollback policies")
342
-
// }
343
-
// }()
344
-
//
345
-
// // mark as registered
346
-
// err = db.Register(tx, domain)
347
-
// if err != nil {
348
-
// log.Println("failed to register domain", err)
349
-
// http.Error(w, err.Error(), http.StatusInternalServerError)
350
-
// return
351
-
// }
352
-
//
353
-
// // set permissions for this did as owner
354
-
// reg, err := db.RegistrationByDomain(tx, domain)
355
-
// if err != nil {
356
-
// log.Println("failed to register domain", err)
357
-
// http.Error(w, err.Error(), http.StatusInternalServerError)
358
-
// return
359
-
// }
360
-
//
361
-
// // add basic acls for this domain
362
-
// err = s.enforcer.AddKnot(domain)
363
-
// if err != nil {
364
-
// log.Println("failed to setup owner of domain", err)
365
-
// http.Error(w, err.Error(), http.StatusInternalServerError)
366
-
// return
367
-
// }
368
-
//
369
-
// // add this did as owner of this domain
370
-
// err = s.enforcer.AddKnotOwner(domain, reg.ByDid)
371
-
// if err != nil {
372
-
// log.Println("failed to setup owner of domain", err)
373
-
// http.Error(w, err.Error(), http.StatusInternalServerError)
374
-
// return
375
-
// }
376
-
//
377
-
// err = tx.Commit()
378
-
// if err != nil {
379
-
// log.Println("failed to commit changes", err)
380
-
// http.Error(w, err.Error(), http.StatusInternalServerError)
381
-
// return
382
-
// }
383
-
//
384
-
// err = s.enforcer.E.SavePolicy()
385
-
// if err != nil {
386
-
// log.Println("failed to update ACLs", err)
387
-
// http.Error(w, err.Error(), http.StatusInternalServerError)
388
-
// return
389
-
// }
390
-
//
391
-
// // add this knot to knotstream
392
-
// go s.knotstream.AddSource(
393
-
// context.Background(),
394
-
// eventconsumer.NewKnotSource(domain),
395
-
// )
396
-
//
397
-
// w.Write([]byte("check success"))
398
-
// }
399
-
400
-
// func (s *State) KnotServerInfo(w http.ResponseWriter, r *http.Request) {
401
-
// domain := chi.URLParam(r, "domain")
402
-
// if domain == "" {
403
-
// http.Error(w, "malformed url", http.StatusBadRequest)
404
-
// return
405
-
// }
406
-
//
407
-
// user := s.oauth.GetUser(r)
408
-
// reg, err := db.RegistrationByDomain(s.db, domain)
409
-
// if err != nil {
410
-
// w.Write([]byte("failed to pull up registration info"))
411
-
// return
412
-
// }
413
-
//
414
-
// var members []string
415
-
// if reg.Registered != nil {
416
-
// members, err = s.enforcer.GetUserByRole("server:member", domain)
417
-
// if err != nil {
418
-
// w.Write([]byte("failed to fetch member list"))
419
-
// return
420
-
// }
421
-
// }
422
-
//
423
-
// var didsToResolve []string
424
-
// for _, m := range members {
425
-
// didsToResolve = append(didsToResolve, m)
426
-
// }
427
-
// didsToResolve = append(didsToResolve, reg.ByDid)
428
-
// resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve)
429
-
// didHandleMap := make(map[string]string)
430
-
// for _, identity := range resolvedIds {
431
-
// if !identity.Handle.IsInvalidHandle() {
432
-
// didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
433
-
// } else {
434
-
// didHandleMap[identity.DID.String()] = identity.DID.String()
435
-
// }
436
-
// }
437
-
//
438
-
// ok, err := s.enforcer.IsKnotOwner(user.Did, domain)
439
-
// isOwner := err == nil && ok
440
-
//
441
-
// p := pages.KnotParams{
442
-
// LoggedInUser: user,
443
-
// DidHandleMap: didHandleMap,
444
-
// Registration: reg,
445
-
// Members: members,
446
-
// IsOwner: isOwner,
447
-
// }
448
-
//
449
-
// s.pages.Knot(w, p)
450
-
// }
451
-
452
-
// get knots registered by this user
453
-
// func (s *State) Knots(w http.ResponseWriter, r *http.Request) {
454
-
// // for now, this is just pubkeys
455
-
// user := s.oauth.GetUser(r)
456
-
// registrations, err := db.RegistrationsByDid(s.db, user.Did)
457
-
// if err != nil {
458
-
// log.Println(err)
459
-
// }
460
-
//
461
-
// s.pages.Knots(w, pages.KnotsParams{
462
-
// LoggedInUser: user,
463
-
// Registrations: registrations,
464
-
// })
465
-
// }
466
-
467
-
// list members of domain, requires auth and requires owner status
468
-
// func (s *State) ListMembers(w http.ResponseWriter, r *http.Request) {
469
-
// domain := chi.URLParam(r, "domain")
470
-
// if domain == "" {
471
-
// http.Error(w, "malformed url", http.StatusBadRequest)
472
-
// return
473
-
// }
474
-
//
475
-
// // list all members for this domain
476
-
// memberDids, err := s.enforcer.GetUserByRole("server:member", domain)
477
-
// if err != nil {
478
-
// w.Write([]byte("failed to fetch member list"))
479
-
// return
480
-
// }
481
-
//
482
-
// w.Write([]byte(strings.Join(memberDids, "\n")))
483
-
// return
484
-
// }
485
-
486
-
// add member to domain, requires auth and requires invite access
487
-
// func (s *State) AddMember(w http.ResponseWriter, r *http.Request) {
488
-
// domain := chi.URLParam(r, "domain")
489
-
// if domain == "" {
490
-
// http.Error(w, "malformed url", http.StatusBadRequest)
491
-
// return
492
-
// }
493
-
//
494
-
// subjectIdentifier := r.FormValue("subject")
495
-
// if subjectIdentifier == "" {
496
-
// http.Error(w, "malformed form", http.StatusBadRequest)
497
-
// return
498
-
// }
499
-
//
500
-
// subjectIdentity, err := s.idResolver.ResolveIdent(r.Context(), subjectIdentifier)
501
-
// if err != nil {
502
-
// w.Write([]byte("failed to resolve member did to a handle"))
503
-
// return
504
-
// }
505
-
// log.Printf("adding %s to %s\n", subjectIdentity.Handle.String(), domain)
506
-
//
507
-
// // announce this relation into the firehose, store into owners' pds
508
-
// client, err := s.oauth.AuthorizedClient(r)
509
-
// if err != nil {
510
-
// http.Error(w, "failed to authorize client", http.StatusInternalServerError)
511
-
// return
512
-
// }
513
-
// currentUser := s.oauth.GetUser(r)
514
-
// createdAt := time.Now().Format(time.RFC3339)
515
-
// resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
516
-
// Collection: tangled.KnotMemberNSID,
517
-
// Repo: currentUser.Did,
518
-
// Rkey: appview.TID(),
519
-
// Record: &lexutil.LexiconTypeDecoder{
520
-
// Val: &tangled.KnotMember{
521
-
// Subject: subjectIdentity.DID.String(),
522
-
// Domain: domain,
523
-
// CreatedAt: createdAt,
524
-
// }},
525
-
// })
526
-
//
527
-
// // invalid record
528
-
// if err != nil {
529
-
// log.Printf("failed to create record: %s", err)
530
-
// return
531
-
// }
532
-
// log.Println("created atproto record: ", resp.Uri)
533
-
//
534
-
// secret, err := db.GetRegistrationKey(s.db, domain)
535
-
// if err != nil {
536
-
// log.Printf("no key found for domain %s: %s\n", domain, err)
537
-
// return
538
-
// }
539
-
//
540
-
// ksClient, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev)
541
-
// if err != nil {
542
-
// log.Println("failed to create client to ", domain)
543
-
// return
544
-
// }
545
-
//
546
-
// ksResp, err := ksClient.AddMember(subjectIdentity.DID.String())
547
-
// if err != nil {
548
-
// log.Printf("failed to make request to %s: %s", domain, err)
549
-
// return
550
-
// }
551
-
//
552
-
// if ksResp.StatusCode != http.StatusNoContent {
553
-
// w.Write([]byte(fmt.Sprint("knotserver failed to add member: ", err)))
554
-
// return
555
-
// }
556
-
//
557
-
// err = s.enforcer.AddKnotMember(domain, subjectIdentity.DID.String())
558
-
// if err != nil {
559
-
// w.Write([]byte(fmt.Sprint("failed to add member: ", err)))
560
-
// return
561
-
// }
562
-
//
563
-
// w.Write([]byte(fmt.Sprint("added member: ", subjectIdentity.Handle.String())))
564
-
// }
565
-
566
-
// func (s *State) RemoveMember(w http.ResponseWriter, r *http.Request) {
567
-
// }
568
-
569
248
func validateRepoName(name string) error {
570
249
// check for path traversal attempts
571
250
if name == "." || name == ".." ||
···
598
277
return nil
599
278
}
600
279
280
+
func stripGitExt(name string) string {
281
+
return strings.TrimSuffix(name, ".git")
282
+
}
283
+
601
284
func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) {
602
285
switch r.Method {
603
286
case http.MethodGet:
···
614
297
})
615
298
616
299
case http.MethodPost:
300
+
l := s.logger.With("handler", "NewRepo")
301
+
617
302
user := s.oauth.GetUser(r)
303
+
l = l.With("did", user.Did)
304
+
l = l.With("handle", user.Handle)
618
305
306
+
// form validation
619
307
domain := r.FormValue("domain")
620
308
if domain == "" {
621
309
s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
622
310
return
623
311
}
312
+
l = l.With("knot", domain)
624
313
625
314
repoName := r.FormValue("name")
626
315
if repoName == "" {
···
632
321
s.pages.Notice(w, "repo", err.Error())
633
322
return
634
323
}
324
+
repoName = stripGitExt(repoName)
325
+
l = l.With("repoName", repoName)
635
326
636
327
defaultBranch := r.FormValue("branch")
637
328
if defaultBranch == "" {
638
329
defaultBranch = "main"
639
330
}
331
+
l = l.With("defaultBranch", defaultBranch)
640
332
641
333
description := r.FormValue("description")
642
334
335
+
// ACL validation
643
336
ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create")
644
337
if err != nil || !ok {
338
+
l.Info("unauthorized")
645
339
s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
646
340
return
647
341
}
648
342
343
+
// Check for existing repos
649
344
existingRepo, err := db.GetRepo(s.db, user.Did, repoName)
650
345
if err == nil && existingRepo != nil {
651
-
s.pages.Notice(w, "repo", fmt.Sprintf("A repo by this name already exists on %s", existingRepo.Knot))
652
-
return
653
-
}
654
-
655
-
secret, err := db.GetRegistrationKey(s.db, domain)
656
-
if err != nil {
657
-
s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain))
658
-
return
659
-
}
660
-
661
-
client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev)
662
-
if err != nil {
663
-
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))
664
348
return
665
349
}
666
350
667
-
rkey := appview.TID()
351
+
// create atproto record for this repo
352
+
rkey := tid.TID()
668
353
repo := &db.Repo{
669
354
Did: user.Did,
670
355
Name: repoName,
···
675
360
676
361
xrpcClient, err := s.oauth.AuthorizedClient(r)
677
362
if err != nil {
363
+
l.Info("PDS write failed", "err", err)
678
364
s.pages.Notice(w, "repo", "Failed to write record to PDS.")
679
365
return
680
366
}
···
693
379
}},
694
380
})
695
381
if err != nil {
696
-
log.Printf("failed to create record: %s", err)
382
+
l.Info("PDS write failed", "err", err)
697
383
s.pages.Notice(w, "repo", "Failed to announce repository creation.")
698
384
return
699
385
}
700
-
log.Println("created repo record: ", atresp.Uri)
386
+
387
+
aturi := atresp.Uri
388
+
l = l.With("aturi", aturi)
389
+
l.Info("wrote to PDS")
701
390
702
391
tx, err := s.db.BeginTx(r.Context(), nil)
703
392
if err != nil {
704
-
log.Println(err)
393
+
l.Info("txn failed", "err", err)
705
394
s.pages.Notice(w, "repo", "Failed to save repository information.")
706
395
return
707
396
}
708
-
defer func() {
709
-
tx.Rollback()
710
-
err = s.enforcer.E.LoadPolicy()
711
-
if err != nil {
712
-
log.Println("failed to rollback policies")
397
+
398
+
// The rollback function reverts a few things on failure:
399
+
// - the pending txn
400
+
// - the ACLs
401
+
// - the atproto record created
402
+
rollback := func() {
403
+
err1 := tx.Rollback()
404
+
err2 := s.enforcer.E.LoadPolicy()
405
+
err3 := rollbackRecord(context.Background(), aturi, xrpcClient)
406
+
407
+
// ignore txn complete errors, this is okay
408
+
if errors.Is(err1, sql.ErrTxDone) {
409
+
err1 = nil
410
+
}
411
+
412
+
if errs := errors.Join(err1, err2, err3); errs != nil {
413
+
l.Error("failed to rollback changes", "errs", errs)
414
+
return
713
415
}
714
-
}()
416
+
}
417
+
defer rollback()
715
418
716
-
resp, err := client.NewRepo(user.Did, repoName, defaultBranch)
419
+
client, err := s.oauth.ServiceClient(
420
+
r,
421
+
oauth.WithService(domain),
422
+
oauth.WithLxm(tangled.RepoCreateNSID),
423
+
oauth.WithDev(s.config.Core.Dev),
424
+
)
717
425
if err != nil {
718
-
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.")
719
428
return
720
429
}
721
430
722
-
switch resp.StatusCode {
723
-
case http.StatusConflict:
724
-
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())
725
441
return
726
-
case http.StatusInternalServerError:
727
-
s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
728
-
case http.StatusNoContent:
729
-
// continue
730
442
}
731
443
732
-
repo.AtUri = atresp.Uri
733
444
err = db.AddRepo(tx, repo)
734
445
if err != nil {
735
-
log.Println(err)
446
+
l.Error("db write failed", "err", err)
736
447
s.pages.Notice(w, "repo", "Failed to save repository information.")
737
448
return
738
449
}
···
741
452
p, _ := securejoin.SecureJoin(user.Did, repoName)
742
453
err = s.enforcer.AddRepo(user.Did, domain, p)
743
454
if err != nil {
744
-
log.Println(err)
455
+
l.Error("acl setup failed", "err", err)
745
456
s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
746
457
return
747
458
}
748
459
749
460
err = tx.Commit()
750
461
if err != nil {
751
-
log.Println("failed to commit changes", err)
462
+
l.Error("txn commit failed", "err", err)
752
463
http.Error(w, err.Error(), http.StatusInternalServerError)
753
464
return
754
465
}
755
466
756
467
err = s.enforcer.E.SavePolicy()
757
468
if err != nil {
758
-
log.Println("failed to update ACLs", err)
469
+
l.Error("acl save failed", "err", err)
759
470
http.Error(w, err.Error(), http.StatusInternalServerError)
760
471
return
761
472
}
762
473
763
-
if !s.config.Core.Dev {
764
-
err = s.posthog.Enqueue(posthog.Capture{
765
-
DistinctId: user.Did,
766
-
Event: "new_repo",
767
-
Properties: posthog.Properties{"repo": repoName, "repo_at": repo.AtUri},
768
-
})
769
-
if err != nil {
770
-
log.Println("failed to enqueue posthog event:", err)
771
-
}
772
-
}
474
+
// reset the ATURI because the transaction completed successfully
475
+
aturi = ""
773
476
477
+
s.notifier.NewRepo(r.Context(), repo)
774
478
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
775
-
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
776
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
777
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
+
}
-11
appview/tid.go
-11
appview/tid.go
+40
appview/xrpcclient/xrpc.go
+40
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
···
87
91
88
92
return &out, nil
89
93
}
94
+
95
+
func (c *Client) ServerGetServiceAuth(ctx context.Context, aud string, exp int64, lxm string) (*atproto.ServerGetServiceAuth_Output, error) {
96
+
var out atproto.ServerGetServiceAuth_Output
97
+
98
+
params := map[string]interface{}{
99
+
"aud": aud,
100
+
"exp": exp,
101
+
"lxm": lxm,
102
+
}
103
+
if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.server.getServiceAuth", params, nil, &out); err != nil {
104
+
return nil, err
105
+
}
106
+
107
+
return &out, nil
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
+
}
+33
-4
avatar/src/index.js
+33
-4
avatar/src/index.js
···
1
1
export default {
2
2
async fetch(request, env) {
3
+
// Helper function to generate a color from a string
4
+
const stringToColor = (str) => {
5
+
let hash = 0;
6
+
for (let i = 0; i < str.length; i++) {
7
+
hash = str.charCodeAt(i) + ((hash << 5) - hash);
8
+
}
9
+
let color = "#";
10
+
for (let i = 0; i < 3; i++) {
11
+
const value = (hash >> (i * 8)) & 0xff;
12
+
color += ("00" + value.toString(16)).substr(-2);
13
+
}
14
+
return color;
15
+
};
16
+
3
17
const url = new URL(request.url);
4
18
const { pathname, searchParams } = url;
5
19
···
60
74
const profile = await profileResponse.json();
61
75
const avatar = profile.avatar;
62
76
63
-
if (!avatar) {
64
-
return new Response(`avatar not found for ${actor}.`, { status: 404 });
77
+
let avatarUrl = profile.avatar;
78
+
79
+
if (!avatarUrl) {
80
+
// Generate a random color based on the actor string
81
+
const bgColor = stringToColor(actor);
82
+
const size = resizeToTiny ? 32 : 128;
83
+
const svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg"><rect width="${size}" height="${size}" fill="${bgColor}"/></svg>`;
84
+
const svgData = new TextEncoder().encode(svg);
85
+
86
+
response = new Response(svgData, {
87
+
headers: {
88
+
"Content-Type": "image/svg+xml",
89
+
"Cache-Control": "public, max-age=43200",
90
+
},
91
+
});
92
+
await cache.put(cacheKey, response.clone());
93
+
return response;
65
94
}
66
95
67
96
// Resize if requested
68
97
let avatarResponse;
69
98
if (resizeToTiny) {
70
-
avatarResponse = await fetch(avatar, {
99
+
avatarResponse = await fetch(avatarUrl, {
71
100
cf: {
72
101
image: {
73
102
width: 32,
···
78
107
},
79
108
});
80
109
} else {
81
-
avatarResponse = await fetch(avatar);
110
+
avatarResponse = await fetch(avatarUrl);
82
111
}
83
112
84
113
if (!avatarResponse.ok) {
+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.
+39
docs/migrations/knot-1.7.0.md
+39
docs/migrations/knot-1.7.0.md
···
1
+
# Upgrading from v1.7.0
2
+
3
+
After v1.7.0, knot secrets have been deprecated. You no
4
+
longer need a secret from the appview to run a knot. All
5
+
authorized commands between services to knots are managed
6
+
via [Service
7
+
Auth](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
8
+
Knots will be read-only until upgraded.
9
+
10
+
Upgrading is quite easy, in essence:
11
+
12
+
- `KNOT_SERVER_SECRET` is no more, you can remove this
13
+
environment variable entirely
14
+
- `KNOT_SERVER_OWNER` is now required on boot, set this to
15
+
your DID. You can find your DID in the
16
+
[settings](https://tangled.sh/settings) page.
17
+
- Restart your knot once you have replace the environment
18
+
variable
19
+
- Head to the [knot dashboard](https://tangled.sh/knots) and
20
+
hit the "retry" button to verify your knot. This simply
21
+
writes a `sh.tangled.knot` record to your PDS.
22
+
23
+
## Nix
24
+
25
+
If you use the nix module, simply bump the flake to the
26
+
latest revision, and change your config block like so:
27
+
28
+
```diff
29
+
services.tangled-knot = {
30
+
enable = true;
31
+
server = {
32
+
- secretFile = /path/to/secret;
33
+
+ owner = "did:plc:foo";
34
+
.
35
+
.
36
+
.
37
+
};
38
+
};
39
+
```
+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
}
+57
-35
go.mod
+57
-35
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
9
+
github.com/avast/retry-go/v4 v4.6.1
10
10
github.com/bluekeyes/go-gitdiff v0.8.1
11
-
github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e
11
+
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb
12
12
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
13
13
github.com/carlmjohnson/versioninfo v0.22.5
14
14
github.com/casbin/casbin/v2 v2.103.0
15
+
github.com/cloudflare/cloudflare-go v0.115.0
15
16
github.com/cyphar/filepath-securejoin v0.4.1
16
17
github.com/dgraph-io/ristretto v0.2.0
17
18
github.com/docker/docker v28.2.2+incompatible
···
21
22
github.com/go-enry/go-enry/v2 v2.9.2
22
23
github.com/go-git/go-git/v5 v5.14.0
23
24
github.com/google/uuid v1.6.0
25
+
github.com/gorilla/feeds v1.2.0
24
26
github.com/gorilla/sessions v1.4.0
25
-
github.com/gorilla/websocket v1.5.3
27
+
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
26
28
github.com/hiddeco/sshsig v0.2.0
27
29
github.com/hpcloud/tail v1.0.0
28
30
github.com/ipfs/go-cid v0.5.0
29
31
github.com/lestrrat-go/jwx/v2 v2.1.6
30
32
github.com/mattn/go-sqlite3 v1.14.24
31
33
github.com/microcosm-cc/bluemonday v1.0.27
34
+
github.com/openbao/openbao/api/v2 v2.3.0
32
35
github.com/posthog/posthog-go v1.5.5
33
-
github.com/redis/go-redis/v9 v9.3.0
36
+
github.com/redis/go-redis/v9 v9.7.3
34
37
github.com/resend/resend-go/v2 v2.15.0
35
38
github.com/sethvargo/go-envconfig v1.1.0
36
39
github.com/stretchr/testify v1.10.0
37
40
github.com/urfave/cli/v3 v3.3.3
38
41
github.com/whyrusleeping/cbor-gen v0.3.1
39
-
github.com/yuin/goldmark v1.4.13
40
-
golang.org/x/crypto v0.38.0
41
-
golang.org/x/net v0.40.0
42
+
github.com/yuin/goldmark v1.4.15
43
+
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
44
+
golang.org/x/crypto v0.40.0
45
+
golang.org/x/net v0.42.0
46
+
golang.org/x/sync v0.16.0
42
47
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
43
48
gopkg.in/yaml.v3 v3.0.1
44
-
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421
49
+
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1
45
50
)
46
51
47
52
require (
48
53
dario.cat/mergo v1.0.1 // indirect
49
54
github.com/Microsoft/go-winio v0.6.2 // indirect
50
-
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
51
57
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
52
-
github.com/avast/retry-go/v4 v4.6.1 // 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
74
-
github.com/go-logr/logr v1.4.2 // indirect
81
+
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
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
-
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
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
84
-
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
96
+
github.com/hashicorp/go-multierror v1.1.1 // indirect
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
-
github.com/ipfs/boxo v0.30.0 // indirect
89
-
github.com/ipfs/go-block-format v0.2.1 // indirect
106
+
github.com/ipfs/boxo v0.33.0 // indirect
107
+
github.com/ipfs/go-block-format v0.2.2 // indirect
90
108
github.com/ipfs/go-datastore v0.8.2 // indirect
91
109
github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect
92
110
github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect
93
-
github.com/ipfs/go-ipld-cbor v0.2.0 // indirect
94
-
github.com/ipfs/go-ipld-format v0.6.1 // indirect
111
+
github.com/ipfs/go-ipld-cbor v0.2.1 // indirect
112
+
github.com/ipfs/go-ipld-format v0.6.2 // indirect
95
113
github.com/ipfs/go-log v1.0.5 // indirect
96
114
github.com/ipfs/go-log/v2 v2.6.0 // indirect
97
115
github.com/ipfs/go-metrics-interface v0.3.0 // indirect
98
116
github.com/kevinburke/ssh_config v1.2.0 // indirect
99
117
github.com/klauspost/compress v1.18.0 // indirect
100
-
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
101
-
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
118
+
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
119
+
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
102
120
github.com/lestrrat-go/httpcc v1.0.1 // indirect
103
121
github.com/lestrrat-go/httprc v1.0.6 // indirect
104
122
github.com/lestrrat-go/iter v1.0.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
125
145
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
126
146
github.com/prometheus/client_golang v1.22.0 // indirect
127
147
github.com/prometheus/client_model v0.6.2 // indirect
128
-
github.com/prometheus/common v0.63.0 // indirect
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
···
136
157
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
137
158
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
138
159
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
139
-
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
140
-
go.opentelemetry.io/otel v1.36.0 // indirect
141
-
go.opentelemetry.io/otel/metric v1.36.0 // indirect
142
-
go.opentelemetry.io/otel/trace v1.36.0 // indirect
160
+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
161
+
go.opentelemetry.io/otel v1.37.0 // indirect
162
+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect
163
+
go.opentelemetry.io/otel/metric v1.37.0 // indirect
164
+
go.opentelemetry.io/otel/trace v1.37.0 // indirect
143
165
go.opentelemetry.io/proto/otlp v1.6.0 // indirect
144
166
go.uber.org/atomic v1.11.0 // indirect
145
167
go.uber.org/multierr v1.11.0 // indirect
146
168
go.uber.org/zap v1.27.0 // indirect
147
-
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
148
-
golang.org/x/sync v0.14.0 // indirect
149
-
golang.org/x/sys v0.33.0 // indirect
150
-
golang.org/x/time v0.8.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
169
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
170
+
golang.org/x/sys v0.34.0 // indirect
171
+
golang.org/x/text v0.27.0 // indirect
172
+
golang.org/x/time v0.12.0 // 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
+136
-88
go.sum
+136
-88
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=
26
+
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ=
27
+
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q=
28
28
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
29
29
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
30
30
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
···
51
51
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
52
52
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
53
53
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
54
-
github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk=
55
-
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=
56
58
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
57
59
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
58
60
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
···
77
79
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
78
80
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
79
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=
80
83
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
81
84
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
82
85
github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw=
···
91
94
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
92
95
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
93
96
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
94
-
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
95
-
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=
96
99
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
97
100
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
98
101
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
99
-
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
100
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=
101
105
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
102
106
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
103
107
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
···
114
118
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
115
119
github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03 h1:LumE+tQdnYW24a9RoO08w64LHTzkNkdUqBD/0QPtlEY=
116
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=
117
123
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
118
124
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
119
-
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
120
-
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
125
+
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
126
+
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
121
127
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
122
128
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
123
129
github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0=
124
130
github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI=
125
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=
126
134
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
127
135
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
128
136
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
129
137
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
130
138
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
131
-
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
132
-
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
139
+
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
140
+
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
133
141
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
134
142
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
135
-
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
136
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=
137
146
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
138
147
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
139
148
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
···
146
155
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
147
156
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
148
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=
149
159
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
150
160
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
151
161
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
152
162
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
153
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=
154
166
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
155
167
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
156
168
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
···
162
174
github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
163
175
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
164
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=
165
179
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
166
180
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
167
181
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
168
182
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
169
-
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
170
-
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=
171
185
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
172
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=
173
190
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
174
191
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
175
192
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
176
193
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
177
-
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
178
-
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=
196
+
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
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=
179
204
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
180
205
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
181
206
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
182
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=
183
210
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
184
211
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
185
212
github.com/hiddeco/sshsig v0.2.0 h1:gMWllgKCITXdydVkDL+Zro0PU96QI55LwUwebSwNTSw=
···
189
216
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
190
217
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
191
218
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
192
-
github.com/ipfs/boxo v0.30.0 h1:7afsoxPGGqfoH7Dum/wOTGUB9M5fb8HyKPMlLfBvIEQ=
193
-
github.com/ipfs/boxo v0.30.0/go.mod h1:BPqgGGyHB9rZZcPSzah2Dc9C+5Or3U1aQe7EH1H7370=
194
-
github.com/ipfs/go-block-format v0.2.1 h1:96kW71XGNNa+mZw/MTzJrCpMhBWCrd9kBLoKm9Iip/Q=
195
-
github.com/ipfs/go-block-format v0.2.1/go.mod h1:frtvXHMQhM6zn7HvEQu+Qz5wSTj+04oEH/I+NjDgEjk=
219
+
github.com/ipfs/boxo v0.33.0 h1:9ow3chwkDzMj0Deq4AWRUEI7WnIIV7SZhPTzzG2mmfw=
220
+
github.com/ipfs/boxo v0.33.0/go.mod h1:3IPh7YFcCIcKp6o02mCHovrPntoT5Pctj/7j4syh/RM=
221
+
github.com/ipfs/go-block-format v0.2.2 h1:uecCTgRwDIXyZPgYspaLXoMiMmxQpSx2aq34eNc4YvQ=
222
+
github.com/ipfs/go-block-format v0.2.2/go.mod h1:vmuefuWU6b+9kIU0vZJgpiJt1yicQz9baHXE8qR+KB8=
196
223
github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg=
197
224
github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk=
198
225
github.com/ipfs/go-datastore v0.8.2 h1:Jy3wjqQR6sg/LhyY0NIePZC3Vux19nLtg7dx0TVqr6U=
···
205
232
github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo=
206
233
github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0=
207
234
github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs=
208
-
github.com/ipfs/go-ipld-cbor v0.2.0 h1:VHIW3HVIjcMd8m4ZLZbrYpwjzqlVUfjLM7oK4T5/YF0=
209
-
github.com/ipfs/go-ipld-cbor v0.2.0/go.mod h1:Cp8T7w1NKcu4AQJLqK0tWpd1nkgTxEVB5C6kVpLW6/0=
210
-
github.com/ipfs/go-ipld-format v0.6.1 h1:lQLmBM/HHbrXvjIkrydRXkn+gc0DE5xO5fqelsCKYOQ=
211
-
github.com/ipfs/go-ipld-format v0.6.1/go.mod h1:8TOH1Hj+LFyqM2PjSqI2/ZnyO0KlfhHbJLkbxFa61hs=
235
+
github.com/ipfs/go-ipld-cbor v0.2.1 h1:H05yEJbK/hxg0uf2AJhyerBDbjOuHX4yi+1U/ogRa7E=
236
+
github.com/ipfs/go-ipld-cbor v0.2.1/go.mod h1:x9Zbeq8CoE5R2WicYgBMcr/9mnkQ0lHddYWJP2sMV3A=
237
+
github.com/ipfs/go-ipld-format v0.6.2 h1:bPZQ+A05ol0b3lsJSl0bLvwbuQ+HQbSsdGTy4xtYUkU=
238
+
github.com/ipfs/go-ipld-format v0.6.2/go.mod h1:nni2xFdHKx5lxvXJ6brt/pndtGxKAE+FPR1rg4jTkyk=
212
239
github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8=
213
240
github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo=
214
241
github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g=
···
216
243
github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8=
217
244
github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU=
218
245
github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY=
219
-
github.com/ipfs/go-test v0.2.1 h1:/D/a8xZ2JzkYqcVcV/7HYlCnc7bv/pKHQiX5TdClkPE=
220
-
github.com/ipfs/go-test v0.2.1/go.mod h1:dzu+KB9cmWjuJnXFDYJwC25T3j1GcN57byN+ixmK39M=
221
246
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
222
247
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
223
248
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
···
229
254
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
230
255
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
231
256
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
232
-
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
233
-
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
257
+
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
258
+
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
234
259
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
235
260
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
236
261
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
···
239
264
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
240
265
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
241
266
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
242
-
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
243
-
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
267
+
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
268
+
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
244
269
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
245
270
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
246
271
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
···
251
276
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
252
277
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
253
278
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
254
-
github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8=
255
-
github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg=
256
-
github.com/libp2p/go-libp2p v0.41.1 h1:8ecNQVT5ev/jqALTvisSJeVNvXYJyK4NhQx1nNRXQZE=
257
-
github.com/libp2p/go-libp2p v0.41.1/go.mod h1:DcGTovJzQl/I7HMrby5ZRjeD0kQkGiy+9w6aEkSZpRI=
258
-
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
259
-
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=
260
281
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
261
282
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
262
283
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
···
265
286
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
266
287
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
267
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=
268
291
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
269
292
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
270
293
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
···
281
304
github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
282
305
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
283
306
github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4=
284
-
github.com/multiformats/go-multiaddr v0.15.0 h1:zB/HeaI/apcZiTDwhY5YqMvNVl/oQYvs3XySU+qeAVo=
285
-
github.com/multiformats/go-multiaddr v0.15.0/go.mod h1:JSVUmXDjsVFiW7RjIFMP7+Ev+h1DTbiJgVeTV/tcmP0=
286
307
github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g=
287
308
github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk=
288
-
github.com/multiformats/go-multicodec v0.9.0 h1:pb/dlPnzee/Sxv/j4PmkDRxCOi3hXTz3IbPKOXWJkmg=
289
-
github.com/multiformats/go-multicodec v0.9.0/go.mod h1:L3QTQvMIaVBkXOXXtVmYE+LI16i14xuaojr/H7Ai54k=
290
309
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
291
310
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
292
311
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
···
318
337
github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg=
319
338
github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM=
320
339
github.com/onsi/gomega v1.25.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM=
321
-
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
322
-
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=
323
344
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
324
345
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
325
346
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
326
347
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
327
-
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
328
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=
329
351
github.com/oppiliappan/chroma/v2 v2.19.0 h1:PN7/pb+6JRKCva30NPTtRJMlrOyzgpPpIroNzy4ekHU=
330
352
github.com/oppiliappan/chroma/v2 v2.19.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
331
353
github.com/oppiliappan/go-git/v5 v5.17.0 h1:CuJnpcIDxr0oiNaSHMconovSWnowHznVDG+AhjGuSEo=
···
346
368
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
347
369
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
348
370
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
349
-
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
350
-
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
371
+
github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4=
372
+
github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
351
373
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
352
374
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
353
375
github.com/redis/go-redis/v9 v9.0.0-rc.4/go.mod h1:Vo3EsyWnicKnSKCA7HhgnvnyA74wOA69Cd2Meli5mmA=
354
-
github.com/redis/go-redis/v9 v9.3.0 h1:RiVDjmig62jIWp7Kk4XVLs0hzV6pI3PyTnnL0cnn0u0=
355
-
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=
356
378
github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE=
357
379
github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
358
380
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
···
360
382
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
361
383
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
362
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=
363
387
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
364
388
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
365
389
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
···
404
428
github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
405
429
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
406
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=
407
432
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
408
-
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
409
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=
410
438
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
411
439
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
412
440
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
413
441
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
414
442
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
415
443
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
416
-
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
417
-
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
418
-
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
419
-
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
420
-
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk=
421
-
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME=
444
+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
445
+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
446
+
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
447
+
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
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=
422
450
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU=
423
451
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0/go.mod h1:2HpZxxQurfGxJlJDblybejHB6RX6pmExPNe517hREw4=
424
-
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
425
-
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
426
-
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
427
-
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
428
-
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
429
-
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
430
-
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
431
-
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
452
+
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
453
+
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
454
+
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
455
+
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
456
+
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
457
+
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
458
+
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
459
+
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
432
460
go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI=
433
461
go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc=
434
462
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
···
451
479
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
452
480
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
453
481
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
454
-
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
455
-
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
456
-
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
457
-
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
482
+
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
483
+
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
484
+
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
485
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
486
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
458
487
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
459
488
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
460
489
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
461
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=
462
492
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
463
493
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
464
494
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
465
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=
466
497
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
467
498
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
468
499
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
···
471
502
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
472
503
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
473
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=
474
506
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
475
507
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
476
508
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
···
480
512
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
481
513
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
482
514
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
483
-
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
484
-
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
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=
485
519
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
486
520
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
487
521
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
···
489
523
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
490
524
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
491
525
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
492
-
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
493
-
golang.org/x/sync v0.14.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=
494
528
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
495
529
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
496
530
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
···
502
536
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
503
537
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
504
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=
505
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=
506
542
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
507
543
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
508
544
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
···
510
546
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
511
547
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
512
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=
513
550
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
514
551
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
515
552
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
516
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=
517
555
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
518
-
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
519
-
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=
558
+
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
559
+
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
520
560
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
521
561
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
522
562
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
523
563
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
524
564
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
525
565
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
526
-
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
527
-
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=
569
+
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
570
+
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
528
571
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
529
572
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
530
573
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
···
532
575
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
533
576
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
534
577
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
535
-
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
536
-
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
537
-
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
538
-
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
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=
581
+
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
582
+
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
583
+
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
584
+
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
539
585
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
540
586
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
541
587
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
···
547
593
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
548
594
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
549
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=
550
597
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
551
598
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
552
599
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
553
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=
554
602
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
555
603
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
556
604
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
557
605
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
558
606
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
559
607
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
560
-
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0=
561
-
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto=
562
-
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34=
563
-
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
564
-
google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=
565
-
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=
566
614
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
567
615
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
568
616
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
···
599
647
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
600
648
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
601
649
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
602
-
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421 h1:ZQNKE1HKWjfQBizxDb2XLhrJcgZqE0MLFIU1iggaF90=
603
-
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421/go.mod h1:Wad0H70uyyY4qZryU/1Ic+QZyw41YxN5QfzNAEBaXkQ=
650
+
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 h1:z1os1aRIqeo5e8d0Tx7hk+LH8OdZZeIOY0zw9VB/ZoU=
651
+
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1/go.mod h1:+oQi9S6IIDll0nxLZVhuzOPX8WKLCYEnE6M5kUKupDg=
604
652
tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc=
605
653
tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+20
-4
guard/guard.go
+20
-4
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"
···
13
15
"github.com/bluesky-social/indigo/atproto/identity"
14
16
securejoin "github.com/cyphar/filepath-securejoin"
15
17
"github.com/urfave/cli/v3"
16
-
"tangled.sh/tangled.sh/core/appview/idresolver"
18
+
"tangled.sh/tangled.sh/core/idresolver"
17
19
"tangled.sh/tangled.sh/core/log"
18
20
)
19
21
···
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)
+116
idresolver/resolver.go
+116
idresolver/resolver.go
···
1
+
package idresolver
2
+
3
+
import (
4
+
"context"
5
+
"net"
6
+
"net/http"
7
+
"sync"
8
+
"time"
9
+
10
+
"github.com/bluesky-social/indigo/atproto/identity"
11
+
"github.com/bluesky-social/indigo/atproto/identity/redisdir"
12
+
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
"github.com/carlmjohnson/versioninfo"
14
+
)
15
+
16
+
type Resolver struct {
17
+
directory identity.Directory
18
+
}
19
+
20
+
func BaseDirectory() identity.Directory {
21
+
base := identity.BaseDirectory{
22
+
PLCURL: identity.DefaultPLCURL,
23
+
HTTPClient: http.Client{
24
+
Timeout: time.Second * 10,
25
+
Transport: &http.Transport{
26
+
// would want this around 100ms for services doing lots of handle resolution. Impacts PLC connections as well, but not too bad.
27
+
IdleConnTimeout: time.Millisecond * 1000,
28
+
MaxIdleConns: 100,
29
+
},
30
+
},
31
+
Resolver: net.Resolver{
32
+
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
33
+
d := net.Dialer{Timeout: time.Second * 3}
34
+
return d.DialContext(ctx, network, address)
35
+
},
36
+
},
37
+
TryAuthoritativeDNS: true,
38
+
// primary Bluesky PDS instance only supports HTTP resolution method
39
+
SkipDNSDomainSuffixes: []string{".bsky.social"},
40
+
UserAgent: "indigo-identity/" + versioninfo.Short(),
41
+
}
42
+
return &base
43
+
}
44
+
45
+
func RedisDirectory(url string) (identity.Directory, error) {
46
+
hitTTL := time.Hour * 24
47
+
errTTL := time.Second * 30
48
+
invalidHandleTTL := time.Minute * 5
49
+
return redisdir.NewRedisDirectory(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000)
50
+
}
51
+
52
+
func DefaultResolver() *Resolver {
53
+
return &Resolver{
54
+
directory: identity.DefaultDirectory(),
55
+
}
56
+
}
57
+
58
+
func RedisResolver(redisUrl string) (*Resolver, error) {
59
+
directory, err := RedisDirectory(redisUrl)
60
+
if err != nil {
61
+
return nil, err
62
+
}
63
+
return &Resolver{
64
+
directory: directory,
65
+
}, nil
66
+
}
67
+
68
+
func (r *Resolver) ResolveIdent(ctx context.Context, arg string) (*identity.Identity, error) {
69
+
id, err := syntax.ParseAtIdentifier(arg)
70
+
if err != nil {
71
+
return nil, err
72
+
}
73
+
74
+
return r.directory.Lookup(ctx, *id)
75
+
}
76
+
77
+
func (r *Resolver) ResolveIdents(ctx context.Context, idents []string) []*identity.Identity {
78
+
results := make([]*identity.Identity, len(idents))
79
+
var wg sync.WaitGroup
80
+
81
+
done := make(chan struct{})
82
+
defer close(done)
83
+
84
+
for idx, ident := range idents {
85
+
wg.Add(1)
86
+
go func(index int, id string) {
87
+
defer wg.Done()
88
+
89
+
select {
90
+
case <-ctx.Done():
91
+
results[index] = nil
92
+
case <-done:
93
+
results[index] = nil
94
+
default:
95
+
identity, _ := r.ResolveIdent(ctx, id)
96
+
results[index] = identity
97
+
}
98
+
}(idx, ident)
99
+
}
100
+
101
+
wg.Wait()
102
+
return results
103
+
}
104
+
105
+
func (r *Resolver) InvalidateIdent(ctx context.Context, arg string) error {
106
+
id, err := syntax.ParseAtIdentifier(arg)
107
+
if err != nil {
108
+
return err
109
+
}
110
+
111
+
return r.directory.Purge(ctx, *id)
112
+
}
113
+
114
+
func (r *Resolver) Directory() identity.Directory {
115
+
return r.directory
116
+
}
+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
+
}
+7
-1
knotserver/config/config.go
+7
-1
knotserver/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
···
15
17
type Server struct {
16
18
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:5555"`
17
19
InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"`
18
-
Secret string `env:"SECRET, required"`
19
20
DBPath string `env:"DB_PATH, default=knotserver.db"`
20
21
Hostname string `env:"HOSTNAME, required"`
21
22
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
23
+
Owner string `env:"OWNER, required"`
22
24
LogDids bool `env:"LOG_DIDS, default=true"`
23
25
24
26
// This disables signature verification so use with caution.
25
27
Dev bool `env:"DEV, default=false"`
28
+
}
29
+
30
+
func (s Server) Did() syntax.DID {
31
+
return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname))
26
32
}
27
33
28
34
type Config struct {
+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)
+1012
-135
knotserver/handler.go
+1012
-135
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/jetstream"
12
-
"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"
13
24
"tangled.sh/tangled.sh/core/knotserver/db"
14
-
"tangled.sh/tangled.sh/core/notifier"
15
-
"tangled.sh/tangled.sh/core/rbac"
25
+
"tangled.sh/tangled.sh/core/knotserver/git"
26
+
"tangled.sh/tangled.sh/core/types"
16
27
)
17
28
18
-
const (
19
-
ThisServer = "thisserver" // resource identifier for rbac enforcement
20
-
)
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
+
}
21
32
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
33
+
func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) {
34
+
w.Header().Set("Content-Type", "application/json")
29
35
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
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
+
}
45
+
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()
70
+
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
+
}
85
+
}
86
+
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
+
}()
38
141
39
-
h := Handle{
40
-
c: c,
41
-
db: db,
42
-
e: e,
43
-
l: l,
44
-
jc: jc,
45
-
n: n,
46
-
init: make(chan struct{}),
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,
47
216
}
48
217
49
-
err := e.AddKnot(ThisServer)
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)
50
230
if err != nil {
51
-
return nil, fmt.Errorf("failed to setup enforcer: %w", err)
231
+
notFound(w)
232
+
return
52
233
}
53
234
54
-
err = h.jc.StartJetstream(ctx, h.processMessages)
235
+
files, err := gr.FileTree(r.Context(), treePath)
55
236
if err != nil {
56
-
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
240
+
}
241
+
242
+
resp := types.RepoTreeResponse{
243
+
Ref: ref,
244
+
Parent: treePath,
245
+
Description: getDescription(path),
246
+
DotDot: filepath.Dir(treePath),
247
+
Files: files,
57
248
}
58
249
59
-
// Check if the knot knows about any Dids;
60
-
// if it does, it is already initialized and we can repopulate the
61
-
// Jetstream subscriptions.
62
-
dids, err := db.GetAllDids()
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)
63
262
if err != nil {
64
-
return nil, fmt.Errorf("failed to get all Dids: %w", err)
263
+
notFound(w)
264
+
return
65
265
}
66
266
67
-
if len(dids) > 0 {
68
-
h.knotInitialized = true
69
-
close(h.init)
70
-
for _, d := range dids {
71
-
h.jc.AddDid(d)
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"
279
+
}
280
+
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
72
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
73
300
}
74
301
75
-
r.Get("/", h.Index)
76
-
r.Get("/capabilities", h.Capabilities)
77
-
r.Get("/version", h.Version)
78
-
r.Route("/{did}", func(r chi.Router) {
79
-
// Repo routes
80
-
r.Route("/{name}", func(r chi.Router) {
81
-
r.Route("/collaborator", func(r chi.Router) {
82
-
r.Use(h.VerifySignature)
83
-
r.Post("/add", h.AddRepoCollaborator)
84
-
})
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")
350
+
351
+
l := h.l.With("handler", "Archive", "name", name, "file", file)
352
+
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
+
}
358
+
359
+
ref := strings.TrimSuffix(file, ".tar.gz")
360
+
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
+
}
402
+
403
+
func (h *Handle) Log(w http.ResponseWriter, r *http.Request) {
404
+
ref := chi.URLParam(r, "ref")
405
+
ref, _ = url.PathUnescape(ref)
406
+
407
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
408
+
409
+
l := h.l.With("handler", "Log", "ref", ref, "path", path)
410
+
411
+
gr, err := git.Open(path, ref)
412
+
if err != nil {
413
+
notFound(w)
414
+
return
415
+
}
416
+
417
+
// Get page parameters
418
+
page := 1
419
+
pageSize := 30
420
+
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
+
}
426
+
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
+
}
432
+
433
+
// convert to offset/limit
434
+
offset := (page - 1) * pageSize
435
+
limit := pageSize
436
+
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
+
}
443
+
444
+
total := len(commits)
445
+
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
+
}
455
+
456
+
writeJSON(w, resp)
457
+
}
458
+
459
+
func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) {
460
+
ref := chi.URLParam(r, "ref")
461
+
ref, _ = url.PathUnescape(ref)
462
+
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
470
+
}
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)
485
+
}
486
+
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")
490
+
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
+
}
85
524
86
-
r.Route("/languages", func(r chi.Router) {
87
-
r.With(h.VerifySignature)
88
-
r.Get("/", h.RepoLanguages)
89
-
r.Get("/{ref}", h.RepoLanguages)
90
-
})
525
+
resp := types.RepoTagsResponse{
526
+
Tags: rtags,
527
+
}
91
528
92
-
r.Get("/", h.RepoIndex)
93
-
r.Get("/info/refs", h.InfoRefs)
94
-
r.Post("/git-upload-pack", h.UploadPack)
95
-
r.Post("/git-receive-pack", h.ReceivePack)
96
-
r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects
529
+
writeJSON(w, resp)
530
+
}
97
531
98
-
r.With(h.VerifySignature).Post("/hidden-ref/{forkRef}/{remoteRef}", h.NewHiddenRef)
532
+
func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) {
533
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
99
534
100
-
r.Route("/merge", func(r chi.Router) {
101
-
r.With(h.VerifySignature)
102
-
r.Post("/", h.Merge)
103
-
r.Post("/check", h.MergeCheck)
104
-
})
535
+
gr, err := git.PlainOpen(path)
536
+
if err != nil {
537
+
notFound(w)
538
+
return
539
+
}
105
540
106
-
r.Route("/tree/{ref}", func(r chi.Router) {
107
-
r.Get("/", h.RepoIndex)
108
-
r.Get("/*", h.RepoTree)
109
-
})
541
+
branches, _ := gr.Branches()
110
542
111
-
r.Route("/blob/{ref}", func(r chi.Router) {
112
-
r.Get("/*", h.Blob)
113
-
})
543
+
resp := types.RepoBranchesResponse{
544
+
Branches: branches,
545
+
}
114
546
115
-
r.Route("/raw/{ref}", func(r chi.Router) {
116
-
r.Get("/*", h.BlobRaw)
117
-
})
547
+
writeJSON(w, resp)
548
+
}
118
549
119
-
r.Get("/log/{ref}", h.Log)
120
-
r.Get("/archive/{file}", h.Archive)
121
-
r.Get("/commit/{ref}", h.Diff)
122
-
r.Get("/tags", h.Tags)
123
-
r.Route("/branches", func(r chi.Router) {
124
-
r.Get("/", h.Branches)
125
-
r.Get("/{branch}", h.Branch)
126
-
r.Route("/default", func(r chi.Router) {
127
-
r.Get("/", h.DefaultBranch)
128
-
r.With(h.VerifySignature).Put("/", h.SetDefaultBranch)
129
-
})
130
-
})
131
-
})
132
-
})
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)
133
554
134
-
// Create a new repository.
135
-
r.Route("/repo", func(r chi.Router) {
136
-
r.Use(h.VerifySignature)
137
-
r.Put("/new", h.NewRepo)
138
-
r.Delete("/", h.RemoveRepo)
139
-
r.Route("/fork", func(r chi.Router) {
140
-
r.Post("/", h.RepoFork)
141
-
r.Post("/sync/{branch}", h.RepoForkSync)
142
-
r.Get("/sync/{branch}", h.RepoForkAheadBehind)
143
-
})
144
-
})
555
+
l := h.l.With("handler", "Branch")
145
556
146
-
r.Route("/member", func(r chi.Router) {
147
-
r.Use(h.VerifySignature)
148
-
r.Put("/add", h.AddMember)
149
-
})
557
+
gr, err := git.PlainOpen(path)
558
+
if err != nil {
559
+
notFound(w)
560
+
return
561
+
}
150
562
151
-
// Socket that streams git oplogs
152
-
r.Get("/events", h.Events)
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
+
}
153
569
154
-
// Initialize the knot with an owner and public key.
155
-
r.With(h.VerifySignature).Post("/init", h.Init)
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
+
}
156
576
157
-
// Health check. Used for two-way verification with appview.
158
-
r.With(h.VerifySignature).Get("/health", h.Health)
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
+
}
159
585
160
-
// All public keys on the knot.
161
-
r.Get("/keys", h.Keys)
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
+
}
162
596
163
-
return r, nil
597
+
writeJSON(w, resp)
164
598
}
165
599
166
-
// version is set during build time.
167
-
var version string
600
+
func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) {
601
+
l := h.l.With("handler", "Keys")
168
602
169
-
func (h *Handle) Version(w http.ResponseWriter, r *http.Request) {
170
-
if version == "" {
171
-
info, ok := debug.ReadBuildInfo()
172
-
if !ok {
173
-
http.Error(w, "failed to read build info", http.StatusInternalServerError)
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())
174
609
return
175
610
}
176
611
177
-
var modVer string
178
-
for _, mod := range info.Deps {
179
-
if mod.Path == "tangled.sh/tangled.sh/knotserver" {
180
-
version = mod.Version
181
-
break
182
-
}
612
+
data := make([]map[string]any, 0)
613
+
for _, key := range keys {
614
+
j := key.JSON()
615
+
data = append(data, j)
183
616
}
617
+
writeJSON(w, data)
618
+
return
184
619
185
-
if modVer == "" {
186
-
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
187
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
188
640
}
641
+
}
189
642
190
-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
191
-
fmt.Fprintf(w, "knotserver/%s", version)
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
732
+
}
733
+
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
+
})
192
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
-
}
+124
-78
knotserver/ingester.go
+124
-78
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"
···
17
16
"github.com/bluesky-social/jetstream/pkg/models"
18
17
securejoin "github.com/cyphar/filepath-securejoin"
19
18
"tangled.sh/tangled.sh/core/api/tangled"
20
-
"tangled.sh/tangled.sh/core/appview/idresolver"
19
+
"tangled.sh/tangled.sh/core/idresolver"
21
20
"tangled.sh/tangled.sh/core/knotserver/db"
22
21
"tangled.sh/tangled.sh/core/knotserver/git"
23
22
"tangled.sh/tangled.sh/core/log"
23
+
"tangled.sh/tangled.sh/core/rbac"
24
24
"tangled.sh/tangled.sh/core/workflow"
25
25
)
26
26
27
-
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 {
28
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
+
29
37
pk := db.PublicKey{
30
38
Did: did,
31
39
PublicKey: record,
···
38
46
return nil
39
47
}
40
48
41
-
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 {
42
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
+
}
43
58
44
59
if record.Domain != h.c.Server.Hostname {
45
60
l.Error("domain mismatch", "domain", record.Domain, "expected", h.c.Server.Hostname)
46
61
return fmt.Errorf("domain mismatch: %s != %s", record.Domain, h.c.Server.Hostname)
47
62
}
48
63
49
-
ok, err := h.e.E.Enforce(did, ThisServer, ThisServer, "server:invite")
64
+
ok, err := h.e.E.Enforce(did, rbac.ThisServer, rbac.ThisServer, "server:invite")
50
65
if err != nil || !ok {
51
66
l.Error("failed to add member", "did", did)
52
67
return fmt.Errorf("failed to enforce permissions: %w", err)
53
68
}
54
69
55
-
if err := h.e.AddKnotMember(ThisServer, record.Subject); err != nil {
70
+
if err := h.e.AddKnotMember(rbac.ThisServer, record.Subject); err != nil {
56
71
l.Error("failed to add member", "error", err)
57
72
return fmt.Errorf("failed to add member: %w", err)
58
73
}
59
74
l.Info("added member from firehose", "member", record.Subject)
60
75
61
-
if err := h.db.AddDid(did); err != nil {
76
+
if err := h.db.AddDid(record.Subject); err != nil {
62
77
l.Error("failed to add did", "error", err)
63
78
return fmt.Errorf("failed to add did: %w", err)
64
79
}
65
-
h.jc.AddDid(did)
80
+
h.jc.AddDid(record.Subject)
66
81
67
-
if err := h.fetchAndAddKeys(ctx, did); err != nil {
82
+
if err := h.fetchAndAddKeys(ctx, record.Subject); err != nil {
68
83
return fmt.Errorf("failed to fetch and add keys: %w", err)
69
84
}
70
85
71
86
return nil
72
87
}
73
88
74
-
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
+
75
98
l := log.FromContext(ctx)
76
99
l = l.With("handler", "processPull")
77
100
l = l.With("did", did)
···
79
102
l = l.With("target_branch", record.TargetBranch)
80
103
81
104
if record.Source == nil {
82
-
reason := "not a branch-based pull request"
83
-
l.Info("ignoring pull record", "reason", reason)
84
-
return fmt.Errorf("ignoring pull record: %s", reason)
105
+
return fmt.Errorf("ignoring pull record: not a branch-based pull request")
85
106
}
86
107
87
108
if record.Source.Repo != nil {
88
-
reason := "fork based pull"
89
-
l.Info("ignoring pull record", "reason", reason)
90
-
return fmt.Errorf("ignoring pull record: %s", reason)
91
-
}
92
-
93
-
allDids, err := h.db.GetAllDids()
94
-
if err != nil {
95
-
return err
96
-
}
97
-
98
-
// presently: we only process PRs from collaborators for pipelines
99
-
if !slices.Contains(allDids, did) {
100
-
reason := "not a known did"
101
-
l.Info("rejecting pull record", "reason", reason)
102
-
return fmt.Errorf("rejected pull record: %s, %s", reason, did)
109
+
return fmt.Errorf("ignoring pull record: fork based pull")
103
110
}
104
111
105
112
repoAt, err := syntax.ParseATURI(record.TargetRepo)
106
113
if err != nil {
107
-
return err
114
+
return fmt.Errorf("failed to parse ATURI: %w", err)
108
115
}
109
116
110
117
// resolve this aturi to extract the repo record
···
120
127
121
128
resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
122
129
if err != nil {
123
-
return err
130
+
return fmt.Errorf("failed to resolver repo: %w", err)
124
131
}
125
132
126
133
repo := resp.Value.Val.(*tangled.Repo)
127
134
128
135
if repo.Knot != h.c.Server.Hostname {
129
-
reason := "not this knot"
130
-
l.Info("rejecting pull record", "reason", reason)
131
-
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)
132
137
}
133
138
134
139
didSlashRepo, err := securejoin.SecureJoin(repo.Owner, repo.Name)
135
140
if err != nil {
136
-
return err
141
+
return fmt.Errorf("failed to construct relative repo path: %w", err)
137
142
}
138
143
139
144
repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
140
145
if err != nil {
141
-
return err
146
+
return fmt.Errorf("failed to construct absolute repo path: %w", err)
142
147
}
143
148
144
149
gr, err := git.Open(repoPath, record.Source.Branch)
145
150
if err != nil {
146
-
return err
151
+
return fmt.Errorf("failed to open git repository: %w", err)
147
152
}
148
153
149
154
workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir)
150
155
if err != nil {
151
-
return err
156
+
return fmt.Errorf("failed to open workflow directory: %w", err)
152
157
}
153
158
154
-
var pipeline workflow.Pipeline
159
+
var pipeline workflow.RawPipeline
155
160
for _, e := range workflowDir {
156
161
if !e.IsFile {
157
162
continue
···
163
168
continue
164
169
}
165
170
166
-
wf, err := workflow.FromFile(e.Name, contents)
167
-
if err != nil {
168
-
// TODO: log here, respond to client that is pushing
169
-
h.l.Error("failed to parse workflow", "err", err, "path", fpath)
170
-
continue
171
-
}
172
-
173
-
pipeline = append(pipeline, wf)
171
+
pipeline = append(pipeline, workflow.RawWorkflow{
172
+
Name: e.Name,
173
+
Contents: contents,
174
+
})
174
175
}
175
176
176
177
trigger := tangled.Pipeline_PullRequestTriggerData{
···
192
193
},
193
194
}
194
195
195
-
cp := compiler.Compile(pipeline)
196
+
cp := compiler.Compile(compiler.Parse(pipeline))
196
197
eventJson, err := json.Marshal(cp)
197
198
if err != nil {
198
-
return err
199
+
return fmt.Errorf("failed to marshal pipeline event: %w", err)
199
200
}
200
201
201
202
// do not run empty pipelines
···
203
204
return nil
204
205
}
205
206
206
-
event := db.Event{
207
+
ev := db.Event{
207
208
Rkey: TID(),
208
209
Nsid: tangled.PipelineNSID,
209
210
EventJson: string(eventJson),
210
211
}
211
212
212
-
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())
213
276
}
214
277
215
278
func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error {
···
239
302
return fmt.Errorf("error reading response body: %w", err)
240
303
}
241
304
242
-
for _, key := range strings.Split(string(plaintext), "\n") {
305
+
for key := range strings.SplitSeq(string(plaintext), "\n") {
243
306
if key == "" {
244
307
continue
245
308
}
···
256
319
}
257
320
258
321
func (h *Handle) processMessages(ctx context.Context, event *models.Event) error {
259
-
did := event.Did
260
322
if event.Kind != models.EventKindCommit {
261
323
return nil
262
324
}
···
265
327
defer func() {
266
328
eventTime := event.TimeUS
267
329
lastTimeUs := eventTime + 1
268
-
fmt.Println("lastTimeUs", lastTimeUs)
269
330
if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil {
270
331
err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
271
332
}
272
333
}()
273
334
274
-
raw := json.RawMessage(event.Commit.Record)
275
-
276
335
switch event.Commit.Collection {
277
336
case tangled.PublicKeyNSID:
278
-
var record tangled.PublicKey
279
-
if err := json.Unmarshal(raw, &record); err != nil {
280
-
return fmt.Errorf("failed to unmarshal record: %w", err)
281
-
}
282
-
if err := h.processPublicKey(ctx, did, record); err != nil {
283
-
return fmt.Errorf("failed to process public key: %w", err)
284
-
}
285
-
337
+
err = h.processPublicKey(ctx, event)
286
338
case tangled.KnotMemberNSID:
287
-
var record tangled.KnotMember
288
-
if err := json.Unmarshal(raw, &record); err != nil {
289
-
return fmt.Errorf("failed to unmarshal record: %w", err)
290
-
}
291
-
if err := h.processKnotMember(ctx, did, record); err != nil {
292
-
return fmt.Errorf("failed to process knot member: %w", err)
293
-
}
339
+
err = h.processKnotMember(ctx, event)
294
340
case tangled.RepoPullNSID:
295
-
var record tangled.RepoPull
296
-
if err := json.Unmarshal(raw, &record); err != nil {
297
-
return fmt.Errorf("failed to unmarshal record: %w", err)
298
-
}
299
-
if err := h.processPull(ctx, did, record); err != nil {
300
-
return fmt.Errorf("failed to process knot member: %w", err)
301
-
}
341
+
err = h.processPull(ctx, event)
342
+
case tangled.RepoCollaboratorNSID:
343
+
err = h.processCollaborator(ctx, event)
344
+
}
345
+
346
+
if err != nil {
347
+
h.l.Debug("failed to process event", "nsid", event.Commit.Collection, "err", err)
302
348
}
303
349
304
-
return err
350
+
return nil
305
351
}
+56
-18
knotserver/internal.go
+56
-18
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"
···
38
40
return
39
41
}
40
42
41
-
ok, err := h.e.IsPushAllowed(user, ThisServer, repo)
43
+
ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo)
42
44
if err != nil || !ok {
43
45
w.WriteHeader(http.StatusForbidden)
44
46
return
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
-
}
+139
-1271
knotserver/routes.go
+139
-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"
32
-
"tangled.sh/tangled.sh/core/types"
15
+
"tangled.sh/tangled.sh/core/knotserver/xrpc"
16
+
tlog "tangled.sh/tangled.sh/core/log"
17
+
"tangled.sh/tangled.sh/core/notifier"
18
+
"tangled.sh/tangled.sh/core/rbac"
19
+
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
33
20
)
34
21
35
-
func (h *Handle) Index(w http.ResponseWriter, r *http.Request) {
36
-
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
37
30
}
38
31
39
-
func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) {
40
-
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()
41
34
42
-
capabilities := map[string]any{
43
-
"pull_requests": map[string]any{
44
-
"format_patch": true,
45
-
"patch_submissions": true,
46
-
"branch_submissions": true,
47
-
"fork_submissions": true,
48
-
},
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(),
49
43
}
50
44
51
-
jsonData, err := json.Marshal(capabilities)
45
+
err := e.AddKnot(rbac.ThisServer)
52
46
if err != nil {
53
-
http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError)
54
-
return
47
+
return nil, fmt.Errorf("failed to setup enforcer: %w", err)
55
48
}
56
49
57
-
w.Write(jsonData)
58
-
}
59
-
60
-
func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) {
61
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
62
-
l := h.l.With("path", path, "handler", "RepoIndex")
63
-
ref := chi.URLParam(r, "ref")
64
-
ref, _ = url.PathUnescape(ref)
65
-
66
-
gr, err := git.Open(path, ref)
67
-
if err != nil {
68
-
plain, err2 := git.PlainOpen(path)
69
-
if err2 != nil {
70
-
l.Error("opening repo", "error", err2.Error())
71
-
notFound(w)
72
-
return
73
-
}
74
-
branches, _ := plain.Branches()
75
-
76
-
log.Println(err)
77
-
78
-
if errors.Is(err, plumbing.ErrReferenceNotFound) {
79
-
resp := types.RepoIndexResponse{
80
-
IsEmpty: true,
81
-
Branches: branches,
82
-
}
83
-
writeJSON(w, resp)
84
-
return
85
-
} else {
86
-
l.Error("opening repo", "error", err.Error())
87
-
notFound(w)
88
-
return
89
-
}
50
+
// configure owner
51
+
if err = h.configureOwner(); err != nil {
52
+
return nil, err
90
53
}
91
-
92
-
var (
93
-
commits []*object.Commit
94
-
total int
95
-
branches []types.Branch
96
-
files []types.NiceTree
97
-
tags []object.Tag
98
-
)
99
-
100
-
var wg sync.WaitGroup
101
-
errorsCh := make(chan error, 5)
102
-
103
-
wg.Add(1)
104
-
go func() {
105
-
defer wg.Done()
106
-
cs, err := gr.Commits(0, 60)
107
-
if err != nil {
108
-
errorsCh <- fmt.Errorf("commits: %w", err)
109
-
return
110
-
}
111
-
commits = cs
112
-
}()
113
-
114
-
wg.Add(1)
115
-
go func() {
116
-
defer wg.Done()
117
-
t, err := gr.TotalCommits()
118
-
if err != nil {
119
-
errorsCh <- fmt.Errorf("calculating total: %w", err)
120
-
return
121
-
}
122
-
total = t
123
-
}()
124
-
125
-
wg.Add(1)
126
-
go func() {
127
-
defer wg.Done()
128
-
bs, err := gr.Branches()
129
-
if err != nil {
130
-
errorsCh <- fmt.Errorf("fetching branches: %w", err)
131
-
return
132
-
}
133
-
branches = bs
134
-
}()
135
-
136
-
wg.Add(1)
137
-
go func() {
138
-
defer wg.Done()
139
-
ts, err := gr.Tags()
140
-
if err != nil {
141
-
errorsCh <- fmt.Errorf("fetching tags: %w", err)
142
-
return
143
-
}
144
-
tags = ts
145
-
}()
146
-
147
-
wg.Add(1)
148
-
go func() {
149
-
defer wg.Done()
150
-
fs, err := gr.FileTree(r.Context(), "")
151
-
if err != nil {
152
-
errorsCh <- fmt.Errorf("fetching filetree: %w", err)
153
-
return
154
-
}
155
-
files = fs
156
-
}()
54
+
h.l.Info("owner set", "did", h.c.Server.Owner)
55
+
h.jc.AddDid(h.c.Server.Owner)
157
56
158
-
wg.Wait()
159
-
close(errorsCh)
160
-
161
-
// show any errors
162
-
for err := range errorsCh {
163
-
l.Error("loading repo", "error", err.Error())
164
-
writeError(w, err.Error(), http.StatusInternalServerError)
165
-
return
166
-
}
167
-
168
-
rtags := []*types.TagReference{}
169
-
for _, tag := range tags {
170
-
var target *object.Tag
171
-
if tag.Target != plumbing.ZeroHash {
172
-
target = &tag
173
-
}
174
-
tr := types.TagReference{
175
-
Tag: target,
176
-
}
177
-
178
-
tr.Reference = types.Reference{
179
-
Name: tag.Name,
180
-
Hash: tag.Hash.String(),
181
-
}
182
-
183
-
if tag.Message != "" {
184
-
tr.Message = tag.Message
185
-
}
186
-
187
-
rtags = append(rtags, &tr)
188
-
}
189
-
190
-
var readmeContent string
191
-
var readmeFile string
192
-
for _, readme := range h.c.Repo.Readme {
193
-
content, _ := gr.FileContent(readme)
194
-
if len(content) > 0 {
195
-
readmeContent = string(content)
196
-
readmeFile = readme
197
-
}
198
-
}
199
-
200
-
if ref == "" {
201
-
mainBranch, err := gr.FindMainBranch()
202
-
if err != nil {
203
-
writeError(w, err.Error(), http.StatusInternalServerError)
204
-
l.Error("finding main branch", "error", err.Error())
205
-
return
206
-
}
207
-
ref = mainBranch
208
-
}
209
-
210
-
resp := types.RepoIndexResponse{
211
-
IsEmpty: false,
212
-
Ref: ref,
213
-
Commits: commits,
214
-
Description: getDescription(path),
215
-
Readme: readmeContent,
216
-
ReadmeFileName: readmeFile,
217
-
Files: files,
218
-
Branches: branches,
219
-
Tags: rtags,
220
-
TotalCommits: total,
221
-
}
222
-
223
-
writeJSON(w, resp)
224
-
return
225
-
}
226
-
227
-
func (h *Handle) RepoTree(w http.ResponseWriter, r *http.Request) {
228
-
treePath := chi.URLParam(r, "*")
229
-
ref := chi.URLParam(r, "ref")
230
-
ref, _ = url.PathUnescape(ref)
231
-
232
-
l := h.l.With("handler", "RepoTree", "ref", ref, "treePath", treePath)
233
-
234
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
235
-
gr, err := git.Open(path, ref)
57
+
// configure known-dids in jetstream consumer
58
+
dids, err := h.db.GetAllDids()
236
59
if err != nil {
237
-
notFound(w)
238
-
return
239
-
}
240
-
241
-
files, err := gr.FileTree(r.Context(), treePath)
242
-
if err != nil {
243
-
writeError(w, err.Error(), http.StatusInternalServerError)
244
-
l.Error("file tree", "error", err.Error())
245
-
return
60
+
return nil, fmt.Errorf("failed to get all dids: %w", err)
246
61
}
247
-
248
-
resp := types.RepoTreeResponse{
249
-
Ref: ref,
250
-
Parent: treePath,
251
-
Description: getDescription(path),
252
-
DotDot: filepath.Dir(treePath),
253
-
Files: files,
62
+
for _, d := range dids {
63
+
jc.AddDid(d)
254
64
}
255
65
256
-
writeJSON(w, resp)
257
-
return
258
-
}
259
-
260
-
func (h *Handle) BlobRaw(w http.ResponseWriter, r *http.Request) {
261
-
treePath := chi.URLParam(r, "*")
262
-
ref := chi.URLParam(r, "ref")
263
-
ref, _ = url.PathUnescape(ref)
264
-
265
-
l := h.l.With("handler", "BlobRaw", "ref", ref, "treePath", treePath)
266
-
267
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
268
-
gr, err := git.Open(path, ref)
66
+
err = h.jc.StartJetstream(ctx, h.processMessages)
269
67
if err != nil {
270
-
notFound(w)
271
-
return
68
+
return nil, fmt.Errorf("failed to start jetstream: %w", err)
272
69
}
273
70
274
-
contents, err := gr.RawContent(treePath)
275
-
if err != nil {
276
-
writeError(w, err.Error(), http.StatusBadRequest)
277
-
l.Error("file content", "error", err.Error())
278
-
return
279
-
}
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) {
280
80
281
-
mimeType := http.DetectContentType(contents)
81
+
r.Route("/languages", func(r chi.Router) {
82
+
r.Get("/", h.RepoLanguages)
83
+
r.Get("/{ref}", h.RepoLanguages)
84
+
})
282
85
283
-
// exception for svg
284
-
if filepath.Ext(treePath) == ".svg" {
285
-
mimeType = "image/svg+xml"
286
-
}
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
287
91
288
-
if !strings.HasPrefix(mimeType, "image/") && !strings.HasPrefix(mimeType, "video/") {
289
-
l.Error("attempted to serve non-image/video file", "mimetype", mimeType)
290
-
writeError(w, "only image and video files can be accessed directly", http.StatusForbidden)
291
-
return
292
-
}
92
+
r.Route("/tree/{ref}", func(r chi.Router) {
93
+
r.Get("/", h.RepoIndex)
94
+
r.Get("/*", h.RepoTree)
95
+
})
293
96
294
-
w.Header().Set("Cache-Control", "public, max-age=86400") // cache for 24 hours
295
-
w.Header().Set("ETag", fmt.Sprintf("%x", sha256.Sum256(contents)))
296
-
w.Header().Set("Content-Type", mimeType)
297
-
w.Write(contents)
298
-
}
97
+
r.Route("/blob/{ref}", func(r chi.Router) {
98
+
r.Get("/*", h.Blob)
99
+
})
299
100
300
-
func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) {
301
-
treePath := chi.URLParam(r, "*")
302
-
ref := chi.URLParam(r, "ref")
303
-
ref, _ = url.PathUnescape(ref)
101
+
r.Route("/raw/{ref}", func(r chi.Router) {
102
+
r.Get("/*", h.BlobRaw)
103
+
})
304
104
305
-
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
+
})
306
116
307
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
308
-
gr, err := git.Open(path, ref)
309
-
if err != nil {
310
-
notFound(w)
311
-
return
312
-
}
313
-
314
-
var isBinaryFile bool = false
315
-
contents, err := gr.FileContent(treePath)
316
-
if errors.Is(err, git.ErrBinaryFile) {
317
-
isBinaryFile = true
318
-
} else if errors.Is(err, object.ErrFileNotFound) {
319
-
notFound(w)
320
-
return
321
-
} else if err != nil {
322
-
writeError(w, err.Error(), http.StatusInternalServerError)
323
-
return
324
-
}
325
-
326
-
bytes := []byte(contents)
327
-
// safe := string(sanitize(bytes))
328
-
sizeHint := len(bytes)
329
-
330
-
resp := types.RepoBlobResponse{
331
-
Ref: ref,
332
-
Contents: string(bytes),
333
-
Path: treePath,
334
-
IsBinary: isBinaryFile,
335
-
SizeHint: uint64(sizeHint),
336
-
}
337
-
338
-
h.showFile(resp, w, l)
339
-
}
340
-
341
-
func (h *Handle) Archive(w http.ResponseWriter, r *http.Request) {
342
-
name := chi.URLParam(r, "name")
343
-
file := chi.URLParam(r, "file")
344
-
345
-
l := h.l.With("handler", "Archive", "name", name, "file", file)
346
-
347
-
// TODO: extend this to add more files compression (e.g.: xz)
348
-
if !strings.HasSuffix(file, ".tar.gz") {
349
-
notFound(w)
350
-
return
351
-
}
352
-
353
-
ref := strings.TrimSuffix(file, ".tar.gz")
354
-
355
-
// This allows the browser to use a proper name for the file when
356
-
// downloading
357
-
filename := fmt.Sprintf("%s-%s.tar.gz", name, ref)
358
-
setContentDisposition(w, filename)
359
-
setGZipMIME(w)
360
-
361
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
362
-
gr, err := git.Open(path, ref)
363
-
if err != nil {
364
-
notFound(w)
365
-
return
366
-
}
367
-
368
-
gw := gzip.NewWriter(w)
369
-
defer gw.Close()
370
-
371
-
prefix := fmt.Sprintf("%s-%s", name, ref)
372
-
err = gr.WriteTar(gw, prefix)
373
-
if err != nil {
374
-
// once we start writing to the body we can't report error anymore
375
-
// so we are only left with printing the error.
376
-
l.Error("writing tar file", "error", err.Error())
377
-
return
378
-
}
379
-
380
-
err = gw.Flush()
381
-
if err != nil {
382
-
// once we start writing to the body we can't report error anymore
383
-
// so we are only left with printing the error.
384
-
l.Error("flushing?", "error", err.Error())
385
-
return
386
-
}
387
-
}
388
-
389
-
func (h *Handle) Log(w http.ResponseWriter, r *http.Request) {
390
-
ref := chi.URLParam(r, "ref")
391
-
ref, _ = url.PathUnescape(ref)
392
-
393
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
394
-
395
-
l := h.l.With("handler", "Log", "ref", ref, "path", path)
396
-
397
-
gr, err := git.Open(path, ref)
398
-
if err != nil {
399
-
notFound(w)
400
-
return
401
-
}
402
-
403
-
// Get page parameters
404
-
page := 1
405
-
pageSize := 30
406
-
407
-
if pageParam := r.URL.Query().Get("page"); pageParam != "" {
408
-
if p, err := strconv.Atoi(pageParam); err == nil && p > 0 {
409
-
page = p
410
-
}
411
-
}
412
-
413
-
if pageSizeParam := r.URL.Query().Get("per_page"); pageSizeParam != "" {
414
-
if ps, err := strconv.Atoi(pageSizeParam); err == nil && ps > 0 {
415
-
pageSize = ps
416
-
}
417
-
}
418
-
419
-
// convert to offset/limit
420
-
offset := (page - 1) * pageSize
421
-
limit := pageSize
422
-
423
-
commits, err := gr.Commits(offset, limit)
424
-
if err != nil {
425
-
writeError(w, err.Error(), http.StatusInternalServerError)
426
-
l.Error("fetching commits", "error", err.Error())
427
-
return
428
-
}
429
-
430
-
total := len(commits)
431
-
432
-
resp := types.RepoLogResponse{
433
-
Commits: commits,
434
-
Ref: ref,
435
-
Description: getDescription(path),
436
-
Log: true,
437
-
Total: total,
438
-
Page: page,
439
-
PerPage: pageSize,
440
-
}
441
-
442
-
writeJSON(w, resp)
443
-
return
444
-
}
445
-
446
-
func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) {
447
-
ref := chi.URLParam(r, "ref")
448
-
ref, _ = url.PathUnescape(ref)
449
-
450
-
l := h.l.With("handler", "Diff", "ref", ref)
451
-
452
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
453
-
gr, err := git.Open(path, ref)
454
-
if err != nil {
455
-
notFound(w)
456
-
return
457
-
}
458
-
459
-
diff, err := gr.Diff()
460
-
if err != nil {
461
-
writeError(w, err.Error(), http.StatusInternalServerError)
462
-
l.Error("getting diff", "error", err.Error())
463
-
return
464
-
}
465
-
466
-
resp := types.RepoCommitResponse{
467
-
Ref: ref,
468
-
Diff: diff,
469
-
}
470
-
471
-
writeJSON(w, resp)
472
-
return
473
-
}
474
-
475
-
func (h *Handle) Tags(w http.ResponseWriter, r *http.Request) {
476
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
477
-
l := h.l.With("handler", "Refs")
478
-
479
-
gr, err := git.Open(path, "")
480
-
if err != nil {
481
-
notFound(w)
482
-
return
483
-
}
484
-
485
-
tags, err := gr.Tags()
486
-
if err != nil {
487
-
// Non-fatal, we *should* have at least one branch to show.
488
-
l.Warn("getting tags", "error", err.Error())
489
-
}
490
-
491
-
rtags := []*types.TagReference{}
492
-
for _, tag := range tags {
493
-
var target *object.Tag
494
-
if tag.Target != plumbing.ZeroHash {
495
-
target = &tag
496
-
}
497
-
tr := types.TagReference{
498
-
Tag: target,
499
-
}
500
-
501
-
tr.Reference = types.Reference{
502
-
Name: tag.Name,
503
-
Hash: tag.Hash.String(),
504
-
}
505
-
506
-
if tag.Message != "" {
507
-
tr.Message = tag.Message
508
-
}
509
-
510
-
rtags = append(rtags, &tr)
511
-
}
512
-
513
-
resp := types.RepoTagsResponse{
514
-
Tags: rtags,
515
-
}
516
-
517
-
writeJSON(w, resp)
518
-
return
519
-
}
520
-
521
-
func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) {
522
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
523
-
524
-
gr, err := git.PlainOpen(path)
525
-
if err != nil {
526
-
notFound(w)
527
-
return
528
-
}
529
-
530
-
branches, _ := gr.Branches()
531
-
532
-
resp := types.RepoBranchesResponse{
533
-
Branches: branches,
534
-
}
535
-
536
-
writeJSON(w, resp)
537
-
return
538
-
}
539
-
540
-
func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) {
541
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
542
-
branchName := chi.URLParam(r, "branch")
543
-
branchName, _ = url.PathUnescape(branchName)
544
-
545
-
l := h.l.With("handler", "Branch")
546
-
547
-
gr, err := git.PlainOpen(path)
548
-
if err != nil {
549
-
notFound(w)
550
-
return
551
-
}
552
-
553
-
ref, err := gr.Branch(branchName)
554
-
if err != nil {
555
-
l.Error("getting branch", "error", err.Error())
556
-
writeError(w, err.Error(), http.StatusInternalServerError)
557
-
return
558
-
}
559
-
560
-
commit, err := gr.Commit(ref.Hash())
561
-
if err != nil {
562
-
l.Error("getting commit object", "error", err.Error())
563
-
writeError(w, err.Error(), http.StatusInternalServerError)
564
-
return
565
-
}
566
-
567
-
defaultBranch, err := gr.FindMainBranch()
568
-
isDefault := false
569
-
if err != nil {
570
-
l.Error("getting default branch", "error", err.Error())
571
-
// do not quit though
572
-
} else if defaultBranch == branchName {
573
-
isDefault = true
574
-
}
575
-
576
-
resp := types.RepoBranchResponse{
577
-
Branch: types.Branch{
578
-
Reference: types.Reference{
579
-
Name: ref.Name().Short(),
580
-
Hash: ref.Hash().String(),
581
-
},
582
-
Commit: commit,
583
-
IsDefault: isDefault,
584
-
},
585
-
}
586
-
587
-
writeJSON(w, resp)
588
-
return
589
-
}
590
-
591
-
func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) {
592
-
l := h.l.With("handler", "Keys")
593
-
594
-
switch r.Method {
595
-
case http.MethodGet:
596
-
keys, err := h.db.GetAllPublicKeys()
597
-
if err != nil {
598
-
writeError(w, err.Error(), http.StatusInternalServerError)
599
-
l.Error("getting public keys", "error", err.Error())
600
-
return
601
-
}
602
-
603
-
data := make([]map[string]any, 0)
604
-
for _, key := range keys {
605
-
j := key.JSON()
606
-
data = append(data, j)
607
-
}
608
-
writeJSON(w, data)
609
-
return
610
-
611
-
case http.MethodPut:
612
-
pk := db.PublicKey{}
613
-
if err := json.NewDecoder(r.Body).Decode(&pk); err != nil {
614
-
writeError(w, "invalid request body", http.StatusBadRequest)
615
-
return
616
-
}
117
+
// xrpc apis
118
+
r.Mount("/xrpc", h.XrpcRouter())
617
119
618
-
_, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key))
619
-
if err != nil {
620
-
writeError(w, "invalid pubkey", http.StatusBadRequest)
621
-
}
120
+
// Socket that streams git oplogs
121
+
r.Get("/events", h.Events)
622
122
623
-
if err := h.db.AddPublicKey(pk); err != nil {
624
-
writeError(w, err.Error(), http.StatusInternalServerError)
625
-
l.Error("adding public key", "error", err.Error())
626
-
return
627
-
}
123
+
// All public keys on the knot.
124
+
r.Get("/keys", h.Keys)
628
125
629
-
w.WriteHeader(http.StatusNoContent)
630
-
return
631
-
}
126
+
return r, nil
632
127
}
633
128
634
-
func (h *Handle) NewRepo(w http.ResponseWriter, r *http.Request) {
635
-
l := h.l.With("handler", "NewRepo")
636
-
637
-
data := struct {
638
-
Did string `json:"did"`
639
-
Name string `json:"name"`
640
-
DefaultBranch string `json:"default_branch,omitempty"`
641
-
}{}
642
-
643
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
644
-
writeError(w, "invalid request body", http.StatusBadRequest)
645
-
return
646
-
}
647
-
648
-
if data.DefaultBranch == "" {
649
-
data.DefaultBranch = h.c.Repo.MainBranch
650
-
}
651
-
652
-
did := data.Did
653
-
name := data.Name
654
-
defaultBranch := data.DefaultBranch
655
-
656
-
if err := validateRepoName(name); err != nil {
657
-
l.Error("creating repo", "error", err.Error())
658
-
writeError(w, err.Error(), http.StatusBadRequest)
659
-
return
660
-
}
129
+
func (h *Handle) XrpcRouter() http.Handler {
130
+
logger := tlog.New("knots")
661
131
662
-
relativeRepoPath := filepath.Join(did, name)
663
-
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
664
-
err := git.InitBare(repoPath, defaultBranch)
665
-
if err != nil {
666
-
l.Error("initializing bare repo", "error", err.Error())
667
-
if errors.Is(err, gogit.ErrRepositoryAlreadyExists) {
668
-
writeError(w, "That repo already exists!", http.StatusConflict)
669
-
return
670
-
} else {
671
-
writeError(w, err.Error(), http.StatusInternalServerError)
672
-
return
673
-
}
674
-
}
132
+
serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String())
675
133
676
-
// add perms for this user to access the repo
677
-
err = h.e.AddRepo(did, ThisServer, relativeRepoPath)
678
-
if err != nil {
679
-
l.Error("adding repo permissions", "error", err.Error())
680
-
writeError(w, err.Error(), http.StatusInternalServerError)
681
-
return
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,
682
143
}
683
-
684
-
hook.SetupRepo(
685
-
hook.Config(
686
-
hook.WithScanPath(h.c.Repo.ScanPath),
687
-
hook.WithInternalApi(h.c.Server.InternalListenAddr),
688
-
),
689
-
repoPath,
690
-
)
691
-
692
-
w.WriteHeader(http.StatusNoContent)
144
+
return xrpc.Router()
693
145
}
694
146
695
-
func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) {
696
-
l := h.l.With("handler", "RepoForkSync")
697
-
698
-
data := struct {
699
-
Did string `json:"did"`
700
-
Source string `json:"source"`
701
-
Name string `json:"name,omitempty"`
702
-
HiddenRef string `json:"hiddenref"`
703
-
}{}
704
-
705
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
706
-
writeError(w, "invalid request body", http.StatusBadRequest)
707
-
return
708
-
}
709
-
710
-
did := data.Did
711
-
source := data.Source
712
-
713
-
if did == "" || source == "" {
714
-
l.Error("invalid request body, empty did or name")
715
-
w.WriteHeader(http.StatusBadRequest)
716
-
return
717
-
}
718
-
719
-
var name string
720
-
if data.Name != "" {
721
-
name = data.Name
722
-
} else {
723
-
name = filepath.Base(source)
724
-
}
725
-
726
-
branch := chi.URLParam(r, "branch")
727
-
branch, _ = url.PathUnescape(branch)
147
+
// version is set during build time.
148
+
var version string
728
149
729
-
relativeRepoPath := filepath.Join(did, name)
730
-
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
731
-
732
-
gr, err := git.PlainOpen(repoPath)
733
-
if err != nil {
734
-
log.Println(err)
735
-
notFound(w)
736
-
return
737
-
}
738
-
739
-
forkCommit, err := gr.ResolveRevision(branch)
740
-
if err != nil {
741
-
l.Error("error resolving ref revision", "msg", err.Error())
742
-
writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest)
743
-
return
744
-
}
745
-
746
-
sourceCommit, err := gr.ResolveRevision(data.HiddenRef)
747
-
if err != nil {
748
-
l.Error("error resolving hidden ref revision", "msg", err.Error())
749
-
writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest)
750
-
return
751
-
}
752
-
753
-
status := types.UpToDate
754
-
if forkCommit.Hash.String() != sourceCommit.Hash.String() {
755
-
isAncestor, err := forkCommit.IsAncestor(sourceCommit)
756
-
if err != nil {
757
-
log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err)
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)
758
155
return
759
156
}
760
157
761
-
if isAncestor {
762
-
status = types.FastForwardable
763
-
} else {
764
-
status = types.Conflict
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
+
}
765
164
}
766
-
}
767
165
768
-
w.Header().Set("Content-Type", "application/json")
769
-
json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status})
770
-
}
771
-
772
-
func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) {
773
-
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
774
-
ref := chi.URLParam(r, "ref")
775
-
ref, _ = url.PathUnescape(ref)
776
-
777
-
l := h.l.With("handler", "RepoLanguages")
778
-
779
-
gr, err := git.Open(repoPath, ref)
780
-
if err != nil {
781
-
l.Error("opening repo", "error", err.Error())
782
-
notFound(w)
783
-
return
784
-
}
785
-
786
-
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
787
-
defer cancel()
788
-
789
-
sizes, err := gr.AnalyzeLanguages(ctx)
790
-
if err != nil {
791
-
l.Error("failed to analyze languages", "error", err.Error())
792
-
writeError(w, err.Error(), http.StatusNoContent)
793
-
return
794
-
}
795
-
796
-
resp := types.RepoLanguageResponse{Languages: sizes}
797
-
798
-
writeJSON(w, resp)
799
-
}
800
-
801
-
func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) {
802
-
l := h.l.With("handler", "RepoForkSync")
803
-
804
-
data := struct {
805
-
Did string `json:"did"`
806
-
Source string `json:"source"`
807
-
Name string `json:"name,omitempty"`
808
-
}{}
809
-
810
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
811
-
writeError(w, "invalid request body", http.StatusBadRequest)
812
-
return
813
-
}
814
-
815
-
did := data.Did
816
-
source := data.Source
817
-
818
-
if did == "" || source == "" {
819
-
l.Error("invalid request body, empty did or name")
820
-
w.WriteHeader(http.StatusBadRequest)
821
-
return
822
-
}
823
-
824
-
var name string
825
-
if data.Name != "" {
826
-
name = data.Name
827
-
} else {
828
-
name = filepath.Base(source)
829
-
}
830
-
831
-
branch := chi.URLParam(r, "branch")
832
-
branch, _ = url.PathUnescape(branch)
833
-
834
-
relativeRepoPath := filepath.Join(did, name)
835
-
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
836
-
837
-
gr, err := git.PlainOpen(repoPath)
838
-
if err != nil {
839
-
log.Println(err)
840
-
notFound(w)
841
-
return
842
-
}
843
-
844
-
err = gr.Sync(branch)
845
-
if err != nil {
846
-
l.Error("error syncing repo fork", "error", err.Error())
847
-
writeError(w, err.Error(), http.StatusInternalServerError)
848
-
return
849
-
}
850
-
851
-
w.WriteHeader(http.StatusNoContent)
852
-
}
853
-
854
-
func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) {
855
-
l := h.l.With("handler", "RepoFork")
856
-
857
-
data := struct {
858
-
Did string `json:"did"`
859
-
Source string `json:"source"`
860
-
Name string `json:"name,omitempty"`
861
-
}{}
862
-
863
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
864
-
writeError(w, "invalid request body", http.StatusBadRequest)
865
-
return
866
-
}
867
-
868
-
did := data.Did
869
-
source := data.Source
870
-
871
-
if did == "" || source == "" {
872
-
l.Error("invalid request body, empty did or name")
873
-
w.WriteHeader(http.StatusBadRequest)
874
-
return
875
-
}
876
-
877
-
var name string
878
-
if data.Name != "" {
879
-
name = data.Name
880
-
} else {
881
-
name = filepath.Base(source)
882
-
}
883
-
884
-
relativeRepoPath := filepath.Join(did, name)
885
-
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
886
-
887
-
err := git.Fork(repoPath, source)
888
-
if err != nil {
889
-
l.Error("forking repo", "error", err.Error())
890
-
writeError(w, err.Error(), http.StatusInternalServerError)
891
-
return
892
-
}
893
-
894
-
// add perms for this user to access the repo
895
-
err = h.e.AddRepo(did, ThisServer, relativeRepoPath)
896
-
if err != nil {
897
-
l.Error("adding repo permissions", "error", err.Error())
898
-
writeError(w, err.Error(), http.StatusInternalServerError)
899
-
return
900
-
}
901
-
902
-
hook.SetupRepo(
903
-
hook.Config(
904
-
hook.WithScanPath(h.c.Repo.ScanPath),
905
-
hook.WithInternalApi(h.c.Server.InternalListenAddr),
906
-
),
907
-
repoPath,
908
-
)
909
-
910
-
w.WriteHeader(http.StatusNoContent)
911
-
}
912
-
913
-
func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) {
914
-
l := h.l.With("handler", "RemoveRepo")
915
-
916
-
data := struct {
917
-
Did string `json:"did"`
918
-
Name string `json:"name"`
919
-
}{}
920
-
921
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
922
-
writeError(w, "invalid request body", http.StatusBadRequest)
923
-
return
924
-
}
925
-
926
-
did := data.Did
927
-
name := data.Name
928
-
929
-
if did == "" || name == "" {
930
-
l.Error("invalid request body, empty did or name")
931
-
w.WriteHeader(http.StatusBadRequest)
932
-
return
933
-
}
934
-
935
-
relativeRepoPath := filepath.Join(did, name)
936
-
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
937
-
err := os.RemoveAll(repoPath)
938
-
if err != nil {
939
-
l.Error("removing repo", "error", err.Error())
940
-
writeError(w, err.Error(), http.StatusInternalServerError)
941
-
return
942
-
}
943
-
944
-
w.WriteHeader(http.StatusNoContent)
945
-
946
-
}
947
-
func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) {
948
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
949
-
950
-
data := types.MergeRequest{}
951
-
952
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
953
-
writeError(w, err.Error(), http.StatusBadRequest)
954
-
h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err)
955
-
return
956
-
}
957
-
958
-
mo := &git.MergeOptions{
959
-
AuthorName: data.AuthorName,
960
-
AuthorEmail: data.AuthorEmail,
961
-
CommitBody: data.CommitBody,
962
-
CommitMessage: data.CommitMessage,
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
-
mo.FormatPatch = patchutil.IsFormatPatch(patch)
974
-
975
-
if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil {
976
-
var mergeErr *git.ErrMerge
977
-
if errors.As(err, &mergeErr) {
978
-
conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts))
979
-
for i, conflict := range mergeErr.Conflicts {
980
-
conflicts[i] = types.ConflictInfo{
981
-
Filename: conflict.Filename,
982
-
Reason: conflict.Reason,
983
-
}
984
-
}
985
-
response := types.MergeCheckResponse{
986
-
IsConflicted: true,
987
-
Conflicts: conflicts,
988
-
Message: mergeErr.Message,
989
-
}
990
-
writeConflict(w, response)
991
-
h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr)
992
-
} else {
993
-
writeError(w, err.Error(), http.StatusBadRequest)
994
-
h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error())
166
+
if modVer == "" {
167
+
version = "unknown"
995
168
}
996
-
return
997
169
}
998
170
999
-
w.WriteHeader(http.StatusOK)
171
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
172
+
fmt.Fprintf(w, "knotserver/%s", version)
1000
173
}
1001
174
1002
-
func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) {
1003
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
1004
-
1005
-
var data struct {
1006
-
Patch string `json:"patch"`
1007
-
Branch string `json:"branch"`
1008
-
}
175
+
func (h *Handle) configureOwner() error {
176
+
cfgOwner := h.c.Server.Owner
1009
177
1010
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
1011
-
writeError(w, err.Error(), http.StatusBadRequest)
1012
-
h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err)
1013
-
return
1014
-
}
178
+
rbacDomain := "thisserver"
1015
179
1016
-
patch := data.Patch
1017
-
branch := data.Branch
1018
-
gr, err := git.Open(path, branch)
180
+
existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain)
1019
181
if err != nil {
1020
-
notFound(w)
1021
-
return
182
+
return err
1022
183
}
1023
184
1024
-
err = gr.MergeCheck([]byte(patch), branch)
1025
-
if err == nil {
1026
-
response := types.MergeCheckResponse{
1027
-
IsConflicted: false,
1028
-
}
1029
-
writeJSON(w, response)
1030
-
return
1031
-
}
185
+
switch len(existing) {
186
+
case 0:
187
+
// no owner configured, continue
188
+
case 1:
189
+
// find existing owner
190
+
existingOwner := existing[0]
1032
191
1033
-
var mergeErr *git.ErrMerge
1034
-
if errors.As(err, &mergeErr) {
1035
-
conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts))
1036
-
for i, conflict := range mergeErr.Conflicts {
1037
-
conflicts[i] = types.ConflictInfo{
1038
-
Filename: conflict.Filename,
1039
-
Reason: conflict.Reason,
1040
-
}
1041
-
}
1042
-
response := types.MergeCheckResponse{
1043
-
IsConflicted: true,
1044
-
Conflicts: conflicts,
1045
-
Message: mergeErr.Message,
192
+
// no ownership change, this is okay
193
+
if existingOwner == h.c.Server.Owner {
194
+
break
1046
195
}
1047
-
writeConflict(w, response)
1048
-
h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error())
1049
-
return
1050
-
}
1051
-
writeError(w, err.Error(), http.StatusInternalServerError)
1052
-
h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error())
1053
-
}
1054
-
1055
-
func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) {
1056
-
rev1 := chi.URLParam(r, "rev1")
1057
-
rev1, _ = url.PathUnescape(rev1)
1058
-
1059
-
rev2 := chi.URLParam(r, "rev2")
1060
-
rev2, _ = url.PathUnescape(rev2)
1061
-
1062
-
l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2)
1063
-
1064
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
1065
-
gr, err := git.PlainOpen(path)
1066
-
if err != nil {
1067
-
notFound(w)
1068
-
return
1069
-
}
1070
-
1071
-
commit1, err := gr.ResolveRevision(rev1)
1072
-
if err != nil {
1073
-
l.Error("error resolving revision 1", "msg", err.Error())
1074
-
writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest)
1075
-
return
1076
-
}
1077
-
1078
-
commit2, err := gr.ResolveRevision(rev2)
1079
-
if err != nil {
1080
-
l.Error("error resolving revision 2", "msg", err.Error())
1081
-
writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest)
1082
-
return
1083
-
}
1084
-
1085
-
rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2)
1086
-
if err != nil {
1087
-
l.Error("error comparing revisions", "msg", err.Error())
1088
-
writeError(w, "error comparing revisions", http.StatusBadRequest)
1089
-
return
1090
-
}
1091
-
1092
-
writeJSON(w, types.RepoFormatPatchResponse{
1093
-
Rev1: commit1.Hash.String(),
1094
-
Rev2: commit2.Hash.String(),
1095
-
FormatPatch: formatPatch,
1096
-
Patch: rawPatch,
1097
-
})
1098
-
return
1099
-
}
1100
-
1101
-
func (h *Handle) NewHiddenRef(w http.ResponseWriter, r *http.Request) {
1102
-
l := h.l.With("handler", "NewHiddenRef")
1103
-
1104
-
forkRef := chi.URLParam(r, "forkRef")
1105
-
forkRef, _ = url.PathUnescape(forkRef)
1106
-
1107
-
remoteRef := chi.URLParam(r, "remoteRef")
1108
-
remoteRef, _ = url.PathUnescape(remoteRef)
1109
-
1110
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
1111
-
gr, err := git.PlainOpen(path)
1112
-
if err != nil {
1113
-
notFound(w)
1114
-
return
1115
-
}
1116
-
1117
-
err = gr.TrackHiddenRemoteRef(forkRef, remoteRef)
1118
-
if err != nil {
1119
-
l.Error("error tracking hidden remote ref", "msg", err.Error())
1120
-
writeError(w, "error tracking hidden remote ref", http.StatusBadRequest)
1121
-
return
1122
-
}
1123
-
1124
-
w.WriteHeader(http.StatusNoContent)
1125
-
return
1126
-
}
1127
-
1128
-
func (h *Handle) AddMember(w http.ResponseWriter, r *http.Request) {
1129
-
l := h.l.With("handler", "AddMember")
1130
-
1131
-
data := struct {
1132
-
Did string `json:"did"`
1133
-
}{}
1134
-
1135
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
1136
-
writeError(w, "invalid request body", http.StatusBadRequest)
1137
-
return
1138
-
}
1139
-
1140
-
did := data.Did
1141
-
1142
-
if err := h.db.AddDid(did); err != nil {
1143
-
l.Error("adding did", "error", err.Error())
1144
-
writeError(w, err.Error(), http.StatusInternalServerError)
1145
-
return
1146
-
}
1147
-
h.jc.AddDid(did)
1148
-
1149
-
if err := h.e.AddKnotMember(ThisServer, did); err != nil {
1150
-
l.Error("adding member", "error", err.Error())
1151
-
writeError(w, err.Error(), http.StatusInternalServerError)
1152
-
return
1153
-
}
1154
-
1155
-
if err := h.fetchAndAddKeys(r.Context(), did); err != nil {
1156
-
l.Error("fetching and adding keys", "error", err.Error())
1157
-
writeError(w, err.Error(), http.StatusInternalServerError)
1158
-
return
1159
-
}
1160
-
1161
-
w.WriteHeader(http.StatusNoContent)
1162
-
}
1163
-
1164
-
func (h *Handle) AddRepoCollaborator(w http.ResponseWriter, r *http.Request) {
1165
-
l := h.l.With("handler", "AddRepoCollaborator")
1166
196
1167
-
data := struct {
1168
-
Did string `json:"did"`
1169
-
}{}
1170
-
1171
-
ownerDid := chi.URLParam(r, "did")
1172
-
repo := chi.URLParam(r, "name")
1173
-
1174
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
1175
-
writeError(w, "invalid request body", http.StatusBadRequest)
1176
-
return
1177
-
}
1178
-
1179
-
if err := h.db.AddDid(data.Did); err != nil {
1180
-
l.Error("adding did", "error", err.Error())
1181
-
writeError(w, err.Error(), http.StatusInternalServerError)
1182
-
return
1183
-
}
1184
-
h.jc.AddDid(data.Did)
1185
-
1186
-
repoName, _ := securejoin.SecureJoin(ownerDid, repo)
1187
-
if err := h.e.AddCollaborator(data.Did, ThisServer, repoName); err != nil {
1188
-
l.Error("adding repo collaborator", "error", err.Error())
1189
-
writeError(w, err.Error(), http.StatusInternalServerError)
1190
-
return
1191
-
}
1192
-
1193
-
if err := h.fetchAndAddKeys(r.Context(), data.Did); err != nil {
1194
-
l.Error("fetching and adding keys", "error", err.Error())
1195
-
writeError(w, err.Error(), http.StatusInternalServerError)
1196
-
return
1197
-
}
1198
-
1199
-
w.WriteHeader(http.StatusNoContent)
1200
-
}
1201
-
1202
-
func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) {
1203
-
l := h.l.With("handler", "DefaultBranch")
1204
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
1205
-
1206
-
gr, err := git.Open(path, "")
1207
-
if err != nil {
1208
-
notFound(w)
1209
-
return
1210
-
}
1211
-
1212
-
branch, err := gr.FindMainBranch()
1213
-
if err != nil {
1214
-
writeError(w, err.Error(), http.StatusInternalServerError)
1215
-
l.Error("getting default branch", "error", err.Error())
1216
-
return
1217
-
}
1218
-
1219
-
writeJSON(w, types.RepoDefaultBranchResponse{
1220
-
Branch: branch,
1221
-
})
1222
-
}
1223
-
1224
-
func (h *Handle) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
1225
-
l := h.l.With("handler", "SetDefaultBranch")
1226
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
1227
-
1228
-
data := struct {
1229
-
Branch string `json:"branch"`
1230
-
}{}
1231
-
1232
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
1233
-
writeError(w, err.Error(), http.StatusBadRequest)
1234
-
return
1235
-
}
1236
-
1237
-
gr, err := git.PlainOpen(path)
1238
-
if err != nil {
1239
-
notFound(w)
1240
-
return
1241
-
}
1242
-
1243
-
err = gr.SetDefaultBranch(data.Branch)
1244
-
if err != nil {
1245
-
writeError(w, err.Error(), http.StatusInternalServerError)
1246
-
l.Error("setting default branch", "error", err.Error())
1247
-
return
1248
-
}
1249
-
1250
-
w.WriteHeader(http.StatusNoContent)
1251
-
}
1252
-
1253
-
func (h *Handle) Init(w http.ResponseWriter, r *http.Request) {
1254
-
l := h.l.With("handler", "Init")
1255
-
1256
-
if h.knotInitialized {
1257
-
writeError(w, "knot already initialized", http.StatusConflict)
1258
-
return
1259
-
}
1260
-
1261
-
data := struct {
1262
-
Did string `json:"did"`
1263
-
}{}
1264
-
1265
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
1266
-
l.Error("failed to decode request body", "error", err.Error())
1267
-
writeError(w, "invalid request body", http.StatusBadRequest)
1268
-
return
1269
-
}
1270
-
1271
-
if data.Did == "" {
1272
-
l.Error("empty DID in request", "did", data.Did)
1273
-
writeError(w, "did is empty", http.StatusBadRequest)
1274
-
return
1275
-
}
1276
-
1277
-
if err := h.db.AddDid(data.Did); err != nil {
1278
-
l.Error("failed to add DID", "error", err.Error())
1279
-
writeError(w, err.Error(), http.StatusInternalServerError)
1280
-
return
1281
-
}
1282
-
h.jc.AddDid(data.Did)
1283
-
1284
-
if err := h.e.AddKnotOwner(ThisServer, data.Did); err != nil {
1285
-
l.Error("adding owner", "error", err.Error())
1286
-
writeError(w, err.Error(), http.StatusInternalServerError)
1287
-
return
1288
-
}
1289
-
1290
-
if err := h.fetchAndAddKeys(r.Context(), data.Did); err != nil {
1291
-
l.Error("fetching and adding keys", "error", err.Error())
1292
-
writeError(w, err.Error(), http.StatusInternalServerError)
1293
-
return
1294
-
}
1295
-
1296
-
close(h.init)
1297
-
1298
-
mac := hmac.New(sha256.New, []byte(h.c.Server.Secret))
1299
-
mac.Write([]byte("ok"))
1300
-
w.Header().Add("X-Signature", hex.EncodeToString(mac.Sum(nil)))
1301
-
1302
-
w.WriteHeader(http.StatusNoContent)
1303
-
}
1304
-
1305
-
func (h *Handle) Health(w http.ResponseWriter, r *http.Request) {
1306
-
w.Write([]byte("ok"))
1307
-
}
1308
-
1309
-
func validateRepoName(name string) error {
1310
-
// check for path traversal attempts
1311
-
if name == "." || name == ".." ||
1312
-
strings.Contains(name, "/") || strings.Contains(name, "\\") {
1313
-
return fmt.Errorf("Repository name contains invalid path characters")
1314
-
}
1315
-
1316
-
// check for sequences that could be used for traversal when normalized
1317
-
if strings.Contains(name, "./") || strings.Contains(name, "../") ||
1318
-
strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
1319
-
return fmt.Errorf("Repository name contains invalid path sequence")
1320
-
}
1321
-
1322
-
// then continue with character validation
1323
-
for _, char := range name {
1324
-
if !((char >= 'a' && char <= 'z') ||
1325
-
(char >= 'A' && char <= 'Z') ||
1326
-
(char >= '0' && char <= '9') ||
1327
-
char == '-' || char == '_' || char == '.') {
1328
-
return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores")
197
+
// remove existing owner
198
+
err = h.e.RemoveKnotOwner(rbacDomain, existingOwner)
199
+
if err != nil {
200
+
return nil
1329
201
}
202
+
default:
203
+
return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", h.c.Server.DBPath)
1330
204
}
1331
205
1332
-
// additional check to prevent multiple sequential dots
1333
-
if strings.Contains(name, "..") {
1334
-
return fmt.Errorf("Repository name cannot contain sequential dots")
1335
-
}
1336
-
1337
-
// if all checks pass
1338
-
return nil
206
+
return h.e.AddKnotOwner(rbacDomain, cfgOwner)
1339
207
}
+1
knotserver/server.go
+1
knotserver/server.go
-5
knotserver/util.go
-5
knotserver/util.go
···
8
8
"github.com/bluesky-social/indigo/atproto/syntax"
9
9
securejoin "github.com/cyphar/filepath-securejoin"
10
10
"github.com/go-chi/chi/v5"
11
-
"github.com/microcosm-cc/bluemonday"
12
11
)
13
-
14
-
func sanitize(content []byte) []byte {
15
-
return bluemonday.UGCPolicy().SanitizeBytes([]byte(content))
16
-
}
17
12
18
13
func didPath(r *http.Request) string {
19
14
did := chi.URLParam(r, "did")
+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
+
}
+89
knotserver/xrpc/set_default_branch.go
+89
knotserver/xrpc/set_default_branch.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
8
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
"github.com/bluesky-social/indigo/xrpc"
11
+
securejoin "github.com/cyphar/filepath-securejoin"
12
+
"tangled.sh/tangled.sh/core/api/tangled"
13
+
"tangled.sh/tangled.sh/core/knotserver/git"
14
+
"tangled.sh/tangled.sh/core/rbac"
15
+
16
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
17
+
)
18
+
19
+
const ActorDid string = "ActorDid"
20
+
21
+
func (x *Xrpc) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
22
+
l := x.Logger
23
+
fail := func(e xrpcerr.XrpcError) {
24
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
25
+
writeError(w, e, http.StatusBadRequest)
26
+
}
27
+
28
+
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
29
+
if !ok {
30
+
fail(xrpcerr.MissingActorDidError)
31
+
return
32
+
}
33
+
34
+
var data tangled.RepoSetDefaultBranch_Input
35
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
36
+
fail(xrpcerr.GenericError(err))
37
+
return
38
+
}
39
+
40
+
// unfortunately we have to resolve repo-at here
41
+
repoAt, err := syntax.ParseATURI(data.Repo)
42
+
if err != nil {
43
+
fail(xrpcerr.InvalidRepoError(data.Repo))
44
+
return
45
+
}
46
+
47
+
// resolve this aturi to extract the repo record
48
+
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
49
+
if err != nil || ident.Handle.IsInvalidHandle() {
50
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
51
+
return
52
+
}
53
+
54
+
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
55
+
resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
56
+
if err != nil {
57
+
fail(xrpcerr.GenericError(err))
58
+
return
59
+
}
60
+
61
+
repo := resp.Value.Val.(*tangled.Repo)
62
+
didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name)
63
+
if err != nil {
64
+
fail(xrpcerr.GenericError(err))
65
+
return
66
+
}
67
+
68
+
if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
69
+
l.Error("insufficent permissions", "did", actorDid.String())
70
+
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
71
+
return
72
+
}
73
+
74
+
path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath)
75
+
gr, err := git.PlainOpen(path)
76
+
if err != nil {
77
+
fail(xrpcerr.GenericError(err))
78
+
return
79
+
}
80
+
81
+
err = gr.SetDefaultBranch(data.DefaultBranch)
82
+
if err != nil {
83
+
l.Error("setting default branch", "error", err.Error())
84
+
writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError)
85
+
return
86
+
}
87
+
88
+
w.WriteHeader(http.StatusOK)
89
+
}
+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
-
}
+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
+
}
+18
-1
rbac/rbac.go
+18
-1
rbac/rbac.go
···
11
11
)
12
12
13
13
const (
14
+
ThisServer = "thisserver" // resource identifier for local rbac enforcement
15
+
)
16
+
17
+
const (
14
18
Model = `
15
19
[request_definition]
16
20
r = sub, dom, obj, act
···
39
43
return nil, err
40
44
}
41
45
42
-
db, err := sql.Open("sqlite3", path)
46
+
db, err := sql.Open("sqlite3", path+"?_foreign_keys=1")
43
47
if err != nil {
44
48
return nil, err
45
49
}
···
93
97
func (e *Enforcer) RemoveSpindle(spindle string) error {
94
98
spindle = intoSpindle(spindle)
95
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)
96
105
return err
97
106
}
98
107
···
266
275
267
276
func (e *Enforcer) IsSpindleInviteAllowed(user, domain string) (bool, error) {
268
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")
269
286
}
270
287
271
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
+
}