+1
-1
.air/appview.toml
+1
-1
.air/appview.toml
+4
.gitignore
+4
.gitignore
+12
.prettierrc.json
+12
.prettierrc.json
+2
.tangled/workflows/build.yml
+2
.tangled/workflows/build.yml
+3
-12
.tangled/workflows/fmt.yml
+3
-12
.tangled/workflows/fmt.yml
···
2
2
- event: ["push", "pull_request"]
3
3
branch: ["master"]
4
4
5
-
dependencies:
6
-
nixpkgs:
7
-
- go
8
-
- alejandra
5
+
engine: nixery
9
6
10
7
steps:
11
-
- name: "nix fmt"
8
+
- name: "Check formatting"
12
9
command: |
13
-
alejandra -c nix/**/*.nix flake.nix
14
-
15
-
- name: "go fmt"
16
-
command: |
17
-
unformatted=$(gofmt -l .)
18
-
test -z "$unformatted" || (echo "$unformatted" && exit 1)
19
-
10
+
nix run .#fmt -- --ci
+2
.tangled/workflows/test.yml
+2
.tangled/workflows/test.yml
-16
.zed/settings.json
-16
.zed/settings.json
···
1
-
// Folder-specific settings
2
-
//
3
-
// For a full list of overridable settings, and general information on folder-specific settings,
4
-
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
5
-
{
6
-
"languages": {
7
-
"HTML": {
8
-
"prettier": {
9
-
"format_on_save": false,
10
-
"allowed": true,
11
-
"parser": "go-template",
12
-
"plugins": ["prettier-plugin-go-template"]
13
-
}
14
-
}
15
-
}
16
-
}
+485
-722
api/tangled/cbor_gen.go
+485
-722
api/tangled/cbor_gen.go
···
2728
2728
2729
2729
return nil
2730
2730
}
2731
-
func (t *Pipeline_Dependency) MarshalCBOR(w io.Writer) error {
2732
-
if t == nil {
2733
-
_, err := w.Write(cbg.CborNull)
2734
-
return err
2735
-
}
2736
-
2737
-
cw := cbg.NewCborWriter(w)
2738
-
2739
-
if _, err := cw.Write([]byte{162}); err != nil {
2740
-
return err
2741
-
}
2742
-
2743
-
// t.Packages ([]string) (slice)
2744
-
if len("packages") > 1000000 {
2745
-
return xerrors.Errorf("Value in field \"packages\" was too long")
2746
-
}
2747
-
2748
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("packages"))); err != nil {
2749
-
return err
2750
-
}
2751
-
if _, err := cw.WriteString(string("packages")); err != nil {
2752
-
return err
2753
-
}
2754
-
2755
-
if len(t.Packages) > 8192 {
2756
-
return xerrors.Errorf("Slice value in field t.Packages was too long")
2757
-
}
2758
-
2759
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Packages))); err != nil {
2760
-
return err
2761
-
}
2762
-
for _, v := range t.Packages {
2763
-
if len(v) > 1000000 {
2764
-
return xerrors.Errorf("Value in field v was too long")
2765
-
}
2766
-
2767
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
2768
-
return err
2769
-
}
2770
-
if _, err := cw.WriteString(string(v)); err != nil {
2771
-
return err
2772
-
}
2773
-
2774
-
}
2775
-
2776
-
// t.Registry (string) (string)
2777
-
if len("registry") > 1000000 {
2778
-
return xerrors.Errorf("Value in field \"registry\" was too long")
2779
-
}
2780
-
2781
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("registry"))); err != nil {
2782
-
return err
2783
-
}
2784
-
if _, err := cw.WriteString(string("registry")); err != nil {
2785
-
return err
2786
-
}
2787
-
2788
-
if len(t.Registry) > 1000000 {
2789
-
return xerrors.Errorf("Value in field t.Registry was too long")
2790
-
}
2791
-
2792
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Registry))); err != nil {
2793
-
return err
2794
-
}
2795
-
if _, err := cw.WriteString(string(t.Registry)); err != nil {
2796
-
return err
2797
-
}
2798
-
return nil
2799
-
}
2800
-
2801
-
func (t *Pipeline_Dependency) UnmarshalCBOR(r io.Reader) (err error) {
2802
-
*t = Pipeline_Dependency{}
2803
-
2804
-
cr := cbg.NewCborReader(r)
2805
-
2806
-
maj, extra, err := cr.ReadHeader()
2807
-
if err != nil {
2808
-
return err
2809
-
}
2810
-
defer func() {
2811
-
if err == io.EOF {
2812
-
err = io.ErrUnexpectedEOF
2813
-
}
2814
-
}()
2815
-
2816
-
if maj != cbg.MajMap {
2817
-
return fmt.Errorf("cbor input should be of type map")
2818
-
}
2819
-
2820
-
if extra > cbg.MaxLength {
2821
-
return fmt.Errorf("Pipeline_Dependency: map struct too large (%d)", extra)
2822
-
}
2823
-
2824
-
n := extra
2825
-
2826
-
nameBuf := make([]byte, 8)
2827
-
for i := uint64(0); i < n; i++ {
2828
-
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
2829
-
if err != nil {
2830
-
return err
2831
-
}
2832
-
2833
-
if !ok {
2834
-
// Field doesn't exist on this type, so ignore it
2835
-
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
2836
-
return err
2837
-
}
2838
-
continue
2839
-
}
2840
-
2841
-
switch string(nameBuf[:nameLen]) {
2842
-
// t.Packages ([]string) (slice)
2843
-
case "packages":
2844
-
2845
-
maj, extra, err = cr.ReadHeader()
2846
-
if err != nil {
2847
-
return err
2848
-
}
2849
-
2850
-
if extra > 8192 {
2851
-
return fmt.Errorf("t.Packages: array too large (%d)", extra)
2852
-
}
2853
-
2854
-
if maj != cbg.MajArray {
2855
-
return fmt.Errorf("expected cbor array")
2856
-
}
2857
-
2858
-
if extra > 0 {
2859
-
t.Packages = make([]string, extra)
2860
-
}
2861
-
2862
-
for i := 0; i < int(extra); i++ {
2863
-
{
2864
-
var maj byte
2865
-
var extra uint64
2866
-
var err error
2867
-
_ = maj
2868
-
_ = extra
2869
-
_ = err
2870
-
2871
-
{
2872
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2873
-
if err != nil {
2874
-
return err
2875
-
}
2876
-
2877
-
t.Packages[i] = string(sval)
2878
-
}
2879
-
2880
-
}
2881
-
}
2882
-
// t.Registry (string) (string)
2883
-
case "registry":
2884
-
2885
-
{
2886
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2887
-
if err != nil {
2888
-
return err
2889
-
}
2890
-
2891
-
t.Registry = string(sval)
2892
-
}
2893
-
2894
-
default:
2895
-
// Field doesn't exist on this type, so ignore it
2896
-
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
2897
-
return err
2898
-
}
2899
-
}
2900
-
}
2901
-
2902
-
return nil
2903
-
}
2904
2731
func (t *Pipeline_ManualTriggerData) MarshalCBOR(w io.Writer) error {
2905
2732
if t == nil {
2906
2733
_, err := w.Write(cbg.CborNull)
···
3916
3743
3917
3744
return nil
3918
3745
}
3919
-
func (t *Pipeline_Step) MarshalCBOR(w io.Writer) error {
3920
-
if t == nil {
3921
-
_, err := w.Write(cbg.CborNull)
3922
-
return err
3923
-
}
3924
-
3925
-
cw := cbg.NewCborWriter(w)
3926
-
fieldCount := 3
3927
-
3928
-
if t.Environment == nil {
3929
-
fieldCount--
3930
-
}
3931
-
3932
-
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
3933
-
return err
3934
-
}
3935
-
3936
-
// t.Name (string) (string)
3937
-
if len("name") > 1000000 {
3938
-
return xerrors.Errorf("Value in field \"name\" was too long")
3939
-
}
3940
-
3941
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("name"))); err != nil {
3942
-
return err
3943
-
}
3944
-
if _, err := cw.WriteString(string("name")); err != nil {
3945
-
return err
3946
-
}
3947
-
3948
-
if len(t.Name) > 1000000 {
3949
-
return xerrors.Errorf("Value in field t.Name was too long")
3950
-
}
3951
-
3952
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Name))); err != nil {
3953
-
return err
3954
-
}
3955
-
if _, err := cw.WriteString(string(t.Name)); err != nil {
3956
-
return err
3957
-
}
3958
-
3959
-
// t.Command (string) (string)
3960
-
if len("command") > 1000000 {
3961
-
return xerrors.Errorf("Value in field \"command\" was too long")
3962
-
}
3963
-
3964
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("command"))); err != nil {
3965
-
return err
3966
-
}
3967
-
if _, err := cw.WriteString(string("command")); err != nil {
3968
-
return err
3969
-
}
3970
-
3971
-
if len(t.Command) > 1000000 {
3972
-
return xerrors.Errorf("Value in field t.Command was too long")
3973
-
}
3974
-
3975
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Command))); err != nil {
3976
-
return err
3977
-
}
3978
-
if _, err := cw.WriteString(string(t.Command)); err != nil {
3979
-
return err
3980
-
}
3981
-
3982
-
// t.Environment ([]*tangled.Pipeline_Pair) (slice)
3983
-
if t.Environment != nil {
3984
-
3985
-
if len("environment") > 1000000 {
3986
-
return xerrors.Errorf("Value in field \"environment\" was too long")
3987
-
}
3988
-
3989
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("environment"))); err != nil {
3990
-
return err
3991
-
}
3992
-
if _, err := cw.WriteString(string("environment")); err != nil {
3993
-
return err
3994
-
}
3995
-
3996
-
if len(t.Environment) > 8192 {
3997
-
return xerrors.Errorf("Slice value in field t.Environment was too long")
3998
-
}
3999
-
4000
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Environment))); err != nil {
4001
-
return err
4002
-
}
4003
-
for _, v := range t.Environment {
4004
-
if err := v.MarshalCBOR(cw); err != nil {
4005
-
return err
4006
-
}
4007
-
4008
-
}
4009
-
}
4010
-
return nil
4011
-
}
4012
-
4013
-
func (t *Pipeline_Step) UnmarshalCBOR(r io.Reader) (err error) {
4014
-
*t = Pipeline_Step{}
4015
-
4016
-
cr := cbg.NewCborReader(r)
4017
-
4018
-
maj, extra, err := cr.ReadHeader()
4019
-
if err != nil {
4020
-
return err
4021
-
}
4022
-
defer func() {
4023
-
if err == io.EOF {
4024
-
err = io.ErrUnexpectedEOF
4025
-
}
4026
-
}()
4027
-
4028
-
if maj != cbg.MajMap {
4029
-
return fmt.Errorf("cbor input should be of type map")
4030
-
}
4031
-
4032
-
if extra > cbg.MaxLength {
4033
-
return fmt.Errorf("Pipeline_Step: map struct too large (%d)", extra)
4034
-
}
4035
-
4036
-
n := extra
4037
-
4038
-
nameBuf := make([]byte, 11)
4039
-
for i := uint64(0); i < n; i++ {
4040
-
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
4041
-
if err != nil {
4042
-
return err
4043
-
}
4044
-
4045
-
if !ok {
4046
-
// Field doesn't exist on this type, so ignore it
4047
-
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
4048
-
return err
4049
-
}
4050
-
continue
4051
-
}
4052
-
4053
-
switch string(nameBuf[:nameLen]) {
4054
-
// t.Name (string) (string)
4055
-
case "name":
4056
-
4057
-
{
4058
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
4059
-
if err != nil {
4060
-
return err
4061
-
}
4062
-
4063
-
t.Name = string(sval)
4064
-
}
4065
-
// t.Command (string) (string)
4066
-
case "command":
4067
-
4068
-
{
4069
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
4070
-
if err != nil {
4071
-
return err
4072
-
}
4073
-
4074
-
t.Command = string(sval)
4075
-
}
4076
-
// t.Environment ([]*tangled.Pipeline_Pair) (slice)
4077
-
case "environment":
4078
-
4079
-
maj, extra, err = cr.ReadHeader()
4080
-
if err != nil {
4081
-
return err
4082
-
}
4083
-
4084
-
if extra > 8192 {
4085
-
return fmt.Errorf("t.Environment: array too large (%d)", extra)
4086
-
}
4087
-
4088
-
if maj != cbg.MajArray {
4089
-
return fmt.Errorf("expected cbor array")
4090
-
}
4091
-
4092
-
if extra > 0 {
4093
-
t.Environment = make([]*Pipeline_Pair, extra)
4094
-
}
4095
-
4096
-
for i := 0; i < int(extra); i++ {
4097
-
{
4098
-
var maj byte
4099
-
var extra uint64
4100
-
var err error
4101
-
_ = maj
4102
-
_ = extra
4103
-
_ = err
4104
-
4105
-
{
4106
-
4107
-
b, err := cr.ReadByte()
4108
-
if err != nil {
4109
-
return err
4110
-
}
4111
-
if b != cbg.CborNull[0] {
4112
-
if err := cr.UnreadByte(); err != nil {
4113
-
return err
4114
-
}
4115
-
t.Environment[i] = new(Pipeline_Pair)
4116
-
if err := t.Environment[i].UnmarshalCBOR(cr); err != nil {
4117
-
return xerrors.Errorf("unmarshaling t.Environment[i] pointer: %w", err)
4118
-
}
4119
-
}
4120
-
4121
-
}
4122
-
4123
-
}
4124
-
}
4125
-
4126
-
default:
4127
-
// Field doesn't exist on this type, so ignore it
4128
-
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
4129
-
return err
4130
-
}
4131
-
}
4132
-
}
4133
-
4134
-
return nil
4135
-
}
4136
3746
func (t *Pipeline_TriggerMetadata) MarshalCBOR(w io.Writer) error {
4137
3747
if t == nil {
4138
3748
_, err := w.Write(cbg.CborNull)
···
4609
4219
4610
4220
cw := cbg.NewCborWriter(w)
4611
4221
4612
-
if _, err := cw.Write([]byte{165}); err != nil {
4222
+
if _, err := cw.Write([]byte{164}); err != nil {
4223
+
return err
4224
+
}
4225
+
4226
+
// t.Raw (string) (string)
4227
+
if len("raw") > 1000000 {
4228
+
return xerrors.Errorf("Value in field \"raw\" was too long")
4229
+
}
4230
+
4231
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("raw"))); err != nil {
4232
+
return err
4233
+
}
4234
+
if _, err := cw.WriteString(string("raw")); err != nil {
4235
+
return err
4236
+
}
4237
+
4238
+
if len(t.Raw) > 1000000 {
4239
+
return xerrors.Errorf("Value in field t.Raw was too long")
4240
+
}
4241
+
4242
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Raw))); err != nil {
4243
+
return err
4244
+
}
4245
+
if _, err := cw.WriteString(string(t.Raw)); err != nil {
4613
4246
return err
4614
4247
}
4615
4248
···
4652
4285
return err
4653
4286
}
4654
4287
4655
-
// t.Steps ([]*tangled.Pipeline_Step) (slice)
4656
-
if len("steps") > 1000000 {
4657
-
return xerrors.Errorf("Value in field \"steps\" was too long")
4288
+
// t.Engine (string) (string)
4289
+
if len("engine") > 1000000 {
4290
+
return xerrors.Errorf("Value in field \"engine\" was too long")
4658
4291
}
4659
4292
4660
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("steps"))); err != nil {
4293
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("engine"))); err != nil {
4661
4294
return err
4662
4295
}
4663
-
if _, err := cw.WriteString(string("steps")); err != nil {
4296
+
if _, err := cw.WriteString(string("engine")); err != nil {
4664
4297
return err
4665
4298
}
4666
4299
4667
-
if len(t.Steps) > 8192 {
4668
-
return xerrors.Errorf("Slice value in field t.Steps was too long")
4300
+
if len(t.Engine) > 1000000 {
4301
+
return xerrors.Errorf("Value in field t.Engine was too long")
4669
4302
}
4670
4303
4671
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Steps))); err != nil {
4304
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Engine))); err != nil {
4672
4305
return err
4673
4306
}
4674
-
for _, v := range t.Steps {
4675
-
if err := v.MarshalCBOR(cw); err != nil {
4676
-
return err
4677
-
}
4678
-
4679
-
}
4680
-
4681
-
// t.Environment ([]*tangled.Pipeline_Pair) (slice)
4682
-
if len("environment") > 1000000 {
4683
-
return xerrors.Errorf("Value in field \"environment\" was too long")
4684
-
}
4685
-
4686
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("environment"))); err != nil {
4687
-
return err
4688
-
}
4689
-
if _, err := cw.WriteString(string("environment")); err != nil {
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 {
4307
+
if _, err := cw.WriteString(string(t.Engine)); err != nil {
4713
4308
return err
4714
4309
}
4715
-
if _, err := cw.WriteString(string("dependencies")); err != nil {
4716
-
return err
4717
-
}
4718
-
4719
-
if len(t.Dependencies) > 8192 {
4720
-
return xerrors.Errorf("Slice value in field t.Dependencies was too long")
4721
-
}
4722
-
4723
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Dependencies))); err != nil {
4724
-
return err
4725
-
}
4726
-
for _, v := range t.Dependencies {
4727
-
if err := v.MarshalCBOR(cw); err != nil {
4728
-
return err
4729
-
}
4730
-
4731
-
}
4732
4310
return nil
4733
4311
}
4734
4312
···
4757
4335
4758
4336
n := extra
4759
4337
4760
-
nameBuf := make([]byte, 12)
4338
+
nameBuf := make([]byte, 6)
4761
4339
for i := uint64(0); i < n; i++ {
4762
4340
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
4763
4341
if err != nil {
···
4773
4351
}
4774
4352
4775
4353
switch string(nameBuf[:nameLen]) {
4776
-
// t.Name (string) (string)
4354
+
// t.Raw (string) (string)
4355
+
case "raw":
4356
+
4357
+
{
4358
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
4359
+
if err != nil {
4360
+
return err
4361
+
}
4362
+
4363
+
t.Raw = string(sval)
4364
+
}
4365
+
// t.Name (string) (string)
4777
4366
case "name":
4778
4367
4779
4368
{
···
4804
4393
}
4805
4394
4806
4395
}
4807
-
// t.Steps ([]*tangled.Pipeline_Step) (slice)
4808
-
case "steps":
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
4396
+
// t.Engine (string) (string)
4397
+
case "engine":
4835
4398
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
-
4399
+
{
4400
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
4401
+
if err != nil {
4402
+
return err
4854
4403
}
4855
-
}
4856
-
// t.Environment ([]*tangled.Pipeline_Pair) (slice)
4857
-
case "environment":
4858
4404
4859
-
maj, extra, err = cr.ReadHeader()
4860
-
if err != nil {
4861
-
return err
4862
-
}
4863
-
4864
-
if extra > 8192 {
4865
-
return fmt.Errorf("t.Environment: array too large (%d)", extra)
4866
-
}
4867
-
4868
-
if maj != cbg.MajArray {
4869
-
return fmt.Errorf("expected cbor array")
4870
-
}
4871
-
4872
-
if extra > 0 {
4873
-
t.Environment = make([]*Pipeline_Pair, extra)
4874
-
}
4875
-
4876
-
for i := 0; i < int(extra); i++ {
4877
-
{
4878
-
var maj byte
4879
-
var extra uint64
4880
-
var err error
4881
-
_ = maj
4882
-
_ = extra
4883
-
_ = err
4884
-
4885
-
{
4886
-
4887
-
b, err := cr.ReadByte()
4888
-
if err != nil {
4889
-
return err
4890
-
}
4891
-
if b != cbg.CborNull[0] {
4892
-
if err := cr.UnreadByte(); err != nil {
4893
-
return err
4894
-
}
4895
-
t.Environment[i] = new(Pipeline_Pair)
4896
-
if err := t.Environment[i].UnmarshalCBOR(cr); err != nil {
4897
-
return xerrors.Errorf("unmarshaling t.Environment[i] pointer: %w", err)
4898
-
}
4899
-
}
4900
-
4901
-
}
4902
-
4903
-
}
4904
-
}
4905
-
// t.Dependencies ([]*tangled.Pipeline_Dependency) (slice)
4906
-
case "dependencies":
4907
-
4908
-
maj, extra, err = cr.ReadHeader()
4909
-
if err != nil {
4910
-
return err
4911
-
}
4912
-
4913
-
if extra > 8192 {
4914
-
return fmt.Errorf("t.Dependencies: array too large (%d)", extra)
4915
-
}
4916
-
4917
-
if maj != cbg.MajArray {
4918
-
return fmt.Errorf("expected cbor array")
4919
-
}
4920
-
4921
-
if extra > 0 {
4922
-
t.Dependencies = make([]*Pipeline_Dependency, extra)
4923
-
}
4924
-
4925
-
for i := 0; i < int(extra); i++ {
4926
-
{
4927
-
var maj byte
4928
-
var extra uint64
4929
-
var err error
4930
-
_ = maj
4931
-
_ = extra
4932
-
_ = err
4933
-
4934
-
{
4935
-
4936
-
b, err := cr.ReadByte()
4937
-
if err != nil {
4938
-
return err
4939
-
}
4940
-
if b != cbg.CborNull[0] {
4941
-
if err := cr.UnreadByte(); err != nil {
4942
-
return err
4943
-
}
4944
-
t.Dependencies[i] = new(Pipeline_Dependency)
4945
-
if err := t.Dependencies[i].UnmarshalCBOR(cr); err != nil {
4946
-
return xerrors.Errorf("unmarshaling t.Dependencies[i] pointer: %w", err)
4947
-
}
4948
-
}
4949
-
4950
-
}
4951
-
4952
-
}
4405
+
t.Engine = string(sval)
4953
4406
}
4954
4407
4955
4408
default:
···
5854
5307
5855
5308
return nil
5856
5309
}
5310
+
func (t *RepoCollaborator) MarshalCBOR(w io.Writer) error {
5311
+
if t == nil {
5312
+
_, err := w.Write(cbg.CborNull)
5313
+
return err
5314
+
}
5315
+
5316
+
cw := cbg.NewCborWriter(w)
5317
+
5318
+
if _, err := cw.Write([]byte{164}); err != nil {
5319
+
return err
5320
+
}
5321
+
5322
+
// t.Repo (string) (string)
5323
+
if len("repo") > 1000000 {
5324
+
return xerrors.Errorf("Value in field \"repo\" was too long")
5325
+
}
5326
+
5327
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil {
5328
+
return err
5329
+
}
5330
+
if _, err := cw.WriteString(string("repo")); err != nil {
5331
+
return err
5332
+
}
5333
+
5334
+
if len(t.Repo) > 1000000 {
5335
+
return xerrors.Errorf("Value in field t.Repo was too long")
5336
+
}
5337
+
5338
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil {
5339
+
return err
5340
+
}
5341
+
if _, err := cw.WriteString(string(t.Repo)); err != nil {
5342
+
return err
5343
+
}
5344
+
5345
+
// t.LexiconTypeID (string) (string)
5346
+
if len("$type") > 1000000 {
5347
+
return xerrors.Errorf("Value in field \"$type\" was too long")
5348
+
}
5349
+
5350
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
5351
+
return err
5352
+
}
5353
+
if _, err := cw.WriteString(string("$type")); err != nil {
5354
+
return err
5355
+
}
5356
+
5357
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.repo.collaborator"))); err != nil {
5358
+
return err
5359
+
}
5360
+
if _, err := cw.WriteString(string("sh.tangled.repo.collaborator")); err != nil {
5361
+
return err
5362
+
}
5363
+
5364
+
// t.Subject (string) (string)
5365
+
if len("subject") > 1000000 {
5366
+
return xerrors.Errorf("Value in field \"subject\" was too long")
5367
+
}
5368
+
5369
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil {
5370
+
return err
5371
+
}
5372
+
if _, err := cw.WriteString(string("subject")); err != nil {
5373
+
return err
5374
+
}
5375
+
5376
+
if len(t.Subject) > 1000000 {
5377
+
return xerrors.Errorf("Value in field t.Subject was too long")
5378
+
}
5379
+
5380
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil {
5381
+
return err
5382
+
}
5383
+
if _, err := cw.WriteString(string(t.Subject)); err != nil {
5384
+
return err
5385
+
}
5386
+
5387
+
// t.CreatedAt (string) (string)
5388
+
if len("createdAt") > 1000000 {
5389
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
5390
+
}
5391
+
5392
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
5393
+
return err
5394
+
}
5395
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
5396
+
return err
5397
+
}
5398
+
5399
+
if len(t.CreatedAt) > 1000000 {
5400
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
5401
+
}
5402
+
5403
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
5404
+
return err
5405
+
}
5406
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
5407
+
return err
5408
+
}
5409
+
return nil
5410
+
}
5411
+
5412
+
func (t *RepoCollaborator) UnmarshalCBOR(r io.Reader) (err error) {
5413
+
*t = RepoCollaborator{}
5414
+
5415
+
cr := cbg.NewCborReader(r)
5416
+
5417
+
maj, extra, err := cr.ReadHeader()
5418
+
if err != nil {
5419
+
return err
5420
+
}
5421
+
defer func() {
5422
+
if err == io.EOF {
5423
+
err = io.ErrUnexpectedEOF
5424
+
}
5425
+
}()
5426
+
5427
+
if maj != cbg.MajMap {
5428
+
return fmt.Errorf("cbor input should be of type map")
5429
+
}
5430
+
5431
+
if extra > cbg.MaxLength {
5432
+
return fmt.Errorf("RepoCollaborator: map struct too large (%d)", extra)
5433
+
}
5434
+
5435
+
n := extra
5436
+
5437
+
nameBuf := make([]byte, 9)
5438
+
for i := uint64(0); i < n; i++ {
5439
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
5440
+
if err != nil {
5441
+
return err
5442
+
}
5443
+
5444
+
if !ok {
5445
+
// Field doesn't exist on this type, so ignore it
5446
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
5447
+
return err
5448
+
}
5449
+
continue
5450
+
}
5451
+
5452
+
switch string(nameBuf[:nameLen]) {
5453
+
// t.Repo (string) (string)
5454
+
case "repo":
5455
+
5456
+
{
5457
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
5458
+
if err != nil {
5459
+
return err
5460
+
}
5461
+
5462
+
t.Repo = string(sval)
5463
+
}
5464
+
// t.LexiconTypeID (string) (string)
5465
+
case "$type":
5466
+
5467
+
{
5468
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
5469
+
if err != nil {
5470
+
return err
5471
+
}
5472
+
5473
+
t.LexiconTypeID = string(sval)
5474
+
}
5475
+
// t.Subject (string) (string)
5476
+
case "subject":
5477
+
5478
+
{
5479
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
5480
+
if err != nil {
5481
+
return err
5482
+
}
5483
+
5484
+
t.Subject = string(sval)
5485
+
}
5486
+
// t.CreatedAt (string) (string)
5487
+
case "createdAt":
5488
+
5489
+
{
5490
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
5491
+
if err != nil {
5492
+
return err
5493
+
}
5494
+
5495
+
t.CreatedAt = string(sval)
5496
+
}
5497
+
5498
+
default:
5499
+
// Field doesn't exist on this type, so ignore it
5500
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
5501
+
return err
5502
+
}
5503
+
}
5504
+
}
5505
+
5506
+
return nil
5507
+
}
5857
5508
func (t *RepoIssue) MarshalCBOR(w io.Writer) error {
5858
5509
if t == nil {
5859
5510
_, err := w.Write(cbg.CborNull)
···
5861
5512
}
5862
5513
5863
5514
cw := cbg.NewCborWriter(w)
5864
-
fieldCount := 7
5515
+
fieldCount := 6
5865
5516
5866
5517
if t.Body == nil {
5867
5518
fieldCount--
···
5989
5640
}
5990
5641
if _, err := cw.WriteString(string(t.Title)); err != nil {
5991
5642
return err
5992
-
}
5993
-
5994
-
// t.IssueId (int64) (int64)
5995
-
if len("issueId") > 1000000 {
5996
-
return xerrors.Errorf("Value in field \"issueId\" was too long")
5997
-
}
5998
-
5999
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("issueId"))); err != nil {
6000
-
return err
6001
-
}
6002
-
if _, err := cw.WriteString(string("issueId")); err != nil {
6003
-
return err
6004
-
}
6005
-
6006
-
if t.IssueId >= 0 {
6007
-
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.IssueId)); err != nil {
6008
-
return err
6009
-
}
6010
-
} else {
6011
-
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.IssueId-1)); err != nil {
6012
-
return err
6013
-
}
6014
5643
}
6015
5644
6016
5645
// t.CreatedAt (string) (string)
···
6144
5773
6145
5774
t.Title = string(sval)
6146
5775
}
6147
-
// t.IssueId (int64) (int64)
6148
-
case "issueId":
6149
-
{
6150
-
maj, extra, err := cr.ReadHeader()
6151
-
if err != nil {
6152
-
return err
6153
-
}
6154
-
var extraI int64
6155
-
switch maj {
6156
-
case cbg.MajUnsignedInt:
6157
-
extraI = int64(extra)
6158
-
if extraI < 0 {
6159
-
return fmt.Errorf("int64 positive overflow")
6160
-
}
6161
-
case cbg.MajNegativeInt:
6162
-
extraI = int64(extra)
6163
-
if extraI < 0 {
6164
-
return fmt.Errorf("int64 negative overflow")
6165
-
}
6166
-
extraI = -1 - extraI
6167
-
default:
6168
-
return fmt.Errorf("wrong type for int64 field: %d", maj)
6169
-
}
6170
-
6171
-
t.IssueId = int64(extraI)
6172
-
}
6173
5776
// t.CreatedAt (string) (string)
6174
5777
case "createdAt":
6175
5778
···
6199
5802
}
6200
5803
6201
5804
cw := cbg.NewCborWriter(w)
6202
-
fieldCount := 7
6203
-
6204
-
if t.CommentId == nil {
6205
-
fieldCount--
6206
-
}
5805
+
fieldCount := 6
6207
5806
6208
5807
if t.Owner == nil {
6209
5808
fieldCount--
···
6346
5945
}
6347
5946
}
6348
5947
6349
-
// t.CommentId (int64) (int64)
6350
-
if t.CommentId != nil {
6351
-
6352
-
if len("commentId") > 1000000 {
6353
-
return xerrors.Errorf("Value in field \"commentId\" was too long")
6354
-
}
6355
-
6356
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commentId"))); err != nil {
6357
-
return err
6358
-
}
6359
-
if _, err := cw.WriteString(string("commentId")); err != nil {
6360
-
return err
6361
-
}
6362
-
6363
-
if t.CommentId == nil {
6364
-
if _, err := cw.Write(cbg.CborNull); err != nil {
6365
-
return err
6366
-
}
6367
-
} else {
6368
-
if *t.CommentId >= 0 {
6369
-
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.CommentId)); err != nil {
6370
-
return err
6371
-
}
6372
-
} else {
6373
-
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.CommentId-1)); err != nil {
6374
-
return err
6375
-
}
6376
-
}
6377
-
}
6378
-
6379
-
}
6380
-
6381
5948
// t.CreatedAt (string) (string)
6382
5949
if len("createdAt") > 1000000 {
6383
5950
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
6517
6084
}
6518
6085
6519
6086
t.Owner = (*string)(&sval)
6520
-
}
6521
-
}
6522
-
// t.CommentId (int64) (int64)
6523
-
case "commentId":
6524
-
{
6525
-
6526
-
b, err := cr.ReadByte()
6527
-
if err != nil {
6528
-
return err
6529
-
}
6530
-
if b != cbg.CborNull[0] {
6531
-
if err := cr.UnreadByte(); err != nil {
6532
-
return err
6533
-
}
6534
-
maj, extra, err := cr.ReadHeader()
6535
-
if err != nil {
6536
-
return err
6537
-
}
6538
-
var extraI int64
6539
-
switch maj {
6540
-
case cbg.MajUnsignedInt:
6541
-
extraI = int64(extra)
6542
-
if extraI < 0 {
6543
-
return fmt.Errorf("int64 positive overflow")
6544
-
}
6545
-
case cbg.MajNegativeInt:
6546
-
extraI = int64(extra)
6547
-
if extraI < 0 {
6548
-
return fmt.Errorf("int64 negative overflow")
6549
-
}
6550
-
extraI = -1 - extraI
6551
-
default:
6552
-
return fmt.Errorf("wrong type for int64 field: %d", maj)
6553
-
}
6554
-
6555
-
t.CommentId = (*int64)(&extraI)
6556
6087
}
6557
6088
}
6558
6089
// t.CreatedAt (string) (string)
···
8225
7756
8226
7757
return nil
8227
7758
}
7759
+
func (t *String) MarshalCBOR(w io.Writer) error {
7760
+
if t == nil {
7761
+
_, err := w.Write(cbg.CborNull)
7762
+
return err
7763
+
}
7764
+
7765
+
cw := cbg.NewCborWriter(w)
7766
+
7767
+
if _, err := cw.Write([]byte{165}); err != nil {
7768
+
return err
7769
+
}
7770
+
7771
+
// t.LexiconTypeID (string) (string)
7772
+
if len("$type") > 1000000 {
7773
+
return xerrors.Errorf("Value in field \"$type\" was too long")
7774
+
}
7775
+
7776
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
7777
+
return err
7778
+
}
7779
+
if _, err := cw.WriteString(string("$type")); err != nil {
7780
+
return err
7781
+
}
7782
+
7783
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.string"))); err != nil {
7784
+
return err
7785
+
}
7786
+
if _, err := cw.WriteString(string("sh.tangled.string")); err != nil {
7787
+
return err
7788
+
}
7789
+
7790
+
// t.Contents (string) (string)
7791
+
if len("contents") > 1000000 {
7792
+
return xerrors.Errorf("Value in field \"contents\" was too long")
7793
+
}
7794
+
7795
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("contents"))); err != nil {
7796
+
return err
7797
+
}
7798
+
if _, err := cw.WriteString(string("contents")); err != nil {
7799
+
return err
7800
+
}
7801
+
7802
+
if len(t.Contents) > 1000000 {
7803
+
return xerrors.Errorf("Value in field t.Contents was too long")
7804
+
}
7805
+
7806
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Contents))); err != nil {
7807
+
return err
7808
+
}
7809
+
if _, err := cw.WriteString(string(t.Contents)); err != nil {
7810
+
return err
7811
+
}
7812
+
7813
+
// t.Filename (string) (string)
7814
+
if len("filename") > 1000000 {
7815
+
return xerrors.Errorf("Value in field \"filename\" was too long")
7816
+
}
7817
+
7818
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("filename"))); err != nil {
7819
+
return err
7820
+
}
7821
+
if _, err := cw.WriteString(string("filename")); err != nil {
7822
+
return err
7823
+
}
7824
+
7825
+
if len(t.Filename) > 1000000 {
7826
+
return xerrors.Errorf("Value in field t.Filename was too long")
7827
+
}
7828
+
7829
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Filename))); err != nil {
7830
+
return err
7831
+
}
7832
+
if _, err := cw.WriteString(string(t.Filename)); err != nil {
7833
+
return err
7834
+
}
7835
+
7836
+
// t.CreatedAt (string) (string)
7837
+
if len("createdAt") > 1000000 {
7838
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
7839
+
}
7840
+
7841
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
7842
+
return err
7843
+
}
7844
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
7845
+
return err
7846
+
}
7847
+
7848
+
if len(t.CreatedAt) > 1000000 {
7849
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
7850
+
}
7851
+
7852
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
7853
+
return err
7854
+
}
7855
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
7856
+
return err
7857
+
}
7858
+
7859
+
// t.Description (string) (string)
7860
+
if len("description") > 1000000 {
7861
+
return xerrors.Errorf("Value in field \"description\" was too long")
7862
+
}
7863
+
7864
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil {
7865
+
return err
7866
+
}
7867
+
if _, err := cw.WriteString(string("description")); err != nil {
7868
+
return err
7869
+
}
7870
+
7871
+
if len(t.Description) > 1000000 {
7872
+
return xerrors.Errorf("Value in field t.Description was too long")
7873
+
}
7874
+
7875
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Description))); err != nil {
7876
+
return err
7877
+
}
7878
+
if _, err := cw.WriteString(string(t.Description)); err != nil {
7879
+
return err
7880
+
}
7881
+
return nil
7882
+
}
7883
+
7884
+
func (t *String) UnmarshalCBOR(r io.Reader) (err error) {
7885
+
*t = String{}
7886
+
7887
+
cr := cbg.NewCborReader(r)
7888
+
7889
+
maj, extra, err := cr.ReadHeader()
7890
+
if err != nil {
7891
+
return err
7892
+
}
7893
+
defer func() {
7894
+
if err == io.EOF {
7895
+
err = io.ErrUnexpectedEOF
7896
+
}
7897
+
}()
7898
+
7899
+
if maj != cbg.MajMap {
7900
+
return fmt.Errorf("cbor input should be of type map")
7901
+
}
7902
+
7903
+
if extra > cbg.MaxLength {
7904
+
return fmt.Errorf("String: map struct too large (%d)", extra)
7905
+
}
7906
+
7907
+
n := extra
7908
+
7909
+
nameBuf := make([]byte, 11)
7910
+
for i := uint64(0); i < n; i++ {
7911
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
7912
+
if err != nil {
7913
+
return err
7914
+
}
7915
+
7916
+
if !ok {
7917
+
// Field doesn't exist on this type, so ignore it
7918
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
7919
+
return err
7920
+
}
7921
+
continue
7922
+
}
7923
+
7924
+
switch string(nameBuf[:nameLen]) {
7925
+
// t.LexiconTypeID (string) (string)
7926
+
case "$type":
7927
+
7928
+
{
7929
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7930
+
if err != nil {
7931
+
return err
7932
+
}
7933
+
7934
+
t.LexiconTypeID = string(sval)
7935
+
}
7936
+
// t.Contents (string) (string)
7937
+
case "contents":
7938
+
7939
+
{
7940
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7941
+
if err != nil {
7942
+
return err
7943
+
}
7944
+
7945
+
t.Contents = string(sval)
7946
+
}
7947
+
// t.Filename (string) (string)
7948
+
case "filename":
7949
+
7950
+
{
7951
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7952
+
if err != nil {
7953
+
return err
7954
+
}
7955
+
7956
+
t.Filename = string(sval)
7957
+
}
7958
+
// t.CreatedAt (string) (string)
7959
+
case "createdAt":
7960
+
7961
+
{
7962
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7963
+
if err != nil {
7964
+
return err
7965
+
}
7966
+
7967
+
t.CreatedAt = string(sval)
7968
+
}
7969
+
// t.Description (string) (string)
7970
+
case "description":
7971
+
7972
+
{
7973
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7974
+
if err != nil {
7975
+
return err
7976
+
}
7977
+
7978
+
t.Description = string(sval)
7979
+
}
7980
+
7981
+
default:
7982
+
// Field doesn't exist on this type, so ignore it
7983
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
7984
+
return err
7985
+
}
7986
+
}
7987
+
}
7988
+
7989
+
return nil
7990
+
}
-1
api/tangled/issuecomment.go
-1
api/tangled/issuecomment.go
···
19
19
type RepoIssueComment struct {
20
20
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"`
21
21
Body string `json:"body" cborgen:"body"`
22
-
CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"`
23
22
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
24
23
Issue string `json:"issue" cborgen:"issue"`
25
24
Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"`
+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
+
}
-1
api/tangled/repoissue.go
-1
api/tangled/repoissue.go
···
20
20
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"`
21
21
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
22
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
-
IssueId int64 `json:"issueId" cborgen:"issueId"`
24
23
Owner string `json:"owner" cborgen:"owner"`
25
24
Repo string `json:"repo" cborgen:"repo"`
26
25
Title string `json:"title" cborgen:"title"`
+4
-18
api/tangled/tangledpipeline.go
+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
+3
appview/config/config.go
+3
appview/config/config.go
···
16
16
AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"`
17
17
Dev bool `env:"DEV, default=false"`
18
18
DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"`
19
+
20
+
// temporarily, to add users to default spindle
21
+
AppPassword string `env:"APP_PASSWORD"`
19
22
}
20
23
21
24
type OAuthConfig struct {
+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
+
}
+117
-24
appview/db/db.go
+117
-24
appview/db/db.go
···
27
27
}
28
28
29
29
func Make(dbPath string) (*DB, error) {
30
-
db, err := sql.Open("sqlite3", dbPath)
30
+
// https://github.com/mattn/go-sqlite3#connection-string
31
+
opts := []string{
32
+
"_foreign_keys=1",
33
+
"_journal_mode=WAL",
34
+
"_synchronous=NORMAL",
35
+
"_auto_vacuum=incremental",
36
+
}
37
+
38
+
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
31
39
if err != nil {
32
40
return nil, err
33
41
}
34
-
_, err = db.Exec(`
35
-
pragma journal_mode = WAL;
36
-
pragma synchronous = normal;
37
-
pragma foreign_keys = on;
38
-
pragma temp_store = memory;
39
-
pragma mmap_size = 30000000000;
40
-
pragma page_size = 32768;
41
-
pragma auto_vacuum = incremental;
42
-
pragma busy_timeout = 5000;
42
+
43
+
ctx := context.Background()
43
44
45
+
conn, err := db.Conn(ctx)
46
+
if err != nil {
47
+
return nil, err
48
+
}
49
+
defer conn.Close()
50
+
51
+
_, err = conn.ExecContext(ctx, `
44
52
create table if not exists registrations (
45
53
id integer primary key autoincrement,
46
54
domain text not null unique,
···
443
451
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
444
452
);
445
453
454
+
create table if not exists strings (
455
+
-- identifiers
456
+
did text not null,
457
+
rkey text not null,
458
+
459
+
-- content
460
+
filename text not null,
461
+
description text,
462
+
content text not null,
463
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
464
+
edited text,
465
+
466
+
primary key (did, rkey)
467
+
);
468
+
446
469
create table if not exists migrations (
447
470
id integer primary key autoincrement,
448
471
name text unique
449
472
);
473
+
474
+
-- indexes for better star query performance
475
+
create index if not exists idx_stars_created on stars(created);
476
+
create index if not exists idx_stars_repo_at_created on stars(repo_at, created);
450
477
`)
451
478
if err != nil {
452
479
return nil, err
453
480
}
454
481
455
482
// run migrations
456
-
runMigration(db, "add-description-to-repos", func(tx *sql.Tx) error {
483
+
runMigration(conn, "add-description-to-repos", func(tx *sql.Tx) error {
457
484
tx.Exec(`
458
485
alter table repos add column description text check (length(description) <= 200);
459
486
`)
460
487
return nil
461
488
})
462
489
463
-
runMigration(db, "add-rkey-to-pubkeys", func(tx *sql.Tx) error {
490
+
runMigration(conn, "add-rkey-to-pubkeys", func(tx *sql.Tx) error {
464
491
// add unconstrained column
465
492
_, err := tx.Exec(`
466
493
alter table public_keys
···
483
510
return nil
484
511
})
485
512
486
-
runMigration(db, "add-rkey-to-comments", func(tx *sql.Tx) error {
513
+
runMigration(conn, "add-rkey-to-comments", func(tx *sql.Tx) error {
487
514
_, err := tx.Exec(`
488
515
alter table comments drop column comment_at;
489
516
alter table comments add column rkey text;
···
491
518
return err
492
519
})
493
520
494
-
runMigration(db, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
521
+
runMigration(conn, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
495
522
_, err := tx.Exec(`
496
523
alter table comments add column deleted text; -- timestamp
497
524
alter table comments add column edited text; -- timestamp
···
499
526
return err
500
527
})
501
528
502
-
runMigration(db, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
529
+
runMigration(conn, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
503
530
_, err := tx.Exec(`
504
531
alter table pulls add column source_branch text;
505
532
alter table pulls add column source_repo_at text;
···
508
535
return err
509
536
})
510
537
511
-
runMigration(db, "add-source-to-repos", func(tx *sql.Tx) error {
538
+
runMigration(conn, "add-source-to-repos", func(tx *sql.Tx) error {
512
539
_, err := tx.Exec(`
513
540
alter table repos add column source text;
514
541
`)
···
519
546
// NOTE: this cannot be done in a transaction, so it is run outside [0]
520
547
//
521
548
// [0]: https://sqlite.org/pragma.html#pragma_foreign_keys
522
-
db.Exec("pragma foreign_keys = off;")
523
-
runMigration(db, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error {
549
+
conn.ExecContext(ctx, "pragma foreign_keys = off;")
550
+
runMigration(conn, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error {
524
551
_, err := tx.Exec(`
525
552
create table pulls_new (
526
553
-- identifiers
···
575
602
`)
576
603
return err
577
604
})
578
-
db.Exec("pragma foreign_keys = on;")
605
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
579
606
580
607
// run migrations
581
-
runMigration(db, "add-spindle-to-repos", func(tx *sql.Tx) error {
608
+
runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error {
582
609
tx.Exec(`
583
610
alter table repos add column spindle text;
584
611
`)
585
612
return nil
586
613
})
587
614
615
+
// recreate and add rkey + created columns with default constraint
616
+
runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error {
617
+
// create new table
618
+
// - repo_at instead of repo integer
619
+
// - rkey field
620
+
// - created field
621
+
_, err := tx.Exec(`
622
+
create table collaborators_new (
623
+
-- identifiers for the record
624
+
id integer primary key autoincrement,
625
+
did text not null,
626
+
rkey text,
627
+
628
+
-- content
629
+
subject_did text not null,
630
+
repo_at text not null,
631
+
632
+
-- meta
633
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
634
+
635
+
-- constraints
636
+
foreign key (repo_at) references repos(at_uri) on delete cascade
637
+
)
638
+
`)
639
+
if err != nil {
640
+
return err
641
+
}
642
+
643
+
// copy data
644
+
_, err = tx.Exec(`
645
+
insert into collaborators_new (id, did, rkey, subject_did, repo_at)
646
+
select
647
+
c.id,
648
+
r.did,
649
+
'',
650
+
c.did,
651
+
r.at_uri
652
+
from collaborators c
653
+
join repos r on c.repo = r.id
654
+
`)
655
+
if err != nil {
656
+
return err
657
+
}
658
+
659
+
// drop old table
660
+
_, err = tx.Exec(`drop table collaborators`)
661
+
if err != nil {
662
+
return err
663
+
}
664
+
665
+
// rename new table
666
+
_, err = tx.Exec(`alter table collaborators_new rename to collaborators`)
667
+
return err
668
+
})
669
+
670
+
runMigration(conn, "add-rkey-to-issues", func(tx *sql.Tx) error {
671
+
_, err := tx.Exec(`
672
+
alter table issues add column rkey text not null default '';
673
+
674
+
-- get last url section from issue_at and save to rkey column
675
+
update issues
676
+
set rkey = replace(issue_at, rtrim(issue_at, replace(issue_at, '/', '')), '');
677
+
`)
678
+
return err
679
+
})
680
+
588
681
return &DB{db}, nil
589
682
}
590
683
591
684
type migrationFn = func(*sql.Tx) error
592
685
593
-
func runMigration(d *sql.DB, name string, migrationFn migrationFn) error {
594
-
tx, err := d.Begin()
686
+
func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error {
687
+
tx, err := c.BeginTx(context.Background(), nil)
595
688
if err != nil {
596
689
return err
597
690
}
···
658
751
kind := rv.Kind()
659
752
660
753
// if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)`
661
-
if kind == reflect.Slice || kind == reflect.Array {
754
+
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
662
755
if rv.Len() == 0 {
663
756
// always false
664
757
return "1 = 0"
···
678
771
func (f filter) Arg() []any {
679
772
rv := reflect.ValueOf(f.arg)
680
773
kind := rv.Kind()
681
-
if kind == reflect.Slice || kind == reflect.Array {
774
+
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
682
775
if rv.Len() == 0 {
683
776
return nil
684
777
}
+1
-1
appview/db/follow.go
+1
-1
appview/db/follow.go
+208
-17
appview/db/issues.go
+208
-17
appview/db/issues.go
···
2
2
3
3
import (
4
4
"database/sql"
5
+
"fmt"
6
+
mathrand "math/rand/v2"
7
+
"strings"
5
8
"time"
6
9
7
10
"github.com/bluesky-social/indigo/atproto/syntax"
11
+
"tangled.sh/tangled.sh/core/api/tangled"
8
12
"tangled.sh/tangled.sh/core/appview/pagination"
9
13
)
10
14
···
13
17
RepoAt syntax.ATURI
14
18
OwnerDid string
15
19
IssueId int
16
-
IssueAt string
20
+
Rkey string
17
21
Created time.Time
18
22
Title string
19
23
Body string
···
42
46
Edited *time.Time
43
47
}
44
48
49
+
func (i *Issue) AtUri() syntax.ATURI {
50
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.OwnerDid, tangled.RepoIssueNSID, i.Rkey))
51
+
}
52
+
53
+
func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue {
54
+
created, err := time.Parse(time.RFC3339, record.CreatedAt)
55
+
if err != nil {
56
+
created = time.Now()
57
+
}
58
+
59
+
body := ""
60
+
if record.Body != nil {
61
+
body = *record.Body
62
+
}
63
+
64
+
return Issue{
65
+
RepoAt: syntax.ATURI(record.Repo),
66
+
OwnerDid: record.Owner,
67
+
Rkey: rkey,
68
+
Created: created,
69
+
Title: record.Title,
70
+
Body: body,
71
+
Open: true, // new issues are open by default
72
+
}
73
+
}
74
+
75
+
func ResolveIssueFromAtUri(e Execer, issueUri syntax.ATURI) (syntax.ATURI, int, error) {
76
+
ownerDid := issueUri.Authority().String()
77
+
issueRkey := issueUri.RecordKey().String()
78
+
79
+
var repoAt string
80
+
var issueId int
81
+
82
+
query := `select repo_at, issue_id from issues where owner_did = ? and rkey = ?`
83
+
err := e.QueryRow(query, ownerDid, issueRkey).Scan(&repoAt, &issueId)
84
+
if err != nil {
85
+
return "", 0, err
86
+
}
87
+
88
+
return syntax.ATURI(repoAt), issueId, nil
89
+
}
90
+
91
+
func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (Comment, error) {
92
+
created, err := time.Parse(time.RFC3339, record.CreatedAt)
93
+
if err != nil {
94
+
created = time.Now()
95
+
}
96
+
97
+
ownerDid := did
98
+
if record.Owner != nil {
99
+
ownerDid = *record.Owner
100
+
}
101
+
102
+
issueUri, err := syntax.ParseATURI(record.Issue)
103
+
if err != nil {
104
+
return Comment{}, err
105
+
}
106
+
107
+
repoAt, issueId, err := ResolveIssueFromAtUri(e, issueUri)
108
+
if err != nil {
109
+
return Comment{}, err
110
+
}
111
+
112
+
comment := Comment{
113
+
OwnerDid: ownerDid,
114
+
RepoAt: repoAt,
115
+
Rkey: rkey,
116
+
Body: record.Body,
117
+
Issue: issueId,
118
+
CommentId: mathrand.IntN(1000000),
119
+
Created: &created,
120
+
}
121
+
122
+
return comment, nil
123
+
}
124
+
45
125
func NewIssue(tx *sql.Tx, issue *Issue) error {
46
126
defer tx.Rollback()
47
127
···
67
147
issue.IssueId = nextId
68
148
69
149
res, err := tx.Exec(`
70
-
insert into issues (repo_at, owner_did, issue_id, title, body)
71
-
values (?, ?, ?, ?, ?)
72
-
`, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body)
150
+
insert into issues (repo_at, owner_did, rkey, issue_at, issue_id, title, body)
151
+
values (?, ?, ?, ?, ?, ?, ?)
152
+
`, issue.RepoAt, issue.OwnerDid, issue.Rkey, issue.AtUri(), issue.IssueId, issue.Title, issue.Body)
73
153
if err != nil {
74
154
return err
75
155
}
···
87
167
return nil
88
168
}
89
169
90
-
func SetIssueAt(e Execer, repoAt syntax.ATURI, issueId int, issueAt string) error {
91
-
_, err := e.Exec(`update issues set issue_at = ? where repo_at = ? and issue_id = ?`, issueAt, repoAt, issueId)
92
-
return err
93
-
}
94
-
95
170
func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
96
171
var issueAt string
97
172
err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt)
···
104
179
return ownerDid, err
105
180
}
106
181
107
-
func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) {
182
+
func GetIssuesPaginated(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) {
108
183
var issues []Issue
109
184
openValue := 0
110
185
if isOpen {
···
117
192
select
118
193
i.id,
119
194
i.owner_did,
195
+
i.rkey,
120
196
i.issue_id,
121
197
i.created,
122
198
i.title,
···
136
212
select
137
213
id,
138
214
owner_did,
215
+
rkey,
139
216
issue_id,
140
217
created,
141
218
title,
142
219
body,
143
220
open,
144
221
comment_count
145
-
from
222
+
from
146
223
numbered_issue
147
-
where
224
+
where
148
225
row_num between ? and ?`,
149
226
repoAt, openValue, page.Offset+1, page.Offset+page.Limit)
150
227
if err != nil {
···
156
233
var issue Issue
157
234
var createdAt string
158
235
var metadata IssueMetadata
159
-
err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount)
236
+
err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount)
160
237
if err != nil {
161
238
return nil, err
162
239
}
···
178
255
return issues, nil
179
256
}
180
257
258
+
func GetIssuesWithLimit(e Execer, limit int, filters ...filter) ([]Issue, error) {
259
+
issues := make([]Issue, 0, limit)
260
+
261
+
var conditions []string
262
+
var args []any
263
+
for _, filter := range filters {
264
+
conditions = append(conditions, filter.Condition())
265
+
args = append(args, filter.Arg()...)
266
+
}
267
+
268
+
whereClause := ""
269
+
if conditions != nil {
270
+
whereClause = " where " + strings.Join(conditions, " and ")
271
+
}
272
+
limitClause := ""
273
+
if limit != 0 {
274
+
limitClause = fmt.Sprintf(" limit %d ", limit)
275
+
}
276
+
277
+
query := fmt.Sprintf(
278
+
`select
279
+
i.id,
280
+
i.owner_did,
281
+
i.repo_at,
282
+
i.issue_id,
283
+
i.created,
284
+
i.title,
285
+
i.body,
286
+
i.open
287
+
from
288
+
issues i
289
+
%s
290
+
order by
291
+
i.created desc
292
+
%s`,
293
+
whereClause, limitClause)
294
+
295
+
rows, err := e.Query(query, args...)
296
+
if err != nil {
297
+
return nil, err
298
+
}
299
+
defer rows.Close()
300
+
301
+
for rows.Next() {
302
+
var issue Issue
303
+
var issueCreatedAt string
304
+
err := rows.Scan(
305
+
&issue.ID,
306
+
&issue.OwnerDid,
307
+
&issue.RepoAt,
308
+
&issue.IssueId,
309
+
&issueCreatedAt,
310
+
&issue.Title,
311
+
&issue.Body,
312
+
&issue.Open,
313
+
)
314
+
if err != nil {
315
+
return nil, err
316
+
}
317
+
318
+
issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt)
319
+
if err != nil {
320
+
return nil, err
321
+
}
322
+
issue.Created = issueCreatedTime
323
+
324
+
issues = append(issues, issue)
325
+
}
326
+
327
+
if err := rows.Err(); err != nil {
328
+
return nil, err
329
+
}
330
+
331
+
return issues, nil
332
+
}
333
+
334
+
func GetIssues(e Execer, filters ...filter) ([]Issue, error) {
335
+
return GetIssuesWithLimit(e, 0, filters...)
336
+
}
337
+
181
338
// timeframe here is directly passed into the sql query filter, and any
182
339
// timeframe in the past should be negative; e.g.: "-3 months"
183
340
func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) {
···
187
344
`select
188
345
i.id,
189
346
i.owner_did,
347
+
i.rkey,
190
348
i.repo_at,
191
349
i.issue_id,
192
350
i.created,
···
219
377
err := rows.Scan(
220
378
&issue.ID,
221
379
&issue.OwnerDid,
380
+
&issue.Rkey,
222
381
&issue.RepoAt,
223
382
&issue.IssueId,
224
383
&issueCreatedAt,
···
262
421
}
263
422
264
423
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) {
265
-
query := `select id, owner_did, created, title, body, open from issues where repo_at = ? and issue_id = ?`
424
+
query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?`
266
425
row := e.QueryRow(query, repoAt, issueId)
267
426
268
427
var issue Issue
269
428
var createdAt string
270
-
err := row.Scan(&issue.ID, &issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open)
429
+
err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
271
430
if err != nil {
272
431
return nil, err
273
432
}
···
282
441
}
283
442
284
443
func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) {
285
-
query := `select id, owner_did, issue_id, created, title, body, open, issue_at from issues where repo_at = ? and issue_id = ?`
444
+
query := `select id, owner_did, rkey, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?`
286
445
row := e.QueryRow(query, repoAt, issueId)
287
446
288
447
var issue Issue
289
448
var createdAt string
290
-
err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &issue.IssueAt)
449
+
err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open)
291
450
if err != nil {
292
451
return nil, nil, err
293
452
}
···
464
623
deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
465
624
where repo_at = ? and issue_id = ? and comment_id = ?
466
625
`, repoAt, issueId, commentId)
626
+
return err
627
+
}
628
+
629
+
func UpdateCommentByRkey(e Execer, ownerDid, rkey, newBody string) error {
630
+
_, err := e.Exec(
631
+
`
632
+
update comments
633
+
set body = ?,
634
+
edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
635
+
where owner_did = ? and rkey = ?
636
+
`, newBody, ownerDid, rkey)
637
+
return err
638
+
}
639
+
640
+
func DeleteCommentByRkey(e Execer, ownerDid, rkey string) error {
641
+
_, err := e.Exec(
642
+
`
643
+
update comments
644
+
set body = "",
645
+
deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
646
+
where owner_did = ? and rkey = ?
647
+
`, ownerDid, rkey)
648
+
return err
649
+
}
650
+
651
+
func UpdateIssueByRkey(e Execer, ownerDid, rkey, title, body string) error {
652
+
_, err := e.Exec(`update issues set title = ?, body = ? where owner_did = ? and rkey = ?`, title, body, ownerDid, rkey)
653
+
return err
654
+
}
655
+
656
+
func DeleteIssueByRkey(e Execer, ownerDid, rkey string) error {
657
+
_, err := e.Exec(`delete from issues where owner_did = ? and rkey = ?`, ownerDid, rkey)
467
658
return err
468
659
}
469
660
-62
appview/db/migrations/20250305_113405.sql
-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;
+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 {
+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 {
+73
-6
appview/db/star.go
+73
-6
appview/db/star.go
···
47
47
// Get a star record
48
48
func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) {
49
49
query := `
50
-
select starred_by_did, repo_at, created, rkey
50
+
select starred_by_did, repo_at, created, rkey
51
51
from stars
52
52
where starred_by_did = ? and repo_at = ?`
53
53
row := e.QueryRow(query, starredByDid, repoAt)
···
119
119
}
120
120
121
121
repoQuery := fmt.Sprintf(
122
-
`select starred_by_did, repo_at, created, rkey
122
+
`select starred_by_did, repo_at, created, rkey
123
123
from stars
124
124
%s
125
125
order by created desc
···
187
187
var stars []Star
188
188
189
189
rows, err := e.Query(`
190
-
select
190
+
select
191
191
s.starred_by_did,
192
192
s.repo_at,
193
193
s.rkey,
···
196
196
r.name,
197
197
r.knot,
198
198
r.rkey,
199
-
r.created,
200
-
r.at_uri
199
+
r.created
201
200
from stars s
202
201
join repos r on s.repo_at = r.at_uri
203
202
`)
···
222
221
&repo.Knot,
223
222
&repo.Rkey,
224
223
&repoCreatedAt,
225
-
&repo.AtUri,
226
224
); err != nil {
227
225
return nil, err
228
226
}
···
246
244
247
245
return stars, nil
248
246
}
247
+
248
+
// GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week
249
+
func GetTopStarredReposLastWeek(e Execer) ([]Repo, error) {
250
+
// first, get the top repo URIs by star count from the last week
251
+
query := `
252
+
with recent_starred_repos as (
253
+
select distinct repo_at
254
+
from stars
255
+
where created >= datetime('now', '-7 days')
256
+
),
257
+
repo_star_counts as (
258
+
select
259
+
s.repo_at,
260
+
count(*) as star_count
261
+
from stars s
262
+
join recent_starred_repos rsr on s.repo_at = rsr.repo_at
263
+
group by s.repo_at
264
+
)
265
+
select rsc.repo_at
266
+
from repo_star_counts rsc
267
+
order by rsc.star_count desc
268
+
limit 8
269
+
`
270
+
271
+
rows, err := e.Query(query)
272
+
if err != nil {
273
+
return nil, err
274
+
}
275
+
defer rows.Close()
276
+
277
+
var repoUris []string
278
+
for rows.Next() {
279
+
var repoUri string
280
+
err := rows.Scan(&repoUri)
281
+
if err != nil {
282
+
return nil, err
283
+
}
284
+
repoUris = append(repoUris, repoUri)
285
+
}
286
+
287
+
if err := rows.Err(); err != nil {
288
+
return nil, err
289
+
}
290
+
291
+
if len(repoUris) == 0 {
292
+
return []Repo{}, nil
293
+
}
294
+
295
+
// get full repo data
296
+
repos, err := GetRepos(e, 0, FilterIn("at_uri", repoUris))
297
+
if err != nil {
298
+
return nil, err
299
+
}
300
+
301
+
// sort repos by the original trending order
302
+
repoMap := make(map[string]Repo)
303
+
for _, repo := range repos {
304
+
repoMap[repo.RepoAt().String()] = repo
305
+
}
306
+
307
+
orderedRepos := make([]Repo, 0, len(repoUris))
308
+
for _, uri := range repoUris {
309
+
if repo, exists := repoMap[uri]; exists {
310
+
orderedRepos = append(orderedRepos, repo)
311
+
}
312
+
}
313
+
314
+
return orderedRepos, nil
315
+
}
+252
appview/db/strings.go
+252
appview/db/strings.go
···
1
+
package db
2
+
3
+
import (
4
+
"bytes"
5
+
"database/sql"
6
+
"errors"
7
+
"fmt"
8
+
"io"
9
+
"strings"
10
+
"time"
11
+
"unicode/utf8"
12
+
13
+
"github.com/bluesky-social/indigo/atproto/syntax"
14
+
"tangled.sh/tangled.sh/core/api/tangled"
15
+
)
16
+
17
+
type String struct {
18
+
Did syntax.DID
19
+
Rkey string
20
+
21
+
Filename string
22
+
Description string
23
+
Contents string
24
+
Created time.Time
25
+
Edited *time.Time
26
+
}
27
+
28
+
func (s *String) StringAt() syntax.ATURI {
29
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey))
30
+
}
31
+
32
+
type StringStats struct {
33
+
LineCount uint64
34
+
ByteCount uint64
35
+
}
36
+
37
+
func (s String) Stats() StringStats {
38
+
lineCount, err := countLines(strings.NewReader(s.Contents))
39
+
if err != nil {
40
+
// non-fatal
41
+
// TODO: log this?
42
+
}
43
+
44
+
return StringStats{
45
+
LineCount: uint64(lineCount),
46
+
ByteCount: uint64(len(s.Contents)),
47
+
}
48
+
}
49
+
50
+
func (s String) Validate() error {
51
+
var err error
52
+
53
+
if utf8.RuneCountInString(s.Filename) > 140 {
54
+
err = errors.Join(err, fmt.Errorf("filename too long"))
55
+
}
56
+
57
+
if utf8.RuneCountInString(s.Description) > 280 {
58
+
err = errors.Join(err, fmt.Errorf("description too long"))
59
+
}
60
+
61
+
if len(s.Contents) == 0 {
62
+
err = errors.Join(err, fmt.Errorf("contents is empty"))
63
+
}
64
+
65
+
return err
66
+
}
67
+
68
+
func (s *String) AsRecord() tangled.String {
69
+
return tangled.String{
70
+
Filename: s.Filename,
71
+
Description: s.Description,
72
+
Contents: s.Contents,
73
+
CreatedAt: s.Created.Format(time.RFC3339),
74
+
}
75
+
}
76
+
77
+
func StringFromRecord(did, rkey string, record tangled.String) String {
78
+
created, err := time.Parse(record.CreatedAt, time.RFC3339)
79
+
if err != nil {
80
+
created = time.Now()
81
+
}
82
+
return String{
83
+
Did: syntax.DID(did),
84
+
Rkey: rkey,
85
+
Filename: record.Filename,
86
+
Description: record.Description,
87
+
Contents: record.Contents,
88
+
Created: created,
89
+
}
90
+
}
91
+
92
+
func AddString(e Execer, s String) error {
93
+
_, err := e.Exec(
94
+
`insert into strings (
95
+
did,
96
+
rkey,
97
+
filename,
98
+
description,
99
+
content,
100
+
created,
101
+
edited
102
+
)
103
+
values (?, ?, ?, ?, ?, ?, null)
104
+
on conflict(did, rkey) do update set
105
+
filename = excluded.filename,
106
+
description = excluded.description,
107
+
content = excluded.content,
108
+
edited = case
109
+
when
110
+
strings.content != excluded.content
111
+
or strings.filename != excluded.filename
112
+
or strings.description != excluded.description then ?
113
+
else strings.edited
114
+
end`,
115
+
s.Did,
116
+
s.Rkey,
117
+
s.Filename,
118
+
s.Description,
119
+
s.Contents,
120
+
s.Created.Format(time.RFC3339),
121
+
time.Now().Format(time.RFC3339),
122
+
)
123
+
return err
124
+
}
125
+
126
+
func GetStrings(e Execer, limit int, filters ...filter) ([]String, error) {
127
+
var all []String
128
+
129
+
var conditions []string
130
+
var args []any
131
+
for _, filter := range filters {
132
+
conditions = append(conditions, filter.Condition())
133
+
args = append(args, filter.Arg()...)
134
+
}
135
+
136
+
whereClause := ""
137
+
if conditions != nil {
138
+
whereClause = " where " + strings.Join(conditions, " and ")
139
+
}
140
+
141
+
limitClause := ""
142
+
if limit != 0 {
143
+
limitClause = fmt.Sprintf(" limit %d ", limit)
144
+
}
145
+
146
+
query := fmt.Sprintf(`select
147
+
did,
148
+
rkey,
149
+
filename,
150
+
description,
151
+
content,
152
+
created,
153
+
edited
154
+
from strings
155
+
%s
156
+
order by created desc
157
+
%s`,
158
+
whereClause,
159
+
limitClause,
160
+
)
161
+
162
+
rows, err := e.Query(query, args...)
163
+
164
+
if err != nil {
165
+
return nil, err
166
+
}
167
+
defer rows.Close()
168
+
169
+
for rows.Next() {
170
+
var s String
171
+
var createdAt string
172
+
var editedAt sql.NullString
173
+
174
+
if err := rows.Scan(
175
+
&s.Did,
176
+
&s.Rkey,
177
+
&s.Filename,
178
+
&s.Description,
179
+
&s.Contents,
180
+
&createdAt,
181
+
&editedAt,
182
+
); err != nil {
183
+
return nil, err
184
+
}
185
+
186
+
s.Created, err = time.Parse(time.RFC3339, createdAt)
187
+
if err != nil {
188
+
s.Created = time.Now()
189
+
}
190
+
191
+
if editedAt.Valid {
192
+
e, err := time.Parse(time.RFC3339, editedAt.String)
193
+
if err != nil {
194
+
e = time.Now()
195
+
}
196
+
s.Edited = &e
197
+
}
198
+
199
+
all = append(all, s)
200
+
}
201
+
202
+
if err := rows.Err(); err != nil {
203
+
return nil, err
204
+
}
205
+
206
+
return all, nil
207
+
}
208
+
209
+
func DeleteString(e Execer, filters ...filter) error {
210
+
var conditions []string
211
+
var args []any
212
+
for _, filter := range filters {
213
+
conditions = append(conditions, filter.Condition())
214
+
args = append(args, filter.Arg()...)
215
+
}
216
+
217
+
whereClause := ""
218
+
if conditions != nil {
219
+
whereClause = " where " + strings.Join(conditions, " and ")
220
+
}
221
+
222
+
query := fmt.Sprintf(`delete from strings %s`, whereClause)
223
+
224
+
_, err := e.Exec(query, args...)
225
+
return err
226
+
}
227
+
228
+
func countLines(r io.Reader) (int, error) {
229
+
buf := make([]byte, 32*1024)
230
+
bufLen := 0
231
+
count := 0
232
+
nl := []byte{'\n'}
233
+
234
+
for {
235
+
c, err := r.Read(buf)
236
+
if c > 0 {
237
+
bufLen += c
238
+
}
239
+
count += bytes.Count(buf[:c], nl)
240
+
241
+
switch {
242
+
case err == io.EOF:
243
+
/* handle last line not having a newline at the end */
244
+
if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' {
245
+
count++
246
+
}
247
+
return count, nil
248
+
case err != nil:
249
+
return 0, err
250
+
}
251
+
}
252
+
}
+1
-1
appview/db/timeline.go
+1
-1
appview/db/timeline.go
+241
-8
appview/ingester.go
+241
-8
appview/ingester.go
···
5
5
"encoding/json"
6
6
"fmt"
7
7
"log/slog"
8
+
"strings"
8
9
"time"
9
10
10
11
"github.com/bluesky-social/indigo/atproto/syntax"
···
14
15
"tangled.sh/tangled.sh/core/api/tangled"
15
16
"tangled.sh/tangled.sh/core/appview/config"
16
17
"tangled.sh/tangled.sh/core/appview/db"
18
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
17
19
"tangled.sh/tangled.sh/core/appview/spindleverify"
18
20
"tangled.sh/tangled.sh/core/idresolver"
19
21
"tangled.sh/tangled.sh/core/rbac"
···
61
63
case tangled.ActorProfileNSID:
62
64
err = i.ingestProfile(e)
63
65
case tangled.SpindleMemberNSID:
64
-
err = i.ingestSpindleMember(e)
66
+
err = i.ingestSpindleMember(ctx, e)
65
67
case tangled.SpindleNSID:
66
-
err = i.ingestSpindle(e)
68
+
err = i.ingestSpindle(ctx, e)
69
+
case tangled.StringNSID:
70
+
err = i.ingestString(e)
71
+
case tangled.RepoIssueNSID:
72
+
err = i.ingestIssue(ctx, e)
73
+
case tangled.RepoIssueCommentNSID:
74
+
err = i.ingestIssueComment(e)
67
75
}
68
76
l = i.Logger.With("nsid", e.Commit.Collection)
69
77
}
70
78
71
79
if err != nil {
72
-
l.Error("error ingesting record", "err", err)
80
+
l.Debug("error ingesting record", "err", err)
73
81
}
74
82
75
-
return err
83
+
return nil
76
84
}
77
85
}
78
86
···
334
342
return nil
335
343
}
336
344
337
-
func (i *Ingester) ingestSpindleMember(e *models.Event) error {
345
+
func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error {
338
346
did := e.Did
339
347
var err error
340
348
···
357
365
return fmt.Errorf("failed to enforce permissions: %w", err)
358
366
}
359
367
360
-
memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject)
368
+
memberId, err := i.IdResolver.ResolveIdent(ctx, record.Subject)
361
369
if err != nil {
362
370
return err
363
371
}
···
385
393
if err != nil {
386
394
return fmt.Errorf("failed to update ACLs: %w", err)
387
395
}
396
+
397
+
l.Info("added spindle member")
388
398
case models.CommitOperationDelete:
389
399
rkey := e.Commit.RKey
390
400
···
431
441
if err = i.Enforcer.E.SavePolicy(); err != nil {
432
442
return fmt.Errorf("failed to save ACLs: %w", err)
433
443
}
444
+
445
+
l.Info("removed spindle member")
434
446
}
435
447
436
448
return nil
437
449
}
438
450
439
-
func (i *Ingester) ingestSpindle(e *models.Event) error {
451
+
func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error {
440
452
did := e.Did
441
453
var err error
442
454
···
469
481
return err
470
482
}
471
483
472
-
err = spindleverify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev)
484
+
err = spindleverify.RunVerification(ctx, instance, did, i.Config.Core.Dev)
473
485
if err != nil {
474
486
l.Error("failed to add spindle to db", "err", err, "instance", instance)
475
487
return err
···
549
561
550
562
return nil
551
563
}
564
+
565
+
func (i *Ingester) ingestString(e *models.Event) error {
566
+
did := e.Did
567
+
rkey := e.Commit.RKey
568
+
569
+
var err error
570
+
571
+
l := i.Logger.With("handler", "ingestString", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
572
+
l.Info("ingesting record")
573
+
574
+
ddb, ok := i.Db.Execer.(*db.DB)
575
+
if !ok {
576
+
return fmt.Errorf("failed to index string record, invalid db cast")
577
+
}
578
+
579
+
switch e.Commit.Operation {
580
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
581
+
raw := json.RawMessage(e.Commit.Record)
582
+
record := tangled.String{}
583
+
err = json.Unmarshal(raw, &record)
584
+
if err != nil {
585
+
l.Error("invalid record", "err", err)
586
+
return err
587
+
}
588
+
589
+
string := db.StringFromRecord(did, rkey, record)
590
+
591
+
if err = string.Validate(); err != nil {
592
+
l.Error("invalid record", "err", err)
593
+
return err
594
+
}
595
+
596
+
if err = db.AddString(ddb, string); err != nil {
597
+
l.Error("failed to add string", "err", err)
598
+
return err
599
+
}
600
+
601
+
return nil
602
+
603
+
case models.CommitOperationDelete:
604
+
if err := db.DeleteString(
605
+
ddb,
606
+
db.FilterEq("did", did),
607
+
db.FilterEq("rkey", rkey),
608
+
); err != nil {
609
+
l.Error("failed to delete", "err", err)
610
+
return fmt.Errorf("failed to delete string record: %w", err)
611
+
}
612
+
613
+
return nil
614
+
}
615
+
616
+
return nil
617
+
}
618
+
619
+
func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error {
620
+
did := e.Did
621
+
rkey := e.Commit.RKey
622
+
623
+
var err error
624
+
625
+
l := i.Logger.With("handler", "ingestIssue", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
626
+
l.Info("ingesting record")
627
+
628
+
ddb, ok := i.Db.Execer.(*db.DB)
629
+
if !ok {
630
+
return fmt.Errorf("failed to index issue record, invalid db cast")
631
+
}
632
+
633
+
switch e.Commit.Operation {
634
+
case models.CommitOperationCreate:
635
+
raw := json.RawMessage(e.Commit.Record)
636
+
record := tangled.RepoIssue{}
637
+
err = json.Unmarshal(raw, &record)
638
+
if err != nil {
639
+
l.Error("invalid record", "err", err)
640
+
return err
641
+
}
642
+
643
+
issue := db.IssueFromRecord(did, rkey, record)
644
+
645
+
sanitizer := markup.NewSanitizer()
646
+
if st := strings.TrimSpace(sanitizer.SanitizeDescription(issue.Title)); st == "" {
647
+
return fmt.Errorf("title is empty after HTML sanitization")
648
+
}
649
+
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(issue.Body)); sb == "" {
650
+
return fmt.Errorf("body is empty after HTML sanitization")
651
+
}
652
+
653
+
tx, err := ddb.BeginTx(ctx, nil)
654
+
if err != nil {
655
+
l.Error("failed to begin transaction", "err", err)
656
+
return err
657
+
}
658
+
659
+
err = db.NewIssue(tx, &issue)
660
+
if err != nil {
661
+
l.Error("failed to create issue", "err", err)
662
+
return err
663
+
}
664
+
665
+
return nil
666
+
667
+
case models.CommitOperationUpdate:
668
+
raw := json.RawMessage(e.Commit.Record)
669
+
record := tangled.RepoIssue{}
670
+
err = json.Unmarshal(raw, &record)
671
+
if err != nil {
672
+
l.Error("invalid record", "err", err)
673
+
return err
674
+
}
675
+
676
+
body := ""
677
+
if record.Body != nil {
678
+
body = *record.Body
679
+
}
680
+
681
+
sanitizer := markup.NewSanitizer()
682
+
if st := strings.TrimSpace(sanitizer.SanitizeDescription(record.Title)); st == "" {
683
+
return fmt.Errorf("title is empty after HTML sanitization")
684
+
}
685
+
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" {
686
+
return fmt.Errorf("body is empty after HTML sanitization")
687
+
}
688
+
689
+
err = db.UpdateIssueByRkey(ddb, did, rkey, record.Title, body)
690
+
if err != nil {
691
+
l.Error("failed to update issue", "err", err)
692
+
return err
693
+
}
694
+
695
+
return nil
696
+
697
+
case models.CommitOperationDelete:
698
+
if err := db.DeleteIssueByRkey(ddb, did, rkey); err != nil {
699
+
l.Error("failed to delete", "err", err)
700
+
return fmt.Errorf("failed to delete issue record: %w", err)
701
+
}
702
+
703
+
return nil
704
+
}
705
+
706
+
return fmt.Errorf("unknown operation: %s", e.Commit.Operation)
707
+
}
708
+
709
+
func (i *Ingester) ingestIssueComment(e *models.Event) error {
710
+
did := e.Did
711
+
rkey := e.Commit.RKey
712
+
713
+
var err error
714
+
715
+
l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
716
+
l.Info("ingesting record")
717
+
718
+
ddb, ok := i.Db.Execer.(*db.DB)
719
+
if !ok {
720
+
return fmt.Errorf("failed to index issue comment record, invalid db cast")
721
+
}
722
+
723
+
switch e.Commit.Operation {
724
+
case models.CommitOperationCreate:
725
+
raw := json.RawMessage(e.Commit.Record)
726
+
record := tangled.RepoIssueComment{}
727
+
err = json.Unmarshal(raw, &record)
728
+
if err != nil {
729
+
l.Error("invalid record", "err", err)
730
+
return err
731
+
}
732
+
733
+
comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record)
734
+
if err != nil {
735
+
l.Error("failed to parse comment from record", "err", err)
736
+
return err
737
+
}
738
+
739
+
sanitizer := markup.NewSanitizer()
740
+
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(comment.Body)); sb == "" {
741
+
return fmt.Errorf("body is empty after HTML sanitization")
742
+
}
743
+
744
+
err = db.NewIssueComment(ddb, &comment)
745
+
if err != nil {
746
+
l.Error("failed to create issue comment", "err", err)
747
+
return err
748
+
}
749
+
750
+
return nil
751
+
752
+
case models.CommitOperationUpdate:
753
+
raw := json.RawMessage(e.Commit.Record)
754
+
record := tangled.RepoIssueComment{}
755
+
err = json.Unmarshal(raw, &record)
756
+
if err != nil {
757
+
l.Error("invalid record", "err", err)
758
+
return err
759
+
}
760
+
761
+
sanitizer := markup.NewSanitizer()
762
+
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(record.Body)); sb == "" {
763
+
return fmt.Errorf("body is empty after HTML sanitization")
764
+
}
765
+
766
+
err = db.UpdateCommentByRkey(ddb, did, rkey, record.Body)
767
+
if err != nil {
768
+
l.Error("failed to update issue comment", "err", err)
769
+
return err
770
+
}
771
+
772
+
return nil
773
+
774
+
case models.CommitOperationDelete:
775
+
if err := db.DeleteCommentByRkey(ddb, did, rkey); err != nil {
776
+
l.Error("failed to delete", "err", err)
777
+
return fmt.Errorf("failed to delete issue comment record: %w", err)
778
+
}
779
+
780
+
return nil
781
+
}
782
+
783
+
return fmt.Errorf("unknown operation: %s", e.Commit.Operation)
784
+
}
+41
-95
appview/issues/issues.go
+41
-95
appview/issues/issues.go
···
7
7
"net/http"
8
8
"slices"
9
9
"strconv"
10
+
"strings"
10
11
"time"
11
12
12
13
comatproto "github.com/bluesky-social/indigo/api/atproto"
13
14
"github.com/bluesky-social/indigo/atproto/data"
14
-
"github.com/bluesky-social/indigo/atproto/syntax"
15
15
lexutil "github.com/bluesky-social/indigo/lex/util"
16
16
"github.com/go-chi/chi/v5"
17
17
···
21
21
"tangled.sh/tangled.sh/core/appview/notify"
22
22
"tangled.sh/tangled.sh/core/appview/oauth"
23
23
"tangled.sh/tangled.sh/core/appview/pages"
24
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
24
25
"tangled.sh/tangled.sh/core/appview/pagination"
25
26
"tangled.sh/tangled.sh/core/appview/reporesolver"
26
27
"tangled.sh/tangled.sh/core/idresolver"
···
73
74
return
74
75
}
75
76
76
-
issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt, issueIdInt)
77
+
issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt(), issueIdInt)
77
78
if err != nil {
78
79
log.Println("failed to get issue and comments", err)
79
80
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
80
81
return
81
82
}
82
83
83
-
reactionCountMap, err := db.GetReactionCountMap(rp.db, syntax.ATURI(issue.IssueAt))
84
+
reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri())
84
85
if err != nil {
85
86
log.Println("failed to get issue reactions")
86
87
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
···
88
89
89
90
userReactions := map[db.ReactionKind]bool{}
90
91
if user != nil {
91
-
userReactions = db.GetReactionStatusMap(rp.db, user.Did, syntax.ATURI(issue.IssueAt))
92
+
userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri())
92
93
}
93
94
94
95
issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid)
···
96
97
log.Println("failed to resolve issue owner", err)
97
98
}
98
99
99
-
identsToResolve := make([]string, len(comments))
100
-
for i, comment := range comments {
101
-
identsToResolve[i] = comment.OwnerDid
102
-
}
103
-
resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve)
104
-
didHandleMap := make(map[string]string)
105
-
for _, identity := range resolvedIds {
106
-
if !identity.Handle.IsInvalidHandle() {
107
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
108
-
} else {
109
-
didHandleMap[identity.DID.String()] = identity.DID.String()
110
-
}
111
-
}
112
-
113
100
rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
114
101
LoggedInUser: user,
115
102
RepoInfo: f.RepoInfo(user),
116
-
Issue: *issue,
103
+
Issue: issue,
117
104
Comments: comments,
118
105
119
106
IssueOwnerHandle: issueOwnerIdent.Handle.String(),
120
-
DidHandleMap: didHandleMap,
121
107
122
108
OrderedReactionKinds: db.OrderedReactionKinds,
123
109
Reactions: reactionCountMap,
···
142
128
return
143
129
}
144
130
145
-
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
131
+
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
146
132
if err != nil {
147
133
log.Println("failed to get issue", err)
148
134
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
···
174
160
Rkey: tid.TID(),
175
161
Record: &lexutil.LexiconTypeDecoder{
176
162
Val: &tangled.RepoIssueState{
177
-
Issue: issue.IssueAt,
163
+
Issue: issue.AtUri().String(),
178
164
State: closed,
179
165
},
180
166
},
···
186
172
return
187
173
}
188
174
189
-
err = db.CloseIssue(rp.db, f.RepoAt, issueIdInt)
175
+
err = db.CloseIssue(rp.db, f.RepoAt(), issueIdInt)
190
176
if err != nil {
191
177
log.Println("failed to close issue", err)
192
178
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
···
218
204
return
219
205
}
220
206
221
-
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
207
+
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
222
208
if err != nil {
223
209
log.Println("failed to get issue", err)
224
210
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
···
235
221
isIssueOwner := user.Did == issue.OwnerDid
236
222
237
223
if isCollaborator || isIssueOwner {
238
-
err := db.ReopenIssue(rp.db, f.RepoAt, issueIdInt)
224
+
err := db.ReopenIssue(rp.db, f.RepoAt(), issueIdInt)
239
225
if err != nil {
240
226
log.Println("failed to reopen issue", err)
241
227
rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
···
279
265
280
266
err := db.NewIssueComment(rp.db, &db.Comment{
281
267
OwnerDid: user.Did,
282
-
RepoAt: f.RepoAt,
268
+
RepoAt: f.RepoAt(),
283
269
Issue: issueIdInt,
284
270
CommentId: commentId,
285
271
Body: body,
···
292
278
}
293
279
294
280
createdAt := time.Now().Format(time.RFC3339)
295
-
commentIdInt64 := int64(commentId)
296
281
ownerDid := user.Did
297
-
issueAt, err := db.GetIssueAt(rp.db, f.RepoAt, issueIdInt)
282
+
issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt)
298
283
if err != nil {
299
284
log.Println("failed to get issue at", err)
300
285
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
301
286
return
302
287
}
303
288
304
-
atUri := f.RepoAt.String()
289
+
atUri := f.RepoAt().String()
305
290
client, err := rp.oauth.AuthorizedClient(r)
306
291
if err != nil {
307
292
log.Println("failed to get authorized client", err)
···
316
301
Val: &tangled.RepoIssueComment{
317
302
Repo: &atUri,
318
303
Issue: issueAt,
319
-
CommentId: &commentIdInt64,
320
304
Owner: &ownerDid,
321
305
Body: body,
322
306
CreatedAt: createdAt,
···
358
342
return
359
343
}
360
344
361
-
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
345
+
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
362
346
if err != nil {
363
347
log.Println("failed to get issue", err)
364
348
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
365
349
return
366
350
}
367
351
368
-
comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
352
+
comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
369
353
if err != nil {
370
354
http.Error(w, "bad comment id", http.StatusBadRequest)
371
355
return
372
356
}
373
357
374
-
identity, err := rp.idResolver.ResolveIdent(r.Context(), comment.OwnerDid)
375
-
if err != nil {
376
-
log.Println("failed to resolve did")
377
-
return
378
-
}
379
-
380
-
didHandleMap := make(map[string]string)
381
-
if !identity.Handle.IsInvalidHandle() {
382
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
383
-
} else {
384
-
didHandleMap[identity.DID.String()] = identity.DID.String()
385
-
}
386
-
387
358
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
388
359
LoggedInUser: user,
389
360
RepoInfo: f.RepoInfo(user),
390
-
DidHandleMap: didHandleMap,
391
361
Issue: issue,
392
362
Comment: comment,
393
363
})
···
417
387
return
418
388
}
419
389
420
-
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
390
+
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
421
391
if err != nil {
422
392
log.Println("failed to get issue", err)
423
393
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
424
394
return
425
395
}
426
396
427
-
comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
397
+
comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
428
398
if err != nil {
429
399
http.Error(w, "bad comment id", http.StatusBadRequest)
430
400
return
···
479
449
repoAt := record["repo"].(string)
480
450
issueAt := record["issue"].(string)
481
451
createdAt := record["createdAt"].(string)
482
-
commentIdInt64 := int64(commentIdInt)
483
452
484
453
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
485
454
Collection: tangled.RepoIssueCommentNSID,
···
490
459
Val: &tangled.RepoIssueComment{
491
460
Repo: &repoAt,
492
461
Issue: issueAt,
493
-
CommentId: &commentIdInt64,
494
462
Owner: &comment.OwnerDid,
495
463
Body: newBody,
496
464
CreatedAt: createdAt,
···
503
471
}
504
472
505
473
// optimistic update for htmx
506
-
didHandleMap := map[string]string{
507
-
user.Did: user.Handle,
508
-
}
509
474
comment.Body = newBody
510
475
comment.Edited = &edited
511
476
···
513
478
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
514
479
LoggedInUser: user,
515
480
RepoInfo: f.RepoInfo(user),
516
-
DidHandleMap: didHandleMap,
517
481
Issue: issue,
518
482
Comment: comment,
519
483
})
···
539
503
return
540
504
}
541
505
542
-
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
506
+
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
543
507
if err != nil {
544
508
log.Println("failed to get issue", err)
545
509
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
···
554
518
return
555
519
}
556
520
557
-
comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
521
+
comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
558
522
if err != nil {
559
523
http.Error(w, "bad comment id", http.StatusBadRequest)
560
524
return
···
572
536
573
537
// optimistic deletion
574
538
deleted := time.Now()
575
-
err = db.DeleteComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
539
+
err = db.DeleteComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
576
540
if err != nil {
577
541
log.Println("failed to delete comment")
578
542
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
···
598
562
}
599
563
600
564
// optimistic update for htmx
601
-
didHandleMap := map[string]string{
602
-
user.Did: user.Handle,
603
-
}
604
565
comment.Body = ""
605
566
comment.Deleted = &deleted
606
567
···
608
569
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
609
570
LoggedInUser: user,
610
571
RepoInfo: f.RepoInfo(user),
611
-
DidHandleMap: didHandleMap,
612
572
Issue: issue,
613
573
Comment: comment,
614
574
})
615
-
return
616
575
}
617
576
618
577
func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) {
···
641
600
return
642
601
}
643
602
644
-
issues, err := db.GetIssues(rp.db, f.RepoAt, isOpen, page)
603
+
issues, err := db.GetIssuesPaginated(rp.db, f.RepoAt(), isOpen, page)
645
604
if err != nil {
646
605
log.Println("failed to get issues", err)
647
606
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
648
607
return
649
608
}
650
609
651
-
identsToResolve := make([]string, len(issues))
652
-
for i, issue := range issues {
653
-
identsToResolve[i] = issue.OwnerDid
654
-
}
655
-
resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve)
656
-
didHandleMap := make(map[string]string)
657
-
for _, identity := range resolvedIds {
658
-
if !identity.Handle.IsInvalidHandle() {
659
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
660
-
} else {
661
-
didHandleMap[identity.DID.String()] = identity.DID.String()
662
-
}
663
-
}
664
-
665
610
rp.pages.RepoIssues(w, pages.RepoIssuesParams{
666
611
LoggedInUser: rp.oauth.GetUser(r),
667
612
RepoInfo: f.RepoInfo(user),
668
613
Issues: issues,
669
-
DidHandleMap: didHandleMap,
670
614
FilteringByOpen: isOpen,
671
615
Page: page,
672
616
})
673
-
return
674
617
}
675
618
676
619
func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) {
···
697
640
return
698
641
}
699
642
643
+
sanitizer := markup.NewSanitizer()
644
+
if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" {
645
+
rp.pages.Notice(w, "issues", "Title is empty after HTML sanitization")
646
+
return
647
+
}
648
+
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" {
649
+
rp.pages.Notice(w, "issues", "Body is empty after HTML sanitization")
650
+
return
651
+
}
652
+
700
653
tx, err := rp.db.BeginTx(r.Context(), nil)
701
654
if err != nil {
702
655
rp.pages.Notice(w, "issues", "Failed to create issue, try again later")
···
704
657
}
705
658
706
659
issue := &db.Issue{
707
-
RepoAt: f.RepoAt,
660
+
RepoAt: f.RepoAt(),
661
+
Rkey: tid.TID(),
708
662
Title: title,
709
663
Body: body,
710
664
OwnerDid: user.Did,
···
722
676
rp.pages.Notice(w, "issues", "Failed to create issue.")
723
677
return
724
678
}
725
-
atUri := f.RepoAt.String()
726
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
679
+
atUri := f.RepoAt().String()
680
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
727
681
Collection: tangled.RepoIssueNSID,
728
682
Repo: user.Did,
729
-
Rkey: tid.TID(),
683
+
Rkey: issue.Rkey,
730
684
Record: &lexutil.LexiconTypeDecoder{
731
685
Val: &tangled.RepoIssue{
732
-
Repo: atUri,
733
-
Title: title,
734
-
Body: &body,
735
-
Owner: user.Did,
736
-
IssueId: int64(issue.IssueId),
686
+
Repo: atUri,
687
+
Title: title,
688
+
Body: &body,
689
+
Owner: user.Did,
737
690
},
738
691
},
739
692
})
740
693
if err != nil {
741
694
log.Println("failed to create issue", err)
742
-
rp.pages.Notice(w, "issues", "Failed to create issue.")
743
-
return
744
-
}
745
-
746
-
err = db.SetIssueAt(rp.db, f.RepoAt, issue.IssueId, resp.Uri)
747
-
if err != nil {
748
-
log.Println("failed to set issue at", err)
749
695
rp.pages.Notice(w, "issues", "Failed to create issue.")
750
696
return
751
697
}
-16
appview/knots/knots.go
-16
appview/knots/knots.go
···
334
334
repoByMember[r.Did] = append(repoByMember[r.Did], r)
335
335
}
336
336
337
-
var didsToResolve []string
338
-
for _, m := range members {
339
-
didsToResolve = append(didsToResolve, m)
340
-
}
341
-
didsToResolve = append(didsToResolve, reg.ByDid)
342
-
resolvedIds := k.IdResolver.ResolveIdents(r.Context(), didsToResolve)
343
-
didHandleMap := make(map[string]string)
344
-
for _, identity := range resolvedIds {
345
-
if !identity.Handle.IsInvalidHandle() {
346
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
347
-
} else {
348
-
didHandleMap[identity.DID.String()] = identity.DID.String()
349
-
}
350
-
}
351
-
352
337
k.Pages.Knot(w, pages.KnotParams{
353
338
LoggedInUser: user,
354
-
DidHandleMap: didHandleMap,
355
339
Registration: reg,
356
340
Members: members,
357
341
Repos: repoByMember,
+16
-21
appview/middleware/middleware.go
+16
-21
appview/middleware/middleware.go
···
5
5
"fmt"
6
6
"log"
7
7
"net/http"
8
+
"net/url"
8
9
"slices"
9
10
"strconv"
10
11
"strings"
11
-
"time"
12
12
13
13
"github.com/bluesky-social/indigo/atproto/identity"
14
14
"github.com/go-chi/chi/v5"
···
46
46
func AuthMiddleware(a *oauth.OAuth) middlewareFunc {
47
47
return func(next http.Handler) http.Handler {
48
48
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
49
+
returnURL := "/"
50
+
if u, err := url.Parse(r.Header.Get("Referer")); err == nil {
51
+
returnURL = u.RequestURI()
52
+
}
53
+
54
+
loginURL := fmt.Sprintf("/login?return_url=%s", url.QueryEscape(returnURL))
55
+
49
56
redirectFunc := func(w http.ResponseWriter, r *http.Request) {
50
-
http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
57
+
http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect)
51
58
}
52
59
if r.Header.Get("HX-Request") == "true" {
53
60
redirectFunc = func(w http.ResponseWriter, _ *http.Request) {
54
-
w.Header().Set("HX-Redirect", "/login")
61
+
w.Header().Set("HX-Redirect", loginURL)
55
62
w.WriteHeader(http.StatusOK)
56
63
}
57
64
}
···
167
174
}
168
175
}
169
176
170
-
func StripLeadingAt(next http.Handler) http.Handler {
171
-
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
172
-
path := req.URL.EscapedPath()
173
-
if strings.HasPrefix(path, "/@") {
174
-
req.URL.RawPath = "/" + strings.TrimPrefix(path, "/@")
175
-
}
176
-
next.ServeHTTP(w, req)
177
-
})
178
-
}
179
-
180
177
func (mw Middleware) ResolveIdent() middlewareFunc {
181
178
excluded := []string{"favicon.ico"}
182
179
···
188
185
return
189
186
}
190
187
188
+
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
189
+
191
190
id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
192
191
if err != nil {
193
192
// invalid did or handle
194
-
log.Println("failed to resolve did/handle:", err)
193
+
log.Printf("failed to resolve did/handle '%s': %s\n", didOrHandle, err)
195
194
mw.pages.Error404(w)
196
195
return
197
196
}
···
222
221
return
223
222
}
224
223
225
-
ctx := context.WithValue(req.Context(), "knot", repo.Knot)
226
-
ctx = context.WithValue(ctx, "repoAt", repo.AtUri)
227
-
ctx = context.WithValue(ctx, "repoDescription", repo.Description)
228
-
ctx = context.WithValue(ctx, "repoSpindle", repo.Spindle)
229
-
ctx = context.WithValue(ctx, "repoAddedAt", repo.Created.Format(time.RFC3339))
224
+
ctx := context.WithValue(req.Context(), "repo", repo)
230
225
next.ServeHTTP(w, req.WithContext(ctx))
231
226
})
232
227
}
···
251
246
return
252
247
}
253
248
254
-
pr, err := db.GetPull(mw.db, f.RepoAt, prIdInt)
249
+
pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt)
255
250
if err != nil {
256
251
log.Println("failed to get pull and comments", err)
257
252
return
···
292
287
return
293
288
}
294
289
295
-
fullName := f.OwnerHandle() + "/" + f.RepoName
290
+
fullName := f.OwnerHandle() + "/" + f.Name
296
291
297
292
if r.Header.Get("User-Agent") == "Go-http-client/1.1" {
298
293
if r.URL.Query().Get("go-get") == "1" {
+158
-2
appview/oauth/handler/handler.go
+158
-2
appview/oauth/handler/handler.go
···
1
1
package oauth
2
2
3
3
import (
4
+
"bytes"
5
+
"context"
4
6
"encoding/json"
5
7
"fmt"
6
8
"log"
7
9
"net/http"
8
10
"net/url"
9
11
"strings"
12
+
"time"
10
13
11
14
"github.com/go-chi/chi/v5"
12
15
"github.com/gorilla/sessions"
13
16
"github.com/lestrrat-go/jwx/v2/jwk"
14
17
"github.com/posthog/posthog-go"
15
18
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
19
+
tangled "tangled.sh/tangled.sh/core/api/tangled"
16
20
sessioncache "tangled.sh/tangled.sh/core/appview/cache/session"
17
21
"tangled.sh/tangled.sh/core/appview/config"
18
22
"tangled.sh/tangled.sh/core/appview/db"
···
23
27
"tangled.sh/tangled.sh/core/idresolver"
24
28
"tangled.sh/tangled.sh/core/knotclient"
25
29
"tangled.sh/tangled.sh/core/rbac"
30
+
"tangled.sh/tangled.sh/core/tid"
26
31
)
27
32
28
33
const (
···
104
109
func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) {
105
110
switch r.Method {
106
111
case http.MethodGet:
107
-
o.pages.Login(w, pages.LoginParams{})
112
+
returnURL := r.URL.Query().Get("return_url")
113
+
o.pages.Login(w, pages.LoginParams{
114
+
ReturnUrl: returnURL,
115
+
})
108
116
case http.MethodPost:
109
117
handle := r.FormValue("handle")
110
118
···
189
197
DpopAuthserverNonce: parResp.DpopAuthserverNonce,
190
198
DpopPrivateJwk: string(dpopKeyJson),
191
199
State: parResp.State,
200
+
ReturnUrl: r.FormValue("return_url"),
192
201
})
193
202
if err != nil {
194
203
log.Println("failed to save oauth request:", err)
···
244
253
return
245
254
}
246
255
256
+
if iss != oauthRequest.AuthserverIss {
257
+
log.Println("mismatched iss:", iss, "!=", oauthRequest.AuthserverIss, "for state:", state)
258
+
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
259
+
return
260
+
}
261
+
247
262
self := o.oauth.ClientMetadata()
248
263
249
264
oauthClient, err := client.NewClient(
···
294
309
295
310
log.Println("session saved successfully")
296
311
go o.addToDefaultKnot(oauthRequest.Did)
312
+
go o.addToDefaultSpindle(oauthRequest.Did)
297
313
298
314
if !o.config.Core.Dev {
299
315
err = o.posthog.Enqueue(posthog.Capture{
···
305
321
}
306
322
}
307
323
308
-
http.Redirect(w, r, "/", http.StatusFound)
324
+
returnUrl := oauthRequest.ReturnUrl
325
+
if returnUrl == "" {
326
+
returnUrl = "/"
327
+
}
328
+
329
+
http.Redirect(w, r, returnUrl, http.StatusFound)
309
330
}
310
331
311
332
func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) {
···
330
351
return nil, err
331
352
}
332
353
return pubKey, nil
354
+
}
355
+
356
+
func (o *OAuthHandler) addToDefaultSpindle(did string) {
357
+
// use the tangled.sh app password to get an accessJwt
358
+
// and create an sh.tangled.spindle.member record with that
359
+
360
+
defaultSpindle := "spindle.tangled.sh"
361
+
appPassword := o.config.Core.AppPassword
362
+
363
+
spindleMembers, err := db.GetSpindleMembers(
364
+
o.db,
365
+
db.FilterEq("instance", "spindle.tangled.sh"),
366
+
db.FilterEq("subject", did),
367
+
)
368
+
if err != nil {
369
+
log.Printf("failed to get spindle members for did %s: %v", did, err)
370
+
return
371
+
}
372
+
373
+
if len(spindleMembers) != 0 {
374
+
log.Printf("did %s is already a member of the default spindle", did)
375
+
return
376
+
}
377
+
378
+
// TODO: hardcoded tangled handle and did for now
379
+
tangledHandle := "tangled.sh"
380
+
tangledDid := "did:plc:wshs7t2adsemcrrd4snkeqli"
381
+
382
+
if appPassword == "" {
383
+
log.Println("no app password configured, skipping spindle member addition")
384
+
return
385
+
}
386
+
387
+
log.Printf("adding %s to default spindle", did)
388
+
389
+
resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid)
390
+
if err != nil {
391
+
log.Printf("failed to resolve tangled.sh DID %s: %v", tangledDid, err)
392
+
return
393
+
}
394
+
395
+
pdsEndpoint := resolved.PDSEndpoint()
396
+
if pdsEndpoint == "" {
397
+
log.Printf("no PDS endpoint found for tangled.sh DID %s", tangledDid)
398
+
return
399
+
}
400
+
401
+
sessionPayload := map[string]string{
402
+
"identifier": tangledHandle,
403
+
"password": appPassword,
404
+
}
405
+
sessionBytes, err := json.Marshal(sessionPayload)
406
+
if err != nil {
407
+
log.Printf("failed to marshal session payload: %v", err)
408
+
return
409
+
}
410
+
411
+
sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession"
412
+
sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes))
413
+
if err != nil {
414
+
log.Printf("failed to create session request: %v", err)
415
+
return
416
+
}
417
+
sessionReq.Header.Set("Content-Type", "application/json")
418
+
419
+
client := &http.Client{Timeout: 30 * time.Second}
420
+
sessionResp, err := client.Do(sessionReq)
421
+
if err != nil {
422
+
log.Printf("failed to create session: %v", err)
423
+
return
424
+
}
425
+
defer sessionResp.Body.Close()
426
+
427
+
if sessionResp.StatusCode != http.StatusOK {
428
+
log.Printf("failed to create session: HTTP %d", sessionResp.StatusCode)
429
+
return
430
+
}
431
+
432
+
var session struct {
433
+
AccessJwt string `json:"accessJwt"`
434
+
}
435
+
if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil {
436
+
log.Printf("failed to decode session response: %v", err)
437
+
return
438
+
}
439
+
440
+
record := tangled.SpindleMember{
441
+
LexiconTypeID: "sh.tangled.spindle.member",
442
+
Subject: did,
443
+
Instance: defaultSpindle,
444
+
CreatedAt: time.Now().Format(time.RFC3339),
445
+
}
446
+
447
+
recordBytes, err := json.Marshal(record)
448
+
if err != nil {
449
+
log.Printf("failed to marshal spindle member record: %v", err)
450
+
return
451
+
}
452
+
453
+
payload := map[string]interface{}{
454
+
"repo": tangledDid,
455
+
"collection": tangled.SpindleMemberNSID,
456
+
"rkey": tid.TID(),
457
+
"record": json.RawMessage(recordBytes),
458
+
}
459
+
460
+
payloadBytes, err := json.Marshal(payload)
461
+
if err != nil {
462
+
log.Printf("failed to marshal request payload: %v", err)
463
+
return
464
+
}
465
+
466
+
url := pdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
467
+
req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes))
468
+
if err != nil {
469
+
log.Printf("failed to create HTTP request: %v", err)
470
+
return
471
+
}
472
+
473
+
req.Header.Set("Content-Type", "application/json")
474
+
req.Header.Set("Authorization", "Bearer "+session.AccessJwt)
475
+
476
+
resp, err := client.Do(req)
477
+
if err != nil {
478
+
log.Printf("failed to add user to default spindle: %v", err)
479
+
return
480
+
}
481
+
defer resp.Body.Close()
482
+
483
+
if resp.StatusCode != http.StatusOK {
484
+
log.Printf("failed to add user to default spindle: HTTP %d", resp.StatusCode)
485
+
return
486
+
}
487
+
488
+
log.Printf("successfully added %s to default spindle", did)
333
489
}
334
490
335
491
func (o *OAuthHandler) addToDefaultKnot(did string) {
+13
-3
appview/oauth/oauth.go
+13
-3
appview/oauth/oauth.go
···
103
103
if err != nil {
104
104
return nil, false, fmt.Errorf("error parsing expiry time: %w", err)
105
105
}
106
-
if expiry.Sub(time.Now()) <= 5*time.Minute {
106
+
if time.Until(expiry) <= 5*time.Minute {
107
107
privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk))
108
108
if err != nil {
109
109
return nil, false, err
···
224
224
s.service = service
225
225
}
226
226
}
227
+
228
+
// Specify the Duration in seconds for the expiry of this token
229
+
//
230
+
// The time of expiry is calculated as time.Now().Unix() + exp
227
231
func WithExp(exp int64) ServiceClientOpt {
228
232
return func(s *ServiceClientOpts) {
229
-
s.exp = exp
233
+
s.exp = time.Now().Unix() + exp
230
234
}
231
235
}
232
236
···
266
270
return nil, err
267
271
}
268
272
273
+
// force expiry to atleast 60 seconds in the future
274
+
sixty := time.Now().Unix() + 60
275
+
if opts.exp < sixty {
276
+
opts.exp = sixty
277
+
}
278
+
269
279
resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm)
270
280
if err != nil {
271
281
return nil, err
···
305
315
redirectURIs := makeRedirectURIs(clientURI)
306
316
307
317
if o.config.Core.Dev {
308
-
clientURI = fmt.Sprintf("http://127.0.0.1:3000")
318
+
clientURI = "http://127.0.0.1:3000"
309
319
redirectURIs = makeRedirectURIs(clientURI)
310
320
311
321
query := url.Values{}
+29
-6
appview/pages/funcmap.go
+29
-6
appview/pages/funcmap.go
···
1
1
package pages
2
2
3
3
import (
4
+
"context"
4
5
"crypto/hmac"
5
6
"crypto/sha256"
6
7
"encoding/hex"
···
18
19
19
20
"github.com/dustin/go-humanize"
20
21
"github.com/go-enry/go-enry/v2"
21
-
"github.com/microcosm-cc/bluemonday"
22
22
"tangled.sh/tangled.sh/core/appview/filetree"
23
23
"tangled.sh/tangled.sh/core/appview/pages/markup"
24
24
)
···
27
27
return template.FuncMap{
28
28
"split": func(s string) []string {
29
29
return strings.Split(s, "\n")
30
+
},
31
+
"resolve": func(s string) string {
32
+
identity, err := p.resolver.ResolveIdent(context.Background(), s)
33
+
34
+
if err != nil {
35
+
return s
36
+
}
37
+
38
+
if identity.Handle.IsInvalidHandle() {
39
+
return "handle.invalid"
40
+
}
41
+
42
+
return "@" + identity.Handle.String()
30
43
},
31
44
"truncateAt30": func(s string) string {
32
45
if len(s) <= 30 {
···
74
87
"negf64": func(a float64) float64 {
75
88
return -a
76
89
},
77
-
"cond": func(cond interface{}, a, b string) string {
90
+
"cond": func(cond any, a, b string) string {
78
91
if cond == nil {
79
92
return b
80
93
}
···
167
180
return html.UnescapeString(s)
168
181
},
169
182
"nl2br": func(text string) template.HTML {
170
-
return template.HTML(strings.Replace(template.HTMLEscapeString(text), "\n", "<br>", -1))
183
+
return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(text), "\n", "<br>"))
171
184
},
172
185
"unwrapText": func(text string) string {
173
186
paragraphs := strings.Split(text, "\n\n")
···
193
206
}
194
207
return v.Slice(0, min(n, v.Len())).Interface()
195
208
},
196
-
197
209
"markdown": func(text string) template.HTML {
198
-
rctx := &markup.RenderContext{RendererType: markup.RendererTypeDefault}
199
-
return template.HTML(bluemonday.UGCPolicy().Sanitize(rctx.RenderMarkdown(text)))
210
+
p.rctx.RendererType = markup.RendererTypeDefault
211
+
htmlString := p.rctx.RenderMarkdown(text)
212
+
sanitized := p.rctx.SanitizeDefault(htmlString)
213
+
return template.HTML(sanitized)
214
+
},
215
+
"description": func(text string) template.HTML {
216
+
p.rctx.RendererType = markup.RendererTypeDefault
217
+
htmlString := p.rctx.RenderMarkdown(text)
218
+
sanitized := p.rctx.SanitizeDescription(htmlString)
219
+
return template.HTML(sanitized)
200
220
},
201
221
"isNil": func(t any) bool {
202
222
// returns false for other "zero" values
···
236
256
},
237
257
"cssContentHash": CssContentHash,
238
258
"fileTree": filetree.FileTree,
259
+
"pathEscape": func(s string) string {
260
+
return url.PathEscape(s)
261
+
},
239
262
"pathUnescape": func(s string) string {
240
263
u, _ := url.PathUnescape(s)
241
264
return u
+61
-31
appview/pages/markup/markdown.go
+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
+
}
+129
-51
appview/pages/pages.go
+129
-51
appview/pages/pages.go
···
24
24
"tangled.sh/tangled.sh/core/appview/pages/markup"
25
25
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
26
26
"tangled.sh/tangled.sh/core/appview/pagination"
27
+
"tangled.sh/tangled.sh/core/idresolver"
27
28
"tangled.sh/tangled.sh/core/patchutil"
28
29
"tangled.sh/tangled.sh/core/types"
29
30
···
31
32
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
32
33
"github.com/alecthomas/chroma/v2/lexers"
33
34
"github.com/alecthomas/chroma/v2/styles"
35
+
"github.com/bluesky-social/indigo/atproto/identity"
34
36
"github.com/bluesky-social/indigo/atproto/syntax"
35
37
"github.com/go-git/go-git/v5/plumbing"
36
38
"github.com/go-git/go-git/v5/plumbing/object"
···
44
46
t map[string]*template.Template
45
47
46
48
avatar config.AvatarConfig
49
+
resolver *idresolver.Resolver
47
50
dev bool
48
51
embedFS embed.FS
49
52
templateDir string // Path to templates on disk for dev mode
50
53
rctx *markup.RenderContext
51
54
}
52
55
53
-
func NewPages(config *config.Config) *Pages {
56
+
func NewPages(config *config.Config, res *idresolver.Resolver) *Pages {
54
57
// initialized with safe defaults, can be overriden per use
55
58
rctx := &markup.RenderContext{
56
59
IsDev: config.Core.Dev,
57
60
CamoUrl: config.Camo.Host,
58
61
CamoSecret: config.Camo.SharedSecret,
62
+
Sanitizer: markup.NewSanitizer(),
59
63
}
60
64
61
65
p := &Pages{
···
65
69
avatar: config.Avatar,
66
70
embedFS: Files,
67
71
rctx: rctx,
72
+
resolver: res,
68
73
templateDir: "appview/pages",
69
74
}
70
75
···
255
260
return p.executeOrReload(name, w, "layouts/repobase", params)
256
261
}
257
262
263
+
func (p *Pages) Favicon(w io.Writer) error {
264
+
return p.executePlain("favicon", w, nil)
265
+
}
266
+
258
267
type LoginParams struct {
268
+
ReturnUrl string
259
269
}
260
270
261
271
func (p *Pages) Login(w io.Writer, params LoginParams) error {
262
272
return p.executePlain("user/login", w, params)
263
273
}
264
274
265
-
type SignupParams struct{}
275
+
func (p *Pages) Signup(w io.Writer) error {
276
+
return p.executePlain("user/signup", w, nil)
277
+
}
266
278
267
-
func (p *Pages) CompleteSignup(w io.Writer, params SignupParams) error {
268
-
return p.executePlain("user/completeSignup", w, params)
279
+
func (p *Pages) CompleteSignup(w io.Writer) error {
280
+
return p.executePlain("user/completeSignup", w, nil)
269
281
}
270
282
271
283
type TermsOfServiceParams struct {
···
287
299
type TimelineParams struct {
288
300
LoggedInUser *oauth.User
289
301
Timeline []db.TimelineEvent
290
-
DidHandleMap map[string]string
302
+
Repos []db.Repo
291
303
}
292
304
293
305
func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
294
-
return p.execute("timeline", w, params)
306
+
return p.execute("timeline/timeline", w, params)
295
307
}
296
308
297
309
type SettingsParams struct {
···
315
327
316
328
type KnotParams struct {
317
329
LoggedInUser *oauth.User
318
-
DidHandleMap map[string]string
319
330
Registration *db.Registration
320
331
Members []string
321
332
Repos map[string][]db.Repo
···
372
383
Spindle db.Spindle
373
384
Members []string
374
385
Repos map[string][]db.Repo
375
-
DidHandleMap map[string]string
376
386
}
377
387
378
388
func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error {
···
405
415
ProfileTimeline *db.ProfileTimeline
406
416
Card ProfileCard
407
417
Punchcard db.Punchcard
408
-
409
-
DidHandleMap map[string]string
410
418
}
411
419
412
420
type ProfileCard struct {
413
421
UserDid string
414
422
UserHandle string
415
423
FollowStatus db.FollowStatus
416
-
AvatarUri string
417
424
Followers int
418
425
Following int
419
426
···
428
435
LoggedInUser *oauth.User
429
436
Repos []db.Repo
430
437
Card ProfileCard
431
-
432
-
DidHandleMap map[string]string
433
438
}
434
439
435
440
func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error {
···
458
463
LoggedInUser *oauth.User
459
464
Profile *db.Profile
460
465
AllRepos []PinnedRepo
461
-
DidHandleMap map[string]string
462
466
}
463
467
464
468
type PinnedRepo struct {
···
517
521
}
518
522
519
523
p.rctx.RepoInfo = params.RepoInfo
524
+
p.rctx.RepoInfo.Ref = params.Ref
520
525
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
521
526
522
527
if params.ReadmeFileName != "" {
523
-
var htmlString string
524
528
ext := filepath.Ext(params.ReadmeFileName)
525
529
switch ext {
526
530
case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
527
-
htmlString = p.rctx.Sanitize(htmlString)
528
-
htmlString = p.rctx.RenderMarkdown(params.Readme)
529
531
params.Raw = false
530
-
params.HTMLReadme = template.HTML(htmlString)
532
+
htmlString := p.rctx.RenderMarkdown(params.Readme)
533
+
sanitized := p.rctx.SanitizeDefault(htmlString)
534
+
params.HTMLReadme = template.HTML(sanitized)
531
535
default:
532
536
params.Raw = true
533
537
}
···
666
670
p.rctx.RepoInfo = params.RepoInfo
667
671
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
668
672
htmlString := p.rctx.RenderMarkdown(params.Contents)
669
-
params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString))
673
+
sanitized := p.rctx.SanitizeDefault(htmlString)
674
+
params.RenderedContents = template.HTML(sanitized)
670
675
}
671
676
}
672
677
673
-
if params.Lines < 5000 {
674
-
c := params.Contents
675
-
formatter := chromahtml.New(
676
-
chromahtml.InlineCode(false),
677
-
chromahtml.WithLineNumbers(true),
678
-
chromahtml.WithLinkableLineNumbers(true, "L"),
679
-
chromahtml.Standalone(false),
680
-
chromahtml.WithClasses(true),
681
-
)
678
+
c := params.Contents
679
+
formatter := chromahtml.New(
680
+
chromahtml.InlineCode(false),
681
+
chromahtml.WithLineNumbers(true),
682
+
chromahtml.WithLinkableLineNumbers(true, "L"),
683
+
chromahtml.Standalone(false),
684
+
chromahtml.WithClasses(true),
685
+
)
682
686
683
-
lexer := lexers.Get(filepath.Base(params.Path))
684
-
if lexer == nil {
685
-
lexer = lexers.Fallback
686
-
}
687
+
lexer := lexers.Get(filepath.Base(params.Path))
688
+
if lexer == nil {
689
+
lexer = lexers.Fallback
690
+
}
687
691
688
-
iterator, err := lexer.Tokenise(nil, c)
689
-
if err != nil {
690
-
return fmt.Errorf("chroma tokenize: %w", err)
691
-
}
692
+
iterator, err := lexer.Tokenise(nil, c)
693
+
if err != nil {
694
+
return fmt.Errorf("chroma tokenize: %w", err)
695
+
}
692
696
693
-
var code bytes.Buffer
694
-
err = formatter.Format(&code, style, iterator)
695
-
if err != nil {
696
-
return fmt.Errorf("chroma format: %w", err)
697
-
}
698
-
699
-
params.Contents = code.String()
697
+
var code bytes.Buffer
698
+
err = formatter.Format(&code, style, iterator)
699
+
if err != nil {
700
+
return fmt.Errorf("chroma format: %w", err)
700
701
}
701
702
703
+
params.Contents = code.String()
702
704
params.Active = "overview"
703
705
return p.executeRepo("repo/blob", w, params)
704
706
}
···
777
779
RepoInfo repoinfo.RepoInfo
778
780
Active string
779
781
Issues []db.Issue
780
-
DidHandleMap map[string]string
781
782
Page pagination.Page
782
783
FilteringByOpen bool
783
784
}
···
791
792
LoggedInUser *oauth.User
792
793
RepoInfo repoinfo.RepoInfo
793
794
Active string
794
-
Issue db.Issue
795
+
Issue *db.Issue
795
796
Comments []db.Comment
796
797
IssueOwnerHandle string
797
-
DidHandleMap map[string]string
798
798
799
799
OrderedReactionKinds []db.ReactionKind
800
800
Reactions map[db.ReactionKind]int
···
848
848
849
849
type SingleIssueCommentParams struct {
850
850
LoggedInUser *oauth.User
851
-
DidHandleMap map[string]string
852
851
RepoInfo repoinfo.RepoInfo
853
852
Issue *db.Issue
854
853
Comment *db.Comment
···
880
879
RepoInfo repoinfo.RepoInfo
881
880
Pulls []*db.Pull
882
881
Active string
883
-
DidHandleMap map[string]string
884
882
FilteringBy db.PullState
885
883
Stacks map[string]db.Stack
886
884
Pipelines map[string]db.Pipeline
···
913
911
LoggedInUser *oauth.User
914
912
RepoInfo repoinfo.RepoInfo
915
913
Active string
916
-
DidHandleMap map[string]string
917
914
Pull *db.Pull
918
915
Stack db.Stack
919
916
AbandonedPulls []*db.Pull
···
933
930
934
931
type RepoPullPatchParams struct {
935
932
LoggedInUser *oauth.User
936
-
DidHandleMap map[string]string
937
933
RepoInfo repoinfo.RepoInfo
938
934
Pull *db.Pull
939
935
Stack db.Stack
···
951
947
952
948
type RepoPullInterdiffParams struct {
953
949
LoggedInUser *oauth.User
954
-
DidHandleMap map[string]string
955
950
RepoInfo repoinfo.RepoInfo
956
951
Pull *db.Pull
957
952
Round int
···
1140
1135
func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error {
1141
1136
params.Active = "pipelines"
1142
1137
return p.executeRepo("repo/pipelines/workflow", w, params)
1138
+
}
1139
+
1140
+
type PutStringParams struct {
1141
+
LoggedInUser *oauth.User
1142
+
Action string
1143
+
1144
+
// this is supplied in the case of editing an existing string
1145
+
String db.String
1146
+
}
1147
+
1148
+
func (p *Pages) PutString(w io.Writer, params PutStringParams) error {
1149
+
return p.execute("strings/put", w, params)
1150
+
}
1151
+
1152
+
type StringsDashboardParams struct {
1153
+
LoggedInUser *oauth.User
1154
+
Card ProfileCard
1155
+
Strings []db.String
1156
+
}
1157
+
1158
+
func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error {
1159
+
return p.execute("strings/dashboard", w, params)
1160
+
}
1161
+
1162
+
type StringTimelineParams struct {
1163
+
LoggedInUser *oauth.User
1164
+
Strings []db.String
1165
+
}
1166
+
1167
+
func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error {
1168
+
return p.execute("strings/timeline", w, params)
1169
+
}
1170
+
1171
+
type SingleStringParams struct {
1172
+
LoggedInUser *oauth.User
1173
+
ShowRendered bool
1174
+
RenderToggle bool
1175
+
RenderedContents template.HTML
1176
+
String db.String
1177
+
Stats db.StringStats
1178
+
Owner identity.Identity
1179
+
}
1180
+
1181
+
func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error {
1182
+
var style *chroma.Style = styles.Get("catpuccin-latte")
1183
+
1184
+
if params.ShowRendered {
1185
+
switch markup.GetFormat(params.String.Filename) {
1186
+
case markup.FormatMarkdown:
1187
+
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
1188
+
htmlString := p.rctx.RenderMarkdown(params.String.Contents)
1189
+
sanitized := p.rctx.SanitizeDefault(htmlString)
1190
+
params.RenderedContents = template.HTML(sanitized)
1191
+
}
1192
+
}
1193
+
1194
+
c := params.String.Contents
1195
+
formatter := chromahtml.New(
1196
+
chromahtml.InlineCode(false),
1197
+
chromahtml.WithLineNumbers(true),
1198
+
chromahtml.WithLinkableLineNumbers(true, "L"),
1199
+
chromahtml.Standalone(false),
1200
+
chromahtml.WithClasses(true),
1201
+
)
1202
+
1203
+
lexer := lexers.Get(filepath.Base(params.String.Filename))
1204
+
if lexer == nil {
1205
+
lexer = lexers.Fallback
1206
+
}
1207
+
1208
+
iterator, err := lexer.Tokenise(nil, c)
1209
+
if err != nil {
1210
+
return fmt.Errorf("chroma tokenize: %w", err)
1211
+
}
1212
+
1213
+
var code bytes.Buffer
1214
+
err = formatter.Format(&code, style, iterator)
1215
+
if err != nil {
1216
+
return fmt.Errorf("chroma format: %w", err)
1217
+
}
1218
+
1219
+
params.String.Contents = code.String()
1220
+
return p.execute("strings/string", w, params)
1143
1221
}
1144
1222
1145
1223
func (p *Pages) Static() http.Handler {
+26
appview/pages/templates/favicon.html
+26
appview/pages/templates/favicon.html
···
1
+
{{ define "favicon" }}
2
+
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
3
+
<style>
4
+
.favicon-text {
5
+
fill: #000000;
6
+
stroke: none;
7
+
}
8
+
9
+
@media (prefers-color-scheme: dark) {
10
+
.favicon-text {
11
+
fill: #ffffff;
12
+
stroke: none;
13
+
}
14
+
}
15
+
</style>
16
+
17
+
<g style="display:inline">
18
+
<path d="M0-2.117h62.177v25.135H0z" style="display:inline;fill:none;fill-opacity:1;stroke-width:.396875" transform="translate(11.01 6.9)"/>
19
+
<path d="M3.64 22.787c-1.697 0-2.943-.45-3.74-1.35-.77-.9-1.156-2.094-1.156-3.585 0-.36.013-.72.038-1.08.052-.385.129-.873.232-1.464L.44 6.826h-5.089l.733-4.394h3.2c.822 0 1.439-.168 1.85-.502.437-.334.72-.938.848-1.812l.771-4.703h5.243L6.84 2.432h7.787l-.733 4.394H6.107L4.257 17.93l.77.27 6.015-4.742 2.775 3.161-2.313 2.005c-.822.694-1.568 1.31-2.236 1.85-.668.515-1.31.952-1.927 1.311a7.406 7.406 0 0 1-1.774.733c-.59.18-1.233.27-1.927.27z"
20
+
aria-label="tangled.sh"
21
+
class="favicon-text"
22
+
style="font-size:16.2278px;font-family:'IBM Plex Mono';-inkscape-font-specification:'IBM Plex Mono, Normal';display:inline;fill-opacity:1"
23
+
transform="translate(11.01 6.9)"/>
24
+
</g>
25
+
</svg>
26
+
{{ end }}
+3
-4
appview/pages/templates/knots/dashboard.html
+3
-4
appview/pages/templates/knots/dashboard.html
···
38
38
<div>
39
39
<div class="flex justify-between items-center">
40
40
<div class="flex items-center gap-2">
41
-
{{ i "user" "size-4" }}
42
-
{{ $user := index $.DidHandleMap . }}
43
-
<a href="/{{ $user }}">{{ $user }} <span class="ml-2 font-mono text-gray-500">{{.}}</span></a>
41
+
{{ template "user/fragments/picHandleLink" . }}
42
+
<span class="ml-2 font-mono text-gray-500">{{.}}</span>
44
43
</div>
45
44
</div>
46
45
<div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700">
···
48
47
{{ range $repos }}
49
48
<div class="flex gap-2 items-center">
50
49
{{ i "book-marked" "size-4" }}
51
-
<a href="/{{ .Did }}/{{ .Name }}">
50
+
<a href="/{{ resolve .Did }}/{{ .Name }}">
52
51
{{ .Name }}
53
52
</a>
54
53
</div>
-12
appview/pages/templates/layouts/base.html
-12
appview/pages/templates/layouts/base.html
···
24
24
{{ block "mainLayout" . }}
25
25
<div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4">
26
26
{{ block "contentLayout" . }}
27
-
<div class="col-span-1 md:col-span-2">
28
-
{{ block "contentLeft" . }} {{ end }}
29
-
</div>
30
27
<main class="col-span-1 md:col-span-8">
31
28
{{ block "content" . }}{{ end }}
32
29
</main>
33
-
<div class="col-span-1 md:col-span-2">
34
-
{{ block "contentRight" . }} {{ end }}
35
-
</div>
36
30
{{ end }}
37
31
38
32
{{ block "contentAfterLayout" . }}
39
-
<div class="col-span-1 md:col-span-2">
40
-
{{ block "contentAfterLeft" . }} {{ end }}
41
-
</div>
42
33
<main class="col-span-1 md:col-span-8">
43
34
{{ block "contentAfter" . }}{{ end }}
44
35
</main>
45
-
<div class="col-span-1 md:col-span-2">
46
-
{{ block "contentAfterRight" . }} {{ end }}
47
-
</div>
48
36
{{ end }}
49
37
</div>
50
38
{{ end }}
+16
-21
appview/pages/templates/layouts/repobase.html
+16
-21
appview/pages/templates/layouts/repobase.html
···
5
5
{{ if .RepoInfo.Source }}
6
6
<p class="text-sm">
7
7
<div class="flex items-center">
8
-
{{ i "git-fork" "w-3 h-3 mr-1"}}
8
+
{{ i "git-fork" "w-3 h-3 mr-1 shrink-0" }}
9
9
forked from
10
10
{{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }}
11
11
<a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a>
···
20
20
</div>
21
21
22
22
<div class="flex items-center gap-2 z-auto">
23
+
<a
24
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
25
+
href="/{{ .RepoInfo.FullName }}/feed.atom"
26
+
>
27
+
{{ i "rss" "size-4" }}
28
+
</a>
23
29
{{ template "repo/fragments/repoStar" .RepoInfo }}
24
-
{{ if .RepoInfo.DisableFork }}
25
-
<button
26
-
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
27
-
disabled
28
-
title="Empty repositories cannot be forked"
29
-
>
30
-
{{ i "git-fork" "w-4 h-4" }}
31
-
fork
32
-
</button>
33
-
{{ else }}
34
-
<a
35
-
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
36
-
hx-boost="true"
37
-
href="/{{ .RepoInfo.FullName }}/fork"
38
-
>
39
-
{{ i "git-fork" "w-4 h-4" }}
40
-
fork
41
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
42
-
</a>
43
-
{{ end }}
30
+
<a
31
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
32
+
hx-boost="true"
33
+
href="/{{ .RepoInfo.FullName }}/fork"
34
+
>
35
+
{{ i "git-fork" "w-4 h-4" }}
36
+
fork
37
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
38
+
</a>
44
39
</div>
45
40
</div>
46
41
{{ template "repo/fragments/repoDescription" . }}
+38
-18
appview/pages/templates/layouts/topbar.html
+38
-18
appview/pages/templates/layouts/topbar.html
···
2
2
<nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
3
3
<div class="flex justify-between p-0 items-center">
4
4
<div id="left-items">
5
-
<a href="/" hx-boost="true" class="flex gap-2 font-semibold italic">
5
+
<a href="/" hx-boost="true" class="flex gap-2 font-bold italic">
6
6
tangled<sub>alpha</sub>
7
7
</a>
8
8
</div>
9
-
<div class="hidden md:flex gap-4 items-center">
10
-
<a href="https://chat.tangled.sh" class="inline-flex gap-1 items-center">
11
-
{{ i "message-circle" "size-4" }} discord
12
-
</a>
13
9
14
-
<a href="https://web.libera.chat/#tangled" class="inline-flex gap-1 items-center">
15
-
{{ i "hash" "size-4" }} irc
16
-
</a>
17
-
18
-
<a href="https://tangled.sh/@tangled.sh/core" class="inline-flex gap-1 items-center">
19
-
{{ i "code" "size-4" }} source
20
-
</a>
21
-
</div>
22
-
<div id="right-items" class="flex items-center gap-4">
10
+
<div id="right-items" class="flex items-center gap-2">
23
11
{{ with .LoggedInUser }}
24
-
<a href="/repo/new" hx-boost="true" class="btn-create hover:no-underline hover:text-white">
25
-
{{ i "plus" "w-4 h-4" }}
26
-
</a>
12
+
{{ block "newButton" . }} {{ end }}
27
13
{{ block "dropDown" . }} {{ end }}
28
14
{{ else }}
29
15
<a href="/login">login</a>
16
+
<span class="text-gray-500 dark:text-gray-400">or</span>
17
+
<a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2">
18
+
join now {{ i "arrow-right" "size-4" }}
19
+
</a>
30
20
{{ end }}
31
21
</div>
32
22
</div>
33
23
</nav>
34
24
{{ end }}
35
25
26
+
{{ define "newButton" }}
27
+
<details class="relative inline-block text-left nav-dropdown">
28
+
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
29
+
{{ i "plus" "w-4 h-4" }} new
30
+
</summary>
31
+
<div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
32
+
<a href="/repo/new" class="flex items-center gap-2">
33
+
{{ i "book-plus" "w-4 h-4" }}
34
+
new repository
35
+
</a>
36
+
<a href="/strings/new" class="flex items-center gap-2">
37
+
{{ i "line-squiggle" "w-4 h-4" }}
38
+
new string
39
+
</a>
40
+
</div>
41
+
</details>
42
+
{{ end }}
43
+
36
44
{{ define "dropDown" }}
37
-
<details class="relative inline-block text-left">
45
+
<details class="relative inline-block text-left nav-dropdown">
38
46
<summary
39
47
class="cursor-pointer list-none flex items-center"
40
48
>
···
46
54
>
47
55
<a href="/{{ $user }}">profile</a>
48
56
<a href="/{{ $user }}?tab=repos">repositories</a>
57
+
<a href="/strings/{{ $user }}">strings</a>
49
58
<a href="/knots">knots</a>
50
59
<a href="/spindles">spindles</a>
51
60
<a href="/settings">settings</a>
···
57
66
</a>
58
67
</div>
59
68
</details>
69
+
70
+
<script>
71
+
document.addEventListener('click', function(event) {
72
+
const dropdowns = document.querySelectorAll('.nav-dropdown');
73
+
dropdowns.forEach(function(dropdown) {
74
+
if (!dropdown.contains(event.target)) {
75
+
dropdown.removeAttribute('open');
76
+
}
77
+
});
78
+
});
79
+
</script>
60
80
{{ end }}
+3
-1
appview/pages/templates/legal/privacy.html
+3
-1
appview/pages/templates/legal/privacy.html
···
1
1
{{ define "title" }} privacy policy {{ end }}
2
2
{{ define "content" }}
3
3
<div class="max-w-4xl mx-auto px-4 py-8">
4
-
<div class="prose prose-gray dark:prose-invert max-w-none">
4
+
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
5
+
<div class="prose prose-gray dark:prose-invert max-w-none">
5
6
<h1>Privacy Policy</h1>
6
7
7
8
<p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p>
···
125
126
126
127
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400">
127
128
<p>This Privacy Policy complies with the EU General Data Protection Regulation (GDPR) and other applicable data protection laws.</p>
129
+
</div>
128
130
</div>
129
131
</div>
130
132
</div>
+3
-1
appview/pages/templates/legal/terms.html
+3
-1
appview/pages/templates/legal/terms.html
···
2
2
3
3
{{ define "content" }}
4
4
<div class="max-w-4xl mx-auto px-4 py-8">
5
-
<div class="prose prose-gray dark:prose-invert max-w-none">
5
+
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
6
+
<div class="prose prose-gray dark:prose-invert max-w-none">
6
7
<h1>Terms of Service</h1>
7
8
8
9
<p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p>
···
63
64
64
65
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400">
65
66
<p>These terms are effective as of the last updated date shown above and will remain in effect except with respect to any changes in their provisions in the future, which will be in effect immediately after being posted on this page.</p>
67
+
</div>
66
68
</div>
67
69
</div>
68
70
</div>
+1
-1
appview/pages/templates/repo/commit.html
+1
-1
appview/pages/templates/repo/commit.html
···
118
118
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
119
119
{{ template "repo/fragments/diffOpts" .DiffOpts }}
120
120
</div>
121
-
<div class="sticky top-0 flex-grow max-h-screen">
121
+
<div class="sticky top-0 flex-grow max-h-screen overflow-y-auto">
122
122
{{ template "repo/fragments/diffChangedFiles" .Diff }}
123
123
</div>
124
124
{{end}}
+1
-1
appview/pages/templates/repo/compare/compare.html
+1
-1
appview/pages/templates/repo/compare/compare.html
···
49
49
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
50
50
{{ template "repo/fragments/diffOpts" .DiffOpts }}
51
51
</div>
52
-
<div class="sticky top-0 flex-grow max-h-screen">
52
+
<div class="sticky top-0 flex-grow max-h-screen overflow-y-auto">
53
53
{{ template "repo/fragments/diffChangedFiles" .Diff }}
54
54
</div>
55
55
{{end}}
+5
-7
appview/pages/templates/repo/empty.html
+5
-7
appview/pages/templates/repo/empty.html
···
32
32
<div class="py-6 w-fit flex flex-col gap-4">
33
33
<p>This is an empty repository. To get started:</p>
34
34
{{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }}
35
-
<p><span class="{{$bullet}}">1</span>Add a public key to your account from the <a href="/settings" class="underline">settings</a> page</p>
36
-
<p><span class="{{$bullet}}">2</span>Configure your remote to <span class="font-mono p-1 rounded bg-gray-100 dark:bg-gray-700 ">git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}<span></p>
37
-
<p><span class="{{$bullet}}">3</span>Push!</p>
35
+
36
+
<p><span class="{{$bullet}}">1</span>First, generate a new <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Generating-Your-SSH-Public-Key" class="underline">SSH key pair</a>.</p>
37
+
<p><span class="{{$bullet}}">2</span>Then add the public key to your account from the <a href="/settings" class="underline">settings</a> page.</p>
38
+
<p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p>
39
+
<p><span class="{{$bullet}}">4</span>Push!</p>
38
40
</div>
39
41
</div>
40
42
{{ else }}
···
42
44
{{ end }}
43
45
</main>
44
46
{{ end }}
45
-
46
-
{{ define "repoAfter" }}
47
-
{{ template "repo/fragments/cloneInstructions" . }}
48
-
{{ end }}
+104
appview/pages/templates/repo/fragments/cloneDropdown.html
+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 }}
+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 min-h-full text-sm">
3
+
<section class="px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm min-h-full text-sm">
4
4
<div class="diff-stat">
5
5
<div class="flex gap-2 items-center">
6
6
<strong class="text-sm uppercase dark:text-gray-200">files</strong>
+1
-1
appview/pages/templates/repo/fragments/repoDescription.html
+1
-1
appview/pages/templates/repo/fragments/repoDescription.html
···
1
1
{{ define "repo/fragments/repoDescription" }}
2
2
<span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML">
3
3
{{ if .RepoInfo.Description }}
4
-
{{ .RepoInfo.Description }}
4
+
{{ .RepoInfo.Description | description }}
5
5
{{ else }}
6
6
<span class="italic">this repo has no description</span>
7
7
{{ end }}
+70
-63
appview/pages/templates/repo/index.html
+70
-63
appview/pages/templates/repo/index.html
···
14
14
{{ end }}
15
15
<div class="flex items-center justify-between pb-5">
16
16
{{ block "branchSelector" . }}{{ end }}
17
-
<div class="flex md:hidden items-center gap-4">
18
-
<a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex items-center text-sm gap-1">
17
+
<div class="flex md:hidden items-center gap-2">
18
+
<a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex items-center text-sm gap-1 font-bold">
19
19
{{ i "git-commit-horizontal" "w-4" "h-4" }} {{ .TotalCommits }}
20
20
</a>
21
-
<a href="/{{ .RepoInfo.FullName }}/branches" class="inline-flex items-center text-sm gap-1">
21
+
<a href="/{{ .RepoInfo.FullName }}/branches" class="inline-flex items-center text-sm gap-1 font-bold">
22
22
{{ i "git-branch" "w-4" "h-4" }} {{ len .Branches }}
23
23
</a>
24
-
<a href="/{{ .RepoInfo.FullName }}/tags" class="inline-flex items-center text-sm gap-1">
24
+
<a href="/{{ .RepoInfo.FullName }}/tags" class="inline-flex items-center text-sm gap-1 font-bold">
25
25
{{ i "tags" "w-4" "h-4" }} {{ len .Tags }}
26
26
</a>
27
+
{{ template "repo/fragments/cloneDropdown" . }}
27
28
</div>
28
29
</div>
29
30
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
···
47
48
48
49
49
50
{{ define "branchSelector" }}
50
-
<div class="flex gap-2 items-center items-stretch justify-center">
51
-
<select
52
-
onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)"
53
-
class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"
54
-
>
55
-
<optgroup label="branches ({{len .Branches}})" class="bold text-sm">
56
-
{{ range .Branches }}
57
-
<option
58
-
value="{{ .Reference.Name }}"
59
-
class="py-1"
60
-
{{ if eq .Reference.Name $.Ref }}
61
-
selected
62
-
{{ end }}
63
-
>
64
-
{{ .Reference.Name }}
65
-
</option>
66
-
{{ end }}
67
-
</optgroup>
68
-
<optgroup label="tags ({{len .Tags}})" class="bold text-sm">
69
-
{{ range .Tags }}
70
-
<option
71
-
value="{{ .Reference.Name }}"
72
-
class="py-1"
73
-
{{ if eq .Reference.Name $.Ref }}
74
-
selected
75
-
{{ end }}
76
-
>
77
-
{{ .Reference.Name }}
78
-
</option>
79
-
{{ else }}
80
-
<option class="py-1" disabled>no tags found</option>
81
-
{{ end }}
82
-
</optgroup>
83
-
</select>
84
-
<div class="flex items-center gap-2">
51
+
<div class="flex gap-2 items-center justify-between w-full">
52
+
<div class="flex gap-2 items-center">
53
+
<select
54
+
onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)"
55
+
class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"
56
+
>
57
+
<optgroup label="branches ({{len .Branches}})" class="bold text-sm">
58
+
{{ range .Branches }}
59
+
<option
60
+
value="{{ .Reference.Name }}"
61
+
class="py-1"
62
+
{{ if eq .Reference.Name $.Ref }}
63
+
selected
64
+
{{ end }}
65
+
>
66
+
{{ .Reference.Name }}
67
+
</option>
68
+
{{ end }}
69
+
</optgroup>
70
+
<optgroup label="tags ({{len .Tags}})" class="bold text-sm">
71
+
{{ range .Tags }}
72
+
<option
73
+
value="{{ .Reference.Name }}"
74
+
class="py-1"
75
+
{{ if eq .Reference.Name $.Ref }}
76
+
selected
77
+
{{ end }}
78
+
>
79
+
{{ .Reference.Name }}
80
+
</option>
81
+
{{ else }}
82
+
<option class="py-1" disabled>no tags found</option>
83
+
{{ end }}
84
+
</optgroup>
85
+
</select>
86
+
<div class="flex items-center gap-2">
85
87
{{ $isOwner := and .LoggedInUser .RepoInfo.Roles.IsOwner }}
86
88
{{ $isCollaborator := and .LoggedInUser .RepoInfo.Roles.IsCollaborator }}
87
89
{{ if and (or $isOwner $isCollaborator) .ForkInfo .ForkInfo.IsFork }}
···
115
117
<span>sync</span>
116
118
</button>
117
119
{{ end }}
118
-
<a
119
-
href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}"
120
-
class="btn flex items-center gap-2 no-underline hover:no-underline"
121
-
title="Compare branches or tags"
122
-
>
123
-
{{ i "git-compare" "w-4 h-4" }}
124
-
</a>
120
+
<a
121
+
href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}"
122
+
class="btn flex items-center gap-2 no-underline hover:no-underline"
123
+
title="Compare branches or tags"
124
+
>
125
+
{{ i "git-compare" "w-4 h-4" }}
126
+
</a>
127
+
</div>
125
128
</div>
126
-
</div>
129
+
130
+
<!-- Clone dropdown in top right -->
131
+
<div class="hidden md:flex items-center ">
132
+
{{ template "repo/fragments/cloneDropdown" . }}
133
+
</div>
134
+
</div>
127
135
{{ end }}
128
136
129
137
{{ define "fileTree" }}
···
131
139
{{ $linkstyle := "no-underline hover:underline dark:text-white" }}
132
140
133
141
{{ range .Files }}
134
-
<div class="grid grid-cols-2 gap-4 items-center py-1">
135
-
<div class="col-span-1">
142
+
<div class="grid grid-cols-3 gap-4 items-center py-1">
143
+
<div class="col-span-2">
136
144
{{ $link := printf "/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) .Name }}
137
145
{{ $icon := "folder" }}
138
146
{{ $iconStyle := "size-4 fill-current" }}
···
144
152
{{ end }}
145
153
<a href="{{ $link }}" class="{{ $linkstyle }}">
146
154
<div class="flex items-center gap-2">
147
-
{{ i $icon $iconStyle }}{{ .Name }}
155
+
{{ i $icon $iconStyle "flex-shrink-0" }}
156
+
<span class="truncate">{{ .Name }}</span>
148
157
</div>
149
158
</a>
150
159
</div>
151
160
152
-
<div class="text-xs col-span-1 text-right">
161
+
<div class="text-sm col-span-1 text-right">
153
162
{{ with .LastCommit }}
154
163
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a>
155
164
{{ end }}
···
210
219
</div>
211
220
212
221
<!-- commit info bar -->
213
-
<div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center">
222
+
<div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center flex-wrap">
214
223
{{ $verified := $.VerifiedCommits.IsVerified .Hash.String }}
215
224
{{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }}
216
225
{{ if $verified }}
···
280
289
</a>
281
290
<div class="flex flex-col gap-1">
282
291
{{ range .BranchesTrunc }}
283
-
<div class="text-base flex items-center justify-between">
284
-
<div class="flex items-center gap-2">
292
+
<div class="text-base flex items-center justify-between overflow-hidden">
293
+
<div class="flex items-center gap-2 min-w-0 flex-1">
285
294
<a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Reference.Name | urlquery }}"
286
-
class="inline no-underline hover:underline dark:text-white">
295
+
class="inline-block truncate no-underline hover:underline dark:text-white">
287
296
{{ .Reference.Name }}
288
297
</a>
289
298
{{ if .Commit }}
290
-
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>
291
-
<span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .Commit.Committer.When }}</span>
299
+
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท'] shrink-0"></span>
300
+
<span class="whitespace-nowrap text-xs text-gray-500 dark:text-gray-400 shrink-0">{{ template "repo/fragments/time" .Commit.Committer.When }}</span>
292
301
{{ end }}
293
302
{{ if .IsDefault }}
294
-
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>
295
-
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-xs font-mono">default</span>
303
+
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท'] shrink-0"></span>
304
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-xs font-mono shrink-0">default</span>
296
305
{{ end }}
297
306
</div>
298
307
{{ if ne $.Ref .Reference.Name }}
299
308
<a href="/{{ $.RepoInfo.FullName }}/compare/{{ $.Ref | urlquery }}...{{ .Reference.Name | urlquery }}"
300
-
class="text-xs flex gap-2 items-center"
309
+
class="text-xs flex gap-2 items-center shrink-0 ml-2"
301
310
title="Compare branches or tags">
302
311
{{ i "git-compare" "w-3 h-3" }} compare
303
312
</a>
304
-
{{end}}
313
+
{{ end }}
305
314
</div>
306
315
{{ end }}
307
316
</div>
···
362
371
{{- end -}}</article>
363
372
</section>
364
373
{{- end -}}
365
-
366
-
{{ template "repo/fragments/cloneInstructions" . }}
367
374
{{ end }}
+1
-2
appview/pages/templates/repo/issues/fragments/issueComment.html
+1
-2
appview/pages/templates/repo/issues/fragments/issueComment.html
···
2
2
{{ with .Comment }}
3
3
<div id="comment-container-{{.CommentId}}">
4
4
<div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap">
5
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
6
-
{{ template "user/fragments/picHandleLink" $owner }}
5
+
{{ template "user/fragments/picHandleLink" .OwnerDid }}
7
6
8
7
<!-- show user "hats" -->
9
8
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
+3
-3
appview/pages/templates/repo/issues/issue.html
+3
-3
appview/pages/templates/repo/issues/issue.html
···
11
11
{{ define "repoContent" }}
12
12
<header class="pb-4">
13
13
<h1 class="text-2xl">
14
-
{{ .Issue.Title }}
14
+
{{ .Issue.Title | description }}
15
15
<span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span>
16
16
</h1>
17
17
</header>
···
54
54
"Kind" $kind
55
55
"Count" (index $.Reactions $kind)
56
56
"IsReacted" (index $.UserReacted $kind)
57
-
"ThreadAt" $.Issue.IssueAt)
57
+
"ThreadAt" $.Issue.AtUri)
58
58
}}
59
59
{{ end }}
60
60
</div>
···
70
70
{{ if gt $index 0 }}
71
71
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
72
72
{{ end }}
73
-
{{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "DidHandleMap" $.DidHandleMap "Issue" $.Issue "Comment" .)}}
73
+
{{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "Issue" $.Issue "Comment" .)}}
74
74
</div>
75
75
{{ end }}
76
76
</section>
+2
-3
appview/pages/templates/repo/issues/issues.html
+2
-3
appview/pages/templates/repo/issues/issues.html
···
45
45
href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}"
46
46
class="no-underline hover:underline"
47
47
>
48
-
{{ .Title }}
48
+
{{ .Title | description }}
49
49
<span class="text-gray-500">#{{ .IssueId }}</span>
50
50
</a>
51
51
</div>
···
65
65
</span>
66
66
67
67
<span class="ml-1">
68
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
69
-
{{ template "user/fragments/picHandleLink" $owner }}
68
+
{{ template "user/fragments/picHandleLink" .OwnerDid }}
70
69
</span>
71
70
72
71
<span class="before:content-['ยท']">
+2
-2
appview/pages/templates/repo/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
+1
-1
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
+1
-1
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
+1
-1
appview/pages/templates/repo/pulls/interdiff.html
+1
-1
appview/pages/templates/repo/pulls/interdiff.html
···
68
68
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
69
69
{{ template "repo/fragments/diffOpts" .DiffOpts }}
70
70
</div>
71
-
<div class="sticky top-0 flex-grow max-h-screen">
71
+
<div class="sticky top-0 flex-grow max-h-screen overflow-y-auto">
72
72
{{ template "repo/fragments/interdiffFiles" .Interdiff }}
73
73
</div>
74
74
{{end}}
+1
-1
appview/pages/templates/repo/pulls/patch.html
+1
-1
appview/pages/templates/repo/pulls/patch.html
···
73
73
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
74
74
{{ template "repo/fragments/diffOpts" .DiffOpts }}
75
75
</div>
76
-
<div class="sticky top-0 flex-grow max-h-screen">
76
+
<div class="sticky top-0 flex-grow max-h-screen overflow-y-auto">
77
77
{{ template "repo/fragments/diffChangedFiles" .Diff }}
78
78
</div>
79
79
{{end}}
+4
-5
appview/pages/templates/repo/pulls/pull.html
+4
-5
appview/pages/templates/repo/pulls/pull.html
···
47
47
<!-- round summary -->
48
48
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
49
49
<span class="gap-1 flex items-center">
50
-
{{ $owner := index $.DidHandleMap $.Pull.OwnerDid }}
50
+
{{ $owner := resolve $.Pull.OwnerDid }}
51
51
{{ $re := "re" }}
52
52
{{ if eq .RoundNumber 0 }}
53
53
{{ $re = "" }}
54
54
{{ end }}
55
55
<span class="hidden md:inline">{{$re}}submitted</span>
56
-
by {{ template "user/fragments/picHandleLink" $owner }}
56
+
by {{ template "user/fragments/picHandleLink" $.Pull.OwnerDid }}
57
57
<span class="select-none before:content-['\00B7']"></span>
58
58
<a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ .RoundNumber }}">{{ template "repo/fragments/shortTime" .Created }}</a>
59
59
<span class="select-none before:content-['ยท']"></span>
···
122
122
{{ end }}
123
123
</div>
124
124
<div class="flex items-center">
125
-
<span>{{ .Title }}</span>
125
+
<span>{{ .Title | description }}</span>
126
126
{{ if gt (len .Body) 0 }}
127
127
<button
128
128
class="py-1/2 px-1 mx-2 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600"
···
151
151
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
152
152
{{ end }}
153
153
<div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1">
154
-
{{ $owner := index $.DidHandleMap $c.OwnerDid }}
155
-
{{ template "user/fragments/picHandleLink" $owner }}
154
+
{{ template "user/fragments/picHandleLink" $c.OwnerDid }}
156
155
<span class="before:content-['ยท']"></span>
157
156
<a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}">{{ template "repo/fragments/time" $c.Created }}</a>
158
157
</div>
+2
-3
appview/pages/templates/repo/pulls/pulls.html
+2
-3
appview/pages/templates/repo/pulls/pulls.html
···
50
50
<div class="px-6 py-4 z-5">
51
51
<div class="pb-2">
52
52
<a href="/{{ $.RepoInfo.FullName }}/pulls/{{ .PullId }}" class="dark:text-white">
53
-
{{ .Title }}
53
+
{{ .Title | description }}
54
54
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
55
55
</a>
56
56
</div>
57
57
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
58
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
59
58
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
60
59
{{ $icon := "ban" }}
61
60
···
76
75
</span>
77
76
78
77
<span class="ml-1">
79
-
{{ template "user/fragments/picHandleLink" $owner }}
78
+
{{ template "user/fragments/picHandleLink" .OwnerDid }}
80
79
</span>
81
80
82
81
<span class="before:content-['ยท']">
+33
-23
appview/pages/templates/repo/settings/pipelines.html
+33
-23
appview/pages/templates/repo/settings/pipelines.html
···
20
20
<div class="col-span-1 md:col-span-2">
21
21
<h2 class="text-sm pb-2 uppercase font-bold">Spindle</h2>
22
22
<p class="text-gray-500 dark:text-gray-400">
23
-
Choose a spindle to execute your workflows on. Spindles can be
24
-
selfhosted,
23
+
Choose a spindle to execute your workflows on. Only repository owners
24
+
can configure spindles. Spindles can be selfhosted,
25
25
<a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">
26
26
click to learn more.
27
27
</a>
28
28
</p>
29
29
</div>
30
-
<form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch">
31
-
<select
32
-
id="spindle"
33
-
name="spindle"
34
-
required
35
-
class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"
36
-
{{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}>
37
-
<option value="" disabled selected >
38
-
Choose a spindle
39
-
</option>
40
-
{{ range $.Spindles }}
41
-
<option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}>
42
-
{{ . }}
30
+
{{ if not $.RepoInfo.Roles.IsOwner }}
31
+
<div class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch">
32
+
{{ or $.CurrentSpindle "No spindle configured" }}
33
+
</div>
34
+
{{ else }}
35
+
<form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch">
36
+
<select
37
+
id="spindle"
38
+
name="spindle"
39
+
required
40
+
class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
41
+
{{/* For some reason, we can't use an empty string in a <select> in all scenarios unless it is preceded by a disabled select?? No idea, could just be a Firefox thing? */}}
42
+
<option value="[[none]]" class="py-1" {{ if not $.CurrentSpindle }}selected{{ end }}>
43
+
{{ if not $.CurrentSpindle }}
44
+
Choose a spindle
45
+
{{ else }}
46
+
Disable pipelines
47
+
{{ end }}
43
48
</option>
44
-
{{ end }}
45
-
</select>
46
-
<button class="btn flex gap-2 items-center" type="submit" {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}>
47
-
{{ i "check" "size-4" }}
48
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
49
-
</button>
50
-
</form>
49
+
{{ range $.Spindles }}
50
+
<option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}>
51
+
{{ . }}
52
+
</option>
53
+
{{ end }}
54
+
</select>
55
+
<button class="btn flex gap-2 items-center" type="submit" {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}>
56
+
{{ i "check" "size-4" }}
57
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
58
+
</button>
59
+
</form>
60
+
{{ end }}
51
61
</div>
52
62
{{ end }}
53
63
···
77
87
{{ end }}
78
88
79
89
{{ define "addSecretButton" }}
80
-
<button
90
+
<button
81
91
class="btn flex items-center gap-2"
82
92
popovertarget="add-secret-modal"
83
93
popovertargetaction="toggle">
-168
appview/pages/templates/repo/settings.html
-168
appview/pages/templates/repo/settings.html
···
1
-
{{ define "title" }}settings · {{ .RepoInfo.FullName }}{{ end }}
2
-
3
-
{{ define "repoContent" }}
4
-
{{ template "collaboratorSettings" . }}
5
-
{{ template "branchSettings" . }}
6
-
{{ template "dangerZone" . }}
7
-
{{ template "spindleSelector" . }}
8
-
{{ template "spindleSecrets" . }}
9
-
{{ end }}
10
-
11
-
{{ define "collaboratorSettings" }}
12
-
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
13
-
Collaborators
14
-
</header>
15
-
16
-
<div id="collaborator-list" class="flex flex-col gap-2 mb-2">
17
-
{{ range .Collaborators }}
18
-
<div id="collaborator" class="mb-2">
19
-
<a
20
-
href="/{{ didOrHandle .Did .Handle }}"
21
-
class="no-underline hover:underline text-black dark:text-white"
22
-
>
23
-
{{ didOrHandle .Did .Handle }}
24
-
</a>
25
-
<div>
26
-
<span class="text-sm text-gray-500 dark:text-gray-400">
27
-
{{ .Role }}
28
-
</span>
29
-
</div>
30
-
</div>
31
-
{{ end }}
32
-
</div>
33
-
34
-
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
35
-
<form
36
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"
37
-
class="group"
38
-
>
39
-
<label for="collaborator" class="dark:text-white">
40
-
add collaborator
41
-
</label>
42
-
<input
43
-
type="text"
44
-
id="collaborator"
45
-
name="collaborator"
46
-
required
47
-
class="dark:bg-gray-700 dark:text-white"
48
-
placeholder="enter did or handle">
49
-
<button class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700" type="text">
50
-
<span>add</span>
51
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
52
-
</button>
53
-
</form>
54
-
{{ end }}
55
-
{{ end }}
56
-
57
-
{{ define "dangerZone" }}
58
-
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
59
-
<form
60
-
hx-confirm="Are you sure you want to delete this repository?"
61
-
hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete"
62
-
class="mt-6"
63
-
hx-indicator="#delete-repo-spinner">
64
-
<label for="branch">delete repository</label>
65
-
<button class="btn my-2 flex items-center" type="text">
66
-
<span>delete</span>
67
-
<span id="delete-repo-spinner" class="group">
68
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
69
-
</span>
70
-
</button>
71
-
<span>
72
-
Deleting a repository is irreversible and permanent.
73
-
</span>
74
-
</form>
75
-
{{ end }}
76
-
{{ end }}
77
-
78
-
{{ define "branchSettings" }}
79
-
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="mt-6 group">
80
-
<label for="branch">default branch</label>
81
-
<div class="flex gap-2 items-center">
82
-
<select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
83
-
<option value="" disabled selected >
84
-
Choose a default branch
85
-
</option>
86
-
{{ range .Branches }}
87
-
<option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} >
88
-
{{ .Name }}
89
-
</option>
90
-
{{ end }}
91
-
</select>
92
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
93
-
<span>save</span>
94
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
95
-
</button>
96
-
</div>
97
-
</form>
98
-
{{ end }}
99
-
100
-
{{ define "spindleSelector" }}
101
-
{{ if .RepoInfo.Roles.IsOwner }}
102
-
<form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="mt-6 group" >
103
-
<label for="spindle">spindle</label>
104
-
<div class="flex gap-2 items-center">
105
-
<select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
106
-
<option value="" selected >
107
-
None
108
-
</option>
109
-
{{ range .Spindles }}
110
-
<option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}>
111
-
{{ . }}
112
-
</option>
113
-
{{ end }}
114
-
</select>
115
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
116
-
<span>save</span>
117
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
118
-
</button>
119
-
</div>
120
-
</form>
121
-
{{ end }}
122
-
{{ end }}
123
-
124
-
{{ define "spindleSecrets" }}
125
-
{{ if $.CurrentSpindle }}
126
-
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
127
-
Secrets
128
-
</header>
129
-
130
-
<div id="secret-list" class="flex flex-col gap-2 mb-2">
131
-
{{ range $idx, $secret := .Secrets }}
132
-
{{ with $secret }}
133
-
<div id="secret-{{$idx}}" class="mb-2">
134
-
{{ .Key }} created on {{ .CreatedAt }} by {{ .CreatedBy }}
135
-
</div>
136
-
{{ end }}
137
-
{{ end }}
138
-
</div>
139
-
<form
140
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets"
141
-
class="mt-6"
142
-
hx-indicator="#add-secret-spinner">
143
-
<label for="key">secret key</label>
144
-
<input
145
-
type="text"
146
-
id="key"
147
-
name="key"
148
-
required
149
-
class="dark:bg-gray-700 dark:text-white"
150
-
placeholder="SECRET_KEY" />
151
-
<label for="value">secret value</label>
152
-
<input
153
-
type="text"
154
-
id="value"
155
-
name="value"
156
-
required
157
-
class="dark:bg-gray-700 dark:text-white"
158
-
placeholder="SECRET VALUE" />
159
-
160
-
<button class="btn my-2 flex items-center" type="text">
161
-
<span>add</span>
162
-
<span id="add-secret-spinner" class="group">
163
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
164
-
</span>
165
-
</button>
166
-
</form>
167
-
{{ end }}
168
-
{{ end }}
+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 }}
+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
+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 text-sm">
175
+
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
176
+
<span id="followers">{{ .Followers }} followers</span>
177
+
<span class="select-none after:content-['ยท']"></span>
178
+
<span id="following">{{ .Following }} following</span>
179
+
</div>
180
+
{{ end }}
181
+
</div>
182
+
</div>
183
+
{{ end }}
-161
appview/pages/templates/timeline.html
-161
appview/pages/templates/timeline.html
···
1
-
{{ define "title" }}timeline{{ end }}
2
-
3
-
{{ define "extrameta" }}
4
-
<meta property="og:title" content="timeline ยท tangled" />
5
-
<meta property="og:type" content="object" />
6
-
<meta property="og:url" content="https://tangled.sh" />
7
-
<meta property="og:description" content="see what's tangling" />
8
-
{{ end }}
9
-
10
-
{{ define "topbar" }}
11
-
{{ template "layouts/topbar" $ }}
12
-
{{ end }}
13
-
14
-
{{ define "content" }}
15
-
{{ with .LoggedInUser }}
16
-
{{ block "timeline" $ }}{{ end }}
17
-
{{ else }}
18
-
{{ block "hero" $ }}{{ end }}
19
-
{{ block "timeline" $ }}{{ end }}
20
-
{{ end }}
21
-
{{ end }}
22
-
23
-
{{ define "hero" }}
24
-
<div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl">
25
-
<div class="font-bold text-4xl">tightly-knit<br>social coding.</div>
26
-
27
-
<p class="text-lg">
28
-
tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>.
29
-
</p>
30
-
<p class="text-lg">
31
-
we envision a place where developers have complete ownership of their
32
-
code, open source communities can freely self-govern and most
33
-
importantly, coding can be social and fun again.
34
-
</p>
35
-
36
-
<div class="flex gap-6 items-center">
37
-
<a href="/login" class="no-underline hover:no-underline ">
38
-
<button class="btn flex gap-2 px-4 items-center">
39
-
join now {{ i "arrow-right" "size-4" }}
40
-
</button>
41
-
</a>
42
-
</div>
43
-
</div>
44
-
{{ end }}
45
-
46
-
{{ define "timeline" }}
47
-
<div>
48
-
<div class="p-6">
49
-
<p class="text-xl font-bold dark:text-white">Timeline</p>
50
-
</div>
51
-
52
-
<div class="flex flex-col gap-4">
53
-
{{ range $i, $e := .Timeline }}
54
-
<div class="relative">
55
-
{{ if ne $i 0 }}
56
-
<div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div>
57
-
{{ end }}
58
-
{{ with $e }}
59
-
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
60
-
{{ if .Repo }}
61
-
{{ block "repoEvent" (list $ .Repo .Source) }} {{ end }}
62
-
{{ else if .Star }}
63
-
{{ block "starEvent" (list $ .Star) }} {{ end }}
64
-
{{ else if .Follow }}
65
-
{{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} {{ end }}
66
-
{{ end }}
67
-
</div>
68
-
{{ end }}
69
-
</div>
70
-
{{ end }}
71
-
</div>
72
-
</div>
73
-
{{ end }}
74
-
75
-
{{ define "repoEvent" }}
76
-
{{ $root := index . 0 }}
77
-
{{ $repo := index . 1 }}
78
-
{{ $source := index . 2 }}
79
-
{{ $userHandle := index $root.DidHandleMap $repo.Did }}
80
-
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
81
-
{{ template "user/fragments/picHandleLink" $userHandle }}
82
-
{{ with $source }}
83
-
forked
84
-
<a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}"class="no-underline hover:underline">
85
-
{{ index $root.DidHandleMap .Did }}/{{ .Name }}
86
-
</a>
87
-
to
88
-
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a>
89
-
{{ else }}
90
-
created
91
-
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">
92
-
{{ $repo.Name }}
93
-
</a>
94
-
{{ end }}
95
-
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span>
96
-
</div>
97
-
{{ with $repo }}
98
-
{{ template "user/fragments/repoCard" (list $root . true) }}
99
-
{{ end }}
100
-
{{ end }}
101
-
102
-
{{ define "starEvent" }}
103
-
{{ $root := index . 0 }}
104
-
{{ $star := index . 1 }}
105
-
{{ with $star }}
106
-
{{ $starrerHandle := index $root.DidHandleMap .StarredByDid }}
107
-
{{ $repoOwnerHandle := index $root.DidHandleMap .Repo.Did }}
108
-
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
109
-
{{ template "user/fragments/picHandleLink" $starrerHandle }}
110
-
starred
111
-
<a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">
112
-
{{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }}
113
-
</a>
114
-
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span>
115
-
</div>
116
-
{{ with .Repo }}
117
-
{{ template "user/fragments/repoCard" (list $root . true) }}
118
-
{{ end }}
119
-
{{ end }}
120
-
{{ end }}
121
-
122
-
123
-
{{ define "followEvent" }}
124
-
{{ $root := index . 0 }}
125
-
{{ $follow := index . 1 }}
126
-
{{ $profile := index . 2 }}
127
-
{{ $stat := index . 3 }}
128
-
129
-
{{ $userHandle := index $root.DidHandleMap $follow.UserDid }}
130
-
{{ $subjectHandle := index $root.DidHandleMap $follow.SubjectDid }}
131
-
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
132
-
{{ template "user/fragments/picHandleLink" $userHandle }}
133
-
followed
134
-
{{ template "user/fragments/picHandleLink" $subjectHandle }}
135
-
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
136
-
</div>
137
-
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
138
-
<div class="flex-shrink-0 max-h-full w-24 h-24">
139
-
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" />
140
-
</div>
141
-
142
-
<div class="flex-1 min-h-0 justify-around flex flex-col">
143
-
<a href="/{{ $subjectHandle }}">
144
-
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span>
145
-
</a>
146
-
{{ with $profile }}
147
-
{{ with .Description }}
148
-
<p class="text-sm pb-2 md:pb-2">{{.}}</p>
149
-
{{ end }}
150
-
{{ end }}
151
-
{{ with $stat }}
152
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
153
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
154
-
<span id="followers">{{ .Followers }} followers</span>
155
-
<span class="select-none after:content-['ยท']"></span>
156
-
<span id="following">{{ .Following }} following</span>
157
-
</div>
158
-
{{ end }}
159
-
</div>
160
-
</div>
161
-
{{ end }}
+5
-5
appview/pages/templates/user/completeSignup.html
+5
-5
appview/pages/templates/user/completeSignup.html
···
38
38
tightly-knit social coding.
39
39
</h2>
40
40
<form
41
-
class="mt-4 max-w-sm mx-auto"
41
+
class="mt-4 max-w-sm mx-auto flex flex-col gap-4"
42
42
hx-post="/signup/complete"
43
43
hx-swap="none"
44
44
hx-disabled-elt="#complete-signup-button"
···
58
58
</span>
59
59
</div>
60
60
61
-
<div class="flex flex-col mt-4">
62
-
<label for="username">desired username</label>
61
+
<div class="flex flex-col">
62
+
<label for="username">username</label>
63
63
<input
64
64
type="text"
65
65
id="username"
···
73
73
</span>
74
74
</div>
75
75
76
-
<div class="flex flex-col mt-4">
76
+
<div class="flex flex-col">
77
77
<label for="password">password</label>
78
78
<input
79
79
type="password"
···
88
88
</div>
89
89
90
90
<button
91
-
class="btn-create w-full my-2 mt-6"
91
+
class="btn-create w-full my-2 mt-6 text-base"
92
92
type="submit"
93
93
id="complete-signup-button"
94
94
tabindex="4"
+1
-1
appview/pages/templates/user/fragments/editPins.html
+1
-1
appview/pages/templates/user/fragments/editPins.html
···
27
27
<input type="checkbox" id="repo-{{$idx}}" name="pinnedRepo{{$idx}}" value="{{.RepoAt}}" {{if .IsPinned}}checked{{end}}>
28
28
<label for="repo-{{$idx}}" class="my-0 py-0 normal-case font-normal w-full">
29
29
<div class="flex justify-between items-center w-full">
30
-
<span class="flex-shrink-0 overflow-hidden text-ellipsis ">{{ index $.DidHandleMap .Did }}/{{.Name}}</span>
30
+
<span class="flex-shrink-0 overflow-hidden text-ellipsis ">{{ resolve .Did }}/{{.Name}}</span>
31
31
<div class="flex gap-1 items-center">
32
32
{{ i "star" "size-4 fill-current" }}
33
33
<span>{{ .RepoStats.StarCount }}</span>
+3
-2
appview/pages/templates/user/fragments/picHandleLink.html
+3
-2
appview/pages/templates/user/fragments/picHandleLink.html
···
1
1
{{ define "user/fragments/picHandleLink" }}
2
-
<a href="/{{ . }}" class="flex items-center">
3
-
{{ template "user/fragments/picHandle" . }}
2
+
{{ $resolved := resolve . }}
3
+
<a href="/{{ $resolved }}" class="flex items-center">
4
+
{{ template "user/fragments/picHandle" $resolved }}
4
5
</a>
5
6
{{ end }}
+8
-7
appview/pages/templates/user/fragments/profileCard.html
+8
-7
appview/pages/templates/user/fragments/profileCard.html
···
2
2
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit">
3
3
<div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center">
4
4
<div id="avatar" class="col-span-1 flex justify-center items-center">
5
-
{{ if .AvatarUri }}
6
5
<div class="w-3/4 aspect-square relative">
7
-
<img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ .AvatarUri }}" />
6
+
<img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ fullAvatar .UserDid }}" />
8
7
</div>
9
-
{{ end }}
10
8
</div>
11
9
<div class="col-span-2">
12
-
<p title="{{ didOrHandle .UserDid .UserHandle }}"
13
-
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
14
-
{{ didOrHandle .UserDid .UserHandle }}
15
-
</p>
10
+
<div class="flex items-center flex-row flex-nowrap gap-2">
11
+
<p title="{{ didOrHandle .UserDid .UserHandle }}"
12
+
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
13
+
{{ didOrHandle .UserDid .UserHandle }}
14
+
</p>
15
+
<a href="/{{ didOrHandle .UserDid .UserHandle }}/feed.atom">{{ i "rss" "size-4" }}</a>
16
+
</div>
16
17
17
18
<div class="md:hidden">
18
19
{{ block "followerFollowing" (list .Followers .Following) }} {{ end }}
+40
-34
appview/pages/templates/user/fragments/repoCard.html
+40
-34
appview/pages/templates/user/fragments/repoCard.html
···
4
4
{{ $fullName := index . 2 }}
5
5
6
6
{{ with $repo }}
7
-
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
8
-
<div class="font-medium dark:text-white flex gap-2 items-center">
9
-
{{- if $fullName -}}
10
-
<a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}">{{ index $root.DidHandleMap .Did }}/{{ .Name }}</a>
11
-
{{- else -}}
12
-
<a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}">{{ .Name }}</a>
13
-
{{- end -}}
7
+
<div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32">
8
+
<div class="font-medium dark:text-white flex items-center">
9
+
{{ if .Source }}
10
+
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
11
+
{{ else }}
12
+
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
13
+
{{ end }}
14
+
15
+
{{ $repoOwner := resolve .Did }}
16
+
{{- if $fullName -}}
17
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a>
18
+
{{- else -}}
19
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a>
20
+
{{- end -}}
21
+
</div>
22
+
{{ with .Description }}
23
+
<div class="text-gray-600 dark:text-gray-300 text-sm line-clamp-2">
24
+
{{ . | description }}
14
25
</div>
15
-
{{ with .Description }}
16
-
<div class="text-gray-600 dark:text-gray-300 text-sm">
17
-
{{ . }}
18
-
</div>
19
-
{{ end }}
26
+
{{ end }}
20
27
21
-
{{ if .RepoStats }}
22
-
{{ block "repoStats" .RepoStats }} {{ end }}
23
-
{{ end }}
28
+
{{ if .RepoStats }}
29
+
{{ block "repoStats" .RepoStats }}{{ end }}
30
+
{{ end }}
24
31
</div>
25
32
{{ end }}
26
33
{{ end }}
27
34
28
35
{{ define "repoStats" }}
29
-
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-4 mt-auto">
36
+
<div class="text-gray-400 text-sm font-mono inline-flex gap-4 mt-auto">
30
37
{{ with .Language }}
31
-
<div class="flex gap-2 items-center text-sm">
32
-
<div class="size-2 rounded-full" style="background-color: {{ langColor . }};"></div>
33
-
<span>{{ . }}</span>
34
-
</div>
38
+
<div class="flex gap-2 items-center text-sm">
39
+
<div class="size-2 rounded-full"
40
+
style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ langColor . }} 70%, white), {{ langColor . }} 30%, color-mix(in srgb, {{ langColor . }} 85%, black));"></div>
41
+
<span>{{ . }}</span>
42
+
</div>
35
43
{{ end }}
36
44
{{ with .StarCount }}
37
-
<div class="flex gap-1 items-center text-sm">
38
-
{{ i "star" "w-3 h-3 fill-current" }}
39
-
<span>{{ . }}</span>
40
-
</div>
45
+
<div class="flex gap-1 items-center text-sm">
46
+
{{ i "star" "w-3 h-3 fill-current" }}
47
+
<span>{{ . }}</span>
48
+
</div>
41
49
{{ end }}
42
50
{{ with .IssueCount.Open }}
43
-
<div class="flex gap-1 items-center text-sm">
44
-
{{ i "circle-dot" "w-3 h-3" }}
45
-
<span>{{ . }}</span>
46
-
</div>
51
+
<div class="flex gap-1 items-center text-sm">
52
+
{{ i "circle-dot" "w-3 h-3" }}
53
+
<span>{{ . }}</span>
54
+
</div>
47
55
{{ end }}
48
56
{{ with .PullCount.Open }}
49
-
<div class="flex gap-1 items-center text-sm">
50
-
{{ i "git-pull-request" "w-3 h-3" }}
51
-
<span>{{ . }}</span>
52
-
</div>
57
+
<div class="flex gap-1 items-center text-sm">
58
+
{{ i "git-pull-request" "w-3 h-3" }}
59
+
<span>{{ . }}</span>
60
+
</div>
53
61
{{ end }}
54
62
</div>
55
63
{{ end }}
56
-
57
-
+12
-79
appview/pages/templates/user/login.html
+12
-79
appview/pages/templates/user/login.html
···
3
3
<html lang="en" class="dark:bg-gray-900">
4
4
<head>
5
5
<meta charset="UTF-8" />
6
-
<meta
7
-
name="viewport"
8
-
content="width=device-width, initial-scale=1.0"
9
-
/>
10
-
<meta
11
-
property="og:title"
12
-
content="login ยท tangled"
13
-
/>
14
-
<meta
15
-
property="og:url"
16
-
content="https://tangled.sh/login"
17
-
/>
18
-
<meta
19
-
property="og:description"
20
-
content="login to or sign up for tangled"
21
-
/>
6
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+
<meta property="og:title" content="login ยท tangled" />
8
+
<meta property="og:url" content="https://tangled.sh/login" />
9
+
<meta property="og:description" content="login to for tangled" />
22
10
<script src="/static/htmx.min.js"></script>
23
-
<link
24
-
rel="stylesheet"
25
-
href="/static/tw.css?{{ cssContentHash }}"
26
-
type="text/css"
27
-
/>
28
-
<title>login or sign up · tangled</title>
11
+
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
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
54
-
placeholder="foo.tngl.sh"
36
+
placeholder="akshay.tngl.sh"
55
37
/>
56
38
<span class="text-sm text-gray-500 mt-1">
57
39
Use your <a href="https://atproto.com">ATProto</a>
···
59
41
your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account.
60
42
</span>
61
43
</div>
44
+
<input type="hidden" name="return_url" value="{{ .ReturnUrl }}">
62
45
63
46
<button
64
-
class="btn w-full my-2 mt-6"
47
+
class="btn w-full my-2 mt-6 text-base "
65
48
type="submit"
66
49
id="login-button"
67
50
tabindex="3"
···
69
52
<span>login</span>
70
53
</button>
71
54
</form>
72
-
<hr class="my-4">
73
-
<p class="text-sm text-gray-500 mt-4">
74
-
Alternatively, you may create an account on Tangled below. You will
75
-
get a <code>user.tngl.sh</code> handle.
55
+
<p class="text-sm text-gray-500">
56
+
Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now!
76
57
</p>
77
58
78
-
<details class="group">
79
-
80
-
<summary
81
-
class="btn cursor-pointer w-full mt-4 flex items-center justify-center gap-2"
82
-
>
83
-
create an account
84
-
85
-
<div class="group-open:hidden flex">{{ i "arrow-right" "w-4 h-4" }}</div>
86
-
<div class="hidden group-open:flex">{{ i "arrow-down" "w-4 h-4" }}</div>
87
-
</summary>
88
-
<form
89
-
class="mt-4 max-w-sm mx-auto"
90
-
hx-post="/signup"
91
-
hx-swap="none"
92
-
hx-disabled-elt="#signup-button"
93
-
>
94
-
<div class="flex flex-col mt-2">
95
-
<label for="email">email</label>
96
-
<input
97
-
type="email"
98
-
id="email"
99
-
name="email"
100
-
tabindex="4"
101
-
required
102
-
placeholder="jason@bourne.co"
103
-
/>
104
-
</div>
105
-
<span class="text-sm text-gray-500 mt-1">
106
-
You will receive an email with a code. Enter that, along with your
107
-
desired username and password in the next page to complete your registration.
108
-
</span>
109
-
<button
110
-
class="btn w-full my-2 mt-6"
111
-
type="submit"
112
-
id="signup-button"
113
-
tabindex="7"
114
-
>
115
-
<span>sign up</span>
116
-
</button>
117
-
</form>
118
-
</details>
119
-
<p class="text-sm text-gray-500 mt-6">
120
-
Join our <a href="https://chat.tangled.sh">Discord</a> or
121
-
IRC channel:
122
-
<a href="https://web.libera.chat/#tangled"
123
-
><code>#tangled</code> on Libera Chat</a
124
-
>.
125
-
</p>
126
59
<p id="login-msg" class="error w-full"></p>
127
60
</main>
128
61
</body>
+13
-20
appview/pages/templates/user/profile.html
+13
-20
appview/pages/templates/user/profile.html
···
50
50
</div>
51
51
{{ else }}
52
52
<div class="flex flex-col gap-1">
53
-
{{ block "repoEvents" (list .RepoEvents $.DidHandleMap) }} {{ end }}
54
-
{{ block "issueEvents" (list .IssueEvents $.DidHandleMap) }} {{ end }}
55
-
{{ block "pullEvents" (list .PullEvents $.DidHandleMap) }} {{ end }}
53
+
{{ block "repoEvents" .RepoEvents }} {{ end }}
54
+
{{ block "issueEvents" .IssueEvents }} {{ end }}
55
+
{{ block "pullEvents" .PullEvents }} {{ end }}
56
56
</div>
57
57
{{ end }}
58
58
</div>
···
66
66
{{ end }}
67
67
68
68
{{ define "repoEvents" }}
69
-
{{ $items := index . 0 }}
70
-
{{ $handleMap := index . 1 }}
71
-
72
-
{{ if gt (len $items) 0 }}
69
+
{{ if gt (len .) 0 }}
73
70
<details>
74
71
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
75
72
<div class="flex flex-wrap items-center gap-2">
76
73
{{ i "book-plus" "w-4 h-4" }}
77
-
created {{ len $items }} {{if eq (len $items) 1 }}repository{{else}}repositories{{end}}
74
+
created {{ len . }} {{if eq (len .) 1 }}repository{{else}}repositories{{end}}
78
75
</div>
79
76
</summary>
80
77
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
81
-
{{ range $items }}
78
+
{{ range . }}
82
79
<div class="flex flex-wrap items-center gap-2">
83
80
<span class="text-gray-500 dark:text-gray-400">
84
81
{{ if .Source }}
···
87
84
{{ i "book-plus" "w-4 h-4" }}
88
85
{{ end }}
89
86
</span>
90
-
<a href="/{{ index $handleMap .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">
87
+
<a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">
91
88
{{- .Repo.Name -}}
92
89
</a>
93
90
</div>
···
98
95
{{ end }}
99
96
100
97
{{ define "issueEvents" }}
101
-
{{ $i := index . 0 }}
102
-
{{ $items := $i.Items }}
103
-
{{ $stats := $i.Stats }}
104
-
{{ $handleMap := index . 1 }}
98
+
{{ $items := .Items }}
99
+
{{ $stats := .Stats }}
105
100
106
101
{{ if gt (len $items) 0 }}
107
102
<details>
···
129
124
</summary>
130
125
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
131
126
{{ range $items }}
132
-
{{ $repoOwner := index $handleMap .Metadata.Repo.Did }}
127
+
{{ $repoOwner := resolve .Metadata.Repo.Did }}
133
128
{{ $repoName := .Metadata.Repo.Name }}
134
129
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
135
130
···
163
158
{{ end }}
164
159
165
160
{{ define "pullEvents" }}
166
-
{{ $i := index . 0 }}
167
-
{{ $items := $i.Items }}
168
-
{{ $stats := $i.Stats }}
169
-
{{ $handleMap := index . 1 }}
161
+
{{ $items := .Items }}
162
+
{{ $stats := .Stats }}
170
163
{{ if gt (len $items) 0 }}
171
164
<details>
172
165
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
···
200
193
</summary>
201
194
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
202
195
{{ range $items }}
203
-
{{ $repoOwner := index $handleMap .Repo.Did }}
196
+
{{ $repoOwner := resolve .Repo.Did }}
204
197
{{ $repoName := .Repo.Name }}
205
198
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
206
199
+53
appview/pages/templates/user/signup.html
+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
+
+40
-90
appview/pulls/pulls.go
+40
-90
appview/pulls/pulls.go
···
19
19
"tangled.sh/tangled.sh/core/appview/notify"
20
20
"tangled.sh/tangled.sh/core/appview/oauth"
21
21
"tangled.sh/tangled.sh/core/appview/pages"
22
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
22
23
"tangled.sh/tangled.sh/core/appview/reporesolver"
23
24
"tangled.sh/tangled.sh/core/idresolver"
24
25
"tangled.sh/tangled.sh/core/knotclient"
···
28
29
29
30
"github.com/bluekeyes/go-gitdiff/gitdiff"
30
31
comatproto "github.com/bluesky-social/indigo/api/atproto"
31
-
"github.com/bluesky-social/indigo/atproto/syntax"
32
32
lexutil "github.com/bluesky-social/indigo/lex/util"
33
33
"github.com/go-chi/chi/v5"
34
34
"github.com/google/uuid"
···
151
151
}
152
152
}
153
153
154
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve)
155
-
didHandleMap := make(map[string]string)
156
-
for _, identity := range resolvedIds {
157
-
if !identity.Handle.IsInvalidHandle() {
158
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
159
-
} else {
160
-
didHandleMap[identity.DID.String()] = identity.DID.String()
161
-
}
162
-
}
163
-
164
154
mergeCheckResponse := s.mergeCheck(f, pull, stack)
165
155
resubmitResult := pages.Unknown
166
156
if user != nil && user.Did == pull.OwnerDid {
···
212
202
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
213
203
LoggedInUser: user,
214
204
RepoInfo: repoInfo,
215
-
DidHandleMap: didHandleMap,
216
205
Pull: pull,
217
206
Stack: stack,
218
207
AbandonedPulls: abandonedPulls,
···
257
246
patch = mergeable.CombinedPatch()
258
247
}
259
248
260
-
resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch)
249
+
resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.Name, pull.TargetBranch)
261
250
if err != nil {
262
251
log.Println("failed to check for mergeability:", err)
263
252
return types.MergeCheckResponse{
···
318
307
// pulls within the same repo
319
308
knot = f.Knot
320
309
ownerDid = f.OwnerDid()
321
-
repoName = f.RepoName
310
+
repoName = f.Name
322
311
}
323
312
324
313
us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev)
···
377
366
return
378
367
}
379
368
380
-
identsToResolve := []string{pull.OwnerDid}
381
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve)
382
-
didHandleMap := make(map[string]string)
383
-
for _, identity := range resolvedIds {
384
-
if !identity.Handle.IsInvalidHandle() {
385
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
386
-
} else {
387
-
didHandleMap[identity.DID.String()] = identity.DID.String()
388
-
}
389
-
}
390
-
391
369
patch := pull.Submissions[roundIdInt].Patch
392
370
diff := patchutil.AsNiceDiff(patch, pull.TargetBranch)
393
371
394
372
s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
395
373
LoggedInUser: user,
396
-
DidHandleMap: didHandleMap,
397
374
RepoInfo: f.RepoInfo(user),
398
375
Pull: pull,
399
376
Stack: stack,
···
440
417
return
441
418
}
442
419
443
-
identsToResolve := []string{pull.OwnerDid}
444
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve)
445
-
didHandleMap := make(map[string]string)
446
-
for _, identity := range resolvedIds {
447
-
if !identity.Handle.IsInvalidHandle() {
448
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
449
-
} else {
450
-
didHandleMap[identity.DID.String()] = identity.DID.String()
451
-
}
452
-
}
453
-
454
420
currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch)
455
421
if err != nil {
456
422
log.Println("failed to interdiff; current patch malformed")
···
472
438
RepoInfo: f.RepoInfo(user),
473
439
Pull: pull,
474
440
Round: roundIdInt,
475
-
DidHandleMap: didHandleMap,
476
441
Interdiff: interdiff,
477
442
DiffOpts: diffOpts,
478
443
})
···
494
459
return
495
460
}
496
461
497
-
identsToResolve := []string{pull.OwnerDid}
498
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve)
499
-
didHandleMap := make(map[string]string)
500
-
for _, identity := range resolvedIds {
501
-
if !identity.Handle.IsInvalidHandle() {
502
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
503
-
} else {
504
-
didHandleMap[identity.DID.String()] = identity.DID.String()
505
-
}
506
-
}
507
-
508
462
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
509
463
w.Write([]byte(pull.Submissions[roundIdInt].Patch))
510
464
}
···
529
483
530
484
pulls, err := db.GetPulls(
531
485
s.db,
532
-
db.FilterEq("repo_at", f.RepoAt),
486
+
db.FilterEq("repo_at", f.RepoAt()),
533
487
db.FilterEq("state", state),
534
488
)
535
489
if err != nil {
···
595
549
m[p.Sha] = p
596
550
}
597
551
598
-
identsToResolve := make([]string, len(pulls))
599
-
for i, pull := range pulls {
600
-
identsToResolve[i] = pull.OwnerDid
601
-
}
602
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve)
603
-
didHandleMap := make(map[string]string)
604
-
for _, identity := range resolvedIds {
605
-
if !identity.Handle.IsInvalidHandle() {
606
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
607
-
} else {
608
-
didHandleMap[identity.DID.String()] = identity.DID.String()
609
-
}
610
-
}
611
-
612
552
s.pages.RepoPulls(w, pages.RepoPullsParams{
613
553
LoggedInUser: s.oauth.GetUser(r),
614
554
RepoInfo: f.RepoInfo(user),
615
555
Pulls: pulls,
616
-
DidHandleMap: didHandleMap,
617
556
FilteringBy: state,
618
557
Stacks: stacks,
619
558
Pipelines: m,
···
671
610
createdAt := time.Now().Format(time.RFC3339)
672
611
ownerDid := user.Did
673
612
674
-
pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId)
613
+
pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId)
675
614
if err != nil {
676
615
log.Println("failed to get pull at", err)
677
616
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
678
617
return
679
618
}
680
619
681
-
atUri := f.RepoAt.String()
620
+
atUri := f.RepoAt().String()
682
621
client, err := s.oauth.AuthorizedClient(r)
683
622
if err != nil {
684
623
log.Println("failed to get authorized client", err)
···
707
646
708
647
comment := &db.PullComment{
709
648
OwnerDid: user.Did,
710
-
RepoAt: f.RepoAt.String(),
649
+
RepoAt: f.RepoAt().String(),
711
650
PullId: pull.PullId,
712
651
Body: body,
713
652
CommentAt: atResp.Uri,
···
753
692
return
754
693
}
755
694
756
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
695
+
result, err := us.Branches(f.OwnerDid(), f.Name)
757
696
if err != nil {
758
697
log.Println("failed to fetch branches", err)
759
698
return
···
799
738
if isPatchBased && !patchutil.IsFormatPatch(patch) {
800
739
if title == "" {
801
740
s.pages.Notice(w, "pull", "Title is required for git-diff patches.")
741
+
return
742
+
}
743
+
sanitizer := markup.NewSanitizer()
744
+
if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" {
745
+
s.pages.Notice(w, "pull", "Title is empty after HTML sanitization")
802
746
return
803
747
}
804
748
}
···
877
821
return
878
822
}
879
823
880
-
comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
824
+
comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, targetBranch, sourceBranch)
881
825
if err != nil {
882
826
log.Println("failed to compare", err)
883
827
s.pages.Notice(w, "pull", err.Error())
···
979
923
return
980
924
}
981
925
982
-
forkAtUri, err := syntax.ParseATURI(fork.AtUri)
983
-
if err != nil {
984
-
log.Println("failed to parse fork AT URI", err)
985
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
986
-
return
987
-
}
926
+
forkAtUri := fork.RepoAt()
927
+
forkAtUriStr := forkAtUri.String()
988
928
989
929
pullSource := &db.PullSource{
990
930
Branch: sourceBranch,
···
992
932
}
993
933
recordPullSource := &tangled.RepoPull_Source{
994
934
Branch: sourceBranch,
995
-
Repo: &fork.AtUri,
935
+
Repo: &forkAtUriStr,
996
936
Sha: sourceRev,
997
937
}
998
938
···
1068
1008
Body: body,
1069
1009
TargetBranch: targetBranch,
1070
1010
OwnerDid: user.Did,
1071
-
RepoAt: f.RepoAt,
1011
+
RepoAt: f.RepoAt(),
1072
1012
Rkey: rkey,
1073
1013
Submissions: []*db.PullSubmission{
1074
1014
&initialSubmission,
···
1081
1021
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1082
1022
return
1083
1023
}
1084
-
pullId, err := db.NextPullId(tx, f.RepoAt)
1024
+
pullId, err := db.NextPullId(tx, f.RepoAt())
1085
1025
if err != nil {
1086
1026
log.Println("failed to get pull id", err)
1087
1027
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
···
1096
1036
Val: &tangled.RepoPull{
1097
1037
Title: title,
1098
1038
PullId: int64(pullId),
1099
-
TargetRepo: string(f.RepoAt),
1039
+
TargetRepo: string(f.RepoAt()),
1100
1040
TargetBranch: targetBranch,
1101
1041
Patch: patch,
1102
1042
Source: recordPullSource,
···
1274
1214
return
1275
1215
}
1276
1216
1277
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
1217
+
result, err := us.Branches(f.OwnerDid(), f.Name)
1278
1218
if err != nil {
1279
1219
log.Println("failed to reach knotserver", err)
1280
1220
return
···
1358
1298
return
1359
1299
}
1360
1300
1361
-
targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName)
1301
+
targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.Name)
1362
1302
if err != nil {
1363
1303
log.Println("failed to reach knotserver for target branches", err)
1364
1304
return
···
1474
1414
return
1475
1415
}
1476
1416
1477
-
comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch)
1417
+
comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, pull.TargetBranch, pull.PullSource.Branch)
1478
1418
if err != nil {
1479
1419
log.Printf("compare request failed: %s", err)
1480
1420
s.pages.Notice(w, "resubmit-error", err.Error())
···
1658
1598
Val: &tangled.RepoPull{
1659
1599
Title: pull.Title,
1660
1600
PullId: int64(pull.PullId),
1661
-
TargetRepo: string(f.RepoAt),
1601
+
TargetRepo: string(f.RepoAt()),
1662
1602
TargetBranch: pull.TargetBranch,
1663
1603
Patch: patch, // new patch
1664
1604
Source: recordPullSource,
···
1774
1714
1775
1715
// deleted pulls are marked as deleted in the DB
1776
1716
for _, p := range deletions {
1717
+
// do not do delete already merged PRs
1718
+
if p.State == db.PullMerged {
1719
+
continue
1720
+
}
1721
+
1777
1722
err := db.DeletePull(tx, p.RepoAt, p.PullId)
1778
1723
if err != nil {
1779
1724
log.Println("failed to delete pull", err, p.PullId)
···
1814
1759
op, _ := origById[id]
1815
1760
np, _ := newById[id]
1816
1761
1762
+
// do not update already merged PRs
1763
+
if op.State == db.PullMerged {
1764
+
continue
1765
+
}
1766
+
1817
1767
submission := np.Submissions[np.LastRoundNumber()]
1818
1768
1819
1769
// resubmit the old pull
···
1985
1935
}
1986
1936
1987
1937
// Merge the pull request
1988
-
resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
1938
+
resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.Name, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
1989
1939
if err != nil {
1990
1940
log.Printf("failed to merge pull request: %s", err)
1991
1941
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
···
2007
1957
defer tx.Rollback()
2008
1958
2009
1959
for _, p := range pullsToMerge {
2010
-
err := db.MergePull(tx, f.RepoAt, p.PullId)
1960
+
err := db.MergePull(tx, f.RepoAt(), p.PullId)
2011
1961
if err != nil {
2012
1962
log.Printf("failed to update pull request status in database: %s", err)
2013
1963
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
···
2023
1973
return
2024
1974
}
2025
1975
2026
-
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
1976
+
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
2027
1977
}
2028
1978
2029
1979
func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) {
···
2075
2025
2076
2026
for _, p := range pullsToClose {
2077
2027
// Close the pull in the database
2078
-
err = db.ClosePull(tx, f.RepoAt, p.PullId)
2028
+
err = db.ClosePull(tx, f.RepoAt(), p.PullId)
2079
2029
if err != nil {
2080
2030
log.Println("failed to close pull", err)
2081
2031
s.pages.Notice(w, "pull-close", "Failed to close pull.")
···
2143
2093
2144
2094
for _, p := range pullsToReopen {
2145
2095
// Close the pull in the database
2146
-
err = db.ReopenPull(tx, f.RepoAt, p.PullId)
2096
+
err = db.ReopenPull(tx, f.RepoAt(), p.PullId)
2147
2097
if err != nil {
2148
2098
log.Println("failed to close pull", err)
2149
2099
s.pages.Notice(w, "pull-close", "Failed to close pull.")
···
2195
2145
Body: body,
2196
2146
TargetBranch: targetBranch,
2197
2147
OwnerDid: user.Did,
2198
-
RepoAt: f.RepoAt,
2148
+
RepoAt: f.RepoAt(),
2199
2149
Rkey: rkey,
2200
2150
Submissions: []*db.PullSubmission{
2201
2151
&initialSubmission,
+6
-6
appview/repo/artifact.go
+6
-6
appview/repo/artifact.go
···
76
76
Artifact: uploadBlobResp.Blob,
77
77
CreatedAt: createdAt.Format(time.RFC3339),
78
78
Name: handler.Filename,
79
-
Repo: f.RepoAt.String(),
79
+
Repo: f.RepoAt().String(),
80
80
Tag: tag.Tag.Hash[:],
81
81
},
82
82
},
···
100
100
artifact := db.Artifact{
101
101
Did: user.Did,
102
102
Rkey: rkey,
103
-
RepoAt: f.RepoAt,
103
+
RepoAt: f.RepoAt(),
104
104
Tag: tag.Tag.Hash,
105
105
CreatedAt: createdAt,
106
106
BlobCid: cid.Cid(uploadBlobResp.Blob.Ref),
···
155
155
156
156
artifacts, err := db.GetArtifact(
157
157
rp.db,
158
-
db.FilterEq("repo_at", f.RepoAt),
158
+
db.FilterEq("repo_at", f.RepoAt()),
159
159
db.FilterEq("tag", tag.Tag.Hash[:]),
160
160
db.FilterEq("name", filename),
161
161
)
···
197
197
198
198
artifacts, err := db.GetArtifact(
199
199
rp.db,
200
-
db.FilterEq("repo_at", f.RepoAt),
200
+
db.FilterEq("repo_at", f.RepoAt()),
201
201
db.FilterEq("tag", tag[:]),
202
202
db.FilterEq("name", filename),
203
203
)
···
239
239
defer tx.Rollback()
240
240
241
241
err = db.DeleteArtifact(tx,
242
-
db.FilterEq("repo_at", f.RepoAt),
242
+
db.FilterEq("repo_at", f.RepoAt()),
243
243
db.FilterEq("tag", artifact.Tag[:]),
244
244
db.FilterEq("name", filename),
245
245
)
···
270
270
return nil, err
271
271
}
272
272
273
-
result, err := us.Tags(f.OwnerDid(), f.RepoName)
273
+
result, err := us.Tags(f.OwnerDid(), f.Name)
274
274
if err != nil {
275
275
log.Println("failed to reach knotserver", err)
276
276
return nil, err
+165
appview/repo/feed.go
+165
appview/repo/feed.go
···
1
+
package repo
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"log"
7
+
"net/http"
8
+
"slices"
9
+
"time"
10
+
11
+
"tangled.sh/tangled.sh/core/appview/db"
12
+
"tangled.sh/tangled.sh/core/appview/reporesolver"
13
+
14
+
"github.com/bluesky-social/indigo/atproto/syntax"
15
+
"github.com/gorilla/feeds"
16
+
)
17
+
18
+
func (rp *Repo) getRepoFeed(ctx context.Context, f *reporesolver.ResolvedRepo) (*feeds.Feed, error) {
19
+
const feedLimitPerType = 100
20
+
21
+
pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt()))
22
+
if err != nil {
23
+
return nil, err
24
+
}
25
+
26
+
issues, err := db.GetIssuesWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt()))
27
+
if err != nil {
28
+
return nil, err
29
+
}
30
+
31
+
feed := &feeds.Feed{
32
+
Title: fmt.Sprintf("activity feed for %s", f.OwnerSlashRepo()),
33
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.AppviewHost, f.OwnerSlashRepo()), Type: "text/html", Rel: "alternate"},
34
+
Items: make([]*feeds.Item, 0),
35
+
Updated: time.UnixMilli(0),
36
+
}
37
+
38
+
for _, pull := range pulls {
39
+
items, err := rp.createPullItems(ctx, pull, f)
40
+
if err != nil {
41
+
return nil, err
42
+
}
43
+
feed.Items = append(feed.Items, items...)
44
+
}
45
+
46
+
for _, issue := range issues {
47
+
item, err := rp.createIssueItem(ctx, issue, f)
48
+
if err != nil {
49
+
return nil, err
50
+
}
51
+
feed.Items = append(feed.Items, item)
52
+
}
53
+
54
+
slices.SortFunc(feed.Items, func(a, b *feeds.Item) int {
55
+
if a.Created.After(b.Created) {
56
+
return -1
57
+
}
58
+
return 1
59
+
})
60
+
61
+
if len(feed.Items) > 0 {
62
+
feed.Updated = feed.Items[0].Created
63
+
}
64
+
65
+
return feed, nil
66
+
}
67
+
68
+
func (rp *Repo) createPullItems(ctx context.Context, pull *db.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) {
69
+
owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid)
70
+
if err != nil {
71
+
return nil, err
72
+
}
73
+
74
+
var items []*feeds.Item
75
+
76
+
state := rp.getPullState(pull)
77
+
description := rp.buildPullDescription(owner.Handle, state, pull, f.OwnerSlashRepo())
78
+
79
+
mainItem := &feeds.Item{
80
+
Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title),
81
+
Description: description,
82
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId)},
83
+
Created: pull.Created,
84
+
Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)},
85
+
}
86
+
items = append(items, mainItem)
87
+
88
+
for _, round := range pull.Submissions {
89
+
if round == nil || round.RoundNumber == 0 {
90
+
continue
91
+
}
92
+
93
+
roundItem := &feeds.Item{
94
+
Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.RoundNumber),
95
+
Description: fmt.Sprintf("@%s submitted changes (at round #%d) on PR #%d in %s", owner.Handle, round.RoundNumber, pull.PullId, f.OwnerSlashRepo()),
96
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId, round.RoundNumber)},
97
+
Created: round.Created,
98
+
Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)},
99
+
}
100
+
items = append(items, roundItem)
101
+
}
102
+
103
+
return items, nil
104
+
}
105
+
106
+
func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) {
107
+
owner, err := rp.idResolver.ResolveIdent(ctx, issue.OwnerDid)
108
+
if err != nil {
109
+
return nil, err
110
+
}
111
+
112
+
state := "closed"
113
+
if issue.Open {
114
+
state = "opened"
115
+
}
116
+
117
+
return &feeds.Item{
118
+
Title: fmt.Sprintf("[Issue #%d] %s", issue.IssueId, issue.Title),
119
+
Description: fmt.Sprintf("@%s %s issue #%d in %s", owner.Handle, state, issue.IssueId, f.OwnerSlashRepo()),
120
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), issue.IssueId)},
121
+
Created: issue.Created,
122
+
Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)},
123
+
}, nil
124
+
}
125
+
126
+
func (rp *Repo) getPullState(pull *db.Pull) string {
127
+
if pull.State == db.PullOpen {
128
+
return "opened"
129
+
}
130
+
return pull.State.String()
131
+
}
132
+
133
+
func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *db.Pull, repoName string) string {
134
+
base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId)
135
+
136
+
if pull.State == db.PullMerged {
137
+
return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName)
138
+
}
139
+
140
+
return fmt.Sprintf("%s in %s", base, repoName)
141
+
}
142
+
143
+
func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) {
144
+
f, err := rp.repoResolver.Resolve(r)
145
+
if err != nil {
146
+
log.Println("failed to fully resolve repo:", err)
147
+
return
148
+
}
149
+
150
+
feed, err := rp.getRepoFeed(r.Context(), f)
151
+
if err != nil {
152
+
log.Println("failed to get repo feed:", err)
153
+
rp.pages.Error500(w)
154
+
return
155
+
}
156
+
157
+
atom, err := feed.ToAtom()
158
+
if err != nil {
159
+
rp.pages.Error500(w)
160
+
return
161
+
}
162
+
163
+
w.Header().Set("content-type", "application/atom+xml")
164
+
w.Write([]byte(atom))
165
+
}
+15
-12
appview/repo/index.go
+15
-12
appview/repo/index.go
···
24
24
25
25
func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) {
26
26
ref := chi.URLParam(r, "ref")
27
+
27
28
f, err := rp.repoResolver.Resolve(r)
28
29
if err != nil {
29
30
log.Println("failed to fully resolve repo", err)
···
37
38
return
38
39
}
39
40
40
-
result, err := us.Index(f.OwnerDid(), f.RepoName, ref)
41
+
result, err := us.Index(f.OwnerDid(), f.Name, ref)
41
42
if err != nil {
42
43
rp.pages.Error503(w)
43
44
log.Println("failed to reach knotserver", err)
···
118
119
119
120
var forkInfo *types.ForkInfo
120
121
if user != nil && (repoInfo.Roles.IsOwner() || repoInfo.Roles.IsCollaborator()) {
121
-
forkInfo, err = getForkInfo(repoInfo, rp, f, user, signedClient)
122
+
forkInfo, err = getForkInfo(repoInfo, rp, f, result.Ref, user, signedClient)
122
123
if err != nil {
123
124
log.Printf("Failed to fetch fork information: %v", err)
124
125
return
···
126
127
}
127
128
128
129
// TODO: a bit dirty
129
-
languageInfo, err := rp.getLanguageInfo(f, signedClient, chi.URLParam(r, "ref") == "")
130
+
languageInfo, err := rp.getLanguageInfo(f, signedClient, result.Ref, ref == "")
130
131
if err != nil {
131
132
log.Printf("failed to compute language percentages: %s", err)
132
133
// non-fatal
···
161
162
func (rp *Repo) getLanguageInfo(
162
163
f *reporesolver.ResolvedRepo,
163
164
signedClient *knotclient.SignedClient,
165
+
currentRef string,
164
166
isDefaultRef bool,
165
167
) ([]types.RepoLanguageDetails, error) {
166
168
// first attempt to fetch from db
167
169
langs, err := db.GetRepoLanguages(
168
170
rp.db,
169
-
db.FilterEq("repo_at", f.RepoAt),
170
-
db.FilterEq("ref", f.Ref),
171
+
db.FilterEq("repo_at", f.RepoAt()),
172
+
db.FilterEq("ref", currentRef),
171
173
)
172
174
173
175
if err != nil || langs == nil {
174
176
// non-fatal, fetch langs from ks
175
-
ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, f.Ref)
177
+
ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.Name, currentRef)
176
178
if err != nil {
177
179
return nil, err
178
180
}
···
182
184
183
185
for l, s := range ls.Languages {
184
186
langs = append(langs, db.RepoLanguage{
185
-
RepoAt: f.RepoAt,
186
-
Ref: f.Ref,
187
+
RepoAt: f.RepoAt(),
188
+
Ref: currentRef,
187
189
IsDefaultRef: isDefaultRef,
188
190
Language: l,
189
191
Bytes: s,
···
234
236
repoInfo repoinfo.RepoInfo,
235
237
rp *Repo,
236
238
f *reporesolver.ResolvedRepo,
239
+
currentRef string,
237
240
user *oauth.User,
238
241
signedClient *knotclient.SignedClient,
239
242
) (*types.ForkInfo, error) {
···
264
267
}
265
268
266
269
if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool {
267
-
return branch.Name == f.Ref
270
+
return branch.Name == currentRef
268
271
}) {
269
272
forkInfo.Status = types.MissingBranch
270
273
return &forkInfo, nil
271
274
}
272
275
273
-
newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, f.Ref, f.Ref)
276
+
newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, currentRef, currentRef)
274
277
if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent {
275
278
log.Printf("failed to update tracking branch: %s", err)
276
279
return nil, err
277
280
}
278
281
279
-
hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref)
282
+
hiddenRef := fmt.Sprintf("hidden/%s/%s", currentRef, currentRef)
280
283
281
284
var status types.AncestorCheckResponse
282
-
forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt), repoInfo.Name, f.Ref, hiddenRef)
285
+
forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt()), repoInfo.Name, currentRef, hiddenRef)
283
286
if err != nil {
284
287
log.Printf("failed to check if fork is ahead/behind: %s", err)
285
288
return nil, err
+157
-67
appview/repo/repo.go
+157
-67
appview/repo/repo.go
···
39
39
"github.com/go-git/go-git/v5/plumbing"
40
40
41
41
comatproto "github.com/bluesky-social/indigo/api/atproto"
42
+
"github.com/bluesky-social/indigo/atproto/syntax"
42
43
lexutil "github.com/bluesky-social/indigo/lex/util"
43
44
)
44
45
···
80
81
}
81
82
}
82
83
84
+
func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
85
+
refParam := chi.URLParam(r, "ref")
86
+
f, err := rp.repoResolver.Resolve(r)
87
+
if err != nil {
88
+
log.Println("failed to get repo and knot", err)
89
+
return
90
+
}
91
+
92
+
var uri string
93
+
if rp.config.Core.Dev {
94
+
uri = "http"
95
+
} else {
96
+
uri = "https"
97
+
}
98
+
url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.Name, url.PathEscape(refParam))
99
+
100
+
http.Redirect(w, r, url, http.StatusFound)
101
+
}
102
+
83
103
func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) {
84
104
f, err := rp.repoResolver.Resolve(r)
85
105
if err != nil {
···
103
123
return
104
124
}
105
125
106
-
repolog, err := us.Log(f.OwnerDid(), f.RepoName, ref, page)
126
+
repolog, err := us.Log(f.OwnerDid(), f.Name, ref, page)
107
127
if err != nil {
108
128
log.Println("failed to reach knotserver", err)
109
129
return
110
130
}
111
131
112
-
tagResult, err := us.Tags(f.OwnerDid(), f.RepoName)
132
+
tagResult, err := us.Tags(f.OwnerDid(), f.Name)
113
133
if err != nil {
114
134
log.Println("failed to reach knotserver", err)
115
135
return
···
124
144
tagMap[hash] = append(tagMap[hash], tag.Name)
125
145
}
126
146
127
-
branchResult, err := us.Branches(f.OwnerDid(), f.RepoName)
147
+
branchResult, err := us.Branches(f.OwnerDid(), f.Name)
128
148
if err != nil {
129
149
log.Println("failed to reach knotserver", err)
130
150
return
···
192
212
return
193
213
}
194
214
195
-
repoAt := f.RepoAt
215
+
repoAt := f.RepoAt()
196
216
rkey := repoAt.RecordKey().String()
197
217
if rkey == "" {
198
218
log.Println("invalid aturi for repo", err)
···
242
262
Record: &lexutil.LexiconTypeDecoder{
243
263
Val: &tangled.Repo{
244
264
Knot: f.Knot,
245
-
Name: f.RepoName,
265
+
Name: f.Name,
246
266
Owner: user.Did,
247
-
CreatedAt: f.CreatedAt,
267
+
CreatedAt: f.Created.Format(time.RFC3339),
248
268
Description: &newDescription,
249
269
Spindle: &f.Spindle,
250
270
},
···
290
310
return
291
311
}
292
312
293
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref))
313
+
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref))
294
314
if err != nil {
295
315
log.Println("failed to reach knotserver", err)
296
316
return
···
355
375
if !rp.config.Core.Dev {
356
376
protocol = "https"
357
377
}
358
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath))
378
+
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, treePath))
359
379
if err != nil {
360
380
log.Println("failed to reach knotserver", err)
361
381
return
···
385
405
user := rp.oauth.GetUser(r)
386
406
387
407
var breadcrumbs [][]string
388
-
breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
408
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
389
409
if treePath != "" {
390
410
for idx, elem := range strings.Split(treePath, "/") {
391
411
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
···
416
436
return
417
437
}
418
438
419
-
result, err := us.Tags(f.OwnerDid(), f.RepoName)
439
+
result, err := us.Tags(f.OwnerDid(), f.Name)
420
440
if err != nil {
421
441
log.Println("failed to reach knotserver", err)
422
442
return
423
443
}
424
444
425
-
artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt))
445
+
artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt()))
426
446
if err != nil {
427
447
log.Println("failed grab artifacts", err)
428
448
return
···
473
493
return
474
494
}
475
495
476
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
496
+
result, err := us.Branches(f.OwnerDid(), f.Name)
477
497
if err != nil {
478
498
log.Println("failed to reach knotserver", err)
479
499
return
···
502
522
if !rp.config.Core.Dev {
503
523
protocol = "https"
504
524
}
505
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
525
+
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath))
506
526
if err != nil {
507
527
log.Println("failed to reach knotserver", err)
508
528
return
···
522
542
}
523
543
524
544
var breadcrumbs [][]string
525
-
breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
545
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
526
546
if filePath != "" {
527
547
for idx, elem := range strings.Split(filePath, "/") {
528
548
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
···
555
575
556
576
// fetch the actual binary content like in RepoBlobRaw
557
577
558
-
blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)
578
+
blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Name, ref, filePath)
559
579
contentSrc = blobURL
560
580
if !rp.config.Core.Dev {
561
581
contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
···
592
612
if !rp.config.Core.Dev {
593
613
protocol = "https"
594
614
}
595
-
blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)
596
-
resp, err := http.Get(blobURL)
615
+
616
+
blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath)
617
+
618
+
req, err := http.NewRequest("GET", blobURL, nil)
597
619
if err != nil {
598
-
log.Println("failed to reach knotserver:", err)
620
+
log.Println("failed to create request", err)
621
+
return
622
+
}
623
+
624
+
// forward the If-None-Match header
625
+
if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
626
+
req.Header.Set("If-None-Match", clientETag)
627
+
}
628
+
629
+
client := &http.Client{}
630
+
resp, err := client.Do(req)
631
+
if err != nil {
632
+
log.Println("failed to reach knotserver", err)
599
633
rp.pages.Error503(w)
600
634
return
601
635
}
602
636
defer resp.Body.Close()
637
+
638
+
// forward 304 not modified
639
+
if resp.StatusCode == http.StatusNotModified {
640
+
w.WriteHeader(http.StatusNotModified)
641
+
return
642
+
}
603
643
604
644
if resp.StatusCode != http.StatusOK {
605
645
log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode)
···
648
688
return
649
689
}
650
690
651
-
repoAt := f.RepoAt
691
+
repoAt := f.RepoAt()
652
692
rkey := repoAt.RecordKey().String()
653
693
if rkey == "" {
654
694
fail("Failed to resolve repo. Try again later", err)
···
656
696
}
657
697
658
698
newSpindle := r.FormValue("spindle")
699
+
removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value
659
700
client, err := rp.oauth.AuthorizedClient(r)
660
701
if err != nil {
661
702
fail("Failed to authorize. Try again later.", err)
662
703
return
663
704
}
664
705
665
-
// ensure that this is a valid spindle for this user
666
-
validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
667
-
if err != nil {
668
-
fail("Failed to find spindles. Try again later.", err)
669
-
return
706
+
if !removingSpindle {
707
+
// ensure that this is a valid spindle for this user
708
+
validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
709
+
if err != nil {
710
+
fail("Failed to find spindles. Try again later.", err)
711
+
return
712
+
}
713
+
714
+
if !slices.Contains(validSpindles, newSpindle) {
715
+
fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles))
716
+
return
717
+
}
670
718
}
671
719
672
-
if !slices.Contains(validSpindles, newSpindle) {
673
-
fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles))
674
-
return
720
+
spindlePtr := &newSpindle
721
+
if removingSpindle {
722
+
spindlePtr = nil
675
723
}
676
724
677
725
// optimistic update
678
-
err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle)
726
+
err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr)
679
727
if err != nil {
680
728
fail("Failed to update spindle. Try again later.", err)
681
729
return
···
694
742
Record: &lexutil.LexiconTypeDecoder{
695
743
Val: &tangled.Repo{
696
744
Knot: f.Knot,
697
-
Name: f.RepoName,
745
+
Name: f.Name,
698
746
Owner: user.Did,
699
-
CreatedAt: f.CreatedAt,
747
+
CreatedAt: f.Created.Format(time.RFC3339),
700
748
Description: &f.Description,
701
-
Spindle: &newSpindle,
749
+
Spindle: spindlePtr,
702
750
},
703
751
},
704
752
})
···
708
756
return
709
757
}
710
758
711
-
// add this spindle to spindle stream
712
-
rp.spindlestream.AddSource(
713
-
context.Background(),
714
-
eventconsumer.NewSpindleSource(newSpindle),
715
-
)
759
+
if !removingSpindle {
760
+
// add this spindle to spindle stream
761
+
rp.spindlestream.AddSource(
762
+
context.Background(),
763
+
eventconsumer.NewSpindleSource(newSpindle),
764
+
)
765
+
}
716
766
717
767
rp.pages.HxRefresh(w)
718
768
}
···
741
791
return
742
792
}
743
793
794
+
// remove a single leading `@`, to make @handle work with ResolveIdent
795
+
collaborator = strings.TrimPrefix(collaborator, "@")
796
+
744
797
collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator)
745
798
if err != nil {
746
799
fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err)
···
751
804
fail("You seem to be adding yourself as a collaborator.", nil)
752
805
return
753
806
}
754
-
755
807
l = l.With("collaborator", collaboratorIdent.Handle)
756
808
l = l.With("knot", f.Knot)
757
-
l.Info("adding to knot")
758
809
810
+
// announce this relation into the firehose, store into owners' pds
811
+
client, err := rp.oauth.AuthorizedClient(r)
812
+
if err != nil {
813
+
fail("Failed to write to PDS.", err)
814
+
return
815
+
}
816
+
817
+
// emit a record
818
+
currentUser := rp.oauth.GetUser(r)
819
+
rkey := tid.TID()
820
+
createdAt := time.Now()
821
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
822
+
Collection: tangled.RepoCollaboratorNSID,
823
+
Repo: currentUser.Did,
824
+
Rkey: rkey,
825
+
Record: &lexutil.LexiconTypeDecoder{
826
+
Val: &tangled.RepoCollaborator{
827
+
Subject: collaboratorIdent.DID.String(),
828
+
Repo: string(f.RepoAt()),
829
+
CreatedAt: createdAt.Format(time.RFC3339),
830
+
}},
831
+
})
832
+
// invalid record
833
+
if err != nil {
834
+
fail("Failed to write record to PDS.", err)
835
+
return
836
+
}
837
+
l = l.With("at-uri", resp.Uri)
838
+
l.Info("wrote record to PDS")
839
+
840
+
l.Info("adding to knot")
759
841
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
760
842
if err != nil {
761
843
fail("Failed to add to knot.", err)
···
768
850
return
769
851
}
770
852
771
-
ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String())
853
+
ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.Name, collaboratorIdent.DID.String())
772
854
if err != nil {
773
855
fail("Knot was unreachable.", err)
774
856
return
···
798
880
return
799
881
}
800
882
801
-
err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot)
883
+
err = db.AddCollaborator(rp.db, db.Collaborator{
884
+
Did: syntax.DID(currentUser.Did),
885
+
Rkey: rkey,
886
+
SubjectDid: collaboratorIdent.DID,
887
+
RepoAt: f.RepoAt(),
888
+
Created: createdAt,
889
+
})
802
890
if err != nil {
803
891
fail("Failed to add collaborator.", err)
804
892
return
···
834
922
log.Println("failed to get authorized client", err)
835
923
return
836
924
}
837
-
repoRkey := f.RepoAt.RecordKey().String()
838
925
_, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
839
926
Collection: tangled.RepoNSID,
840
927
Repo: user.Did,
841
-
Rkey: repoRkey,
928
+
Rkey: f.Rkey,
842
929
})
843
930
if err != nil {
844
931
log.Printf("failed to delete record: %s", err)
845
932
rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.")
846
933
return
847
934
}
848
-
log.Println("removed repo record ", f.RepoAt.String())
935
+
log.Println("removed repo record ", f.RepoAt().String())
849
936
850
937
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
851
938
if err != nil {
···
859
946
return
860
947
}
861
948
862
-
ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName)
949
+
ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.Name)
863
950
if err != nil {
864
951
log.Printf("failed to make request to %s: %s", f.Knot, err)
865
952
return
···
905
992
}
906
993
907
994
// remove repo from db
908
-
err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName)
995
+
err = db.RemoveRepo(tx, f.OwnerDid(), f.Name)
909
996
if err != nil {
910
997
rp.pages.Notice(w, "settings-delete", "Failed to update appview")
911
998
return
···
954
1041
return
955
1042
}
956
1043
957
-
ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch)
1044
+
ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.Name, branch)
958
1045
if err != nil {
959
1046
log.Printf("failed to make request to %s: %s", f.Knot, err)
960
1047
return
···
994
1081
r,
995
1082
oauth.WithService(f.Spindle),
996
1083
oauth.WithLxm(lxm),
1084
+
oauth.WithExp(60),
997
1085
oauth.WithDev(rp.config.Core.Dev),
998
1086
)
999
1087
if err != nil {
···
1021
1109
r.Context(),
1022
1110
spindleClient,
1023
1111
&tangled.RepoAddSecret_Input{
1024
-
Repo: f.RepoAt.String(),
1112
+
Repo: f.RepoAt().String(),
1025
1113
Key: key,
1026
1114
Value: value,
1027
1115
},
···
1039
1127
r.Context(),
1040
1128
spindleClient,
1041
1129
&tangled.RepoRemoveSecret_Input{
1042
-
Repo: f.RepoAt.String(),
1130
+
Repo: f.RepoAt().String(),
1043
1131
Key: key,
1044
1132
},
1045
1133
)
···
1101
1189
// return
1102
1190
// }
1103
1191
1104
-
// result, err := us.Branches(f.OwnerDid(), f.RepoName)
1192
+
// result, err := us.Branches(f.OwnerDid(), f.Name)
1105
1193
// if err != nil {
1106
1194
// log.Println("failed to reach knotserver", err)
1107
1195
// return
···
1123
1211
// oauth.WithDev(rp.config.Core.Dev),
1124
1212
// ); err != nil {
1125
1213
// log.Println("failed to create spindle client", err)
1126
-
// } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil {
1214
+
// } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
1127
1215
// log.Println("failed to fetch secrets", err)
1128
1216
// } else {
1129
1217
// secrets = resp.Secrets
···
1152
1240
return
1153
1241
}
1154
1242
1155
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
1243
+
result, err := us.Branches(f.OwnerDid(), f.Name)
1156
1244
if err != nil {
1157
1245
log.Println("failed to reach knotserver", err)
1158
1246
return
···
1189
1277
f, err := rp.repoResolver.Resolve(r)
1190
1278
user := rp.oauth.GetUser(r)
1191
1279
1192
-
// all spindles that this user is a member of
1193
-
spindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
1280
+
// all spindles that the repo owner is a member of
1281
+
spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
1194
1282
if err != nil {
1195
1283
log.Println("failed to fetch spindles", err)
1196
1284
return
···
1202
1290
r,
1203
1291
oauth.WithService(f.Spindle),
1204
1292
oauth.WithLxm(tangled.RepoListSecretsNSID),
1293
+
oauth.WithExp(60),
1205
1294
oauth.WithDev(rp.config.Core.Dev),
1206
1295
); err != nil {
1207
1296
log.Println("failed to create spindle client", err)
1208
-
} else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil {
1297
+
} else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
1209
1298
log.Println("failed to fetch secrets", err)
1210
1299
} else {
1211
1300
secrets = resp.Secrets
···
1246
1335
}
1247
1336
1248
1337
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
1338
+
ref := chi.URLParam(r, "ref")
1339
+
1249
1340
user := rp.oauth.GetUser(r)
1250
1341
f, err := rp.repoResolver.Resolve(r)
1251
1342
if err != nil {
···
1273
1364
} else {
1274
1365
uri = "https"
1275
1366
}
1276
-
forkName := fmt.Sprintf("%s", f.RepoName)
1277
-
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1367
+
forkName := fmt.Sprintf("%s", f.Name)
1368
+
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name)
1278
1369
1279
-
_, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref)
1370
+
_, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, ref)
1280
1371
if err != nil {
1281
1372
rp.pages.Notice(w, "repo", "Failed to sync repository fork.")
1282
1373
return
···
1324
1415
return
1325
1416
}
1326
1417
1327
-
forkName := fmt.Sprintf("%s", f.RepoName)
1418
+
forkName := fmt.Sprintf("%s", f.Name)
1328
1419
1329
1420
// this check is *only* to see if the forked repo name already exists
1330
1421
// in the user's account.
1331
-
existingRepo, err := db.GetRepo(rp.db, user.Did, f.RepoName)
1422
+
existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name)
1332
1423
if err != nil {
1333
1424
if errors.Is(err, sql.ErrNoRows) {
1334
1425
// no existing repo with this name found, we can use the name as is
···
1359
1450
} else {
1360
1451
uri = "https"
1361
1452
}
1362
-
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1363
-
sourceAt := f.RepoAt.String()
1453
+
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name)
1454
+
sourceAt := f.RepoAt().String()
1364
1455
1365
1456
rkey := tid.TID()
1366
1457
repo := &db.Repo{
···
1429
1520
}
1430
1521
log.Println("created repo record: ", atresp.Uri)
1431
1522
1432
-
repo.AtUri = atresp.Uri
1433
1523
err = db.AddRepo(tx, repo)
1434
1524
if err != nil {
1435
1525
log.Println(err)
···
1480
1570
return
1481
1571
}
1482
1572
1483
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
1573
+
result, err := us.Branches(f.OwnerDid(), f.Name)
1484
1574
if err != nil {
1485
1575
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1486
1576
log.Println("failed to reach knotserver", err)
···
1510
1600
head = queryHead
1511
1601
}
1512
1602
1513
-
tags, err := us.Tags(f.OwnerDid(), f.RepoName)
1603
+
tags, err := us.Tags(f.OwnerDid(), f.Name)
1514
1604
if err != nil {
1515
1605
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1516
1606
log.Println("failed to reach knotserver", err)
···
1572
1662
return
1573
1663
}
1574
1664
1575
-
branches, err := us.Branches(f.OwnerDid(), f.RepoName)
1665
+
branches, err := us.Branches(f.OwnerDid(), f.Name)
1576
1666
if err != nil {
1577
1667
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1578
1668
log.Println("failed to reach knotserver", err)
1579
1669
return
1580
1670
}
1581
1671
1582
-
tags, err := us.Tags(f.OwnerDid(), f.RepoName)
1672
+
tags, err := us.Tags(f.OwnerDid(), f.Name)
1583
1673
if err != nil {
1584
1674
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1585
1675
log.Println("failed to reach knotserver", err)
1586
1676
return
1587
1677
}
1588
1678
1589
-
formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head)
1679
+
formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head)
1590
1680
if err != nil {
1591
1681
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1592
1682
log.Println("failed to compare", err)
+5
appview/repo/router.go
+5
appview/repo/router.go
···
10
10
func (rp *Repo) Router(mw *middleware.Middleware) http.Handler {
11
11
r := chi.NewRouter()
12
12
r.Get("/", rp.RepoIndex)
13
+
r.Get("/feed.atom", rp.RepoAtomFeed)
13
14
r.Get("/commits/{ref}", rp.RepoLog)
14
15
r.Route("/tree/{ref}", func(r chi.Router) {
15
16
r.Get("/", rp.RepoIndex)
···
37
38
})
38
39
r.Get("/blob/{ref}/*", rp.RepoBlob)
39
40
r.Get("/raw/{ref}/*", rp.RepoBlobRaw)
41
+
42
+
// intentionally doesn't use /* as this isn't
43
+
// a file path
44
+
r.Get("/archive/{ref}", rp.DownloadArchive)
40
45
41
46
r.Route("/fork", func(r chi.Router) {
42
47
r.Use(middleware.AuthMiddleware(rp.oauth))
+37
-104
appview/reporesolver/resolver.go
+37
-104
appview/reporesolver/resolver.go
···
7
7
"fmt"
8
8
"log"
9
9
"net/http"
10
-
"net/url"
11
10
"path"
11
+
"regexp"
12
12
"strings"
13
13
14
14
"github.com/bluesky-social/indigo/atproto/identity"
15
-
"github.com/bluesky-social/indigo/atproto/syntax"
16
15
securejoin "github.com/cyphar/filepath-securejoin"
17
16
"github.com/go-chi/chi/v5"
18
17
"tangled.sh/tangled.sh/core/appview/config"
···
21
20
"tangled.sh/tangled.sh/core/appview/pages"
22
21
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
23
22
"tangled.sh/tangled.sh/core/idresolver"
24
-
"tangled.sh/tangled.sh/core/knotclient"
25
23
"tangled.sh/tangled.sh/core/rbac"
26
24
)
27
25
28
26
type ResolvedRepo struct {
29
-
Knot string
30
-
OwnerId identity.Identity
31
-
RepoName string
32
-
RepoAt syntax.ATURI
33
-
Description string
34
-
Spindle string
35
-
CreatedAt string
36
-
Ref string
37
-
CurrentDir string
27
+
db.Repo
28
+
OwnerId identity.Identity
29
+
CurrentDir string
30
+
Ref string
38
31
39
32
rr *RepoResolver
40
33
}
···
51
44
}
52
45
53
46
func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) {
54
-
repoName := chi.URLParam(r, "repo")
55
-
knot, ok := r.Context().Value("knot").(string)
47
+
repo, ok := r.Context().Value("repo").(*db.Repo)
56
48
if !ok {
57
-
log.Println("malformed middleware")
49
+
log.Println("malformed middleware: `repo` not exist in context")
58
50
return nil, fmt.Errorf("malformed middleware")
59
51
}
60
52
id, ok := r.Context().Value("resolvedId").(identity.Identity)
···
63
55
return nil, fmt.Errorf("malformed middleware")
64
56
}
65
57
66
-
repoAt, ok := r.Context().Value("repoAt").(string)
67
-
if !ok {
68
-
log.Println("malformed middleware")
69
-
return nil, fmt.Errorf("malformed middleware")
70
-
}
71
-
72
-
parsedRepoAt, err := syntax.ParseATURI(repoAt)
73
-
if err != nil {
74
-
log.Println("malformed repo at-uri")
75
-
return nil, fmt.Errorf("malformed middleware")
76
-
}
77
-
58
+
currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath()))
78
59
ref := chi.URLParam(r, "ref")
79
60
80
-
if ref == "" {
81
-
us, err := knotclient.NewUnsignedClient(knot, rr.config.Core.Dev)
82
-
if err != nil {
83
-
return nil, err
84
-
}
85
-
86
-
defaultBranch, err := us.DefaultBranch(id.DID.String(), repoName)
87
-
if err != nil {
88
-
return nil, err
89
-
}
90
-
91
-
ref = defaultBranch.Branch
92
-
}
93
-
94
-
currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath(), ref))
95
-
96
-
// pass through values from the middleware
97
-
description, ok := r.Context().Value("repoDescription").(string)
98
-
addedAt, ok := r.Context().Value("repoAddedAt").(string)
99
-
spindle, ok := r.Context().Value("repoSpindle").(string)
100
-
101
61
return &ResolvedRepo{
102
-
Knot: knot,
103
-
OwnerId: id,
104
-
RepoName: repoName,
105
-
RepoAt: parsedRepoAt,
106
-
Description: description,
107
-
CreatedAt: addedAt,
108
-
Ref: ref,
109
-
CurrentDir: currentDir,
110
-
Spindle: spindle,
62
+
Repo: *repo,
63
+
OwnerId: id,
64
+
CurrentDir: currentDir,
65
+
Ref: ref,
111
66
112
67
rr: rr,
113
68
}, nil
···
126
81
127
82
var p string
128
83
if handle != "" && !handle.IsInvalidHandle() {
129
-
p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName)
84
+
p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.Name)
130
85
} else {
131
-
p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
86
+
p, _ = securejoin.SecureJoin(f.OwnerDid(), f.Name)
132
87
}
133
88
134
-
return p
135
-
}
136
-
137
-
func (f *ResolvedRepo) DidSlashRepo() string {
138
-
p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
139
89
return p
140
90
}
141
91
···
187
137
// this function is a bit weird since it now returns RepoInfo from an entirely different
188
138
// package. we should refactor this or get rid of RepoInfo entirely.
189
139
func (f *ResolvedRepo) RepoInfo(user *oauth.User) repoinfo.RepoInfo {
140
+
repoAt := f.RepoAt()
190
141
isStarred := false
191
142
if user != nil {
192
-
isStarred = db.GetStarStatus(f.rr.execer, user.Did, syntax.ATURI(f.RepoAt))
143
+
isStarred = db.GetStarStatus(f.rr.execer, user.Did, repoAt)
193
144
}
194
145
195
-
starCount, err := db.GetStarCount(f.rr.execer, f.RepoAt)
146
+
starCount, err := db.GetStarCount(f.rr.execer, repoAt)
196
147
if err != nil {
197
-
log.Println("failed to get star count for ", f.RepoAt)
148
+
log.Println("failed to get star count for ", repoAt)
198
149
}
199
-
issueCount, err := db.GetIssueCount(f.rr.execer, f.RepoAt)
150
+
issueCount, err := db.GetIssueCount(f.rr.execer, repoAt)
200
151
if err != nil {
201
-
log.Println("failed to get issue count for ", f.RepoAt)
152
+
log.Println("failed to get issue count for ", repoAt)
202
153
}
203
-
pullCount, err := db.GetPullCount(f.rr.execer, f.RepoAt)
154
+
pullCount, err := db.GetPullCount(f.rr.execer, repoAt)
204
155
if err != nil {
205
-
log.Println("failed to get issue count for ", f.RepoAt)
156
+
log.Println("failed to get issue count for ", repoAt)
206
157
}
207
-
source, err := db.GetRepoSource(f.rr.execer, f.RepoAt)
158
+
source, err := db.GetRepoSource(f.rr.execer, repoAt)
208
159
if errors.Is(err, sql.ErrNoRows) {
209
160
source = ""
210
161
} else if err != nil {
211
-
log.Println("failed to get repo source for ", f.RepoAt, err)
162
+
log.Println("failed to get repo source for ", repoAt, err)
212
163
}
213
164
214
165
var sourceRepo *db.Repo
···
228
179
}
229
180
230
181
knot := f.Knot
231
-
var disableFork bool
232
-
us, err := knotclient.NewUnsignedClient(knot, f.rr.config.Core.Dev)
233
-
if err != nil {
234
-
log.Printf("failed to create unsigned client for %s: %v", knot, err)
235
-
} else {
236
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
237
-
if err != nil {
238
-
log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err)
239
-
}
240
-
241
-
if len(result.Branches) == 0 {
242
-
disableFork = true
243
-
}
244
-
}
245
182
246
183
repoInfo := repoinfo.RepoInfo{
247
184
OwnerDid: f.OwnerDid(),
248
185
OwnerHandle: f.OwnerHandle(),
249
-
Name: f.RepoName,
250
-
RepoAt: f.RepoAt,
186
+
Name: f.Name,
187
+
RepoAt: repoAt,
251
188
Description: f.Description,
252
-
Ref: f.Ref,
253
189
IsStarred: isStarred,
254
190
Knot: knot,
255
191
Spindle: f.Spindle,
···
259
195
IssueCount: issueCount,
260
196
PullCount: pullCount,
261
197
},
262
-
DisableFork: disableFork,
263
-
CurrentDir: f.CurrentDir,
198
+
CurrentDir: f.CurrentDir,
199
+
Ref: f.Ref,
264
200
}
265
201
266
202
if sourceRepo != nil {
···
284
220
// after the ref. for example:
285
221
//
286
222
// /@icyphox.sh/foorepo/blob/main/abc/xyz/ => abc/xyz/
287
-
func extractPathAfterRef(fullPath, ref string) string {
223
+
func extractPathAfterRef(fullPath string) string {
288
224
fullPath = strings.TrimPrefix(fullPath, "/")
289
225
290
-
ref = url.PathEscape(ref)
226
+
// match blob/, tree/, or raw/ followed by any ref and then a slash
227
+
//
228
+
// captures everything after the final slash
229
+
pattern := `(?:blob|tree|raw)/[^/]+/(.*)$`
291
230
292
-
prefixes := []string{
293
-
fmt.Sprintf("blob/%s/", ref),
294
-
fmt.Sprintf("tree/%s/", ref),
295
-
fmt.Sprintf("raw/%s/", ref),
296
-
}
231
+
re := regexp.MustCompile(pattern)
232
+
matches := re.FindStringSubmatch(fullPath)
297
233
298
-
for _, prefix := range prefixes {
299
-
idx := strings.Index(fullPath, prefix)
300
-
if idx != -1 {
301
-
return fullPath[idx+len(prefix):]
302
-
}
234
+
if len(matches) > 1 {
235
+
return matches[1]
303
236
}
304
237
305
238
return ""
+57
-50
appview/signup/signup.go
+57
-50
appview/signup/signup.go
···
104
104
105
105
func (s *Signup) Router() http.Handler {
106
106
r := chi.NewRouter()
107
+
r.Get("/", s.signup)
107
108
r.Post("/", s.signup)
108
109
r.Get("/complete", s.complete)
109
110
r.Post("/complete", s.complete)
···
112
113
}
113
114
114
115
func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
115
-
if s.cf == nil {
116
-
http.Error(w, "signup is disabled", http.StatusFailedDependency)
117
-
}
118
-
emailId := r.FormValue("email")
116
+
switch r.Method {
117
+
case http.MethodGet:
118
+
s.pages.Signup(w)
119
+
case http.MethodPost:
120
+
if s.cf == nil {
121
+
http.Error(w, "signup is disabled", http.StatusFailedDependency)
122
+
}
123
+
emailId := r.FormValue("email")
119
124
120
-
if !email.IsValidEmail(emailId) {
121
-
s.pages.Notice(w, "login-msg", "Invalid email address.")
122
-
return
123
-
}
125
+
noticeId := "signup-msg"
126
+
if !email.IsValidEmail(emailId) {
127
+
s.pages.Notice(w, noticeId, "Invalid email address.")
128
+
return
129
+
}
124
130
125
-
exists, err := db.CheckEmailExistsAtAll(s.db, emailId)
126
-
if err != nil {
127
-
s.l.Error("failed to check email existence", "error", err)
128
-
s.pages.Notice(w, "login-msg", "Failed to complete signup. Try again later.")
129
-
return
130
-
}
131
-
if exists {
132
-
s.pages.Notice(w, "login-msg", "Email already exists.")
133
-
return
134
-
}
131
+
exists, err := db.CheckEmailExistsAtAll(s.db, emailId)
132
+
if err != nil {
133
+
s.l.Error("failed to check email existence", "error", err)
134
+
s.pages.Notice(w, noticeId, "Failed to complete signup. Try again later.")
135
+
return
136
+
}
137
+
if exists {
138
+
s.pages.Notice(w, noticeId, "Email already exists.")
139
+
return
140
+
}
135
141
136
-
code, err := s.inviteCodeRequest()
137
-
if err != nil {
138
-
s.l.Error("failed to create invite code", "error", err)
139
-
s.pages.Notice(w, "login-msg", "Failed to create invite code.")
140
-
return
141
-
}
142
+
code, err := s.inviteCodeRequest()
143
+
if err != nil {
144
+
s.l.Error("failed to create invite code", "error", err)
145
+
s.pages.Notice(w, noticeId, "Failed to create invite code.")
146
+
return
147
+
}
142
148
143
-
em := email.Email{
144
-
APIKey: s.config.Resend.ApiKey,
145
-
From: s.config.Resend.SentFrom,
146
-
To: emailId,
147
-
Subject: "Verify your Tangled account",
148
-
Text: `Copy and paste this code below to verify your account on Tangled.
149
+
em := email.Email{
150
+
APIKey: s.config.Resend.ApiKey,
151
+
From: s.config.Resend.SentFrom,
152
+
To: emailId,
153
+
Subject: "Verify your Tangled account",
154
+
Text: `Copy and paste this code below to verify your account on Tangled.
149
155
` + code,
150
-
Html: `<p>Copy and paste this code below to verify your account on Tangled.</p>
156
+
Html: `<p>Copy and paste this code below to verify your account on Tangled.</p>
151
157
<p><code>` + code + `</code></p>`,
152
-
}
158
+
}
159
+
160
+
err = email.SendEmail(em)
161
+
if err != nil {
162
+
s.l.Error("failed to send email", "error", err)
163
+
s.pages.Notice(w, noticeId, "Failed to send email.")
164
+
return
165
+
}
166
+
err = db.AddInflightSignup(s.db, db.InflightSignup{
167
+
Email: emailId,
168
+
InviteCode: code,
169
+
})
170
+
if err != nil {
171
+
s.l.Error("failed to add inflight signup", "error", err)
172
+
s.pages.Notice(w, noticeId, "Failed to complete sign up. Try again later.")
173
+
return
174
+
}
153
175
154
-
err = email.SendEmail(em)
155
-
if err != nil {
156
-
s.l.Error("failed to send email", "error", err)
157
-
s.pages.Notice(w, "login-msg", "Failed to send email.")
158
-
return
176
+
s.pages.HxRedirect(w, "/signup/complete")
159
177
}
160
-
err = db.AddInflightSignup(s.db, db.InflightSignup{
161
-
Email: emailId,
162
-
InviteCode: code,
163
-
})
164
-
if err != nil {
165
-
s.l.Error("failed to add inflight signup", "error", err)
166
-
s.pages.Notice(w, "login-msg", "Failed to complete sign up. Try again later.")
167
-
return
168
-
}
169
-
170
-
s.pages.HxRedirect(w, "/signup/complete")
171
178
}
172
179
173
180
func (s *Signup) complete(w http.ResponseWriter, r *http.Request) {
174
181
switch r.Method {
175
182
case http.MethodGet:
176
-
s.pages.CompleteSignup(w, pages.SignupParams{})
183
+
s.pages.CompleteSignup(w)
177
184
case http.MethodPost:
178
185
username := r.FormValue("username")
179
186
password := r.FormValue("password")
···
212
219
err = s.cf.CreateDNSRecord(r.Context(), dns.Record{
213
220
Type: "TXT",
214
221
Name: "_atproto." + username,
215
-
Content: "did=" + did,
222
+
Content: fmt.Sprintf(`"did=%s"`, did),
216
223
TTL: 6400,
217
224
Proxied: false,
218
225
})
+4
-17
appview/spindles/spindles.go
+4
-17
appview/spindles/spindles.go
···
113
113
return
114
114
}
115
115
116
-
identsToResolve := make([]string, len(members))
117
-
copy(identsToResolve, members)
118
-
resolvedIds := s.IdResolver.ResolveIdents(r.Context(), identsToResolve)
119
-
didHandleMap := make(map[string]string)
120
-
for _, identity := range resolvedIds {
121
-
if !identity.Handle.IsInvalidHandle() {
122
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
123
-
} else {
124
-
didHandleMap[identity.DID.String()] = identity.DID.String()
125
-
}
126
-
}
127
-
128
116
// organize repos by did
129
117
repoMap := make(map[string][]db.Repo)
130
118
for _, r := range repos {
···
136
124
Spindle: spindle,
137
125
Members: members,
138
126
Repos: repoMap,
139
-
DidHandleMap: didHandleMap,
140
127
})
141
128
}
142
129
···
619
606
620
607
if string(spindles[0].Owner) != user.Did {
621
608
l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner)
622
-
s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.")
609
+
s.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.")
623
610
return
624
611
}
625
612
626
613
member := r.FormValue("member")
627
614
if member == "" {
628
615
l.Error("empty member")
629
-
s.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
616
+
s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
630
617
return
631
618
}
632
619
l = l.With("member", member)
···
634
621
memberId, err := s.IdResolver.ResolveIdent(r.Context(), member)
635
622
if err != nil {
636
623
l.Error("failed to resolve member identity to handle", "err", err)
637
-
s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
624
+
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
638
625
return
639
626
}
640
627
if memberId.Handle.IsInvalidHandle() {
641
628
l.Error("failed to resolve member identity to handle")
642
-
s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
629
+
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
643
630
return
644
631
}
645
632
+9
-12
appview/state/git_http.go
+9
-12
appview/state/git_http.go
···
3
3
import (
4
4
"fmt"
5
5
"io"
6
+
"maps"
6
7
"net/http"
7
8
8
9
"github.com/bluesky-social/indigo/atproto/identity"
9
10
"github.com/go-chi/chi/v5"
11
+
"tangled.sh/tangled.sh/core/appview/db"
10
12
)
11
13
12
14
func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) {
13
15
user := r.Context().Value("resolvedId").(identity.Identity)
14
-
knot := r.Context().Value("knot").(string)
15
-
repo := chi.URLParam(r, "repo")
16
+
repo := r.Context().Value("repo").(*db.Repo)
16
17
17
18
scheme := "https"
18
19
if s.config.Core.Dev {
19
20
scheme = "http"
20
21
}
21
22
22
-
targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, knot, user.DID, repo, r.URL.RawQuery)
23
+
targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery)
23
24
s.proxyRequest(w, r, targetURL)
24
25
25
26
}
···
30
31
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
31
32
return
32
33
}
33
-
knot := r.Context().Value("knot").(string)
34
-
repo := chi.URLParam(r, "repo")
34
+
repo := r.Context().Value("repo").(*db.Repo)
35
35
36
36
scheme := "https"
37
37
if s.config.Core.Dev {
38
38
scheme = "http"
39
39
}
40
40
41
-
targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery)
41
+
targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery)
42
42
s.proxyRequest(w, r, targetURL)
43
43
}
44
44
···
48
48
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
49
49
return
50
50
}
51
-
knot := r.Context().Value("knot").(string)
52
-
repo := chi.URLParam(r, "repo")
51
+
repo := r.Context().Value("repo").(*db.Repo)
53
52
54
53
scheme := "https"
55
54
if s.config.Core.Dev {
56
55
scheme = "http"
57
56
}
58
57
59
-
targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery)
58
+
targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery)
60
59
s.proxyRequest(w, r, targetURL)
61
60
}
62
61
···
85
84
defer resp.Body.Close()
86
85
87
86
// Copy response headers
88
-
for k, v := range resp.Header {
89
-
w.Header()[k] = v
90
-
}
87
+
maps.Copy(w.Header(), resp.Header)
91
88
92
89
// Set response status code
93
90
w.WriteHeader(resp.StatusCode)
+144
-62
appview/state/profile.go
+144
-62
appview/state/profile.go
···
1
1
package state
2
2
3
3
import (
4
-
"crypto/hmac"
5
-
"crypto/sha256"
6
-
"encoding/hex"
4
+
"context"
7
5
"fmt"
8
6
"log"
9
7
"net/http"
···
16
14
"github.com/bluesky-social/indigo/atproto/syntax"
17
15
lexutil "github.com/bluesky-social/indigo/lex/util"
18
16
"github.com/go-chi/chi/v5"
17
+
"github.com/gorilla/feeds"
19
18
"tangled.sh/tangled.sh/core/api/tangled"
20
19
"tangled.sh/tangled.sh/core/appview/db"
21
20
"tangled.sh/tangled.sh/core/appview/pages"
···
90
89
log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err)
91
90
}
92
91
93
-
var didsToResolve []string
94
-
for _, r := range collaboratingRepos {
95
-
didsToResolve = append(didsToResolve, r.Did)
96
-
}
97
-
for _, byMonth := range timeline.ByMonth {
98
-
for _, pe := range byMonth.PullEvents.Items {
99
-
didsToResolve = append(didsToResolve, pe.Repo.Did)
100
-
}
101
-
for _, ie := range byMonth.IssueEvents.Items {
102
-
didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did)
103
-
}
104
-
for _, re := range byMonth.RepoEvents {
105
-
didsToResolve = append(didsToResolve, re.Repo.Did)
106
-
if re.Source != nil {
107
-
didsToResolve = append(didsToResolve, re.Source.Did)
108
-
}
109
-
}
110
-
}
111
-
112
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve)
113
-
didHandleMap := make(map[string]string)
114
-
for _, identity := range resolvedIds {
115
-
if !identity.Handle.IsInvalidHandle() {
116
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
117
-
} else {
118
-
didHandleMap[identity.DID.String()] = identity.DID.String()
119
-
}
120
-
}
121
-
122
-
followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String())
92
+
followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String())
123
93
if err != nil {
124
94
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
125
95
}
···
142
112
log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err)
143
113
}
144
114
145
-
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
146
115
s.pages.ProfilePage(w, pages.ProfilePageParams{
147
116
LoggedInUser: loggedInUser,
148
117
Repos: pinnedRepos,
149
118
CollaboratingRepos: pinnedCollaboratingRepos,
150
-
DidHandleMap: didHandleMap,
151
119
Card: pages.ProfileCard{
152
120
UserDid: ident.DID.String(),
153
121
UserHandle: ident.Handle.String(),
154
-
AvatarUri: profileAvatarUri,
155
122
Profile: profile,
156
123
FollowStatus: followStatus,
157
124
Followers: followers,
···
189
156
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
190
157
}
191
158
192
-
followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String())
159
+
followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String())
193
160
if err != nil {
194
161
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
195
162
}
196
-
197
-
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
198
163
199
164
s.pages.ReposPage(w, pages.ReposPageParams{
200
165
LoggedInUser: loggedInUser,
201
166
Repos: repos,
202
-
DidHandleMap: map[string]string{ident.DID.String(): ident.Handle.String()},
203
167
Card: pages.ProfileCard{
204
168
UserDid: ident.DID.String(),
205
169
UserHandle: ident.Handle.String(),
206
-
AvatarUri: profileAvatarUri,
207
170
Profile: profile,
208
171
FollowStatus: followStatus,
209
172
Followers: followers,
···
212
175
})
213
176
}
214
177
215
-
func (s *State) GetAvatarUri(handle string) string {
216
-
secret := s.config.Avatar.SharedSecret
217
-
h := hmac.New(sha256.New, []byte(secret))
218
-
h.Write([]byte(handle))
219
-
signature := hex.EncodeToString(h.Sum(nil))
220
-
return fmt.Sprintf("%s/%s/%s", s.config.Avatar.Host, signature, handle)
178
+
func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) {
179
+
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
180
+
if !ok {
181
+
s.pages.Error404(w)
182
+
return
183
+
}
184
+
185
+
feed, err := s.getProfileFeed(r.Context(), &ident)
186
+
if err != nil {
187
+
s.pages.Error500(w)
188
+
return
189
+
}
190
+
191
+
if feed == nil {
192
+
return
193
+
}
194
+
195
+
atom, err := feed.ToAtom()
196
+
if err != nil {
197
+
s.pages.Error500(w)
198
+
return
199
+
}
200
+
201
+
w.Header().Set("content-type", "application/atom+xml")
202
+
w.Write([]byte(atom))
203
+
}
204
+
205
+
func (s *State) getProfileFeed(ctx context.Context, id *identity.Identity) (*feeds.Feed, error) {
206
+
timeline, err := db.MakeProfileTimeline(s.db, id.DID.String())
207
+
if err != nil {
208
+
return nil, err
209
+
}
210
+
211
+
author := &feeds.Author{
212
+
Name: fmt.Sprintf("@%s", id.Handle),
213
+
}
214
+
215
+
feed := feeds.Feed{
216
+
Title: fmt.Sprintf("%s's timeline", author.Name),
217
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, id.Handle), Type: "text/html", Rel: "alternate"},
218
+
Items: make([]*feeds.Item, 0),
219
+
Updated: time.UnixMilli(0),
220
+
Author: author,
221
+
}
222
+
223
+
for _, byMonth := range timeline.ByMonth {
224
+
if err := s.addPullRequestItems(ctx, &feed, byMonth.PullEvents.Items, author); err != nil {
225
+
return nil, err
226
+
}
227
+
if err := s.addIssueItems(ctx, &feed, byMonth.IssueEvents.Items, author); err != nil {
228
+
return nil, err
229
+
}
230
+
if err := s.addRepoItems(ctx, &feed, byMonth.RepoEvents, author); err != nil {
231
+
return nil, err
232
+
}
233
+
}
234
+
235
+
slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int {
236
+
return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli())
237
+
})
238
+
239
+
if len(feed.Items) > 0 {
240
+
feed.Updated = feed.Items[0].Created
241
+
}
242
+
243
+
return &feed, nil
244
+
}
245
+
246
+
func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error {
247
+
for _, pull := range pulls {
248
+
owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did)
249
+
if err != nil {
250
+
return err
251
+
}
252
+
253
+
// Add pull request creation item
254
+
feed.Items = append(feed.Items, s.createPullRequestItem(pull, owner, author))
255
+
}
256
+
return nil
257
+
}
258
+
259
+
func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error {
260
+
for _, issue := range issues {
261
+
owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did)
262
+
if err != nil {
263
+
return err
264
+
}
265
+
266
+
feed.Items = append(feed.Items, s.createIssueItem(issue, owner, author))
267
+
}
268
+
return nil
269
+
}
270
+
271
+
func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error {
272
+
for _, repo := range repos {
273
+
item, err := s.createRepoItem(ctx, repo, author)
274
+
if err != nil {
275
+
return err
276
+
}
277
+
feed.Items = append(feed.Items, item)
278
+
}
279
+
return nil
280
+
}
281
+
282
+
func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item {
283
+
return &feeds.Item{
284
+
Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name),
285
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"},
286
+
Created: pull.Created,
287
+
Author: author,
288
+
}
289
+
}
290
+
291
+
func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item {
292
+
return &feeds.Item{
293
+
Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name),
294
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Metadata.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"},
295
+
Created: issue.Created,
296
+
Author: author,
297
+
}
298
+
}
299
+
300
+
func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) {
301
+
var title string
302
+
if repo.Source != nil {
303
+
sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did)
304
+
if err != nil {
305
+
return nil, err
306
+
}
307
+
title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, sourceOwner.Handle, repo.Source.Name, repo.Repo.Name)
308
+
} else {
309
+
title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name)
310
+
}
311
+
312
+
return &feeds.Item{
313
+
Title: title,
314
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.AppviewHost, author.Name[1:], repo.Repo.Name), Type: "text/html", Rel: "alternate"}, // Remove @ prefix
315
+
Created: repo.Repo.Created,
316
+
Author: author,
317
+
}, nil
221
318
}
222
319
223
320
func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
···
422
519
})
423
520
}
424
521
425
-
var didsToResolve []string
426
-
for _, r := range allRepos {
427
-
didsToResolve = append(didsToResolve, r.Did)
428
-
}
429
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve)
430
-
didHandleMap := make(map[string]string)
431
-
for _, identity := range resolvedIds {
432
-
if !identity.Handle.IsInvalidHandle() {
433
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
434
-
} else {
435
-
didHandleMap[identity.DID.String()] = identity.DID.String()
436
-
}
437
-
}
438
-
439
522
s.pages.EditPinsFragment(w, pages.EditPinsParams{
440
523
LoggedInUser: user,
441
524
Profile: profile,
442
525
AllRepos: allRepos,
443
-
DidHandleMap: didHandleMap,
444
526
})
445
527
}
+34
-6
appview/state/router.go
+34
-6
appview/state/router.go
···
17
17
"tangled.sh/tangled.sh/core/appview/signup"
18
18
"tangled.sh/tangled.sh/core/appview/spindles"
19
19
"tangled.sh/tangled.sh/core/appview/state/userutil"
20
+
avstrings "tangled.sh/tangled.sh/core/appview/strings"
20
21
"tangled.sh/tangled.sh/core/log"
21
22
)
22
23
···
31
32
s.pages,
32
33
)
33
34
35
+
router.Get("/favicon.svg", s.Favicon)
36
+
router.Get("/favicon.ico", s.Favicon)
37
+
38
+
userRouter := s.UserRouter(&middleware)
39
+
standardRouter := s.StandardRouter(&middleware)
40
+
34
41
router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
35
42
pat := chi.URLParam(r, "*")
36
43
if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") {
37
-
s.UserRouter(&middleware).ServeHTTP(w, r)
44
+
userRouter.ServeHTTP(w, r)
38
45
} else {
39
46
// Check if the first path element is a valid handle without '@' or a flattened DID
40
47
pathParts := strings.SplitN(pat, "/", 2)
···
57
64
return
58
65
}
59
66
}
60
-
s.StandardRouter(&middleware).ServeHTTP(w, r)
67
+
standardRouter.ServeHTTP(w, r)
61
68
}
62
69
})
63
70
···
67
74
func (s *State) UserRouter(mw *middleware.Middleware) http.Handler {
68
75
r := chi.NewRouter()
69
76
70
-
// strip @ from user
71
-
r.Use(middleware.StripLeadingAt)
72
-
73
77
r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) {
74
78
r.Get("/", s.Profile)
79
+
r.Get("/feed.atom", s.AtomFeedPage)
80
+
81
+
// redirect /@handle/repo.git -> /@handle/repo
82
+
r.Get("/{repo}.git", func(w http.ResponseWriter, r *http.Request) {
83
+
nonDotGitPath := strings.TrimSuffix(r.URL.Path, ".git")
84
+
http.Redirect(w, r, nonDotGitPath, http.StatusMovedPermanently)
85
+
})
75
86
76
87
r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) {
77
88
r.Use(mw.GoImport())
78
-
79
89
r.Mount("/", s.RepoRouter(mw))
80
90
r.Mount("/issues", s.IssuesRouter(mw))
81
91
r.Mount("/pulls", s.PullsRouter(mw))
···
136
146
})
137
147
138
148
r.Mount("/settings", s.SettingsRouter())
149
+
r.Mount("/strings", s.StringsRouter(mw))
139
150
r.Mount("/knots", s.KnotsRouter(mw))
140
151
r.Mount("/spindles", s.SpindlesRouter())
141
152
r.Mount("/signup", s.SignupRouter())
···
199
210
}
200
211
201
212
return knots.Router(mw)
213
+
}
214
+
215
+
func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {
216
+
logger := log.New("strings")
217
+
218
+
strs := &avstrings.Strings{
219
+
Db: s.db,
220
+
OAuth: s.oauth,
221
+
Pages: s.pages,
222
+
Config: s.config,
223
+
Enforcer: s.enforcer,
224
+
IdResolver: s.idResolver,
225
+
Knotstream: s.knotstream,
226
+
Logger: logger,
227
+
}
228
+
229
+
return strs.Router(mw)
202
230
}
203
231
204
232
func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
+30
-30
appview/state/state.go
+30
-30
appview/state/state.go
···
61
61
return nil, fmt.Errorf("failed to create enforcer: %w", err)
62
62
}
63
63
64
-
pgs := pages.NewPages(config)
65
-
66
64
res, err := idresolver.RedisResolver(config.Redis.ToURL())
67
65
if err != nil {
68
66
log.Printf("failed to create redis resolver: %v", err)
69
67
res = idresolver.DefaultResolver()
70
68
}
69
+
70
+
pgs := pages.NewPages(config, res)
71
71
72
72
cache := cache.New(config.Redis.Addr)
73
73
sess := session.New(cache)
···
93
93
tangled.ActorProfileNSID,
94
94
tangled.SpindleMemberNSID,
95
95
tangled.SpindleNSID,
96
+
tangled.StringNSID,
97
+
tangled.RepoIssueNSID,
98
+
tangled.RepoIssueCommentNSID,
96
99
},
97
100
nil,
98
101
slog.Default(),
···
156
159
return state, nil
157
160
}
158
161
162
+
func (s *State) Favicon(w http.ResponseWriter, r *http.Request) {
163
+
w.Header().Set("Content-Type", "image/svg+xml")
164
+
w.Header().Set("Cache-Control", "public, max-age=31536000") // one year
165
+
w.Header().Set("ETag", `"favicon-svg-v1"`)
166
+
167
+
if match := r.Header.Get("If-None-Match"); match == `"favicon-svg-v1"` {
168
+
w.WriteHeader(http.StatusNotModified)
169
+
return
170
+
}
171
+
172
+
s.pages.Favicon(w)
173
+
}
174
+
159
175
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
160
176
user := s.oauth.GetUser(r)
161
177
s.pages.TermsOfService(w, pages.TermsOfServiceParams{
···
179
195
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
180
196
}
181
197
182
-
var didsToResolve []string
183
-
for _, ev := range timeline {
184
-
if ev.Repo != nil {
185
-
didsToResolve = append(didsToResolve, ev.Repo.Did)
186
-
if ev.Source != nil {
187
-
didsToResolve = append(didsToResolve, ev.Source.Did)
188
-
}
189
-
}
190
-
if ev.Follow != nil {
191
-
didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid)
192
-
}
193
-
if ev.Star != nil {
194
-
didsToResolve = append(didsToResolve, ev.Star.StarredByDid, ev.Star.Repo.Did)
195
-
}
196
-
}
197
-
198
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve)
199
-
didHandleMap := make(map[string]string)
200
-
for _, identity := range resolvedIds {
201
-
if !identity.Handle.IsInvalidHandle() {
202
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
203
-
} else {
204
-
didHandleMap[identity.DID.String()] = identity.DID.String()
205
-
}
198
+
repos, err := db.GetTopStarredReposLastWeek(s.db)
199
+
if err != nil {
200
+
log.Println(err)
201
+
s.pages.Notice(w, "topstarredrepos", "Unable to load.")
202
+
return
206
203
}
207
204
208
205
s.pages.Timeline(w, pages.TimelineParams{
209
206
LoggedInUser: user,
210
207
Timeline: timeline,
211
-
DidHandleMap: didHandleMap,
208
+
Repos: repos,
212
209
})
213
-
214
-
return
215
210
}
216
211
217
212
func (s *State) Keys(w http.ResponseWriter, r *http.Request) {
···
278
273
return nil
279
274
}
280
275
276
+
func stripGitExt(name string) string {
277
+
return strings.TrimSuffix(name, ".git")
278
+
}
279
+
281
280
func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) {
282
281
switch r.Method {
283
282
case http.MethodGet:
···
312
311
s.pages.Notice(w, "repo", err.Error())
313
312
return
314
313
}
314
+
315
+
repoName = stripGitExt(repoName)
315
316
316
317
defaultBranch := r.FormValue("branch")
317
318
if defaultBranch == "" {
···
409
410
// continue
410
411
}
411
412
412
-
repo.AtUri = atresp.Uri
413
413
err = db.AddRepo(tx, repo)
414
414
if err != nil {
415
415
log.Println(err)
+465
appview/strings/strings.go
+465
appview/strings/strings.go
···
1
+
package strings
2
+
3
+
import (
4
+
"fmt"
5
+
"log/slog"
6
+
"net/http"
7
+
"path"
8
+
"slices"
9
+
"strconv"
10
+
"time"
11
+
12
+
"tangled.sh/tangled.sh/core/api/tangled"
13
+
"tangled.sh/tangled.sh/core/appview/config"
14
+
"tangled.sh/tangled.sh/core/appview/db"
15
+
"tangled.sh/tangled.sh/core/appview/middleware"
16
+
"tangled.sh/tangled.sh/core/appview/oauth"
17
+
"tangled.sh/tangled.sh/core/appview/pages"
18
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
19
+
"tangled.sh/tangled.sh/core/eventconsumer"
20
+
"tangled.sh/tangled.sh/core/idresolver"
21
+
"tangled.sh/tangled.sh/core/rbac"
22
+
"tangled.sh/tangled.sh/core/tid"
23
+
24
+
"github.com/bluesky-social/indigo/api/atproto"
25
+
"github.com/bluesky-social/indigo/atproto/identity"
26
+
"github.com/bluesky-social/indigo/atproto/syntax"
27
+
lexutil "github.com/bluesky-social/indigo/lex/util"
28
+
"github.com/go-chi/chi/v5"
29
+
)
30
+
31
+
type Strings struct {
32
+
Db *db.DB
33
+
OAuth *oauth.OAuth
34
+
Pages *pages.Pages
35
+
Config *config.Config
36
+
Enforcer *rbac.Enforcer
37
+
IdResolver *idresolver.Resolver
38
+
Logger *slog.Logger
39
+
Knotstream *eventconsumer.Consumer
40
+
}
41
+
42
+
func (s *Strings) Router(mw *middleware.Middleware) http.Handler {
43
+
r := chi.NewRouter()
44
+
45
+
r.
46
+
Get("/", s.timeline)
47
+
48
+
r.
49
+
With(mw.ResolveIdent()).
50
+
Route("/{user}", func(r chi.Router) {
51
+
r.Get("/", s.dashboard)
52
+
53
+
r.Route("/{rkey}", func(r chi.Router) {
54
+
r.Get("/", s.contents)
55
+
r.Delete("/", s.delete)
56
+
r.Get("/raw", s.contents)
57
+
r.Get("/edit", s.edit)
58
+
r.Post("/edit", s.edit)
59
+
r.
60
+
With(middleware.AuthMiddleware(s.OAuth)).
61
+
Post("/comment", s.comment)
62
+
})
63
+
})
64
+
65
+
r.
66
+
With(middleware.AuthMiddleware(s.OAuth)).
67
+
Route("/new", func(r chi.Router) {
68
+
r.Get("/", s.create)
69
+
r.Post("/", s.create)
70
+
})
71
+
72
+
return r
73
+
}
74
+
75
+
func (s *Strings) timeline(w http.ResponseWriter, r *http.Request) {
76
+
l := s.Logger.With("handler", "timeline")
77
+
78
+
strings, err := db.GetStrings(s.Db, 50)
79
+
if err != nil {
80
+
l.Error("failed to fetch string", "err", err)
81
+
w.WriteHeader(http.StatusInternalServerError)
82
+
return
83
+
}
84
+
85
+
s.Pages.StringsTimeline(w, pages.StringTimelineParams{
86
+
LoggedInUser: s.OAuth.GetUser(r),
87
+
Strings: strings,
88
+
})
89
+
}
90
+
91
+
func (s *Strings) contents(w http.ResponseWriter, r *http.Request) {
92
+
l := s.Logger.With("handler", "contents")
93
+
94
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
95
+
if !ok {
96
+
l.Error("malformed middleware")
97
+
w.WriteHeader(http.StatusInternalServerError)
98
+
return
99
+
}
100
+
l = l.With("did", id.DID, "handle", id.Handle)
101
+
102
+
rkey := chi.URLParam(r, "rkey")
103
+
if rkey == "" {
104
+
l.Error("malformed url, empty rkey")
105
+
w.WriteHeader(http.StatusBadRequest)
106
+
return
107
+
}
108
+
l = l.With("rkey", rkey)
109
+
110
+
strings, err := db.GetStrings(
111
+
s.Db,
112
+
0,
113
+
db.FilterEq("did", id.DID),
114
+
db.FilterEq("rkey", rkey),
115
+
)
116
+
if err != nil {
117
+
l.Error("failed to fetch string", "err", err)
118
+
w.WriteHeader(http.StatusInternalServerError)
119
+
return
120
+
}
121
+
if len(strings) < 1 {
122
+
l.Error("string not found")
123
+
s.Pages.Error404(w)
124
+
return
125
+
}
126
+
if len(strings) != 1 {
127
+
l.Error("incorrect number of records returned", "len(strings)", len(strings))
128
+
w.WriteHeader(http.StatusInternalServerError)
129
+
return
130
+
}
131
+
string := strings[0]
132
+
133
+
if path.Base(r.URL.Path) == "raw" {
134
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
135
+
if string.Filename != "" {
136
+
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", string.Filename))
137
+
}
138
+
w.Header().Set("Content-Length", strconv.Itoa(len(string.Contents)))
139
+
140
+
_, err = w.Write([]byte(string.Contents))
141
+
if err != nil {
142
+
l.Error("failed to write raw response", "err", err)
143
+
}
144
+
return
145
+
}
146
+
147
+
var showRendered, renderToggle bool
148
+
if markup.GetFormat(string.Filename) == markup.FormatMarkdown {
149
+
renderToggle = true
150
+
showRendered = r.URL.Query().Get("code") != "true"
151
+
}
152
+
153
+
s.Pages.SingleString(w, pages.SingleStringParams{
154
+
LoggedInUser: s.OAuth.GetUser(r),
155
+
RenderToggle: renderToggle,
156
+
ShowRendered: showRendered,
157
+
String: string,
158
+
Stats: string.Stats(),
159
+
Owner: id,
160
+
})
161
+
}
162
+
163
+
func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) {
164
+
l := s.Logger.With("handler", "dashboard")
165
+
166
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
167
+
if !ok {
168
+
l.Error("malformed middleware")
169
+
w.WriteHeader(http.StatusInternalServerError)
170
+
return
171
+
}
172
+
l = l.With("did", id.DID, "handle", id.Handle)
173
+
174
+
all, err := db.GetStrings(
175
+
s.Db,
176
+
0,
177
+
db.FilterEq("did", id.DID),
178
+
)
179
+
if err != nil {
180
+
l.Error("failed to fetch strings", "err", err)
181
+
w.WriteHeader(http.StatusInternalServerError)
182
+
return
183
+
}
184
+
185
+
slices.SortFunc(all, func(a, b db.String) int {
186
+
if a.Created.After(b.Created) {
187
+
return -1
188
+
} else {
189
+
return 1
190
+
}
191
+
})
192
+
193
+
profile, err := db.GetProfile(s.Db, id.DID.String())
194
+
if err != nil {
195
+
l.Error("failed to fetch user profile", "err", err)
196
+
w.WriteHeader(http.StatusInternalServerError)
197
+
return
198
+
}
199
+
loggedInUser := s.OAuth.GetUser(r)
200
+
followStatus := db.IsNotFollowing
201
+
if loggedInUser != nil {
202
+
followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String())
203
+
}
204
+
205
+
followers, following, err := db.GetFollowerFollowingCount(s.Db, id.DID.String())
206
+
if err != nil {
207
+
l.Error("failed to get follow stats", "err", err)
208
+
}
209
+
210
+
s.Pages.StringsDashboard(w, pages.StringsDashboardParams{
211
+
LoggedInUser: s.OAuth.GetUser(r),
212
+
Card: pages.ProfileCard{
213
+
UserDid: id.DID.String(),
214
+
UserHandle: id.Handle.String(),
215
+
Profile: profile,
216
+
FollowStatus: followStatus,
217
+
Followers: followers,
218
+
Following: following,
219
+
},
220
+
Strings: all,
221
+
})
222
+
}
223
+
224
+
func (s *Strings) edit(w http.ResponseWriter, r *http.Request) {
225
+
l := s.Logger.With("handler", "edit")
226
+
227
+
user := s.OAuth.GetUser(r)
228
+
229
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
230
+
if !ok {
231
+
l.Error("malformed middleware")
232
+
w.WriteHeader(http.StatusInternalServerError)
233
+
return
234
+
}
235
+
l = l.With("did", id.DID, "handle", id.Handle)
236
+
237
+
rkey := chi.URLParam(r, "rkey")
238
+
if rkey == "" {
239
+
l.Error("malformed url, empty rkey")
240
+
w.WriteHeader(http.StatusBadRequest)
241
+
return
242
+
}
243
+
l = l.With("rkey", rkey)
244
+
245
+
// get the string currently being edited
246
+
all, err := db.GetStrings(
247
+
s.Db,
248
+
0,
249
+
db.FilterEq("did", id.DID),
250
+
db.FilterEq("rkey", rkey),
251
+
)
252
+
if err != nil {
253
+
l.Error("failed to fetch string", "err", err)
254
+
w.WriteHeader(http.StatusInternalServerError)
255
+
return
256
+
}
257
+
if len(all) != 1 {
258
+
l.Error("incorrect number of records returned", "len(strings)", len(all))
259
+
w.WriteHeader(http.StatusInternalServerError)
260
+
return
261
+
}
262
+
first := all[0]
263
+
264
+
// verify that the logged in user owns this string
265
+
if user.Did != id.DID.String() {
266
+
l.Error("unauthorized request", "expected", id.DID, "got", user.Did)
267
+
w.WriteHeader(http.StatusUnauthorized)
268
+
return
269
+
}
270
+
271
+
switch r.Method {
272
+
case http.MethodGet:
273
+
// return the form with prefilled fields
274
+
s.Pages.PutString(w, pages.PutStringParams{
275
+
LoggedInUser: s.OAuth.GetUser(r),
276
+
Action: "edit",
277
+
String: first,
278
+
})
279
+
case http.MethodPost:
280
+
fail := func(msg string, err error) {
281
+
l.Error(msg, "err", err)
282
+
s.Pages.Notice(w, "error", msg)
283
+
}
284
+
285
+
filename := r.FormValue("filename")
286
+
if filename == "" {
287
+
fail("Empty filename.", nil)
288
+
return
289
+
}
290
+
291
+
content := r.FormValue("content")
292
+
if content == "" {
293
+
fail("Empty contents.", nil)
294
+
return
295
+
}
296
+
297
+
description := r.FormValue("description")
298
+
299
+
// construct new string from form values
300
+
entry := db.String{
301
+
Did: first.Did,
302
+
Rkey: first.Rkey,
303
+
Filename: filename,
304
+
Description: description,
305
+
Contents: content,
306
+
Created: first.Created,
307
+
}
308
+
309
+
record := entry.AsRecord()
310
+
311
+
client, err := s.OAuth.AuthorizedClient(r)
312
+
if err != nil {
313
+
fail("Failed to create record.", err)
314
+
return
315
+
}
316
+
317
+
// first replace the existing record in the PDS
318
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey)
319
+
if err != nil {
320
+
fail("Failed to updated existing record.", err)
321
+
return
322
+
}
323
+
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
324
+
Collection: tangled.StringNSID,
325
+
Repo: entry.Did.String(),
326
+
Rkey: entry.Rkey,
327
+
SwapRecord: ex.Cid,
328
+
Record: &lexutil.LexiconTypeDecoder{
329
+
Val: &record,
330
+
},
331
+
})
332
+
if err != nil {
333
+
fail("Failed to updated existing record.", err)
334
+
return
335
+
}
336
+
l := l.With("aturi", resp.Uri)
337
+
l.Info("edited string")
338
+
339
+
// if that went okay, updated the db
340
+
if err = db.AddString(s.Db, entry); err != nil {
341
+
fail("Failed to update string.", err)
342
+
return
343
+
}
344
+
345
+
// if that went okay, redir to the string
346
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey)
347
+
}
348
+
349
+
}
350
+
351
+
func (s *Strings) create(w http.ResponseWriter, r *http.Request) {
352
+
l := s.Logger.With("handler", "create")
353
+
user := s.OAuth.GetUser(r)
354
+
355
+
switch r.Method {
356
+
case http.MethodGet:
357
+
s.Pages.PutString(w, pages.PutStringParams{
358
+
LoggedInUser: s.OAuth.GetUser(r),
359
+
Action: "new",
360
+
})
361
+
case http.MethodPost:
362
+
fail := func(msg string, err error) {
363
+
l.Error(msg, "err", err)
364
+
s.Pages.Notice(w, "error", msg)
365
+
}
366
+
367
+
filename := r.FormValue("filename")
368
+
if filename == "" {
369
+
fail("Empty filename.", nil)
370
+
return
371
+
}
372
+
373
+
content := r.FormValue("content")
374
+
if content == "" {
375
+
fail("Empty contents.", nil)
376
+
return
377
+
}
378
+
379
+
description := r.FormValue("description")
380
+
381
+
string := db.String{
382
+
Did: syntax.DID(user.Did),
383
+
Rkey: tid.TID(),
384
+
Filename: filename,
385
+
Description: description,
386
+
Contents: content,
387
+
Created: time.Now(),
388
+
}
389
+
390
+
record := string.AsRecord()
391
+
392
+
client, err := s.OAuth.AuthorizedClient(r)
393
+
if err != nil {
394
+
fail("Failed to create record.", err)
395
+
return
396
+
}
397
+
398
+
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
399
+
Collection: tangled.StringNSID,
400
+
Repo: user.Did,
401
+
Rkey: string.Rkey,
402
+
Record: &lexutil.LexiconTypeDecoder{
403
+
Val: &record,
404
+
},
405
+
})
406
+
if err != nil {
407
+
fail("Failed to create record.", err)
408
+
return
409
+
}
410
+
l := l.With("aturi", resp.Uri)
411
+
l.Info("created record")
412
+
413
+
// insert into DB
414
+
if err = db.AddString(s.Db, string); err != nil {
415
+
fail("Failed to create string.", err)
416
+
return
417
+
}
418
+
419
+
// successful
420
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey)
421
+
}
422
+
}
423
+
424
+
func (s *Strings) delete(w http.ResponseWriter, r *http.Request) {
425
+
l := s.Logger.With("handler", "create")
426
+
user := s.OAuth.GetUser(r)
427
+
fail := func(msg string, err error) {
428
+
l.Error(msg, "err", err)
429
+
s.Pages.Notice(w, "error", msg)
430
+
}
431
+
432
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
433
+
if !ok {
434
+
l.Error("malformed middleware")
435
+
w.WriteHeader(http.StatusInternalServerError)
436
+
return
437
+
}
438
+
l = l.With("did", id.DID, "handle", id.Handle)
439
+
440
+
rkey := chi.URLParam(r, "rkey")
441
+
if rkey == "" {
442
+
l.Error("malformed url, empty rkey")
443
+
w.WriteHeader(http.StatusBadRequest)
444
+
return
445
+
}
446
+
447
+
if user.Did != id.DID.String() {
448
+
fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String()))
449
+
return
450
+
}
451
+
452
+
if err := db.DeleteString(
453
+
s.Db,
454
+
db.FilterEq("did", user.Did),
455
+
db.FilterEq("rkey", rkey),
456
+
); err != nil {
457
+
fail("Failed to delete string.", err)
458
+
return
459
+
}
460
+
461
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle)
462
+
}
463
+
464
+
func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
465
+
}
+2
-2
cmd/gen.go
+2
-2
cmd/gen.go
···
27
27
tangled.KnotMember{},
28
28
tangled.Pipeline{},
29
29
tangled.Pipeline_CloneOpts{},
30
-
tangled.Pipeline_Dependency{},
31
30
tangled.Pipeline_ManualTriggerData{},
32
31
tangled.Pipeline_Pair{},
33
32
tangled.Pipeline_PullRequestTriggerData{},
34
33
tangled.Pipeline_PushTriggerData{},
35
34
tangled.PipelineStatus{},
36
-
tangled.Pipeline_Step{},
37
35
tangled.Pipeline_TriggerMetadata{},
38
36
tangled.Pipeline_TriggerRepo{},
39
37
tangled.Pipeline_Workflow{},
40
38
tangled.PublicKey{},
41
39
tangled.Repo{},
42
40
tangled.RepoArtifact{},
41
+
tangled.RepoCollaborator{},
43
42
tangled.RepoIssue{},
44
43
tangled.RepoIssueComment{},
45
44
tangled.RepoIssueState{},
···
49
48
tangled.RepoPullStatus{},
50
49
tangled.Spindle{},
51
50
tangled.SpindleMember{},
51
+
tangled.String{},
52
52
); err != nil {
53
53
panic(err)
54
54
}
+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.
+20
-15
docs/hacking.md
+20
-15
docs/hacking.md
···
56
56
`nixosConfiguration` to do so.
57
57
58
58
To begin, head to `http://localhost:3000/knots` in the browser
59
-
and generate a knot secret. Replace the existing secret in
60
-
`nix/vm.nix` (`KNOT_SERVER_SECRET`) with the newly generated
61
-
secret.
59
+
and create a knot with hostname `localhost:6000`. This will
60
+
generate a knot secret. Set `$TANGLED_VM_KNOT_SECRET` to it,
61
+
ideally in a `.envrc` with [direnv](https://direnv.net) so you
62
+
don't lose it.
62
63
63
-
You can now start a lightweight NixOS VM using
64
-
`nixos-shell` like so:
64
+
You will also need to set the `$TANGLED_VM_SPINDLE_OWNER`
65
+
variable to some value. If you don't want to [set up a
66
+
spindle](#running-a-spindle), you can use any placeholder
67
+
value.
68
+
69
+
You can now start a lightweight NixOS VM like so:
65
70
66
71
```bash
67
-
nix run .#vm
68
-
# or nixos-shell --flake .#vm
72
+
nix run --impure .#vm
69
73
70
-
# hit Ctrl-a + c + q to exit the VM
74
+
# type `poweroff` at the shell to exit the VM
71
75
```
72
76
73
77
This starts a knot on port 6000, a spindle on port 6555
···
91
95
92
96
## running a spindle
93
97
94
-
Be sure to change the `owner` field for the spindle in
95
-
`nix/vm.nix` to your own DID. The above VM should already
96
-
be running a spindle on `localhost:6555`. You can head to
97
-
the spindle dashboard on `http://localhost:3000/spindles`,
98
-
and register a spindle with hostname `localhost:6555`. It
99
-
should instantly be verified. You can then configure each
100
-
repository to use this spindle and run CI jobs.
98
+
You will need to find out your DID by entering your login handle into
99
+
<https://pdsls.dev/>. Set `$TANGLED_VM_SPINDLE_OWNER` to your DID.
100
+
101
+
The above VM should already be running a spindle on `localhost:6555`.
102
+
You can head to the spindle dashboard on `http://localhost:3000/spindles`,
103
+
and register a spindle with hostname `localhost:6555`. It should instantly
104
+
be verified. You can then configure each repository to use this spindle
105
+
and run CI jobs.
101
106
102
107
Of interest when debugging spindles:
103
108
+11
-5
docs/knot-hosting.md
+11
-5
docs/knot-hosting.md
···
2
2
3
3
So you want to run your own knot server? Great! Here are a few prerequisites:
4
4
5
-
1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux of some kind.
5
+
1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind.
6
6
2. A (sub)domain name. People generally use `knot.example.com`.
7
7
3. A valid SSL certificate for your domain.
8
8
···
57
57
AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys
58
58
AuthorizedKeysCommandUser nobody
59
59
EOF
60
+
```
61
+
62
+
Then, reload `sshd`:
63
+
64
+
```
65
+
sudo systemctl reload ssh
60
66
```
61
67
62
68
Next, create the `git` user. We'll use the `git` user's home directory
···
67
73
```
68
74
69
75
Create `/home/git/.knot.env` with the following, updating the values as
70
-
necessary. The `KNOT_SERVER_SECRET` can be obtaind from the
71
-
[/knots](/knots) page on Tangled.
76
+
necessary. The `KNOT_SERVER_SECRET` can be obtained from the
77
+
[/knots](https://tangled.sh/knots) page on Tangled.
72
78
73
79
```
74
80
KNOT_REPO_SCAN_PATH=/home/git
···
89
95
systemctl start knotserver
90
96
```
91
97
92
-
The last step is to configure a reverse proxy like Nginx or Caddy to front yourself
98
+
The last step is to configure a reverse proxy like Nginx or Caddy to front your
93
99
knot. Here's an example configuration for Nginx:
94
100
95
101
```
···
123
129
knot domain.
124
130
125
131
You should now have a running knot server! You can finalize your registration by hitting the
126
-
`initialize` button on the [/knots](/knots) page.
132
+
`initialize` button on the [/knots](https://tangled.sh/knots) page.
127
133
128
134
### custom paths
129
135
+193
-38
docs/spindle/openbao.md
+193
-38
docs/spindle/openbao.md
···
1
1
# spindle secrets with openbao
2
2
3
3
This document covers setting up Spindle to use OpenBao for secrets
4
-
management instead of the default SQLite backend.
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.
5
14
6
15
## installation
7
16
8
17
Install OpenBao from nixpkgs:
9
18
10
19
```bash
11
-
nix-env -iA nixpkgs.openbao
20
+
nix shell nixpkgs#openbao # for a local server
12
21
```
13
22
14
-
## local development setup
23
+
## setup
24
+
25
+
The setup process can is documented for both local development and production.
26
+
27
+
### local development
15
28
16
29
Start OpenBao in dev mode:
17
30
18
31
```bash
19
-
bao server -dev
32
+
bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201
20
33
```
21
34
22
-
This starts OpenBao on `http://localhost:8200` with a root token. Save
23
-
the root token from the output -- you'll need it.
35
+
This starts OpenBao on `http://localhost:8201` with a root token.
24
36
25
37
Set up environment for bao CLI:
26
38
27
39
```bash
28
40
export BAO_ADDR=http://localhost:8200
29
-
export BAO_TOKEN=hvs.your-root-token-here
41
+
export BAO_TOKEN=root
30
42
```
31
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
+
32
64
Create the spindle KV mount:
33
65
34
66
```bash
35
67
bao secrets enable -path=spindle -version=2 kv
36
68
```
37
69
38
-
Set up AppRole authentication:
70
+
Set up AppRole authentication and policy:
39
71
40
72
Create a policy file `spindle-policy.hcl`:
41
73
42
74
```hcl
75
+
# Full access to spindle KV v2 data
43
76
path "spindle/data/*" {
44
-
capabilities = ["create", "read", "update", "delete", "list"]
77
+
capabilities = ["create", "read", "update", "delete"]
45
78
}
46
79
80
+
# Access to metadata for listing and management
47
81
path "spindle/metadata/*" {
48
-
capabilities = ["list", "read", "delete"]
82
+
capabilities = ["list", "read", "delete", "update"]
49
83
}
50
84
51
-
path "spindle/*" {
85
+
# Allow listing at root level
86
+
path "spindle/" {
52
87
capabilities = ["list"]
53
88
}
89
+
90
+
# Required for connection testing and health checks
91
+
path "auth/token/lookup-self" {
92
+
capabilities = ["read"]
93
+
}
54
94
```
55
95
56
96
Apply the policy and create an AppRole:
···
61
101
bao write auth/approle/role/spindle \
62
102
token_policies="spindle-policy" \
63
103
token_ttl=1h \
64
-
token_max_ttl=4h
104
+
token_max_ttl=4h \
105
+
bind_secret_id=true \
106
+
secret_id_ttl=0 \
107
+
secret_id_num_uses=0
65
108
```
66
109
67
110
Get the credentials:
68
111
69
112
```bash
70
-
bao read auth/approle/role/spindle/role-id
71
-
bao write -f auth/approle/role/spindle/secret-id
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"
72
182
```
73
183
74
-
Configure Spindle:
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
75
196
76
197
Set these environment variables for Spindle:
77
198
78
199
```bash
79
200
export SPINDLE_SERVER_SECRETS_PROVIDER=openbao
80
-
export SPINDLE_SERVER_SECRETS_OPENBAO_ADDR=http://localhost:8200
81
-
export SPINDLE_SERVER_SECRETS_OPENBAO_ROLE_ID=your-role-id-from-above
82
-
export SPINDLE_SERVER_SECRETS_OPENBAO_SECRET_ID=your-secret-id-from-above
201
+
export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201
83
202
export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle
84
203
```
85
204
86
205
Start Spindle:
87
206
88
-
Spindle will now use OpenBao for secrets storage with automatic token
89
-
renewal.
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.
90
216
91
217
## verifying setup
92
218
93
-
List all secrets:
219
+
Test the proxy directly:
94
220
95
221
```bash
96
-
bao kv list spindle/
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
97
227
```
98
228
99
-
Add a test secret via Spindle API, then check it exists:
229
+
Test OpenBao operations through the server:
100
230
101
231
```bash
232
+
# List all secrets
233
+
bao kv list spindle/
234
+
235
+
# Add a test secret via Spindle API, then check it exists
102
236
bao kv list spindle/repos/
103
-
```
104
237
105
-
Get a specific secret:
106
-
107
-
```bash
238
+
# Get a specific secret
108
239
bao kv get spindle/repos/your_repo_path/SECRET_NAME
109
240
```
110
241
111
242
## how it works
112
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
113
247
- Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}`
114
-
- Each repository gets its own namespace
115
-
- Repository paths like `at://did:plc:alice/myrepo` become
116
-
`at_did_plc_alice_myrepo`
117
-
- The system automatically handles token renewal using AppRole
118
-
authentication
119
-
- On shutdown, Spindle cleanly stops the token renewal process
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
120
251
121
252
## troubleshooting
122
253
123
-
**403 errors**: Check that your BAO_TOKEN is set and the spindle mount
124
-
exists
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.
125
259
126
260
**404 route errors**: The spindle KV mount probably doesn't exist - run
127
-
the mount creation step again
261
+
the mount creation step again.
128
262
129
-
**Token expired**: The AppRole system should handle this automatically,
130
-
but you can check token status with `bao token lookup`
263
+
**Proxy authentication failures**: Check the proxy logs and verify the
264
+
role-id and secret-id files are readable and contain valid credentials.
265
+
266
+
**Secret not found after writing**: This can indicate policy permission
267
+
issues. Verify the policy includes both `spindle/data/*` and
268
+
`spindle/metadata/*` paths with appropriate capabilities.
269
+
270
+
Check proxy logs:
271
+
272
+
```bash
273
+
# If running as systemd service
274
+
journalctl -u openbao-proxy -f
275
+
276
+
# If running directly, check the console output
277
+
```
278
+
279
+
Test AppRole authentication manually:
280
+
281
+
```bash
282
+
bao write auth/approle/login \
283
+
role_id="$(cat /tmp/openbao/role-id)" \
284
+
secret_id="$(cat /tmp/openbao/secret-id)"
285
+
```
+26
-3
docs/spindle/pipeline.md
+26
-3
docs/spindle/pipeline.md
···
4
4
repo. Generally:
5
5
6
6
* Pipelines are defined in YAML.
7
-
* Dependencies can be specified from
8
-
[Nixpkgs](https://search.nixos.org) or custom registries.
9
-
* Environment variables can be set globally or per-step.
7
+
* Workflows can run using different *engines*.
8
+
9
+
The most barebones workflow looks like this:
10
+
11
+
```yaml
12
+
when:
13
+
- event: ["push"]
14
+
branch: ["main"]
15
+
16
+
engine: "nixery"
17
+
18
+
# optional
19
+
clone:
20
+
skip: false
21
+
depth: 50
22
+
submodules: true
23
+
```
24
+
25
+
The `when` and `engine` fields are required, while every other aspect
26
+
of how the definition is parsed is up to the engine. Currently, a spindle
27
+
provides at least one of these built-in engines:
28
+
29
+
## `nixery`
30
+
31
+
The Nixery engine uses an instance of [Nixery](https://nixery.dev) to run
32
+
steps that use dependencies from [Nixpkgs](https://github.com/NixOS/nixpkgs).
10
33
11
34
Here's an example that uses all fields:
12
35
+1
-1
eventconsumer/cursor/sqlite.go
+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 api/tangled/*
256
+
lexgen --build-file lexicon-build-config.json lexicons
257
+
sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/*
258
+
${pkgs.gotools}/bin/goimports -w api/tangled/*
259
+
go run cmd/gen.go
260
+
lexgen --build-file lexicon-build-config.json lexicons
261
+
rm api/tangled/*.bak
262
+
'';
263
+
})
264
+
+ /bin/lexgen;
265
+
};
191
266
});
192
267
193
268
nixosModules.appview = {
···
217
292
218
293
services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
219
294
};
220
-
nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;};
221
295
};
222
296
}
+3
-1
go.mod
+3
-1
go.mod
···
22
22
github.com/go-enry/go-enry/v2 v2.9.2
23
23
github.com/go-git/go-git/v5 v5.14.0
24
24
github.com/google/uuid v1.6.0
25
+
github.com/gorilla/feeds v1.2.0
25
26
github.com/gorilla/sessions v1.4.0
26
27
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
27
28
github.com/hiddeco/sshsig v0.2.0
···
38
39
github.com/stretchr/testify v1.10.0
39
40
github.com/urfave/cli/v3 v3.3.3
40
41
github.com/whyrusleeping/cbor-gen v0.3.1
41
-
github.com/yuin/goldmark v1.4.13
42
+
github.com/yuin/goldmark v1.4.15
43
+
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
42
44
golang.org/x/crypto v0.40.0
43
45
golang.org/x/net v0.42.0
44
46
golang.org/x/sync v0.16.0
+7
-1
go.sum
+7
-1
go.sum
···
79
79
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
80
80
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
81
81
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
82
+
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
82
83
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
83
84
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
84
85
github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw=
···
173
174
github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
174
175
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
175
176
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
177
+
github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc=
178
+
github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y=
176
179
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
177
180
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
178
181
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
···
427
430
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
428
431
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
429
432
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
430
-
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
431
433
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
434
+
github.com/yuin/goldmark v1.4.15 h1:CFa84T0goNn/UIXYS+dmjjVxMyTAvpOmzld40N/nfK0=
435
+
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
436
+
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
437
+
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
432
438
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
433
439
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
434
440
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
+83
-6
input.css
+83
-6
input.css
···
13
13
@font-face {
14
14
font-family: "InterVariable";
15
15
src: url("/static/fonts/InterVariable-Italic.woff2") format("woff2");
16
-
font-weight: 400;
16
+
font-weight: normal;
17
17
font-style: italic;
18
18
font-display: swap;
19
19
}
20
20
21
21
@font-face {
22
22
font-family: "InterVariable";
23
-
src: url("/static/fonts/InterVariable.woff2") format("woff2");
24
-
font-weight: 600;
23
+
src: url("/static/fonts/InterDisplay-Bold.woff2") format("woff2");
24
+
font-weight: bold;
25
25
font-style: normal;
26
26
font-display: swap;
27
27
}
28
28
29
29
@font-face {
30
+
font-family: "InterVariable";
31
+
src: url("/static/fonts/InterDisplay-BoldItalic.woff2") format("woff2");
32
+
font-weight: bold;
33
+
font-style: italic;
34
+
font-display: swap;
35
+
}
36
+
37
+
@font-face {
30
38
font-family: "IBMPlexMono";
31
39
src: url("/static/fonts/IBMPlexMono-Regular.woff2") format("woff2");
40
+
font-weight: normal;
41
+
font-style: normal;
42
+
font-display: swap;
43
+
}
44
+
45
+
@font-face {
46
+
font-family: "IBMPlexMono";
47
+
src: url("/static/fonts/IBMPlexMono-Italic.woff2") format("woff2");
32
48
font-weight: normal;
33
49
font-style: italic;
34
50
font-display: swap;
35
51
}
36
52
53
+
@font-face {
54
+
font-family: "IBMPlexMono";
55
+
src: url("/static/fonts/IBMPlexMono-Bold.woff2") format("woff2");
56
+
font-weight: bold;
57
+
font-style: normal;
58
+
font-display: swap;
59
+
}
60
+
61
+
@font-face {
62
+
font-family: "IBMPlexMono";
63
+
src: url("/static/fonts/IBMPlexMono-BoldItalic.woff2") format("woff2");
64
+
font-weight: bold;
65
+
font-style: italic;
66
+
font-display: swap;
67
+
}
68
+
37
69
::selection {
38
70
@apply bg-yellow-400 text-black bg-opacity-30 dark:bg-yellow-600 dark:bg-opacity-50 dark:text-white;
39
71
}
···
46
78
@supports (font-variation-settings: normal) {
47
79
html {
48
80
font-feature-settings:
49
-
"ss01" 1,
50
81
"kern" 1,
51
82
"liga" 1,
52
83
"cv05" 1,
···
70
101
details summary::-webkit-details-marker {
71
102
display: none;
72
103
}
104
+
105
+
code {
106
+
@apply font-mono rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white;
107
+
}
73
108
}
74
109
75
110
@layer components {
···
98
133
disabled:before:bg-green-400 dark:disabled:before:bg-green-600;
99
134
}
100
135
136
+
.prose hr {
137
+
@apply my-2;
138
+
}
139
+
140
+
.prose li:has(input) {
141
+
@apply list-none;
142
+
}
143
+
144
+
.prose ul:has(input) {
145
+
@apply pl-2;
146
+
}
147
+
148
+
.prose .heading .anchor {
149
+
@apply no-underline mx-2 opacity-0;
150
+
}
151
+
152
+
.prose .heading:hover .anchor {
153
+
@apply opacity-70;
154
+
}
155
+
156
+
.prose .heading .anchor:hover {
157
+
@apply opacity-70;
158
+
}
159
+
160
+
.prose a.footnote-backref {
161
+
@apply no-underline;
162
+
}
163
+
164
+
.prose li {
165
+
@apply my-0 py-0;
166
+
}
167
+
168
+
.prose ul, .prose ol {
169
+
@apply my-1 py-0;
170
+
}
171
+
101
172
.prose img {
102
173
display: inline;
103
174
margin: 0;
104
175
vertical-align: middle;
176
+
}
177
+
178
+
.prose input {
179
+
@apply inline-block my-0 mb-1 mx-1;
180
+
}
181
+
182
+
.prose input[type="checkbox"] {
183
+
@apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500;
105
184
}
106
185
}
107
186
@layer utilities {
···
122
201
/* PreWrapper */
123
202
.chroma {
124
203
color: #4c4f69;
125
-
background-color: #eff1f5;
126
204
}
127
205
/* Error */
128
206
.chroma .err {
···
459
537
/* PreWrapper */
460
538
.chroma {
461
539
color: #cad3f5;
462
-
background-color: #24273a;
463
540
}
464
541
/* Error */
465
542
.chroma .err {
+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 {
+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)
+7
-7
knotserver/handler.go
+7
-7
knotserver/handler.go
···
52
52
return nil, fmt.Errorf("failed to setup enforcer: %w", err)
53
53
}
54
54
55
-
err = h.jc.StartJetstream(ctx, h.processMessages)
56
-
if err != nil {
57
-
return nil, fmt.Errorf("failed to start jetstream: %w", err)
58
-
}
59
-
60
55
// Check if the knot knows about any Dids;
61
56
// if it does, it is already initialized and we can repopulate the
62
57
// Jetstream subscriptions.
···
71
66
for _, d := range dids {
72
67
h.jc.AddDid(d)
73
68
}
69
+
}
70
+
71
+
err = h.jc.StartJetstream(ctx, h.processMessages)
72
+
if err != nil {
73
+
return nil, fmt.Errorf("failed to start jetstream: %w", err)
74
74
}
75
75
76
76
r.Get("/", h.Index)
···
142
142
r.Delete("/", h.RemoveRepo)
143
143
r.Route("/fork", func(r chi.Router) {
144
144
r.Post("/", h.RepoFork)
145
-
r.Post("/sync/{branch}", h.RepoForkSync)
146
-
r.Get("/sync/{branch}", h.RepoForkAheadBehind)
145
+
r.Post("/sync/*", h.RepoForkSync)
146
+
r.Get("/sync/*", h.RepoForkAheadBehind)
147
147
})
148
148
})
149
149
+102
-42
knotserver/ingester.go
+102
-42
knotserver/ingester.go
···
25
25
"tangled.sh/tangled.sh/core/workflow"
26
26
)
27
27
28
-
func (h *Handle) processPublicKey(ctx context.Context, did string, record tangled.PublicKey) error {
28
+
func (h *Handle) processPublicKey(ctx context.Context, event *models.Event) error {
29
29
l := log.FromContext(ctx)
30
+
raw := json.RawMessage(event.Commit.Record)
31
+
did := event.Did
32
+
33
+
var record tangled.PublicKey
34
+
if err := json.Unmarshal(raw, &record); err != nil {
35
+
return fmt.Errorf("failed to unmarshal record: %w", err)
36
+
}
37
+
30
38
pk := db.PublicKey{
31
39
Did: did,
32
40
PublicKey: record,
···
39
47
return nil
40
48
}
41
49
42
-
func (h *Handle) processKnotMember(ctx context.Context, did string, record tangled.KnotMember) error {
50
+
func (h *Handle) processKnotMember(ctx context.Context, event *models.Event) error {
43
51
l := log.FromContext(ctx)
52
+
raw := json.RawMessage(event.Commit.Record)
53
+
did := event.Did
54
+
55
+
var record tangled.KnotMember
56
+
if err := json.Unmarshal(raw, &record); err != nil {
57
+
return fmt.Errorf("failed to unmarshal record: %w", err)
58
+
}
44
59
45
60
if record.Domain != h.c.Server.Hostname {
46
61
l.Error("domain mismatch", "domain", record.Domain, "expected", h.c.Server.Hostname)
···
72
87
return nil
73
88
}
74
89
75
-
func (h *Handle) processPull(ctx context.Context, did string, record tangled.RepoPull) error {
90
+
func (h *Handle) processPull(ctx context.Context, event *models.Event) error {
91
+
raw := json.RawMessage(event.Commit.Record)
92
+
did := event.Did
93
+
94
+
var record tangled.RepoPull
95
+
if err := json.Unmarshal(raw, &record); err != nil {
96
+
return fmt.Errorf("failed to unmarshal record: %w", err)
97
+
}
98
+
76
99
l := log.FromContext(ctx)
77
100
l = l.With("handler", "processPull")
78
101
l = l.With("did", did)
···
152
175
return err
153
176
}
154
177
155
-
var pipeline workflow.Pipeline
178
+
var pipeline workflow.RawPipeline
156
179
for _, e := range workflowDir {
157
180
if !e.IsFile {
158
181
continue
···
164
187
continue
165
188
}
166
189
167
-
wf, err := workflow.FromFile(e.Name, contents)
168
-
if err != nil {
169
-
// TODO: log here, respond to client that is pushing
170
-
h.l.Error("failed to parse workflow", "err", err, "path", fpath)
171
-
continue
172
-
}
173
-
174
-
pipeline = append(pipeline, wf)
190
+
pipeline = append(pipeline, workflow.RawWorkflow{
191
+
Name: e.Name,
192
+
Contents: contents,
193
+
})
175
194
}
176
195
177
196
trigger := tangled.Pipeline_PullRequestTriggerData{
···
193
212
},
194
213
}
195
214
196
-
cp := compiler.Compile(pipeline)
215
+
cp := compiler.Compile(compiler.Parse(pipeline))
197
216
eventJson, err := json.Marshal(cp)
198
217
if err != nil {
199
218
return err
···
204
223
return nil
205
224
}
206
225
207
-
event := db.Event{
226
+
ev := db.Event{
208
227
Rkey: TID(),
209
228
Nsid: tangled.PipelineNSID,
210
229
EventJson: string(eventJson),
211
230
}
212
231
213
-
return h.db.InsertEvent(event, h.n)
232
+
return h.db.InsertEvent(ev, h.n)
233
+
}
234
+
235
+
// duplicated from add collaborator
236
+
func (h *Handle) processCollaborator(ctx context.Context, event *models.Event) error {
237
+
raw := json.RawMessage(event.Commit.Record)
238
+
did := event.Did
239
+
240
+
var record tangled.RepoCollaborator
241
+
if err := json.Unmarshal(raw, &record); err != nil {
242
+
return fmt.Errorf("failed to unmarshal record: %w", err)
243
+
}
244
+
245
+
repoAt, err := syntax.ParseATURI(record.Repo)
246
+
if err != nil {
247
+
return err
248
+
}
249
+
250
+
resolver := idresolver.DefaultResolver()
251
+
252
+
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
253
+
if err != nil || subjectId.Handle.IsInvalidHandle() {
254
+
return err
255
+
}
256
+
257
+
// TODO: fix this for good, we need to fetch the record here unfortunately
258
+
// resolve this aturi to extract the repo record
259
+
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
260
+
if err != nil || owner.Handle.IsInvalidHandle() {
261
+
return fmt.Errorf("failed to resolve handle: %w", err)
262
+
}
263
+
264
+
xrpcc := xrpc.Client{
265
+
Host: owner.PDSEndpoint(),
266
+
}
267
+
268
+
resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
269
+
if err != nil {
270
+
return err
271
+
}
272
+
273
+
repo := resp.Value.Val.(*tangled.Repo)
274
+
didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name)
275
+
276
+
// check perms for this user
277
+
if ok, err := h.e.IsCollaboratorInviteAllowed(did, rbac.ThisServer, didSlashRepo); !ok || err != nil {
278
+
return fmt.Errorf("insufficient permissions: %w", err)
279
+
}
280
+
281
+
if err := h.db.AddDid(subjectId.DID.String()); err != nil {
282
+
return err
283
+
}
284
+
h.jc.AddDid(subjectId.DID.String())
285
+
286
+
if err := h.e.AddCollaborator(subjectId.DID.String(), rbac.ThisServer, didSlashRepo); err != nil {
287
+
return err
288
+
}
289
+
290
+
return h.fetchAndAddKeys(ctx, subjectId.DID.String())
214
291
}
215
292
216
293
func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error {
···
257
334
}
258
335
259
336
func (h *Handle) processMessages(ctx context.Context, event *models.Event) error {
260
-
did := event.Did
261
337
if event.Kind != models.EventKindCommit {
262
338
return nil
263
339
}
···
266
342
defer func() {
267
343
eventTime := event.TimeUS
268
344
lastTimeUs := eventTime + 1
269
-
fmt.Println("lastTimeUs", lastTimeUs)
270
345
if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil {
271
346
err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
272
347
}
273
348
}()
274
-
275
-
raw := json.RawMessage(event.Commit.Record)
276
349
277
350
switch event.Commit.Collection {
278
351
case tangled.PublicKeyNSID:
279
-
var record tangled.PublicKey
280
-
if err := json.Unmarshal(raw, &record); err != nil {
281
-
return fmt.Errorf("failed to unmarshal record: %w", err)
282
-
}
283
-
if err := h.processPublicKey(ctx, did, record); err != nil {
284
-
return fmt.Errorf("failed to process public key: %w", err)
285
-
}
286
-
352
+
err = h.processPublicKey(ctx, event)
287
353
case tangled.KnotMemberNSID:
288
-
var record tangled.KnotMember
289
-
if err := json.Unmarshal(raw, &record); err != nil {
290
-
return fmt.Errorf("failed to unmarshal record: %w", err)
291
-
}
292
-
if err := h.processKnotMember(ctx, did, record); err != nil {
293
-
return fmt.Errorf("failed to process knot member: %w", err)
294
-
}
354
+
err = h.processKnotMember(ctx, event)
295
355
case tangled.RepoPullNSID:
296
-
var record tangled.RepoPull
297
-
if err := json.Unmarshal(raw, &record); err != nil {
298
-
return fmt.Errorf("failed to unmarshal record: %w", err)
299
-
}
300
-
if err := h.processPull(ctx, did, record); err != nil {
301
-
return fmt.Errorf("failed to process knot member: %w", err)
302
-
}
356
+
err = h.processPull(ctx, event)
357
+
case tangled.RepoCollaboratorNSID:
358
+
err = h.processCollaborator(ctx, event)
303
359
}
304
360
305
-
return err
361
+
if err != nil {
362
+
h.l.Debug("failed to process event", "nsid", event.Commit.Collection, "err", err)
363
+
}
364
+
365
+
return nil
306
366
}
+20
-37
knotserver/internal.go
+20
-37
knotserver/internal.go
···
3
3
import (
4
4
"context"
5
5
"encoding/json"
6
+
"errors"
6
7
"fmt"
7
8
"log/slog"
8
9
"net/http"
···
145
146
return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err)
146
147
}
147
148
148
-
meta := gr.RefUpdateMeta(line)
149
+
var errs error
150
+
meta, err := gr.RefUpdateMeta(line)
151
+
errors.Join(errs, err)
149
152
150
153
metaRecord := meta.AsRecord()
151
154
···
169
172
EventJson: string(eventJson),
170
173
}
171
174
172
-
return h.db.InsertEvent(event, h.n)
175
+
return errors.Join(errs, h.db.InsertEvent(event, h.n))
173
176
}
174
177
175
178
func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error {
···
197
200
return err
198
201
}
199
202
200
-
pipelineParseErrors := []string{}
201
-
202
-
var pipeline workflow.Pipeline
203
+
var pipeline workflow.RawPipeline
203
204
for _, e := range workflowDir {
204
205
if !e.IsFile {
205
206
continue
···
211
212
continue
212
213
}
213
214
214
-
wf, err := workflow.FromFile(e.Name, contents)
215
-
if err != nil {
216
-
h.l.Error("failed to parse workflow", "err", err, "path", fpath)
217
-
pipelineParseErrors = append(pipelineParseErrors, fmt.Sprintf("- at %s: %s\n", fpath, err))
218
-
continue
219
-
}
220
-
221
-
pipeline = append(pipeline, wf)
215
+
pipeline = append(pipeline, workflow.RawWorkflow{
216
+
Name: e.Name,
217
+
Contents: contents,
218
+
})
222
219
}
223
220
224
221
trigger := tangled.Pipeline_PushTriggerData{
···
239
236
},
240
237
}
241
238
242
-
cp := compiler.Compile(pipeline)
239
+
cp := compiler.Compile(compiler.Parse(pipeline))
243
240
eventJson, err := json.Marshal(cp)
244
241
if err != nil {
245
242
return err
243
+
}
244
+
245
+
for _, e := range compiler.Diagnostics.Errors {
246
+
*clientMsgs = append(*clientMsgs, e.String())
246
247
}
247
248
248
249
if pushOptions.verboseCi {
249
-
hasDiagnostics := false
250
-
if len(pipelineParseErrors) > 0 {
251
-
hasDiagnostics = true
252
-
*clientMsgs = append(*clientMsgs, "error: failed to parse workflow(s):")
253
-
for _, error := range pipelineParseErrors {
254
-
*clientMsgs = append(*clientMsgs, error)
255
-
}
256
-
}
257
-
if len(compiler.Diagnostics.Errors) > 0 {
258
-
hasDiagnostics = true
259
-
*clientMsgs = append(*clientMsgs, "error(s) on pipeline:")
260
-
for _, error := range compiler.Diagnostics.Errors {
261
-
*clientMsgs = append(*clientMsgs, fmt.Sprintf("- %s:", error))
262
-
}
263
-
}
264
-
if len(compiler.Diagnostics.Warnings) > 0 {
265
-
hasDiagnostics = true
266
-
*clientMsgs = append(*clientMsgs, "warning(s) on pipeline:")
267
-
for _, warning := range compiler.Diagnostics.Warnings {
268
-
*clientMsgs = append(*clientMsgs, fmt.Sprintf("- at %s: %s: %s", warning.Path, warning.Type, warning.Reason))
269
-
}
250
+
if compiler.Diagnostics.IsEmpty() {
251
+
*clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics")
270
252
}
271
-
if !hasDiagnostics {
272
-
*clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics")
253
+
254
+
for _, w := range compiler.Diagnostics.Warnings {
255
+
*clientMsgs = append(*clientMsgs, w.String())
273
256
}
274
257
}
275
258
+27
-14
knotserver/routes.go
+27
-14
knotserver/routes.go
···
286
286
mimeType = "image/svg+xml"
287
287
}
288
288
289
+
contentHash := sha256.Sum256(contents)
290
+
eTag := fmt.Sprintf("\"%x\"", contentHash)
291
+
289
292
// allow image, video, and text/plain files to be served directly
290
293
switch {
291
-
case strings.HasPrefix(mimeType, "image/"):
292
-
// allowed
293
-
case strings.HasPrefix(mimeType, "video/"):
294
-
// allowed
294
+
case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"):
295
+
if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag {
296
+
w.WriteHeader(http.StatusNotModified)
297
+
return
298
+
}
299
+
w.Header().Set("ETag", eTag)
300
+
295
301
case strings.HasPrefix(mimeType, "text/plain"):
296
-
// allowed
302
+
w.Header().Set("Cache-Control", "public, no-cache")
303
+
297
304
default:
298
305
l.Error("attempted to serve disallowed file type", "mimetype", mimeType)
299
306
writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden)
300
307
return
301
308
}
302
309
303
-
w.Header().Set("Cache-Control", "public, max-age=86400") // cache for 24 hours
304
-
w.Header().Set("ETag", fmt.Sprintf("%x", sha256.Sum256(contents)))
305
310
w.Header().Set("Content-Type", mimeType)
306
311
w.Write(contents)
307
312
}
···
361
366
362
367
ref := strings.TrimSuffix(file, ".tar.gz")
363
368
369
+
unescapedRef, err := url.PathUnescape(ref)
370
+
if err != nil {
371
+
notFound(w)
372
+
return
373
+
}
374
+
375
+
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-")
376
+
364
377
// This allows the browser to use a proper name for the file when
365
378
// downloading
366
-
filename := fmt.Sprintf("%s-%s.tar.gz", name, ref)
379
+
filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename)
367
380
setContentDisposition(w, filename)
368
381
setGZipMIME(w)
369
382
370
383
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
371
-
gr, err := git.Open(path, ref)
384
+
gr, err := git.Open(path, unescapedRef)
372
385
if err != nil {
373
386
notFound(w)
374
387
return
···
377
390
gw := gzip.NewWriter(w)
378
391
defer gw.Close()
379
392
380
-
prefix := fmt.Sprintf("%s-%s", name, ref)
393
+
prefix := fmt.Sprintf("%s-%s", name, safeRefFilename)
381
394
err = gr.WriteTar(gw, prefix)
382
395
if err != nil {
383
396
// once we start writing to the body we can't report error anymore
···
702
715
}
703
716
704
717
func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) {
705
-
l := h.l.With("handler", "RepoForkSync")
718
+
l := h.l.With("handler", "RepoForkAheadBehind")
706
719
707
720
data := struct {
708
721
Did string `json:"did"`
···
837
850
name = filepath.Base(source)
838
851
}
839
852
840
-
branch := chi.URLParam(r, "branch")
853
+
branch := chi.URLParam(r, "*")
841
854
branch, _ = url.PathUnescape(branch)
842
855
843
856
relativeRepoPath := filepath.Join(did, name)
844
857
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
845
858
846
-
gr, err := git.PlainOpen(repoPath)
859
+
gr, err := git.Open(repoPath, branch)
847
860
if err != nil {
848
861
log.Println(err)
849
862
notFound(w)
850
863
return
851
864
}
852
865
853
-
err = gr.Sync(branch)
866
+
err = gr.Sync()
854
867
if err != nil {
855
868
l.Error("error syncing repo fork", "error", err.Error())
856
869
writeError(w, err.Error(), http.StatusInternalServerError)
+1
knotserver/server.go
+1
knotserver/server.go
-37
lexicons/addSecret.json
-37
lexicons/addSecret.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.repo.addSecret",
4
-
"defs": {
5
-
"main": {
6
-
"type": "procedure",
7
-
"description": "Add a CI secret",
8
-
"input": {
9
-
"encoding": "application/json",
10
-
"schema": {
11
-
"type": "object",
12
-
"required": [
13
-
"repo",
14
-
"key",
15
-
"value"
16
-
],
17
-
"properties": {
18
-
"repo": {
19
-
"type": "string",
20
-
"format": "at-uri"
21
-
},
22
-
"key": {
23
-
"type": "string",
24
-
"maxLength": 50,
25
-
"minLength": 1
26
-
},
27
-
"value": {
28
-
"type": "string",
29
-
"maxLength": 200,
30
-
"minLength": 1
31
-
}
32
-
}
33
-
}
34
-
}
35
-
}
36
-
}
37
-
}
-52
lexicons/artifact.json
-52
lexicons/artifact.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.repo.artifact",
4
-
"needsCbor": true,
5
-
"needsType": true,
6
-
"defs": {
7
-
"main": {
8
-
"type": "record",
9
-
"key": "tid",
10
-
"record": {
11
-
"type": "object",
12
-
"required": [
13
-
"name",
14
-
"repo",
15
-
"tag",
16
-
"createdAt",
17
-
"artifact"
18
-
],
19
-
"properties": {
20
-
"name": {
21
-
"type": "string",
22
-
"description": "name of the artifact"
23
-
},
24
-
"repo": {
25
-
"type": "string",
26
-
"format": "at-uri",
27
-
"description": "repo that this artifact is being uploaded to"
28
-
},
29
-
"tag": {
30
-
"type": "bytes",
31
-
"description": "hash of the tag object that this artifact is attached to (only annotated tags are supported)",
32
-
"minLength": 20,
33
-
"maxLength": 20
34
-
},
35
-
"createdAt": {
36
-
"type": "string",
37
-
"format": "datetime",
38
-
"description": "time of creation of this artifact"
39
-
},
40
-
"artifact": {
41
-
"type": "blob",
42
-
"description": "the artifact",
43
-
"accept": [
44
-
"*/*"
45
-
],
46
-
"maxSize": 52428800
47
-
}
48
-
}
49
-
}
50
-
}
51
-
}
52
-
}
-29
lexicons/defaultBranch.json
-29
lexicons/defaultBranch.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.repo.setDefaultBranch",
4
-
"defs": {
5
-
"main": {
6
-
"type": "procedure",
7
-
"description": "Set the default branch for a repository",
8
-
"input": {
9
-
"encoding": "application/json",
10
-
"schema": {
11
-
"type": "object",
12
-
"required": [
13
-
"repo",
14
-
"defaultBranch"
15
-
],
16
-
"properties": {
17
-
"repo": {
18
-
"type": "string",
19
-
"format": "at-uri"
20
-
},
21
-
"defaultBranch": {
22
-
"type": "string"
23
-
}
24
-
}
25
-
}
26
-
}
27
-
}
28
-
}
29
-
}
+1
-8
lexicons/issue/comment.json
+1
-8
lexicons/issue/comment.json
···
9
9
"key": "tid",
10
10
"record": {
11
11
"type": "object",
12
-
"required": [
13
-
"issue",
14
-
"body",
15
-
"createdAt"
16
-
],
12
+
"required": ["issue", "body", "createdAt"],
17
13
"properties": {
18
14
"issue": {
19
15
"type": "string",
···
22
18
"repo": {
23
19
"type": "string",
24
20
"format": "at-uri"
25
-
},
26
-
"commentId": {
27
-
"type": "integer"
28
21
},
29
22
"owner": {
30
23
"type": "string",
+1
-10
lexicons/issue/issue.json
+1
-10
lexicons/issue/issue.json
···
9
9
"key": "tid",
10
10
"record": {
11
11
"type": "object",
12
-
"required": [
13
-
"repo",
14
-
"issueId",
15
-
"owner",
16
-
"title",
17
-
"createdAt"
18
-
],
12
+
"required": ["repo", "owner", "title", "createdAt"],
19
13
"properties": {
20
14
"repo": {
21
15
"type": "string",
22
16
"format": "at-uri"
23
-
},
24
-
"issueId": {
25
-
"type": "integer"
26
17
},
27
18
"owner": {
28
19
"type": "string",
-67
lexicons/listSecrets.json
-67
lexicons/listSecrets.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.repo.listSecrets",
4
-
"defs": {
5
-
"main": {
6
-
"type": "query",
7
-
"parameters": {
8
-
"type": "params",
9
-
"required": [
10
-
"repo"
11
-
],
12
-
"properties": {
13
-
"repo": {
14
-
"type": "string",
15
-
"format": "at-uri"
16
-
}
17
-
}
18
-
},
19
-
"output": {
20
-
"encoding": "application/json",
21
-
"schema": {
22
-
"type": "object",
23
-
"required": [
24
-
"secrets"
25
-
],
26
-
"properties": {
27
-
"secrets": {
28
-
"type": "array",
29
-
"items": {
30
-
"type": "ref",
31
-
"ref": "#secret"
32
-
}
33
-
}
34
-
}
35
-
}
36
-
}
37
-
},
38
-
"secret": {
39
-
"type": "object",
40
-
"required": [
41
-
"repo",
42
-
"key",
43
-
"createdAt",
44
-
"createdBy"
45
-
],
46
-
"properties": {
47
-
"repo": {
48
-
"type": "string",
49
-
"format": "at-uri"
50
-
},
51
-
"key": {
52
-
"type": "string",
53
-
"maxLength": 50,
54
-
"minLength": 1
55
-
},
56
-
"createdAt": {
57
-
"type": "string",
58
-
"format": "datetime"
59
-
},
60
-
"createdBy": {
61
-
"type": "string",
62
-
"format": "did"
63
-
}
64
-
}
65
-
}
66
-
}
67
-
}
+207
lexicons/pipeline/pipeline.json
+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
-
}
-31
lexicons/removeSecret.json
-31
lexicons/removeSecret.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.repo.removeSecret",
4
-
"defs": {
5
-
"main": {
6
-
"type": "procedure",
7
-
"description": "Remove a CI secret",
8
-
"input": {
9
-
"encoding": "application/json",
10
-
"schema": {
11
-
"type": "object",
12
-
"required": [
13
-
"repo",
14
-
"key"
15
-
],
16
-
"properties": {
17
-
"repo": {
18
-
"type": "string",
19
-
"format": "at-uri"
20
-
},
21
-
"key": {
22
-
"type": "string",
23
-
"maxLength": 50,
24
-
"minLength": 1
25
-
}
26
-
}
27
-
}
28
-
}
29
-
}
30
-
}
31
-
}
+37
lexicons/repo/addSecret.json
+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
+
+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
+
}
+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
+
}
+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)})
+14
-2
nix/gomod2nix.toml
+14
-2
nix/gomod2nix.toml
···
66
66
[mod."github.com/cloudflare/circl"]
67
67
version = "v1.6.2-0.20250618153321-aa837fd1539d"
68
68
hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y="
69
+
[mod."github.com/cloudflare/cloudflare-go"]
70
+
version = "v0.115.0"
71
+
hash = "sha256-jezmDs6IsHA4rag7DzcHDfDgde0vU4iKgCN9+0XDViw="
69
72
[mod."github.com/containerd/errdefs"]
70
73
version = "v1.0.0"
71
74
hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI="
···
169
172
[mod."github.com/golang/mock"]
170
173
version = "v1.6.0"
171
174
hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno="
175
+
[mod."github.com/google/go-querystring"]
176
+
version = "v1.1.0"
177
+
hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY="
172
178
[mod."github.com/google/uuid"]
173
179
version = "v1.6.0"
174
180
hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw="
175
181
[mod."github.com/gorilla/css"]
176
182
version = "v1.0.1"
177
183
hash = "sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A="
184
+
[mod."github.com/gorilla/feeds"]
185
+
version = "v1.2.0"
186
+
hash = "sha256-ptczizo27t6Bsq6rHJ4WiHmBRP54UC5yNfHghAqOBQk="
178
187
[mod."github.com/gorilla/securecookie"]
179
188
version = "v1.1.2"
180
189
hash = "sha256-KeMHNM9emxX+N0WYiZsTii7n8sNsmjWwbnQ9SaJfTKE="
···
417
426
version = "v0.3.1"
418
427
hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc="
419
428
[mod."github.com/yuin/goldmark"]
420
-
version = "v1.4.13"
421
-
hash = "sha256-GVwFKZY6moIS6I0ZGuio/WtDif+lkZRfqWS6b4AAJyI="
429
+
version = "v1.4.15"
430
+
hash = "sha256-MvSOT6dwf5hVYkIg4MnqMpsy5ZtWZ7amAE7Zo9HkEa0="
431
+
[mod."github.com/yuin/goldmark-highlighting/v2"]
432
+
version = "v2.0.0-20230729083705-37449abec8cc"
433
+
hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
422
434
[mod."gitlab.com/yawning/secp256k1-voi"]
423
435
version = "v0.0.0-20230925100816-f2616030848b"
424
436
hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA="
+14
nix/modules/appview.nix
+14
nix/modules/appview.nix
···
27
27
default = "00000000000000000000000000000000";
28
28
description = "Cookie secret";
29
29
};
30
+
environmentFile = mkOption {
31
+
type = with types; nullOr path;
32
+
default = null;
33
+
example = "/etc/tangled-appview.env";
34
+
description = ''
35
+
Additional environment file as defined in {manpage}`systemd.exec(5)`.
36
+
37
+
Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET` may be
38
+
passed to the service without makeing them world readable in the
39
+
nix store.
40
+
41
+
'';
42
+
};
30
43
};
31
44
};
32
45
···
39
52
ListenStream = "0.0.0.0:${toString cfg.port}";
40
53
ExecStart = "${cfg.package}/bin/appview";
41
54
Restart = "always";
55
+
EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile;
42
56
};
43
57
44
58
environment = {
+27
-24
nix/modules/knot.nix
+27
-24
nix/modules/knot.nix
···
126
126
cfg.package
127
127
];
128
128
129
-
system.activationScripts.gitConfig = let
130
-
setMotd =
131
-
if cfg.motdFile != null && cfg.motd != null
132
-
then throw "motdFile and motd cannot be both set"
133
-
else ''
134
-
${optionalString (cfg.motdFile != null) "cat ${cfg.motdFile} > ${cfg.stateDir}/motd"}
135
-
${optionalString (cfg.motd != null) ''printf "${cfg.motd}" > ${cfg.stateDir}/motd''}
136
-
'';
137
-
in ''
138
-
mkdir -p "${cfg.repo.scanPath}"
139
-
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}"
140
-
141
-
mkdir -p "${cfg.stateDir}/.config/git"
142
-
cat > "${cfg.stateDir}/.config/git/config" << EOF
143
-
[user]
144
-
name = Git User
145
-
email = git@example.com
146
-
[receive]
147
-
advertisePushOptions = true
148
-
EOF
149
-
${setMotd}
150
-
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
151
-
'';
152
-
153
129
users.users.${cfg.gitUser} = {
154
130
isSystemUser = true;
155
131
useDefaultShell = true;
···
185
161
description = "knot service";
186
162
after = ["network.target" "sshd.service"];
187
163
wantedBy = ["multi-user.target"];
164
+
enableStrictShellChecks = true;
165
+
166
+
preStart = let
167
+
setMotd =
168
+
if cfg.motdFile != null && cfg.motd != null
169
+
then throw "motdFile and motd cannot be both set"
170
+
else ''
171
+
${optionalString (cfg.motdFile != null) "cat ${cfg.motdFile} > ${cfg.stateDir}/motd"}
172
+
${optionalString (cfg.motd != null) ''printf "${cfg.motd}" > ${cfg.stateDir}/motd''}
173
+
'';
174
+
in ''
175
+
mkdir -p "${cfg.repo.scanPath}"
176
+
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}"
177
+
178
+
mkdir -p "${cfg.stateDir}/.config/git"
179
+
cat > "${cfg.stateDir}/.config/git/config" << EOF
180
+
[user]
181
+
name = Git User
182
+
email = git@example.com
183
+
[receive]
184
+
advertisePushOptions = true
185
+
EOF
186
+
${setMotd}
187
+
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
188
+
'';
189
+
188
190
serviceConfig = {
189
191
User = cfg.gitUser;
192
+
PermissionsStartOnly = true;
190
193
WorkingDirectory = cfg.stateDir;
191
194
Environment = [
192
195
"KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}"
+24
-2
nix/modules/spindle.nix
+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
+120
-64
nix/vm.nix
+120
-64
nix/vm.nix
···
1
1
{
2
2
nixpkgs,
3
+
system,
4
+
hostSystem,
3
5
self,
4
-
}:
5
-
nixpkgs.lib.nixosSystem {
6
-
system = "x86_64-linux";
7
-
modules = [
8
-
self.nixosModules.knot
9
-
self.nixosModules.spindle
10
-
({
11
-
config,
12
-
pkgs,
13
-
...
14
-
}: {
15
-
virtualisation = {
16
-
memorySize = 2048;
17
-
diskSize = 10 * 1024;
18
-
cores = 2;
19
-
forwardPorts = [
20
-
# ssh
21
-
{
22
-
from = "host";
23
-
host.port = 2222;
24
-
guest.port = 22;
25
-
}
26
-
# knot
27
-
{
28
-
from = "host";
29
-
host.port = 6000;
30
-
guest.port = 6000;
31
-
}
32
-
# spindle
33
-
{
34
-
from = "host";
35
-
host.port = 6555;
36
-
guest.port = 6555;
37
-
}
38
-
];
39
-
};
40
-
services.getty.autologinUser = "root";
41
-
environment.systemPackages = with pkgs; [curl vim git];
42
-
systemd.tmpfiles.rules = let
43
-
u = config.services.tangled-knot.gitUser;
44
-
g = config.services.tangled-knot.gitUser;
45
-
in [
46
-
"d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first
47
-
"f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=168c426fa6d9829fcbe85c96bdf144e800fb9737d6ca87f21acc543b1aa3e440"
48
-
];
49
-
services.tangled-knot = {
50
-
enable = true;
51
-
motd = "Welcome to the development knot!\n";
52
-
server = {
53
-
secretFile = "/var/lib/knot/secret";
54
-
hostname = "localhost:6000";
55
-
listenAddr = "0.0.0.0:6000";
6
+
}: let
7
+
envVar = name: let
8
+
var = builtins.getEnv name;
9
+
in
10
+
if var == ""
11
+
then throw "\$${name} must be defined, see docs/hacking.md for more details"
12
+
else var;
13
+
in
14
+
nixpkgs.lib.nixosSystem {
15
+
inherit system;
16
+
modules = [
17
+
self.nixosModules.knot
18
+
self.nixosModules.spindle
19
+
({
20
+
lib,
21
+
config,
22
+
pkgs,
23
+
...
24
+
}: {
25
+
virtualisation.vmVariant.virtualisation = {
26
+
host.pkgs = import nixpkgs {system = hostSystem;};
27
+
28
+
graphics = false;
29
+
memorySize = 2048;
30
+
diskSize = 10 * 1024;
31
+
cores = 2;
32
+
forwardPorts = [
33
+
# ssh
34
+
{
35
+
from = "host";
36
+
host.port = 2222;
37
+
guest.port = 22;
38
+
}
39
+
# knot
40
+
{
41
+
from = "host";
42
+
host.port = 6000;
43
+
guest.port = 6000;
44
+
}
45
+
# spindle
46
+
{
47
+
from = "host";
48
+
host.port = 6555;
49
+
guest.port = 6555;
50
+
}
51
+
];
52
+
sharedDirectories = {
53
+
# We can't use the 9p mounts directly for most of these
54
+
# as SQLite is incompatible with them. So instead we
55
+
# mount the shared directories to a different location
56
+
# and copy the contents around on service start/stop.
57
+
knotData = {
58
+
source = "$TANGLED_VM_DATA_DIR/knot";
59
+
target = "/mnt/knot-data";
60
+
};
61
+
spindleData = {
62
+
source = "$TANGLED_VM_DATA_DIR/spindle";
63
+
target = "/mnt/spindle-data";
64
+
};
65
+
spindleLogs = {
66
+
source = "$TANGLED_VM_DATA_DIR/spindle-logs";
67
+
target = "/var/log/spindle";
68
+
};
69
+
};
56
70
};
57
-
};
58
-
services.tangled-spindle = {
59
-
enable = true;
60
-
server = {
61
-
owner = "did:plc:qfpnj4og54vl56wngdriaxug";
62
-
hostname = "localhost:6555";
63
-
listenAddr = "0.0.0.0:6555";
64
-
dev = true;
71
+
# This is fine because any and all ports that are forwarded to host are explicitly marked above, we don't need a separate guest firewall
72
+
networking.firewall.enable = false;
73
+
services.getty.autologinUser = "root";
74
+
environment.systemPackages = with pkgs; [curl vim git sqlite litecli];
75
+
services.tangled-knot = {
76
+
enable = true;
77
+
motd = "Welcome to the development knot!\n";
78
+
server = {
79
+
secretFile = builtins.toFile "knot-secret" ("KNOT_SERVER_SECRET=" + (envVar "TANGLED_VM_KNOT_SECRET"));
80
+
hostname = "localhost:6000";
81
+
listenAddr = "0.0.0.0:6000";
82
+
};
65
83
};
66
-
};
67
-
})
68
-
];
69
-
}
84
+
services.tangled-spindle = {
85
+
enable = true;
86
+
server = {
87
+
owner = envVar "TANGLED_VM_SPINDLE_OWNER";
88
+
hostname = "localhost:6555";
89
+
listenAddr = "0.0.0.0:6555";
90
+
dev = true;
91
+
secrets = {
92
+
provider = "sqlite";
93
+
};
94
+
};
95
+
};
96
+
users = {
97
+
# So we don't have to deal with permission clashing between
98
+
# blank disk VMs and existing state
99
+
users.${config.services.tangled-knot.gitUser}.uid = 666;
100
+
groups.${config.services.tangled-knot.gitUser}.gid = 666;
101
+
102
+
# TODO: separate spindle user
103
+
};
104
+
systemd.services = let
105
+
mkDataSyncScripts = source: target: {
106
+
enableStrictShellChecks = true;
107
+
108
+
preStart = lib.mkBefore ''
109
+
mkdir -p ${target}
110
+
${lib.getExe pkgs.rsync} -a ${source}/ ${target}
111
+
'';
112
+
113
+
postStop = lib.mkAfter ''
114
+
${lib.getExe pkgs.rsync} -a ${target}/ ${source}
115
+
'';
116
+
117
+
serviceConfig.PermissionsStartOnly = true;
118
+
};
119
+
in {
120
+
knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled-knot.stateDir;
121
+
spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled-spindle.server.dbPath);
122
+
};
123
+
})
124
+
];
125
+
}
+1
-1
rbac/rbac.go
+1
-1
rbac/rbac.go
+1
-1
rbac/rbac_test.go
+1
-1
rbac/rbac_test.go
+4
-4
spindle/config/config.go
+4
-4
spindle/config/config.go
···
16
16
Dev bool `env:"DEV, default=false"`
17
17
Owner string `env:"OWNER, required"`
18
18
Secrets Secrets `env:",prefix=SECRETS_"`
19
+
LogDir string `env:"LOG_DIR, default=/var/log/spindle"`
19
20
}
20
21
21
22
func (s Server) Did() syntax.DID {
···
32
33
Mount string `env:"MOUNT, default=spindle"`
33
34
}
34
35
35
-
type Pipelines struct {
36
+
type NixeryPipelines struct {
36
37
Nixery string `env:"NIXERY, default=nixery.tangled.sh"`
37
38
WorkflowTimeout string `env:"WORKFLOW_TIMEOUT, default=5m"`
38
-
LogDir string `env:"LOG_DIR, default=/var/log/spindle"`
39
39
}
40
40
41
41
type Config struct {
42
-
Server Server `env:",prefix=SPINDLE_SERVER_"`
43
-
Pipelines Pipelines `env:",prefix=SPINDLE_PIPELINES_"`
42
+
Server Server `env:",prefix=SPINDLE_SERVER_"`
43
+
NixeryPipelines NixeryPipelines `env:",prefix=SPINDLE_NIXERY_PIPELINES_"`
44
44
}
45
45
46
46
func Load(ctx context.Context) (*Config, error) {
+29
-10
spindle/db/db.go
+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
-
}
+68
-415
spindle/engine/engine.go
+68
-415
spindle/engine/engine.go
···
4
4
"context"
5
5
"errors"
6
6
"fmt"
7
-
"io"
8
7
"log/slog"
9
-
"os"
10
-
"strings"
11
-
"sync"
12
-
"time"
13
8
14
9
securejoin "github.com/cyphar/filepath-securejoin"
15
-
"github.com/docker/docker/api/types/container"
16
-
"github.com/docker/docker/api/types/image"
17
-
"github.com/docker/docker/api/types/mount"
18
-
"github.com/docker/docker/api/types/network"
19
-
"github.com/docker/docker/api/types/volume"
20
-
"github.com/docker/docker/client"
21
-
"github.com/docker/docker/pkg/stdcopy"
22
10
"golang.org/x/sync/errgroup"
23
-
"tangled.sh/tangled.sh/core/log"
24
11
"tangled.sh/tangled.sh/core/notifier"
25
12
"tangled.sh/tangled.sh/core/spindle/config"
26
13
"tangled.sh/tangled.sh/core/spindle/db"
···
28
15
"tangled.sh/tangled.sh/core/spindle/secrets"
29
16
)
30
17
31
-
const (
32
-
workspaceDir = "/tangled/workspace"
18
+
var (
19
+
ErrTimedOut = errors.New("timed out")
20
+
ErrWorkflowFailed = errors.New("workflow failed")
33
21
)
34
22
35
-
type cleanupFunc func(context.Context) error
36
-
37
-
type Engine struct {
38
-
docker client.APIClient
39
-
l *slog.Logger
40
-
db *db.DB
41
-
n *notifier.Notifier
42
-
cfg *config.Config
43
-
vault secrets.Manager
44
-
45
-
cleanupMu sync.Mutex
46
-
cleanup map[string][]cleanupFunc
47
-
}
48
-
49
-
func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier, vault secrets.Manager) (*Engine, error) {
50
-
dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
51
-
if err != nil {
52
-
return nil, err
53
-
}
54
-
55
-
l := log.FromContext(ctx).With("component", "spindle")
56
-
57
-
e := &Engine{
58
-
docker: dcli,
59
-
l: l,
60
-
db: db,
61
-
n: n,
62
-
cfg: cfg,
63
-
vault: vault,
64
-
}
65
-
66
-
e.cleanup = make(map[string][]cleanupFunc)
67
-
68
-
return e, nil
69
-
}
70
-
71
-
func (e *Engine) StartWorkflows(ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) {
72
-
e.l.Info("starting all workflows in parallel", "pipeline", pipelineId)
23
+
func StartWorkflows(l *slog.Logger, vault secrets.Manager, cfg *config.Config, db *db.DB, n *notifier.Notifier, ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) {
24
+
l.Info("starting all workflows in parallel", "pipeline", pipelineId)
73
25
74
26
// extract secrets
75
27
var allSecrets []secrets.UnlockedSecret
76
28
if didSlashRepo, err := securejoin.SecureJoin(pipeline.RepoOwner, pipeline.RepoName); err == nil {
77
-
if res, err := e.vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)); err == nil {
29
+
if res, err := vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)); err == nil {
78
30
allSecrets = res
79
31
}
80
32
}
81
33
82
-
workflowTimeoutStr := e.cfg.Pipelines.WorkflowTimeout
83
-
workflowTimeout, err := time.ParseDuration(workflowTimeoutStr)
84
-
if err != nil {
85
-
e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr)
86
-
workflowTimeout = 5 * time.Minute
87
-
}
88
-
e.l.Info("using workflow timeout", "timeout", workflowTimeout)
89
-
90
34
eg, ctx := errgroup.WithContext(ctx)
91
-
for _, w := range pipeline.Workflows {
92
-
eg.Go(func() error {
93
-
wid := models.WorkflowId{
94
-
PipelineId: pipelineId,
95
-
Name: w.Name,
96
-
}
97
-
98
-
err := e.db.StatusRunning(wid, e.n)
99
-
if err != nil {
100
-
return err
101
-
}
35
+
for eng, wfs := range pipeline.Workflows {
36
+
workflowTimeout := eng.WorkflowTimeout()
37
+
l.Info("using workflow timeout", "timeout", workflowTimeout)
102
38
103
-
err = e.SetupWorkflow(ctx, wid)
104
-
if err != nil {
105
-
e.l.Error("setting up worklow", "wid", wid, "err", err)
106
-
return err
107
-
}
108
-
defer e.DestroyWorkflow(ctx, wid)
109
-
110
-
reader, err := e.docker.ImagePull(ctx, w.Image, image.PullOptions{})
111
-
if err != nil {
112
-
e.l.Error("pipeline image pull failed!", "image", w.Image, "workflowId", wid, "error", err.Error())
39
+
for _, w := range wfs {
40
+
eg.Go(func() error {
41
+
wid := models.WorkflowId{
42
+
PipelineId: pipelineId,
43
+
Name: w.Name,
44
+
}
113
45
114
-
err := e.db.StatusFailed(wid, err.Error(), -1, e.n)
46
+
err := db.StatusRunning(wid, n)
115
47
if err != nil {
116
48
return err
117
49
}
118
50
119
-
return fmt.Errorf("pulling image: %w", err)
120
-
}
121
-
defer reader.Close()
122
-
io.Copy(os.Stdout, reader)
123
-
124
-
ctx, cancel := context.WithTimeout(ctx, workflowTimeout)
125
-
defer cancel()
51
+
err = eng.SetupWorkflow(ctx, wid, &w)
52
+
if err != nil {
53
+
// TODO(winter): Should this always set StatusFailed?
54
+
// In the original, we only do in a subset of cases.
55
+
l.Error("setting up worklow", "wid", wid, "err", err)
126
56
127
-
err = e.StartSteps(ctx, wid, w, allSecrets)
128
-
if err != nil {
129
-
if errors.Is(err, ErrTimedOut) {
130
-
dbErr := e.db.StatusTimeout(wid, e.n)
131
-
if dbErr != nil {
132
-
return dbErr
57
+
destroyErr := eng.DestroyWorkflow(ctx, wid)
58
+
if destroyErr != nil {
59
+
l.Error("failed to destroy workflow after setup failure", "error", destroyErr)
133
60
}
134
-
} else {
135
-
dbErr := e.db.StatusFailed(wid, err.Error(), -1, e.n)
61
+
62
+
dbErr := db.StatusFailed(wid, err.Error(), -1, n)
136
63
if dbErr != nil {
137
64
return dbErr
138
65
}
66
+
return err
139
67
}
68
+
defer eng.DestroyWorkflow(ctx, wid)
140
69
141
-
return fmt.Errorf("starting steps image: %w", err)
142
-
}
70
+
wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid)
71
+
if err != nil {
72
+
l.Warn("failed to setup step logger; logs will not be persisted", "error", err)
73
+
wfLogger = nil
74
+
} else {
75
+
defer wfLogger.Close()
76
+
}
143
77
144
-
err = e.db.StatusSuccess(wid, e.n)
145
-
if err != nil {
146
-
return err
147
-
}
78
+
ctx, cancel := context.WithTimeout(ctx, workflowTimeout)
79
+
defer cancel()
148
80
149
-
return nil
150
-
})
151
-
}
81
+
for stepIdx, step := range w.Steps {
82
+
if wfLogger != nil {
83
+
ctl := wfLogger.ControlWriter(stepIdx, step)
84
+
ctl.Write([]byte(step.Name()))
85
+
}
152
86
153
-
if err = eg.Wait(); err != nil {
154
-
e.l.Error("failed to run one or more workflows", "err", err)
155
-
} else {
156
-
e.l.Error("successfully ran full pipeline")
157
-
}
158
-
}
87
+
err = eng.RunStep(ctx, wid, &w, stepIdx, allSecrets, wfLogger)
88
+
if err != nil {
89
+
if errors.Is(err, ErrTimedOut) {
90
+
dbErr := db.StatusTimeout(wid, n)
91
+
if dbErr != nil {
92
+
return dbErr
93
+
}
94
+
} else {
95
+
dbErr := db.StatusFailed(wid, err.Error(), -1, n)
96
+
if dbErr != nil {
97
+
return dbErr
98
+
}
99
+
}
159
100
160
-
// SetupWorkflow sets up a new network for the workflow and volumes for
161
-
// the workspace and Nix store. These are persisted across steps and are
162
-
// destroyed at the end of the workflow.
163
-
func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId) error {
164
-
e.l.Info("setting up workflow", "workflow", wid)
101
+
return fmt.Errorf("starting steps image: %w", err)
102
+
}
103
+
}
165
104
166
-
_, err := e.docker.VolumeCreate(ctx, volume.CreateOptions{
167
-
Name: workspaceVolume(wid),
168
-
Driver: "local",
169
-
})
170
-
if err != nil {
171
-
return err
172
-
}
173
-
e.registerCleanup(wid, func(ctx context.Context) error {
174
-
return e.docker.VolumeRemove(ctx, workspaceVolume(wid), true)
175
-
})
176
-
177
-
_, err = e.docker.VolumeCreate(ctx, volume.CreateOptions{
178
-
Name: nixVolume(wid),
179
-
Driver: "local",
180
-
})
181
-
if err != nil {
182
-
return err
183
-
}
184
-
e.registerCleanup(wid, func(ctx context.Context) error {
185
-
return e.docker.VolumeRemove(ctx, nixVolume(wid), true)
186
-
})
187
-
188
-
_, err = e.docker.NetworkCreate(ctx, networkName(wid), network.CreateOptions{
189
-
Driver: "bridge",
190
-
})
191
-
if err != nil {
192
-
return err
193
-
}
194
-
e.registerCleanup(wid, func(ctx context.Context) error {
195
-
return e.docker.NetworkRemove(ctx, networkName(wid))
196
-
})
105
+
err = db.StatusSuccess(wid, n)
106
+
if err != nil {
107
+
return err
108
+
}
197
109
198
-
return nil
199
-
}
200
-
201
-
// StartSteps starts all steps sequentially with the same base image.
202
-
// ONLY marks pipeline as failed if container's exit code is non-zero.
203
-
// All other errors are bubbled up.
204
-
// Fixed version of the step execution logic
205
-
func (e *Engine) StartSteps(ctx context.Context, wid models.WorkflowId, w models.Workflow, secrets []secrets.UnlockedSecret) error {
206
-
workflowEnvs := ConstructEnvs(w.Environment)
207
-
for _, s := range secrets {
208
-
workflowEnvs.AddEnv(s.Key, s.Value)
209
-
}
210
-
211
-
for stepIdx, step := range w.Steps {
212
-
select {
213
-
case <-ctx.Done():
214
-
return ctx.Err()
215
-
default:
216
-
}
217
-
218
-
envs := append(EnvVars(nil), workflowEnvs...)
219
-
for k, v := range step.Environment {
220
-
envs.AddEnv(k, v)
221
-
}
222
-
envs.AddEnv("HOME", workspaceDir)
223
-
e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice())
224
-
225
-
hostConfig := hostConfig(wid)
226
-
resp, err := e.docker.ContainerCreate(ctx, &container.Config{
227
-
Image: w.Image,
228
-
Cmd: []string{"bash", "-c", step.Command},
229
-
WorkingDir: workspaceDir,
230
-
Tty: false,
231
-
Hostname: "spindle",
232
-
Env: envs.Slice(),
233
-
}, hostConfig, nil, nil, "")
234
-
defer e.DestroyStep(ctx, resp.ID)
235
-
if err != nil {
236
-
return fmt.Errorf("creating container: %w", err)
237
-
}
238
-
239
-
err = e.docker.NetworkConnect(ctx, networkName(wid), resp.ID, nil)
240
-
if err != nil {
241
-
return fmt.Errorf("connecting network: %w", err)
242
-
}
243
-
244
-
err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{})
245
-
if err != nil {
246
-
return err
247
-
}
248
-
e.l.Info("started container", "name", resp.ID, "step", step.Name)
249
-
250
-
// start tailing logs in background
251
-
tailDone := make(chan error, 1)
252
-
go func() {
253
-
tailDone <- e.TailStep(ctx, resp.ID, wid, stepIdx, step)
254
-
}()
255
-
256
-
// wait for container completion or timeout
257
-
waitDone := make(chan struct{})
258
-
var state *container.State
259
-
var waitErr error
260
-
261
-
go func() {
262
-
defer close(waitDone)
263
-
state, waitErr = e.WaitStep(ctx, resp.ID)
264
-
}()
265
-
266
-
select {
267
-
case <-waitDone:
268
-
269
-
// wait for tailing to complete
270
-
<-tailDone
271
-
272
-
case <-ctx.Done():
273
-
e.l.Warn("step timed out; killing container", "container", resp.ID, "step", step.Name)
274
-
err = e.DestroyStep(context.Background(), resp.ID)
275
-
if err != nil {
276
-
e.l.Error("failed to destroy step", "container", resp.ID, "error", err)
277
-
}
278
-
279
-
// wait for both goroutines to finish
280
-
<-waitDone
281
-
<-tailDone
282
-
283
-
return ErrTimedOut
284
-
}
285
-
286
-
select {
287
-
case <-ctx.Done():
288
-
return ctx.Err()
289
-
default:
290
-
}
291
-
292
-
if waitErr != nil {
293
-
return waitErr
294
-
}
295
-
296
-
err = e.DestroyStep(ctx, resp.ID)
297
-
if err != nil {
298
-
return err
299
-
}
300
-
301
-
if state.ExitCode != 0 {
302
-
e.l.Error("workflow failed!", "workflow_id", wid.String(), "error", state.Error, "exit_code", state.ExitCode, "oom_killed", state.OOMKilled)
303
-
if state.OOMKilled {
304
-
return ErrOOMKilled
305
-
}
306
-
return ErrWorkflowFailed
110
+
return nil
111
+
})
307
112
}
308
113
}
309
114
310
-
return nil
311
-
}
312
-
313
-
func (e *Engine) WaitStep(ctx context.Context, containerID string) (*container.State, error) {
314
-
wait, errCh := e.docker.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
315
-
select {
316
-
case err := <-errCh:
317
-
if err != nil {
318
-
return nil, err
319
-
}
320
-
case <-wait:
321
-
}
322
-
323
-
e.l.Info("waited for container", "name", containerID)
324
-
325
-
info, err := e.docker.ContainerInspect(ctx, containerID)
326
-
if err != nil {
327
-
return nil, err
328
-
}
329
-
330
-
return info.State, nil
331
-
}
332
-
333
-
func (e *Engine) TailStep(ctx context.Context, containerID string, wid models.WorkflowId, stepIdx int, step models.Step) error {
334
-
wfLogger, err := NewWorkflowLogger(e.cfg.Pipelines.LogDir, wid)
335
-
if err != nil {
336
-
e.l.Warn("failed to setup step logger; logs will not be persisted", "error", err)
337
-
return err
115
+
if err := eg.Wait(); err != nil {
116
+
l.Error("failed to run one or more workflows", "err", err)
117
+
} else {
118
+
l.Error("successfully ran full pipeline")
338
119
}
339
-
defer wfLogger.Close()
340
-
341
-
ctl := wfLogger.ControlWriter(stepIdx, step)
342
-
ctl.Write([]byte(step.Name))
343
-
344
-
logs, err := e.docker.ContainerLogs(ctx, containerID, container.LogsOptions{
345
-
Follow: true,
346
-
ShowStdout: true,
347
-
ShowStderr: true,
348
-
Details: false,
349
-
Timestamps: false,
350
-
})
351
-
if err != nil {
352
-
return err
353
-
}
354
-
355
-
_, err = stdcopy.StdCopy(
356
-
wfLogger.DataWriter("stdout"),
357
-
wfLogger.DataWriter("stderr"),
358
-
logs,
359
-
)
360
-
if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) {
361
-
return fmt.Errorf("failed to copy logs: %w", err)
362
-
}
363
-
364
-
return nil
365
-
}
366
-
367
-
func (e *Engine) DestroyStep(ctx context.Context, containerID string) error {
368
-
err := e.docker.ContainerKill(ctx, containerID, "9") // SIGKILL
369
-
if err != nil && !isErrContainerNotFoundOrNotRunning(err) {
370
-
return err
371
-
}
372
-
373
-
if err := e.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{
374
-
RemoveVolumes: true,
375
-
RemoveLinks: false,
376
-
Force: false,
377
-
}); err != nil && !isErrContainerNotFoundOrNotRunning(err) {
378
-
return err
379
-
}
380
-
381
-
return nil
382
-
}
383
-
384
-
func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error {
385
-
e.cleanupMu.Lock()
386
-
key := wid.String()
387
-
388
-
fns := e.cleanup[key]
389
-
delete(e.cleanup, key)
390
-
e.cleanupMu.Unlock()
391
-
392
-
for _, fn := range fns {
393
-
if err := fn(ctx); err != nil {
394
-
e.l.Error("failed to cleanup workflow resource", "workflowId", wid, "error", err)
395
-
}
396
-
}
397
-
return nil
398
-
}
399
-
400
-
func (e *Engine) registerCleanup(wid models.WorkflowId, fn cleanupFunc) {
401
-
e.cleanupMu.Lock()
402
-
defer e.cleanupMu.Unlock()
403
-
404
-
key := wid.String()
405
-
e.cleanup[key] = append(e.cleanup[key], fn)
406
-
}
407
-
408
-
func workspaceVolume(wid models.WorkflowId) string {
409
-
return fmt.Sprintf("workspace-%s", wid)
410
-
}
411
-
412
-
func nixVolume(wid models.WorkflowId) string {
413
-
return fmt.Sprintf("nix-%s", wid)
414
-
}
415
-
416
-
func networkName(wid models.WorkflowId) string {
417
-
return fmt.Sprintf("workflow-network-%s", wid)
418
-
}
419
-
420
-
func hostConfig(wid models.WorkflowId) *container.HostConfig {
421
-
hostConfig := &container.HostConfig{
422
-
Mounts: []mount.Mount{
423
-
{
424
-
Type: mount.TypeVolume,
425
-
Source: workspaceVolume(wid),
426
-
Target: workspaceDir,
427
-
},
428
-
{
429
-
Type: mount.TypeVolume,
430
-
Source: nixVolume(wid),
431
-
Target: "/nix",
432
-
},
433
-
{
434
-
Type: mount.TypeTmpfs,
435
-
Target: "/tmp",
436
-
ReadOnly: false,
437
-
TmpfsOptions: &mount.TmpfsOptions{
438
-
Mode: 0o1777, // world-writeable sticky bit
439
-
Options: [][]string{
440
-
{"exec"},
441
-
},
442
-
},
443
-
},
444
-
{
445
-
Type: mount.TypeVolume,
446
-
Source: "etc-nix-" + wid.String(),
447
-
Target: "/etc/nix",
448
-
},
449
-
},
450
-
ReadonlyRootfs: false,
451
-
CapDrop: []string{"ALL"},
452
-
CapAdd: []string{"CAP_DAC_OVERRIDE"},
453
-
SecurityOpt: []string{"no-new-privileges"},
454
-
ExtraHosts: []string{"host.docker.internal:host-gateway"},
455
-
}
456
-
457
-
return hostConfig
458
-
}
459
-
460
-
// thanks woodpecker
461
-
func isErrContainerNotFoundOrNotRunning(err error) bool {
462
-
// Error response from daemon: Cannot kill container: ...: No such container: ...
463
-
// Error response from daemon: Cannot kill container: ...: Container ... is not running"
464
-
// Error response from podman daemon: can only kill running containers. ... is in state exited
465
-
// Error: No such container: ...
466
-
return err != nil && (strings.Contains(err.Error(), "No such container") || strings.Contains(err.Error(), "is not running") || strings.Contains(err.Error(), "can only kill running containers"))
467
120
}
-28
spindle/engine/envs.go
-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
+
}
+418
spindle/engines/nixery/engine.go
+418
spindle/engines/nixery/engine.go
···
1
+
package nixery
2
+
3
+
import (
4
+
"context"
5
+
"errors"
6
+
"fmt"
7
+
"io"
8
+
"log/slog"
9
+
"os"
10
+
"path"
11
+
"runtime"
12
+
"sync"
13
+
"time"
14
+
15
+
"github.com/docker/docker/api/types/container"
16
+
"github.com/docker/docker/api/types/image"
17
+
"github.com/docker/docker/api/types/mount"
18
+
"github.com/docker/docker/api/types/network"
19
+
"github.com/docker/docker/client"
20
+
"github.com/docker/docker/pkg/stdcopy"
21
+
"gopkg.in/yaml.v3"
22
+
"tangled.sh/tangled.sh/core/api/tangled"
23
+
"tangled.sh/tangled.sh/core/log"
24
+
"tangled.sh/tangled.sh/core/spindle/config"
25
+
"tangled.sh/tangled.sh/core/spindle/engine"
26
+
"tangled.sh/tangled.sh/core/spindle/models"
27
+
"tangled.sh/tangled.sh/core/spindle/secrets"
28
+
)
29
+
30
+
const (
31
+
workspaceDir = "/tangled/workspace"
32
+
homeDir = "/tangled/home"
33
+
)
34
+
35
+
type cleanupFunc func(context.Context) error
36
+
37
+
type Engine struct {
38
+
docker client.APIClient
39
+
l *slog.Logger
40
+
cfg *config.Config
41
+
42
+
cleanupMu sync.Mutex
43
+
cleanup map[string][]cleanupFunc
44
+
}
45
+
46
+
type Step struct {
47
+
name string
48
+
kind models.StepKind
49
+
command string
50
+
environment map[string]string
51
+
}
52
+
53
+
func (s Step) Name() string {
54
+
return s.name
55
+
}
56
+
57
+
func (s Step) Command() string {
58
+
return s.command
59
+
}
60
+
61
+
func (s Step) Kind() models.StepKind {
62
+
return s.kind
63
+
}
64
+
65
+
// setupSteps get added to start of Steps
66
+
type setupSteps []models.Step
67
+
68
+
// addStep adds a step to the beginning of the workflow's steps.
69
+
func (ss *setupSteps) addStep(step models.Step) {
70
+
*ss = append(*ss, step)
71
+
}
72
+
73
+
type addlFields struct {
74
+
image string
75
+
container string
76
+
env map[string]string
77
+
}
78
+
79
+
func (e *Engine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) {
80
+
swf := &models.Workflow{}
81
+
addl := addlFields{}
82
+
83
+
dwf := &struct {
84
+
Steps []struct {
85
+
Command string `yaml:"command"`
86
+
Name string `yaml:"name"`
87
+
Environment map[string]string `yaml:"environment"`
88
+
} `yaml:"steps"`
89
+
Dependencies map[string][]string `yaml:"dependencies"`
90
+
Environment map[string]string `yaml:"environment"`
91
+
}{}
92
+
err := yaml.Unmarshal([]byte(twf.Raw), &dwf)
93
+
if err != nil {
94
+
return nil, err
95
+
}
96
+
97
+
for _, dstep := range dwf.Steps {
98
+
sstep := Step{}
99
+
sstep.environment = dstep.Environment
100
+
sstep.command = dstep.Command
101
+
sstep.name = dstep.Name
102
+
sstep.kind = models.StepKindUser
103
+
swf.Steps = append(swf.Steps, sstep)
104
+
}
105
+
swf.Name = twf.Name
106
+
addl.env = dwf.Environment
107
+
addl.image = workflowImage(dwf.Dependencies, e.cfg.NixeryPipelines.Nixery)
108
+
109
+
setup := &setupSteps{}
110
+
111
+
setup.addStep(nixConfStep())
112
+
setup.addStep(cloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev))
113
+
// this step could be empty
114
+
if s := dependencyStep(dwf.Dependencies); s != nil {
115
+
setup.addStep(*s)
116
+
}
117
+
118
+
// append setup steps in order to the start of workflow steps
119
+
swf.Steps = append(*setup, swf.Steps...)
120
+
swf.Data = addl
121
+
122
+
return swf, nil
123
+
}
124
+
125
+
func (e *Engine) WorkflowTimeout() time.Duration {
126
+
workflowTimeoutStr := e.cfg.NixeryPipelines.WorkflowTimeout
127
+
workflowTimeout, err := time.ParseDuration(workflowTimeoutStr)
128
+
if err != nil {
129
+
e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr)
130
+
workflowTimeout = 5 * time.Minute
131
+
}
132
+
133
+
return workflowTimeout
134
+
}
135
+
136
+
func workflowImage(deps map[string][]string, nixery string) string {
137
+
var dependencies string
138
+
for reg, ds := range deps {
139
+
if reg == "nixpkgs" {
140
+
dependencies = path.Join(ds...)
141
+
}
142
+
}
143
+
144
+
// load defaults from somewhere else
145
+
dependencies = path.Join(dependencies, "bash", "git", "coreutils", "nix")
146
+
147
+
if runtime.GOARCH == "arm64" {
148
+
dependencies = path.Join("arm64", dependencies)
149
+
}
150
+
151
+
return path.Join(nixery, dependencies)
152
+
}
153
+
154
+
func New(ctx context.Context, cfg *config.Config) (*Engine, error) {
155
+
dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
156
+
if err != nil {
157
+
return nil, err
158
+
}
159
+
160
+
l := log.FromContext(ctx).With("component", "spindle")
161
+
162
+
e := &Engine{
163
+
docker: dcli,
164
+
l: l,
165
+
cfg: cfg,
166
+
}
167
+
168
+
e.cleanup = make(map[string][]cleanupFunc)
169
+
170
+
return e, nil
171
+
}
172
+
173
+
func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId, wf *models.Workflow) error {
174
+
e.l.Info("setting up workflow", "workflow", wid)
175
+
176
+
_, err := e.docker.NetworkCreate(ctx, networkName(wid), network.CreateOptions{
177
+
Driver: "bridge",
178
+
})
179
+
if err != nil {
180
+
return err
181
+
}
182
+
e.registerCleanup(wid, func(ctx context.Context) error {
183
+
return e.docker.NetworkRemove(ctx, networkName(wid))
184
+
})
185
+
186
+
addl := wf.Data.(addlFields)
187
+
188
+
reader, err := e.docker.ImagePull(ctx, addl.image, image.PullOptions{})
189
+
if err != nil {
190
+
e.l.Error("pipeline image pull failed!", "image", addl.image, "workflowId", wid, "error", err.Error())
191
+
192
+
return fmt.Errorf("pulling image: %w", err)
193
+
}
194
+
defer reader.Close()
195
+
io.Copy(os.Stdout, reader)
196
+
197
+
resp, err := e.docker.ContainerCreate(ctx, &container.Config{
198
+
Image: addl.image,
199
+
Cmd: []string{"cat"},
200
+
OpenStdin: true, // so cat stays alive :3
201
+
Tty: false,
202
+
Hostname: "spindle",
203
+
WorkingDir: workspaceDir,
204
+
// TODO(winter): investigate whether environment variables passed here
205
+
// get propagated to ContainerExec processes
206
+
}, &container.HostConfig{
207
+
Mounts: []mount.Mount{
208
+
{
209
+
Type: mount.TypeTmpfs,
210
+
Target: "/tmp",
211
+
ReadOnly: false,
212
+
TmpfsOptions: &mount.TmpfsOptions{
213
+
Mode: 0o1777, // world-writeable sticky bit
214
+
Options: [][]string{
215
+
{"exec"},
216
+
},
217
+
},
218
+
},
219
+
},
220
+
ReadonlyRootfs: false,
221
+
CapDrop: []string{"ALL"},
222
+
CapAdd: []string{"CAP_DAC_OVERRIDE"},
223
+
SecurityOpt: []string{"no-new-privileges"},
224
+
ExtraHosts: []string{"host.docker.internal:host-gateway"},
225
+
}, nil, nil, "")
226
+
if err != nil {
227
+
return fmt.Errorf("creating container: %w", err)
228
+
}
229
+
e.registerCleanup(wid, func(ctx context.Context) error {
230
+
err = e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{})
231
+
if err != nil {
232
+
return err
233
+
}
234
+
235
+
return e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{
236
+
RemoveVolumes: true,
237
+
RemoveLinks: false,
238
+
Force: false,
239
+
})
240
+
})
241
+
242
+
err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{})
243
+
if err != nil {
244
+
return fmt.Errorf("starting container: %w", err)
245
+
}
246
+
247
+
mkExecResp, err := e.docker.ContainerExecCreate(ctx, resp.ID, container.ExecOptions{
248
+
Cmd: []string{"mkdir", "-p", workspaceDir, homeDir},
249
+
AttachStdout: true, // NOTE(winter): pretty sure this will make it so that when stdout read is done below, mkdir is done. maybe??
250
+
AttachStderr: true, // for good measure, backed up by docker/cli ("If -d is not set, attach to everything by default")
251
+
})
252
+
if err != nil {
253
+
return err
254
+
}
255
+
256
+
// This actually *starts* the command. Thanks, Docker!
257
+
execResp, err := e.docker.ContainerExecAttach(ctx, mkExecResp.ID, container.ExecAttachOptions{})
258
+
if err != nil {
259
+
return err
260
+
}
261
+
defer execResp.Close()
262
+
263
+
// This is apparently best way to wait for the command to complete.
264
+
_, err = io.ReadAll(execResp.Reader)
265
+
if err != nil {
266
+
return err
267
+
}
268
+
269
+
execInspectResp, err := e.docker.ContainerExecInspect(ctx, mkExecResp.ID)
270
+
if err != nil {
271
+
return err
272
+
}
273
+
274
+
if execInspectResp.ExitCode != 0 {
275
+
return fmt.Errorf("mkdir exited with exit code %d", execInspectResp.ExitCode)
276
+
} else if execInspectResp.Running {
277
+
return errors.New("mkdir is somehow still running??")
278
+
}
279
+
280
+
addl.container = resp.ID
281
+
wf.Data = addl
282
+
283
+
return nil
284
+
}
285
+
286
+
func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *models.WorkflowLogger) error {
287
+
addl := w.Data.(addlFields)
288
+
workflowEnvs := ConstructEnvs(addl.env)
289
+
// TODO(winter): should SetupWorkflow also have secret access?
290
+
// IMO yes, but probably worth thinking on.
291
+
for _, s := range secrets {
292
+
workflowEnvs.AddEnv(s.Key, s.Value)
293
+
}
294
+
295
+
step := w.Steps[idx].(Step)
296
+
297
+
select {
298
+
case <-ctx.Done():
299
+
return ctx.Err()
300
+
default:
301
+
}
302
+
303
+
envs := append(EnvVars(nil), workflowEnvs...)
304
+
for k, v := range step.environment {
305
+
envs.AddEnv(k, v)
306
+
}
307
+
envs.AddEnv("HOME", homeDir)
308
+
309
+
mkExecResp, err := e.docker.ContainerExecCreate(ctx, addl.container, container.ExecOptions{
310
+
Cmd: []string{"bash", "-c", step.command},
311
+
AttachStdout: true,
312
+
AttachStderr: true,
313
+
Env: envs,
314
+
})
315
+
if err != nil {
316
+
return fmt.Errorf("creating exec: %w", err)
317
+
}
318
+
319
+
// start tailing logs in background
320
+
tailDone := make(chan error, 1)
321
+
go func() {
322
+
tailDone <- e.tailStep(ctx, wfLogger, mkExecResp.ID, wid, idx, step)
323
+
}()
324
+
325
+
select {
326
+
case <-tailDone:
327
+
328
+
case <-ctx.Done():
329
+
// cleanup will be handled by DestroyWorkflow, since
330
+
// Docker doesn't provide an API to kill an exec run
331
+
// (sure, we could grab the PID and kill it ourselves,
332
+
// but that's wasted effort)
333
+
e.l.Warn("step timed out", "step", step.Name)
334
+
335
+
<-tailDone
336
+
337
+
return engine.ErrTimedOut
338
+
}
339
+
340
+
select {
341
+
case <-ctx.Done():
342
+
return ctx.Err()
343
+
default:
344
+
}
345
+
346
+
execInspectResp, err := e.docker.ContainerExecInspect(ctx, mkExecResp.ID)
347
+
if err != nil {
348
+
return err
349
+
}
350
+
351
+
if execInspectResp.ExitCode != 0 {
352
+
inspectResp, err := e.docker.ContainerInspect(ctx, addl.container)
353
+
if err != nil {
354
+
return err
355
+
}
356
+
357
+
e.l.Error("workflow failed!", "workflow_id", wid.String(), "exit_code", execInspectResp.ExitCode, "oom_killed", inspectResp.State.OOMKilled)
358
+
359
+
if inspectResp.State.OOMKilled {
360
+
return ErrOOMKilled
361
+
}
362
+
return engine.ErrWorkflowFailed
363
+
}
364
+
365
+
return nil
366
+
}
367
+
368
+
func (e *Engine) tailStep(ctx context.Context, wfLogger *models.WorkflowLogger, execID string, wid models.WorkflowId, stepIdx int, step models.Step) error {
369
+
if wfLogger == nil {
370
+
return nil
371
+
}
372
+
373
+
// This actually *starts* the command. Thanks, Docker!
374
+
logs, err := e.docker.ContainerExecAttach(ctx, execID, container.ExecAttachOptions{})
375
+
if err != nil {
376
+
return err
377
+
}
378
+
defer logs.Close()
379
+
380
+
_, err = stdcopy.StdCopy(
381
+
wfLogger.DataWriter("stdout"),
382
+
wfLogger.DataWriter("stderr"),
383
+
logs.Reader,
384
+
)
385
+
if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) {
386
+
return fmt.Errorf("failed to copy logs: %w", err)
387
+
}
388
+
389
+
return nil
390
+
}
391
+
392
+
func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error {
393
+
e.cleanupMu.Lock()
394
+
key := wid.String()
395
+
396
+
fns := e.cleanup[key]
397
+
delete(e.cleanup, key)
398
+
e.cleanupMu.Unlock()
399
+
400
+
for _, fn := range fns {
401
+
if err := fn(ctx); err != nil {
402
+
e.l.Error("failed to cleanup workflow resource", "workflowId", wid, "error", err)
403
+
}
404
+
}
405
+
return nil
406
+
}
407
+
408
+
func (e *Engine) registerCleanup(wid models.WorkflowId, fn cleanupFunc) {
409
+
e.cleanupMu.Lock()
410
+
defer e.cleanupMu.Unlock()
411
+
412
+
key := wid.String()
413
+
e.cleanup[key] = append(e.cleanup[key], fn)
414
+
}
415
+
416
+
func networkName(wid models.WorkflowId) string {
417
+
return fmt.Sprintf("workflow-network-%s", wid)
418
+
}
+28
spindle/engines/nixery/envs.go
+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
+
}
+168
-10
spindle/ingester.go
+168
-10
spindle/ingester.go
···
3
3
import (
4
4
"context"
5
5
"encoding/json"
6
+
"errors"
6
7
"fmt"
7
-
"path/filepath"
8
+
"time"
8
9
9
10
"tangled.sh/tangled.sh/core/api/tangled"
10
11
"tangled.sh/tangled.sh/core/eventconsumer"
12
+
"tangled.sh/tangled.sh/core/idresolver"
11
13
"tangled.sh/tangled.sh/core/rbac"
14
+
"tangled.sh/tangled.sh/core/spindle/db"
12
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"
13
20
"github.com/bluesky-social/jetstream/pkg/models"
21
+
securejoin "github.com/cyphar/filepath-securejoin"
14
22
)
15
23
16
24
type Ingester func(ctx context.Context, e *models.Event) error
···
32
40
33
41
switch e.Commit.Collection {
34
42
case tangled.SpindleMemberNSID:
35
-
s.ingestMember(ctx, e)
43
+
err = s.ingestMember(ctx, e)
36
44
case tangled.RepoNSID:
37
-
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)
38
52
}
39
53
40
-
return err
54
+
return nil
41
55
}
42
56
}
43
57
44
58
func (s *Spindle) ingestMember(_ context.Context, e *models.Event) error {
59
+
var err error
45
60
did := e.Did
46
-
var err error
61
+
rkey := e.Commit.RKey
47
62
48
63
l := s.l.With("component", "ingester", "record", tangled.SpindleMemberNSID)
49
64
···
58
73
}
59
74
60
75
domain := s.cfg.Server.Hostname
61
-
if s.cfg.Server.Dev {
62
-
domain = s.cfg.Server.ListenAddr
63
-
}
64
76
recordInstance := record.Instance
65
77
66
78
if recordInstance != domain {
···
74
86
return fmt.Errorf("failed to enforce permissions: %w", err)
75
87
}
76
88
89
+
if err := db.AddSpindleMember(s.db, db.SpindleMember{
90
+
Did: syntax.DID(did),
91
+
Rkey: rkey,
92
+
Instance: recordInstance,
93
+
Subject: syntax.DID(record.Subject),
94
+
Created: time.Now(),
95
+
}); err != nil {
96
+
l.Error("failed to add member", "error", err)
97
+
return fmt.Errorf("failed to add member: %w", err)
98
+
}
99
+
77
100
if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil {
78
101
l.Error("failed to add member", "error", err)
79
102
return fmt.Errorf("failed to add member: %w", err)
···
88
111
89
112
return nil
90
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
+
91
138
}
92
139
return nil
93
140
}
94
141
95
-
func (s *Spindle) ingestRepo(_ context.Context, e *models.Event) error {
142
+
func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error {
96
143
var err error
144
+
did := e.Did
145
+
resolver := idresolver.DefaultResolver()
97
146
98
147
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
99
148
···
129
178
return fmt.Errorf("failed to add repo: %w", err)
130
179
}
131
180
181
+
didSlashRepo, err := securejoin.SecureJoin(record.Owner, record.Name)
182
+
if err != nil {
183
+
return err
184
+
}
185
+
132
186
// add repo to rbac
133
-
if err := s.e.AddRepo(record.Owner, rbac.ThisServer, filepath.Join(record.Owner, record.Name)); err != nil {
187
+
if err := s.e.AddRepo(record.Owner, rbac.ThisServer, didSlashRepo); err != nil {
134
188
l.Error("failed to add repo to enforcer", "error", err)
135
189
return fmt.Errorf("failed to add repo: %w", err)
136
190
}
137
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
+
138
201
// add this knot to the event consumer
139
202
src := eventconsumer.NewKnotSource(record.Knot)
140
203
s.ks.AddSource(context.Background(), src)
···
144
207
}
145
208
return nil
146
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
}
+8
-103
spindle/models/pipeline.go
+8
-103
spindle/models/pipeline.go
···
1
1
package models
2
2
3
-
import (
4
-
"path"
5
-
6
-
"tangled.sh/tangled.sh/core/api/tangled"
7
-
"tangled.sh/tangled.sh/core/spindle/config"
8
-
)
9
-
10
3
type Pipeline struct {
11
4
RepoOwner string
12
5
RepoName string
13
-
Workflows []Workflow
6
+
Workflows map[Engine][]Workflow
14
7
}
15
8
16
-
type Step struct {
17
-
Command string
18
-
Name string
19
-
Environment map[string]string
20
-
Kind StepKind
9
+
type Step interface {
10
+
Name() string
11
+
Command() string
12
+
Kind() StepKind
21
13
}
22
14
23
15
type StepKind int
···
30
22
)
31
23
32
24
type Workflow struct {
33
-
Steps []Step
34
-
Environment map[string]string
35
-
Name string
36
-
Image string
37
-
}
38
-
39
-
// setupSteps get added to start of Steps
40
-
type setupSteps []Step
41
-
42
-
// addStep adds a step to the beginning of the workflow's steps.
43
-
func (ss *setupSteps) addStep(step Step) {
44
-
*ss = append(*ss, step)
45
-
}
46
-
47
-
// ToPipeline converts a tangled.Pipeline into a model.Pipeline.
48
-
// In the process, dependencies are resolved: nixpkgs deps
49
-
// are constructed atop nixery and set as the Workflow.Image,
50
-
// and ones from custom registries
51
-
func ToPipeline(pl tangled.Pipeline, cfg config.Config) *Pipeline {
52
-
workflows := []Workflow{}
53
-
54
-
for _, twf := range pl.Workflows {
55
-
swf := &Workflow{}
56
-
for _, tstep := range twf.Steps {
57
-
sstep := Step{}
58
-
sstep.Environment = stepEnvToMap(tstep.Environment)
59
-
sstep.Command = tstep.Command
60
-
sstep.Name = tstep.Name
61
-
sstep.Kind = StepKindUser
62
-
swf.Steps = append(swf.Steps, sstep)
63
-
}
64
-
swf.Name = twf.Name
65
-
swf.Environment = workflowEnvToMap(twf.Environment)
66
-
swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery)
67
-
68
-
setup := &setupSteps{}
69
-
70
-
setup.addStep(nixConfStep())
71
-
setup.addStep(cloneStep(*twf, *pl.TriggerMetadata, cfg.Server.Dev))
72
-
// this step could be empty
73
-
if s := dependencyStep(*twf); s != nil {
74
-
setup.addStep(*s)
75
-
}
76
-
77
-
// append setup steps in order to the start of workflow steps
78
-
swf.Steps = append(*setup, swf.Steps...)
79
-
80
-
workflows = append(workflows, *swf)
81
-
}
82
-
repoOwner := pl.TriggerMetadata.Repo.Did
83
-
repoName := pl.TriggerMetadata.Repo.Repo
84
-
return &Pipeline{
85
-
RepoOwner: repoOwner,
86
-
RepoName: repoName,
87
-
Workflows: workflows,
88
-
}
89
-
}
90
-
91
-
func workflowEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string {
92
-
envMap := map[string]string{}
93
-
for _, env := range envs {
94
-
if env != nil {
95
-
envMap[env.Key] = env.Value
96
-
}
97
-
}
98
-
return envMap
99
-
}
100
-
101
-
func stepEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string {
102
-
envMap := map[string]string{}
103
-
for _, env := range envs {
104
-
if env != nil {
105
-
envMap[env.Key] = env.Value
106
-
}
107
-
}
108
-
return envMap
109
-
}
110
-
111
-
func workflowImage(deps []*tangled.Pipeline_Dependency, nixery string) string {
112
-
var dependencies string
113
-
for _, d := range deps {
114
-
if d.Registry == "nixpkgs" {
115
-
dependencies = path.Join(d.Packages...)
116
-
}
117
-
}
118
-
119
-
// load defaults from somewhere else
120
-
dependencies = path.Join(dependencies, "bash", "git", "coreutils", "nix")
121
-
122
-
return path.Join(nixery, dependencies)
25
+
Steps []Step
26
+
Name string
27
+
Data any
123
28
}
-128
spindle/models/setup_steps.go
-128
spindle/models/setup_steps.go
···
1
-
package models
2
-
3
-
import (
4
-
"fmt"
5
-
"path"
6
-
"strings"
7
-
8
-
"tangled.sh/tangled.sh/core/api/tangled"
9
-
"tangled.sh/tangled.sh/core/workflow"
10
-
)
11
-
12
-
func nixConfStep() Step {
13
-
setupCmd := `echo 'extra-experimental-features = nix-command flakes' >> /etc/nix/nix.conf
14
-
echo 'build-users-group = ' >> /etc/nix/nix.conf`
15
-
return Step{
16
-
Command: setupCmd,
17
-
Name: "Configure Nix",
18
-
}
19
-
}
20
-
21
-
// cloneOptsAsSteps processes clone options and adds corresponding steps
22
-
// to the beginning of the workflow's step list if cloning is not skipped.
23
-
//
24
-
// the steps to do here are:
25
-
// - git init
26
-
// - git remote add origin <url>
27
-
// - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha>
28
-
// - git checkout FETCH_HEAD
29
-
func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step {
30
-
if twf.Clone.Skip {
31
-
return Step{}
32
-
}
33
-
34
-
var commands []string
35
-
36
-
// initialize git repo in workspace
37
-
commands = append(commands, "git init")
38
-
39
-
// add repo as git remote
40
-
scheme := "https://"
41
-
if dev {
42
-
scheme = "http://"
43
-
tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal")
44
-
}
45
-
url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo)
46
-
commands = append(commands, fmt.Sprintf("git remote add origin %s", url))
47
-
48
-
// run git fetch
49
-
{
50
-
var fetchArgs []string
51
-
52
-
// default clone depth is 1
53
-
depth := 1
54
-
if twf.Clone.Depth > 1 {
55
-
depth = int(twf.Clone.Depth)
56
-
}
57
-
fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth))
58
-
59
-
// optionally recurse submodules
60
-
if twf.Clone.Submodules {
61
-
fetchArgs = append(fetchArgs, "--recurse-submodules=yes")
62
-
}
63
-
64
-
// set remote to fetch from
65
-
fetchArgs = append(fetchArgs, "origin")
66
-
67
-
// set revision to checkout
68
-
switch workflow.TriggerKind(tr.Kind) {
69
-
case workflow.TriggerKindManual:
70
-
// TODO: unimplemented
71
-
case workflow.TriggerKindPush:
72
-
fetchArgs = append(fetchArgs, tr.Push.NewSha)
73
-
case workflow.TriggerKindPullRequest:
74
-
fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha)
75
-
}
76
-
77
-
commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " ")))
78
-
}
79
-
80
-
// run git checkout
81
-
commands = append(commands, "git checkout FETCH_HEAD")
82
-
83
-
cloneStep := Step{
84
-
Command: strings.Join(commands, "\n"),
85
-
Name: "Clone repository into workspace",
86
-
}
87
-
return cloneStep
88
-
}
89
-
90
-
// dependencyStep processes dependencies defined in the workflow.
91
-
// For dependencies using a custom registry (i.e. not nixpkgs), it collects
92
-
// all packages and adds a single 'nix profile install' step to the
93
-
// beginning of the workflow's step list.
94
-
func dependencyStep(twf tangled.Pipeline_Workflow) *Step {
95
-
var customPackages []string
96
-
97
-
for _, d := range twf.Dependencies {
98
-
registry := d.Registry
99
-
packages := d.Packages
100
-
101
-
if registry == "nixpkgs" {
102
-
continue
103
-
}
104
-
105
-
if len(packages) == 0 {
106
-
customPackages = append(customPackages, registry)
107
-
}
108
-
// collect packages from custom registries
109
-
for _, pkg := range packages {
110
-
customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg))
111
-
}
112
-
}
113
-
114
-
if len(customPackages) > 0 {
115
-
installCmd := "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install"
116
-
cmd := fmt.Sprintf("%s %s", installCmd, strings.Join(customPackages, " "))
117
-
installStep := Step{
118
-
Command: cmd,
119
-
Name: "Install custom dependencies",
120
-
Environment: map[string]string{
121
-
"NIX_NO_COLOR": "1",
122
-
"NIX_SHOW_DOWNLOAD_PROGRESS": "0",
123
-
},
124
-
}
125
-
return &installStep
126
-
}
127
-
return nil
128
-
}
+1
-1
spindle/secrets/openbao.go
+1
-1
spindle/secrets/openbao.go
+1
-1
spindle/secrets/sqlite.go
+1
-1
spindle/secrets/sqlite.go
···
24
24
}
25
25
26
26
func NewSQLiteManager(dbPath string, opts ...SqliteManagerOpt) (*SqliteManager, error) {
27
-
db, err := sql.Open("sqlite3", dbPath)
27
+
db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1")
28
28
if err != nil {
29
29
return nil, fmt.Errorf("failed to open sqlite database: %w", err)
30
30
}
+53
-9
spindle/server.go
+53
-9
spindle/server.go
···
20
20
"tangled.sh/tangled.sh/core/spindle/config"
21
21
"tangled.sh/tangled.sh/core/spindle/db"
22
22
"tangled.sh/tangled.sh/core/spindle/engine"
23
+
"tangled.sh/tangled.sh/core/spindle/engines/nixery"
23
24
"tangled.sh/tangled.sh/core/spindle/models"
24
25
"tangled.sh/tangled.sh/core/spindle/queue"
25
26
"tangled.sh/tangled.sh/core/spindle/secrets"
···
39
40
e *rbac.Enforcer
40
41
l *slog.Logger
41
42
n *notifier.Notifier
42
-
eng *engine.Engine
43
+
engs map[string]models.Engine
43
44
jq *queue.Queue
44
45
cfg *config.Config
45
46
ks *eventconsumer.Consumer
···
93
94
return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
94
95
}
95
96
96
-
eng, err := engine.New(ctx, cfg, d, &n, vault)
97
+
nixeryEng, err := nixery.New(ctx, cfg)
97
98
if err != nil {
98
99
return err
99
100
}
100
101
101
-
jq := queue.NewQueue(100, 2)
102
+
jq := queue.NewQueue(100, 5)
102
103
103
104
collections := []string{
104
105
tangled.SpindleMemberNSID,
105
106
tangled.RepoNSID,
107
+
tangled.RepoCollaboratorNSID,
106
108
}
107
109
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true)
108
110
if err != nil {
···
110
112
}
111
113
jc.AddDid(cfg.Server.Owner)
112
114
115
+
// Check if the spindle knows about any Dids;
116
+
dids, err := d.GetAllDids()
117
+
if err != nil {
118
+
return fmt.Errorf("failed to get all dids: %w", err)
119
+
}
120
+
for _, d := range dids {
121
+
jc.AddDid(d)
122
+
}
123
+
113
124
resolver := idresolver.DefaultResolver()
114
125
115
126
spindle := Spindle{
···
118
129
db: d,
119
130
l: logger,
120
131
n: &n,
121
-
eng: eng,
132
+
engs: map[string]models.Engine{"nixery": nixeryEng},
122
133
jq: jq,
123
134
cfg: cfg,
124
135
res: resolver,
···
206
217
Logger: logger,
207
218
Db: s.db,
208
219
Enforcer: s.e,
209
-
Engine: s.eng,
220
+
Engines: s.engs,
210
221
Config: s.cfg,
211
222
Resolver: s.res,
212
223
Vault: s.vault,
···
230
241
231
242
if tpl.TriggerMetadata.Repo == nil {
232
243
return fmt.Errorf("no repo data found")
244
+
}
245
+
246
+
if src.Key() != tpl.TriggerMetadata.Repo.Knot {
247
+
return fmt.Errorf("repo knot does not match event source: %s != %s", src.Key(), tpl.TriggerMetadata.Repo.Knot)
233
248
}
234
249
235
250
// filter by repos
···
247
262
Rkey: msg.Rkey,
248
263
}
249
264
265
+
workflows := make(map[models.Engine][]models.Workflow)
266
+
250
267
for _, w := range tpl.Workflows {
251
268
if w != nil {
252
-
err := s.db.StatusPending(models.WorkflowId{
269
+
if _, ok := s.engs[w.Engine]; !ok {
270
+
err = s.db.StatusFailed(models.WorkflowId{
271
+
PipelineId: pipelineId,
272
+
Name: w.Name,
273
+
}, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n)
274
+
if err != nil {
275
+
return err
276
+
}
277
+
278
+
continue
279
+
}
280
+
281
+
eng := s.engs[w.Engine]
282
+
283
+
if _, ok := workflows[eng]; !ok {
284
+
workflows[eng] = []models.Workflow{}
285
+
}
286
+
287
+
ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl)
288
+
if err != nil {
289
+
return err
290
+
}
291
+
292
+
workflows[eng] = append(workflows[eng], *ewf)
293
+
294
+
err = s.db.StatusPending(models.WorkflowId{
253
295
PipelineId: pipelineId,
254
296
Name: w.Name,
255
297
}, s.n)
···
259
301
}
260
302
}
261
303
262
-
spl := models.ToPipeline(tpl, *s.cfg)
263
-
264
304
ok := s.jq.Enqueue(queue.Job{
265
305
Run: func() error {
266
-
s.eng.StartWorkflows(ctx, spl, pipelineId)
306
+
engine.StartWorkflows(s.l, s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{
307
+
RepoOwner: tpl.TriggerMetadata.Repo.Did,
308
+
RepoName: tpl.TriggerMetadata.Repo.Repo,
309
+
Workflows: workflows,
310
+
}, pipelineId)
267
311
return nil
268
312
},
269
313
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,
+2
-2
spindle/xrpc/xrpc.go
+2
-2
spindle/xrpc/xrpc.go
···
17
17
"tangled.sh/tangled.sh/core/rbac"
18
18
"tangled.sh/tangled.sh/core/spindle/config"
19
19
"tangled.sh/tangled.sh/core/spindle/db"
20
-
"tangled.sh/tangled.sh/core/spindle/engine"
20
+
"tangled.sh/tangled.sh/core/spindle/models"
21
21
"tangled.sh/tangled.sh/core/spindle/secrets"
22
22
)
23
23
···
27
27
Logger *slog.Logger
28
28
Db *db.DB
29
29
Enforcer *rbac.Enforcer
30
-
Engine *engine.Engine
30
+
Engines map[string]models.Engine
31
31
Config *config.Config
32
32
Resolver *idresolver.Resolver
33
33
Vault secrets.Manager
+1
-3
tailwind.config.js
+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
-
}