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

Compare changes

Choose any two refs to compare.

Changed files
+7132 -4486
.air
.tangled
.zed
api
appview
cmd
genjwks
punchcardPopulate
docs
eventconsumer
cursor
jetstream
knotserver
lexicons
log
nix
rbac
spindle
workflow
+1 -1
.air/appview.toml
··· 5 5 6 6 exclude_regex = [".*_templ.go"] 7 7 include_ext = ["go", "templ", "html", "css"] 8 - exclude_dir = ["target", "atrium"] 8 + exclude_dir = ["target", "atrium", "nix"]
+4
.gitignore
··· 14 14 .DS_Store 15 15 .env 16 16 *.rdb 17 + .envrc 18 + # Created if following hacking.md 19 + genjwks.out 20 + /nix/vm-data
+12
.prettierrc.json
··· 1 + { 2 + "overrides": [ 3 + { 4 + "files": ["*.html"], 5 + "options": { 6 + "parser": "go-template" 7 + } 8 + } 9 + ], 10 + "bracketSameLine": true, 11 + "htmlWhitespaceSensitivity": "ignore" 12 + }
+2
.tangled/workflows/build.yml
··· 2 2 - event: ["push", "pull_request"] 3 3 branch: ["master"] 4 4 5 + engine: nixery 6 + 5 7 dependencies: 6 8 nixpkgs: 7 9 - go
+3 -12
.tangled/workflows/fmt.yml
··· 2 2 - event: ["push", "pull_request"] 3 3 branch: ["master"] 4 4 5 - dependencies: 6 - nixpkgs: 7 - - go 8 - - alejandra 5 + engine: nixery 9 6 10 7 steps: 11 - - name: "nix fmt" 8 + - name: "Check formatting" 12 9 command: | 13 - alejandra -c nix/**/*.nix flake.nix 14 - 15 - - name: "go fmt" 16 - command: | 17 - unformatted=$(gofmt -l .) 18 - test -z "$unformatted" || (echo "$unformatted" && exit 1) 19 - 10 + nix run .#fmt -- --ci
+2
.tangled/workflows/test.yml
··· 2 2 - event: ["push", "pull_request"] 3 3 branch: ["master"] 4 4 5 + engine: nixery 6 + 5 7 dependencies: 6 8 nixpkgs: 7 9 - go
-16
.zed/settings.json
··· 1 - // Folder-specific settings 2 - // 3 - // For a full list of overridable settings, and general information on folder-specific settings, 4 - // see the documentation: https://zed.dev/docs/configuring-zed#settings-files 5 - { 6 - "languages": { 7 - "HTML": { 8 - "prettier": { 9 - "format_on_save": false, 10 - "allowed": true, 11 - "parser": "go-template", 12 - "plugins": ["prettier-plugin-go-template"] 13 - } 14 - } 15 - } 16 - }
+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
··· 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
··· 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
··· 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
··· 29 29 Submodules bool `json:"submodules" cborgen:"submodules"` 30 30 } 31 31 32 - // Pipeline_Dependency is a "dependency" in the sh.tangled.pipeline schema. 33 - type Pipeline_Dependency struct { 34 - Packages []string `json:"packages" cborgen:"packages"` 35 - Registry string `json:"registry" cborgen:"registry"` 36 - } 37 - 38 32 // Pipeline_ManualTriggerData is a "manualTriggerData" in the sh.tangled.pipeline schema. 39 33 type Pipeline_ManualTriggerData struct { 40 34 Inputs []*Pipeline_Pair `json:"inputs,omitempty" cborgen:"inputs,omitempty"` ··· 61 55 Ref string `json:"ref" cborgen:"ref"` 62 56 } 63 57 64 - // Pipeline_Step is a "step" in the sh.tangled.pipeline schema. 65 - type Pipeline_Step struct { 66 - Command string `json:"command" cborgen:"command"` 67 - Environment []*Pipeline_Pair `json:"environment,omitempty" cborgen:"environment,omitempty"` 68 - Name string `json:"name" cborgen:"name"` 69 - } 70 - 71 58 // Pipeline_TriggerMetadata is a "triggerMetadata" in the sh.tangled.pipeline schema. 72 59 type Pipeline_TriggerMetadata struct { 73 60 Kind string `json:"kind" cborgen:"kind"` ··· 87 74 88 75 // Pipeline_Workflow is a "workflow" in the sh.tangled.pipeline schema. 89 76 type Pipeline_Workflow struct { 90 - Clone *Pipeline_CloneOpts `json:"clone" cborgen:"clone"` 91 - Dependencies []*Pipeline_Dependency `json:"dependencies" cborgen:"dependencies"` 92 - Environment []*Pipeline_Pair `json:"environment" cborgen:"environment"` 93 - Name string `json:"name" cborgen:"name"` 94 - Steps []*Pipeline_Step `json:"steps" cborgen:"steps"` 77 + Clone *Pipeline_CloneOpts `json:"clone" cborgen:"clone"` 78 + Engine string `json:"engine" cborgen:"engine"` 79 + Name string `json:"name" cborgen:"name"` 80 + Raw string `json:"raw" cborgen:"raw"` 95 81 }
+25
api/tangled/tangledstring.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.string 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + StringNSID = "sh.tangled.string" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.string", &String{}) 17 + } // 18 + // RECORDTYPE: String 19 + type String struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.string" cborgen:"$type,const=sh.tangled.string"` 21 + Contents string `json:"contents" cborgen:"contents"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Description string `json:"description" cborgen:"description"` 24 + Filename string `json:"filename" cborgen:"filename"` 25 + }
+1
appview/cache/session/store.go
··· 31 31 PkceVerifier string 32 32 DpopAuthserverNonce string 33 33 DpopPrivateJwk string 34 + ReturnUrl string 34 35 } 35 36 36 37 type SessionStore struct {
+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
··· 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
··· 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
··· 53 53 return err 54 54 } 55 55 56 - func GetFollowerFollowing(e Execer, did string) (int, int, error) { 56 + func GetFollowerFollowingCount(e Execer, did string) (int, int, error) { 57 57 followers, following := 0, 0 58 58 err := e.QueryRow( 59 59 `SELECT
+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
··· 1 - -- Simplified SQLite Database Migration Script for Issues and Comments 2 - 3 - -- Migration for issues table 4 - CREATE TABLE issues_new ( 5 - id integer primary key autoincrement, 6 - owner_did text not null, 7 - repo_at text not null, 8 - issue_id integer not null, 9 - title text not null, 10 - body text not null, 11 - open integer not null default 1, 12 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 13 - issue_at text, 14 - unique(repo_at, issue_id), 15 - foreign key (repo_at) references repos(at_uri) on delete cascade 16 - ); 17 - 18 - -- Migrate data to new issues table 19 - INSERT INTO issues_new ( 20 - id, owner_did, repo_at, issue_id, 21 - title, body, open, created, issue_at 22 - ) 23 - SELECT 24 - id, owner_did, repo_at, issue_id, 25 - title, body, open, created, issue_at 26 - FROM issues; 27 - 28 - -- Drop old issues table 29 - DROP TABLE issues; 30 - 31 - -- Rename new issues table 32 - ALTER TABLE issues_new RENAME TO issues; 33 - 34 - -- Migration for comments table 35 - CREATE TABLE comments_new ( 36 - id integer primary key autoincrement, 37 - owner_did text not null, 38 - issue_id integer not null, 39 - repo_at text not null, 40 - comment_id integer not null, 41 - comment_at text not null, 42 - body text not null, 43 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 44 - unique(issue_id, comment_id), 45 - foreign key (repo_at, issue_id) references issues(repo_at, issue_id) on delete cascade 46 - ); 47 - 48 - -- Migrate data to new comments table 49 - INSERT INTO comments_new ( 50 - id, owner_did, issue_id, repo_at, 51 - comment_id, comment_at, body, created 52 - ) 53 - SELECT 54 - id, owner_did, issue_id, repo_at, 55 - comment_id, comment_at, body, created 56 - FROM comments; 57 - 58 - -- Drop old comments table 59 - DROP TABLE comments; 60 - 61 - -- Rename new comments table 62 - ALTER TABLE comments_new RENAME TO comments;
-66
appview/db/migrations/validate.sql
··· 1 - -- Validation Queries for Database Migration 2 - 3 - -- 1. Verify Issues Table Structure 4 - PRAGMA table_info(issues); 5 - 6 - -- 2. Verify Comments Table Structure 7 - PRAGMA table_info(comments); 8 - 9 - -- 3. Check Total Row Count Consistency 10 - SELECT 11 - 'Issues Row Count' AS check_type, 12 - (SELECT COUNT(*) FROM issues) AS row_count 13 - UNION ALL 14 - SELECT 15 - 'Comments Row Count' AS check_type, 16 - (SELECT COUNT(*) FROM comments) AS row_count; 17 - 18 - -- 4. Verify Unique Constraint on Issues 19 - SELECT 20 - repo_at, 21 - issue_id, 22 - COUNT(*) as duplicate_count 23 - FROM issues 24 - GROUP BY repo_at, issue_id 25 - HAVING duplicate_count > 1; 26 - 27 - -- 5. Verify Foreign Key Integrity for Comments 28 - SELECT 29 - 'Orphaned Comments' AS check_type, 30 - COUNT(*) AS orphaned_count 31 - FROM comments c 32 - LEFT JOIN issues i ON c.repo_at = i.repo_at AND c.issue_id = i.issue_id 33 - WHERE i.id IS NULL; 34 - 35 - -- 6. Check Foreign Key Constraint 36 - PRAGMA foreign_key_list(comments); 37 - 38 - -- 7. Sample Data Integrity Check 39 - SELECT 40 - 'Sample Issues' AS check_type, 41 - repo_at, 42 - issue_id, 43 - title, 44 - created 45 - FROM issues 46 - LIMIT 5; 47 - 48 - -- 8. Sample Comments Data Integrity Check 49 - SELECT 50 - 'Sample Comments' AS check_type, 51 - repo_at, 52 - issue_id, 53 - comment_id, 54 - body, 55 - created 56 - FROM comments 57 - LIMIT 5; 58 - 59 - -- 9. Verify Constraint on Comments (Issue ID and Comment ID Uniqueness) 60 - SELECT 61 - issue_id, 62 - comment_id, 63 - COUNT(*) as duplicate_count 64 - FROM comments 65 - GROUP BY issue_id, comment_id 66 - HAVING duplicate_count > 1;
+22 -3
appview/db/pulls.go
··· 310 310 return pullId - 1, err 311 311 } 312 312 313 - func GetPulls(e Execer, filters ...filter) ([]*Pull, error) { 313 + func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*Pull, error) { 314 314 pulls := make(map[int]*Pull) 315 315 316 316 var conditions []string ··· 323 323 whereClause := "" 324 324 if conditions != nil { 325 325 whereClause = " where " + strings.Join(conditions, " and ") 326 + } 327 + limitClause := "" 328 + if limit != 0 { 329 + limitClause = fmt.Sprintf(" limit %d ", limit) 326 330 } 327 331 328 332 query := fmt.Sprintf(` ··· 344 348 from 345 349 pulls 346 350 %s 347 - `, whereClause) 351 + order by 352 + created desc 353 + %s 354 + `, whereClause, limitClause) 348 355 349 356 rows, err := e.Query(query, args...) 350 357 if err != nil { ··· 412 419 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 413 420 submissionsQuery := fmt.Sprintf(` 414 421 select 415 - id, pull_id, round_number, patch, source_rev 422 + id, pull_id, round_number, patch, created, source_rev 416 423 from 417 424 pull_submissions 418 425 where ··· 438 445 for submissionsRows.Next() { 439 446 var s PullSubmission 440 447 var sourceRev sql.NullString 448 + var createdAt string 441 449 err := submissionsRows.Scan( 442 450 &s.ID, 443 451 &s.PullId, 444 452 &s.RoundNumber, 445 453 &s.Patch, 454 + &createdAt, 446 455 &sourceRev, 447 456 ) 448 457 if err != nil { 449 458 return nil, err 450 459 } 460 + 461 + createdTime, err := time.Parse(time.RFC3339, createdAt) 462 + if err != nil { 463 + return nil, err 464 + } 465 + s.Created = createdTime 451 466 452 467 if sourceRev.Valid { 453 468 s.SourceRev = sourceRev.String ··· 511 526 }) 512 527 513 528 return orderedByPullId, nil 529 + } 530 + 531 + func GetPulls(e Execer, filters ...filter) ([]*Pull, error) { 532 + return GetPullsWithLimit(e, 0, filters...) 514 533 } 515 534 516 535 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) {
+7 -7
appview/db/reaction.go
··· 11 11 12 12 const ( 13 13 Like ReactionKind = "๐Ÿ‘" 14 - Unlike = "๐Ÿ‘Ž" 15 - Laugh = "๐Ÿ˜†" 16 - Celebration = "๐ŸŽ‰" 17 - Confused = "๐Ÿซค" 18 - Heart = "โค๏ธ" 19 - Rocket = "๐Ÿš€" 20 - Eyes = "๐Ÿ‘€" 14 + Unlike ReactionKind = "๐Ÿ‘Ž" 15 + Laugh ReactionKind = "๐Ÿ˜†" 16 + Celebration ReactionKind = "๐ŸŽ‰" 17 + Confused ReactionKind = "๐Ÿซค" 18 + Heart ReactionKind = "โค๏ธ" 19 + Rocket ReactionKind = "๐Ÿš€" 20 + Eyes ReactionKind = "๐Ÿ‘€" 21 21 ) 22 22 23 23 func (rk ReactionKind) String() string {
+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
··· 47 47 // Get a star record 48 48 func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) { 49 49 query := ` 50 - select starred_by_did, repo_at, created, rkey 50 + select starred_by_did, repo_at, created, rkey 51 51 from stars 52 52 where starred_by_did = ? and repo_at = ?` 53 53 row := e.QueryRow(query, starredByDid, repoAt) ··· 119 119 } 120 120 121 121 repoQuery := fmt.Sprintf( 122 - `select starred_by_did, repo_at, created, rkey 122 + `select starred_by_did, repo_at, created, rkey 123 123 from stars 124 124 %s 125 125 order by created desc ··· 187 187 var stars []Star 188 188 189 189 rows, err := e.Query(` 190 - select 190 + select 191 191 s.starred_by_did, 192 192 s.repo_at, 193 193 s.rkey, ··· 196 196 r.name, 197 197 r.knot, 198 198 r.rkey, 199 - r.created, 200 - r.at_uri 199 + r.created 201 200 from stars s 202 201 join repos r on s.repo_at = r.at_uri 203 202 `) ··· 222 221 &repo.Knot, 223 222 &repo.Rkey, 224 223 &repoCreatedAt, 225 - &repo.AtUri, 226 224 ); err != nil { 227 225 return nil, err 228 226 } ··· 246 244 247 245 return stars, nil 248 246 } 247 + 248 + // GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week 249 + func GetTopStarredReposLastWeek(e Execer) ([]Repo, error) { 250 + // first, get the top repo URIs by star count from the last week 251 + query := ` 252 + with recent_starred_repos as ( 253 + select distinct repo_at 254 + from stars 255 + where created >= datetime('now', '-7 days') 256 + ), 257 + repo_star_counts as ( 258 + select 259 + s.repo_at, 260 + count(*) as star_count 261 + from stars s 262 + join recent_starred_repos rsr on s.repo_at = rsr.repo_at 263 + group by s.repo_at 264 + ) 265 + select rsc.repo_at 266 + from repo_star_counts rsc 267 + order by rsc.star_count desc 268 + limit 8 269 + ` 270 + 271 + rows, err := e.Query(query) 272 + if err != nil { 273 + return nil, err 274 + } 275 + defer rows.Close() 276 + 277 + var repoUris []string 278 + for rows.Next() { 279 + var repoUri string 280 + err := rows.Scan(&repoUri) 281 + if err != nil { 282 + return nil, err 283 + } 284 + repoUris = append(repoUris, repoUri) 285 + } 286 + 287 + if err := rows.Err(); err != nil { 288 + return nil, err 289 + } 290 + 291 + if len(repoUris) == 0 { 292 + return []Repo{}, nil 293 + } 294 + 295 + // get full repo data 296 + repos, err := GetRepos(e, 0, FilterIn("at_uri", repoUris)) 297 + if err != nil { 298 + return nil, err 299 + } 300 + 301 + // sort repos by the original trending order 302 + repoMap := make(map[string]Repo) 303 + for _, repo := range repos { 304 + repoMap[repo.RepoAt().String()] = repo 305 + } 306 + 307 + orderedRepos := make([]Repo, 0, len(repoUris)) 308 + for _, uri := range repoUris { 309 + if repo, exists := repoMap[uri]; exists { 310 + orderedRepos = append(orderedRepos, repo) 311 + } 312 + } 313 + 314 + return orderedRepos, nil 315 + }
+252
appview/db/strings.go
··· 1 + package db 2 + 3 + import ( 4 + "bytes" 5 + "database/sql" 6 + "errors" 7 + "fmt" 8 + "io" 9 + "strings" 10 + "time" 11 + "unicode/utf8" 12 + 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + "tangled.sh/tangled.sh/core/api/tangled" 15 + ) 16 + 17 + type String struct { 18 + Did syntax.DID 19 + Rkey string 20 + 21 + Filename string 22 + Description string 23 + Contents string 24 + Created time.Time 25 + Edited *time.Time 26 + } 27 + 28 + func (s *String) StringAt() syntax.ATURI { 29 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey)) 30 + } 31 + 32 + type StringStats struct { 33 + LineCount uint64 34 + ByteCount uint64 35 + } 36 + 37 + func (s String) Stats() StringStats { 38 + lineCount, err := countLines(strings.NewReader(s.Contents)) 39 + if err != nil { 40 + // non-fatal 41 + // TODO: log this? 42 + } 43 + 44 + return StringStats{ 45 + LineCount: uint64(lineCount), 46 + ByteCount: uint64(len(s.Contents)), 47 + } 48 + } 49 + 50 + func (s String) Validate() error { 51 + var err error 52 + 53 + if utf8.RuneCountInString(s.Filename) > 140 { 54 + err = errors.Join(err, fmt.Errorf("filename too long")) 55 + } 56 + 57 + if utf8.RuneCountInString(s.Description) > 280 { 58 + err = errors.Join(err, fmt.Errorf("description too long")) 59 + } 60 + 61 + if len(s.Contents) == 0 { 62 + err = errors.Join(err, fmt.Errorf("contents is empty")) 63 + } 64 + 65 + return err 66 + } 67 + 68 + func (s *String) AsRecord() tangled.String { 69 + return tangled.String{ 70 + Filename: s.Filename, 71 + Description: s.Description, 72 + Contents: s.Contents, 73 + CreatedAt: s.Created.Format(time.RFC3339), 74 + } 75 + } 76 + 77 + func StringFromRecord(did, rkey string, record tangled.String) String { 78 + created, err := time.Parse(record.CreatedAt, time.RFC3339) 79 + if err != nil { 80 + created = time.Now() 81 + } 82 + return String{ 83 + Did: syntax.DID(did), 84 + Rkey: rkey, 85 + Filename: record.Filename, 86 + Description: record.Description, 87 + Contents: record.Contents, 88 + Created: created, 89 + } 90 + } 91 + 92 + func AddString(e Execer, s String) error { 93 + _, err := e.Exec( 94 + `insert into strings ( 95 + did, 96 + rkey, 97 + filename, 98 + description, 99 + content, 100 + created, 101 + edited 102 + ) 103 + values (?, ?, ?, ?, ?, ?, null) 104 + on conflict(did, rkey) do update set 105 + filename = excluded.filename, 106 + description = excluded.description, 107 + content = excluded.content, 108 + edited = case 109 + when 110 + strings.content != excluded.content 111 + or strings.filename != excluded.filename 112 + or strings.description != excluded.description then ? 113 + else strings.edited 114 + end`, 115 + s.Did, 116 + s.Rkey, 117 + s.Filename, 118 + s.Description, 119 + s.Contents, 120 + s.Created.Format(time.RFC3339), 121 + time.Now().Format(time.RFC3339), 122 + ) 123 + return err 124 + } 125 + 126 + func GetStrings(e Execer, limit int, filters ...filter) ([]String, error) { 127 + var all []String 128 + 129 + var conditions []string 130 + var args []any 131 + for _, filter := range filters { 132 + conditions = append(conditions, filter.Condition()) 133 + args = append(args, filter.Arg()...) 134 + } 135 + 136 + whereClause := "" 137 + if conditions != nil { 138 + whereClause = " where " + strings.Join(conditions, " and ") 139 + } 140 + 141 + limitClause := "" 142 + if limit != 0 { 143 + limitClause = fmt.Sprintf(" limit %d ", limit) 144 + } 145 + 146 + query := fmt.Sprintf(`select 147 + did, 148 + rkey, 149 + filename, 150 + description, 151 + content, 152 + created, 153 + edited 154 + from strings 155 + %s 156 + order by created desc 157 + %s`, 158 + whereClause, 159 + limitClause, 160 + ) 161 + 162 + rows, err := e.Query(query, args...) 163 + 164 + if err != nil { 165 + return nil, err 166 + } 167 + defer rows.Close() 168 + 169 + for rows.Next() { 170 + var s String 171 + var createdAt string 172 + var editedAt sql.NullString 173 + 174 + if err := rows.Scan( 175 + &s.Did, 176 + &s.Rkey, 177 + &s.Filename, 178 + &s.Description, 179 + &s.Contents, 180 + &createdAt, 181 + &editedAt, 182 + ); err != nil { 183 + return nil, err 184 + } 185 + 186 + s.Created, err = time.Parse(time.RFC3339, createdAt) 187 + if err != nil { 188 + s.Created = time.Now() 189 + } 190 + 191 + if editedAt.Valid { 192 + e, err := time.Parse(time.RFC3339, editedAt.String) 193 + if err != nil { 194 + e = time.Now() 195 + } 196 + s.Edited = &e 197 + } 198 + 199 + all = append(all, s) 200 + } 201 + 202 + if err := rows.Err(); err != nil { 203 + return nil, err 204 + } 205 + 206 + return all, nil 207 + } 208 + 209 + func DeleteString(e Execer, filters ...filter) error { 210 + var conditions []string 211 + var args []any 212 + for _, filter := range filters { 213 + conditions = append(conditions, filter.Condition()) 214 + args = append(args, filter.Arg()...) 215 + } 216 + 217 + whereClause := "" 218 + if conditions != nil { 219 + whereClause = " where " + strings.Join(conditions, " and ") 220 + } 221 + 222 + query := fmt.Sprintf(`delete from strings %s`, whereClause) 223 + 224 + _, err := e.Exec(query, args...) 225 + return err 226 + } 227 + 228 + func countLines(r io.Reader) (int, error) { 229 + buf := make([]byte, 32*1024) 230 + bufLen := 0 231 + count := 0 232 + nl := []byte{'\n'} 233 + 234 + for { 235 + c, err := r.Read(buf) 236 + if c > 0 { 237 + bufLen += c 238 + } 239 + count += bytes.Count(buf[:c], nl) 240 + 241 + switch { 242 + case err == io.EOF: 243 + /* handle last line not having a newline at the end */ 244 + if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { 245 + count++ 246 + } 247 + return count, nil 248 + case err != nil: 249 + return 0, err 250 + } 251 + } 252 + }
+1 -1
appview/db/timeline.go
··· 162 162 163 163 followStatMap := make(map[string]FollowStats) 164 164 for _, s := range subjects { 165 - followers, following, err := GetFollowerFollowing(e, s) 165 + followers, following, err := GetFollowerFollowingCount(e, s) 166 166 if err != nil { 167 167 return nil, err 168 168 }
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 9 9 "path" 10 10 "strings" 11 11 12 - "github.com/microcosm-cc/bluemonday" 12 + chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 13 + "github.com/alecthomas/chroma/v2/styles" 13 14 "github.com/yuin/goldmark" 15 + highlighting "github.com/yuin/goldmark-highlighting/v2" 14 16 "github.com/yuin/goldmark/ast" 15 17 "github.com/yuin/goldmark/extension" 16 18 "github.com/yuin/goldmark/parser" ··· 40 42 repoinfo.RepoInfo 41 43 IsDev bool 42 44 RendererType RendererType 45 + Sanitizer Sanitizer 43 46 } 44 47 45 48 func (rctx *RenderContext) RenderMarkdown(source string) string { 46 49 md := goldmark.New( 47 - goldmark.WithExtensions(extension.GFM), 50 + goldmark.WithExtensions( 51 + extension.GFM, 52 + highlighting.NewHighlighting( 53 + highlighting.WithFormatOptions( 54 + chromahtml.Standalone(false), 55 + chromahtml.WithClasses(true), 56 + ), 57 + highlighting.WithCustomStyle(styles.Get("catppuccin-latte")), 58 + ), 59 + extension.NewFootnote( 60 + extension.WithFootnoteIDPrefix([]byte("footnote")), 61 + ), 62 + ), 48 63 goldmark.WithParserOptions( 49 64 parser.WithAutoHeadingID(), 50 65 ), ··· 145 160 } 146 161 } 147 162 148 - func (rctx *RenderContext) Sanitize(html string) string { 149 - policy := bluemonday.UGCPolicy() 150 - 151 - // video 152 - policy.AllowElements("video") 153 - policy.AllowAttrs("controls").OnElements("video") 154 - policy.AllowElements("source") 155 - policy.AllowAttrs("src", "type").OnElements("source") 156 - 157 - // centering content 158 - policy.AllowElements("center") 163 + func (rctx *RenderContext) SanitizeDefault(html string) string { 164 + return rctx.Sanitizer.SanitizeDefault(html) 165 + } 159 166 160 - policy.AllowAttrs("align", "style", "width", "height").Globally() 161 - policy.AllowStyles( 162 - "margin", 163 - "padding", 164 - "text-align", 165 - "font-weight", 166 - "text-decoration", 167 - "padding-left", 168 - "padding-right", 169 - "padding-top", 170 - "padding-bottom", 171 - "margin-left", 172 - "margin-right", 173 - "margin-top", 174 - "margin-bottom", 175 - ) 176 - return policy.Sanitize(html) 167 + func (rctx *RenderContext) SanitizeDescription(html string) string { 168 + return rctx.Sanitizer.SanitizeDescription(html) 177 169 } 178 170 179 171 type MarkdownTransformer struct { ··· 189 181 switch a.rctx.RendererType { 190 182 case RendererTypeRepoMarkdown: 191 183 switch n := n.(type) { 184 + case *ast.Heading: 185 + a.rctx.anchorHeadingTransformer(n) 192 186 case *ast.Link: 193 187 a.rctx.relativeLinkTransformer(n) 194 188 case *ast.Image: ··· 197 191 } 198 192 case RendererTypeDefault: 199 193 switch n := n.(type) { 194 + case *ast.Heading: 195 + a.rctx.anchorHeadingTransformer(n) 200 196 case *ast.Image: 201 197 a.rctx.imageFromKnotAstTransformer(n) 202 198 a.rctx.camoImageLinkAstTransformer(n) ··· 211 207 212 208 dst := string(link.Destination) 213 209 214 - if isAbsoluteUrl(dst) { 210 + if isAbsoluteUrl(dst) || isFragment(dst) || isMail(dst) { 215 211 return 216 212 } 217 213 ··· 252 248 img.Destination = []byte(rctx.imageFromKnotTransformer(dst)) 253 249 } 254 250 251 + func (rctx *RenderContext) anchorHeadingTransformer(h *ast.Heading) { 252 + idGeneric, exists := h.AttributeString("id") 253 + if !exists { 254 + return // no id, nothing to do 255 + } 256 + id, ok := idGeneric.([]byte) 257 + if !ok { 258 + return 259 + } 260 + 261 + // create anchor link 262 + anchor := ast.NewLink() 263 + anchor.Destination = fmt.Appendf(nil, "#%s", string(id)) 264 + anchor.SetAttribute([]byte("class"), []byte("anchor")) 265 + 266 + // create icon text 267 + iconText := ast.NewString([]byte("#")) 268 + anchor.AppendChild(anchor, iconText) 269 + 270 + // set class on heading 271 + h.SetAttribute([]byte("class"), []byte("heading")) 272 + 273 + // append anchor to heading 274 + h.AppendChild(h, anchor) 275 + } 276 + 255 277 // actualPath decides when to join the file path with the 256 278 // current repository directory (essentially only when the link 257 279 // destination is relative. if it's absolute then we assume the ··· 271 293 } 272 294 return parsed.IsAbs() 273 295 } 296 + 297 + func isFragment(link string) bool { 298 + return strings.HasPrefix(link, "#") 299 + } 300 + 301 + func isMail(link string) bool { 302 + return strings.HasPrefix(link, "mailto:") 303 + }
+117
appview/pages/markup/sanitizer.go
··· 1 + package markup 2 + 3 + import ( 4 + "maps" 5 + "regexp" 6 + "slices" 7 + "strings" 8 + 9 + "github.com/alecthomas/chroma/v2" 10 + "github.com/microcosm-cc/bluemonday" 11 + ) 12 + 13 + type Sanitizer struct { 14 + defaultPolicy *bluemonday.Policy 15 + descriptionPolicy *bluemonday.Policy 16 + } 17 + 18 + func NewSanitizer() Sanitizer { 19 + return Sanitizer{ 20 + defaultPolicy: defaultPolicy(), 21 + descriptionPolicy: descriptionPolicy(), 22 + } 23 + } 24 + 25 + func (s *Sanitizer) SanitizeDefault(html string) string { 26 + return s.defaultPolicy.Sanitize(html) 27 + } 28 + func (s *Sanitizer) SanitizeDescription(html string) string { 29 + return s.descriptionPolicy.Sanitize(html) 30 + } 31 + 32 + func defaultPolicy() *bluemonday.Policy { 33 + policy := bluemonday.UGCPolicy() 34 + 35 + // Allow generally safe attributes 36 + generalSafeAttrs := []string{ 37 + "abbr", "accept", "accept-charset", 38 + "accesskey", "action", "align", "alt", 39 + "aria-describedby", "aria-hidden", "aria-label", "aria-labelledby", 40 + "axis", "border", "cellpadding", "cellspacing", "char", 41 + "charoff", "charset", "checked", 42 + "clear", "cols", "colspan", "color", 43 + "compact", "coords", "datetime", "dir", 44 + "disabled", "enctype", "for", "frame", 45 + "headers", "height", "hreflang", 46 + "hspace", "ismap", "label", "lang", 47 + "maxlength", "media", "method", 48 + "multiple", "name", "nohref", "noshade", 49 + "nowrap", "open", "prompt", "readonly", "rel", "rev", 50 + "rows", "rowspan", "rules", "scope", 51 + "selected", "shape", "size", "span", 52 + "start", "summary", "tabindex", "target", 53 + "title", "type", "usemap", "valign", "value", 54 + "vspace", "width", "itemprop", 55 + } 56 + 57 + generalSafeElements := []string{ 58 + "h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "br", "b", "i", "strong", "em", "a", "pre", "code", "img", "tt", 59 + "div", "ins", "del", "sup", "sub", "p", "ol", "ul", "table", "thead", "tbody", "tfoot", "blockquote", "label", 60 + "dl", "dt", "dd", "kbd", "q", "samp", "var", "hr", "ruby", "rt", "rp", "li", "tr", "td", "th", "s", "strike", "summary", 61 + "details", "caption", "figure", "figcaption", 62 + "abbr", "bdo", "cite", "dfn", "mark", "small", "span", "time", "video", "wbr", 63 + } 64 + 65 + policy.AllowAttrs(generalSafeAttrs...).OnElements(generalSafeElements...) 66 + 67 + // video 68 + policy.AllowAttrs("src", "autoplay", "controls").OnElements("video") 69 + 70 + // checkboxes 71 + policy.AllowAttrs("type").Matching(regexp.MustCompile(`^checkbox$`)).OnElements("input") 72 + policy.AllowAttrs("checked", "disabled", "data-source-position").OnElements("input") 73 + 74 + // for code blocks 75 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`chroma`)).OnElements("pre") 76 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`anchor|footnote-ref|footnote-backref`)).OnElements("a") 77 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8") 78 + policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span") 79 + 80 + // centering content 81 + policy.AllowElements("center") 82 + 83 + policy.AllowAttrs("align", "style", "width", "height").Globally() 84 + policy.AllowStyles( 85 + "margin", 86 + "padding", 87 + "text-align", 88 + "font-weight", 89 + "text-decoration", 90 + "padding-left", 91 + "padding-right", 92 + "padding-top", 93 + "padding-bottom", 94 + "margin-left", 95 + "margin-right", 96 + "margin-top", 97 + "margin-bottom", 98 + ) 99 + 100 + return policy 101 + } 102 + 103 + func descriptionPolicy() *bluemonday.Policy { 104 + policy := bluemonday.NewPolicy() 105 + policy.AllowStandardURLs() 106 + 107 + // allow italics and bold. 108 + policy.AllowElements("i", "b", "em", "strong") 109 + 110 + // allow code. 111 + policy.AllowElements("code") 112 + 113 + // allow links 114 + policy.AllowAttrs("href", "target", "rel").OnElements("a") 115 + 116 + return policy 117 + }
+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
··· 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
··· 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
··· 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 }}
+42 -8
appview/pages/templates/layouts/footer.html
··· 1 1 {{ define "layouts/footer" }} 2 - <div class="w-full p-4 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 - <div class="container mx-auto text-center text-gray-600 dark:text-gray-400 text-sm"> 4 - <div class="mb-2"> 5 - <a href="/terms" class="hover:text-gray-900 dark:hover:text-gray-200 underline">Terms of Service</a> 6 - &nbsp;โ€ข&nbsp; 7 - <a href="/privacy" class="hover:text-gray-900 dark:hover:text-gray-200 underline">Privacy Policy</a> 2 + <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 + <div class="container mx-auto max-w-7xl px-4"> 4 + <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 + <div class="mb-4 md:mb-0"> 6 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 7 + tangled<sub>alpha</sub> 8 + </a> 9 + </div> 10 + 11 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 + <div class="flex flex-col gap-1"> 16 + <div class="{{ $headerStyle }}">legal</div> 17 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 8 19 </div> 9 - <div> 10 - <span class="font-semibold italic">tangled</span> &mdash; made by <a href="/@oppi.li">@oppi.li</a> and <a href="/@icyphox.sh">@icyphox.sh</a> 20 + 21 + <div class="flex flex-col gap-1"> 22 + <div class="{{ $headerStyle }}">resources</div> 23 + <a href="https://blog.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 + <a href="https://tangled.sh/@tangled.sh/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 + <a href="https://tangled.sh/@tangled.sh/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 + </div> 27 + 28 + <div class="flex flex-col gap-1"> 29 + <div class="{{ $headerStyle }}">social</div> 30 + <a href="https://chat.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 31 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 32 + <a href="https://bsky.app/profile/tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 11 33 </div> 34 + 35 + <div class="flex flex-col gap-1"> 36 + <div class="{{ $headerStyle }}">contact</div> 37 + <a href="mailto:team@tangled.sh" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh</a> 38 + <a href="mailto:security@tangled.sh" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh</a> 39 + </div> 40 + </div> 41 + 42 + <div class="text-center lg:text-right flex-shrink-0"> 43 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 44 + </div> 12 45 </div> 46 + </div> 13 47 </div> 14 48 {{ end }}
+16 -21
appview/pages/templates/layouts/repobase.html
··· 5 5 {{ if .RepoInfo.Source }} 6 6 <p class="text-sm"> 7 7 <div class="flex items-center"> 8 - {{ i "git-fork" "w-3 h-3 mr-1"}} 8 + {{ i "git-fork" "w-3 h-3 mr-1 shrink-0" }} 9 9 forked from 10 10 {{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }} 11 11 <a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a> ··· 20 20 </div> 21 21 22 22 <div class="flex items-center gap-2 z-auto"> 23 + <a 24 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 25 + href="/{{ .RepoInfo.FullName }}/feed.atom" 26 + > 27 + {{ i "rss" "size-4" }} 28 + </a> 23 29 {{ template "repo/fragments/repoStar" .RepoInfo }} 24 - {{ if .RepoInfo.DisableFork }} 25 - <button 26 - class="btn text-sm no-underline hover:no-underline flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" 27 - disabled 28 - title="Empty repositories cannot be forked" 29 - > 30 - {{ i "git-fork" "w-4 h-4" }} 31 - fork 32 - </button> 33 - {{ else }} 34 - <a 35 - class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 36 - hx-boost="true" 37 - href="/{{ .RepoInfo.FullName }}/fork" 38 - > 39 - {{ i "git-fork" "w-4 h-4" }} 40 - fork 41 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 42 - </a> 43 - {{ end }} 30 + <a 31 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 32 + hx-boost="true" 33 + href="/{{ .RepoInfo.FullName }}/fork" 34 + > 35 + {{ i "git-fork" "w-4 h-4" }} 36 + fork 37 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 38 + </a> 44 39 </div> 45 40 </div> 46 41 {{ template "repo/fragments/repoDescription" . }}
+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
··· 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
··· 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
··· 118 118 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 119 119 {{ template "repo/fragments/diffOpts" .DiffOpts }} 120 120 </div> 121 - <div class="sticky top-0 flex-grow max-h-screen"> 121 + <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 122 122 {{ template "repo/fragments/diffChangedFiles" .Diff }} 123 123 </div> 124 124 {{end}}
+1 -1
appview/pages/templates/repo/compare/compare.html
··· 49 49 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 50 50 {{ template "repo/fragments/diffOpts" .DiffOpts }} 51 51 </div> 52 - <div class="sticky top-0 flex-grow max-h-screen"> 52 + <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 53 53 {{ template "repo/fragments/diffChangedFiles" .Diff }} 54 54 </div> 55 55 {{end}}
+5 -7
appview/pages/templates/repo/empty.html
··· 32 32 <div class="py-6 w-fit flex flex-col gap-4"> 33 33 <p>This is an empty repository. To get started:</p> 34 34 {{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }} 35 - <p><span class="{{$bullet}}">1</span>Add a public key to your account from the <a href="/settings" class="underline">settings</a> page</p> 36 - <p><span class="{{$bullet}}">2</span>Configure your remote to <span class="font-mono p-1 rounded bg-gray-100 dark:bg-gray-700 ">git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}<span></p> 37 - <p><span class="{{$bullet}}">3</span>Push!</p> 35 + 36 + <p><span class="{{$bullet}}">1</span>First, generate a new <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Generating-Your-SSH-Public-Key" class="underline">SSH key pair</a>.</p> 37 + <p><span class="{{$bullet}}">2</span>Then add the public key to your account from the <a href="/settings" class="underline">settings</a> page.</p> 38 + <p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p> 39 + <p><span class="{{$bullet}}">4</span>Push!</p> 38 40 </div> 39 41 </div> 40 42 {{ else }} ··· 42 44 {{ end }} 43 45 </main> 44 46 {{ end }} 45 - 46 - {{ define "repoAfter" }} 47 - {{ template "repo/fragments/cloneInstructions" . }} 48 - {{ end }}
+104
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 1 + {{ define "repo/fragments/cloneDropdown" }} 2 + {{ $knot := .RepoInfo.Knot }} 3 + {{ if eq $knot "knot1.tangled.sh" }} 4 + {{ $knot = "tangled.sh" }} 5 + {{ end }} 6 + 7 + <details id="clone-dropdown" class="relative inline-block text-left group"> 8 + <summary class="btn-create cursor-pointer list-none flex items-center gap-2"> 9 + {{ i "download" "w-4 h-4" }} 10 + <span class="hidden md:inline">code</span> 11 + <span class="group-open:hidden"> 12 + {{ i "chevron-down" "w-4 h-4" }} 13 + </span> 14 + <span class="hidden group-open:flex"> 15 + {{ i "chevron-up" "w-4 h-4" }} 16 + </span> 17 + </summary> 18 + 19 + <div class="absolute right-0 mt-2 w-96 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 drop-shadow-sm dark:text-white z-[9999]"> 20 + <div class="p-4"> 21 + <div class="mb-3"> 22 + <h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">Clone this repository</h3> 23 + </div> 24 + 25 + <!-- HTTPS Clone --> 26 + <div class="mb-3"> 27 + <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">HTTPS</label> 28 + <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 29 + <code 30 + class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 31 + onclick="window.getSelection().selectAllChildren(this)" 32 + data-url="https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}" 33 + >https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code> 34 + <button 35 + onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 36 + class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" 37 + title="Copy to clipboard" 38 + > 39 + {{ i "copy" "w-4 h-4" }} 40 + </button> 41 + </div> 42 + </div> 43 + 44 + <!-- SSH Clone --> 45 + <div class="mb-3"> 46 + <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">SSH</label> 47 + <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 48 + <code 49 + class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 50 + onclick="window.getSelection().selectAllChildren(this)" 51 + data-url="git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}" 52 + >git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code> 53 + <button 54 + onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 55 + class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" 56 + title="Copy to clipboard" 57 + > 58 + {{ i "copy" "w-4 h-4" }} 59 + </button> 60 + </div> 61 + </div> 62 + 63 + <!-- Note for self-hosted --> 64 + <p class="text-xs text-gray-500 dark:text-gray-400"> 65 + For self-hosted knots, clone URLs may differ based on your setup. 66 + </p> 67 + 68 + <!-- Download Archive --> 69 + <div class="pt-2 mt-2 border-t border-gray-200 dark:border-gray-700"> 70 + <a 71 + href="/{{ .RepoInfo.FullName }}/archive/{{ .Ref | urlquery }}" 72 + class="flex items-center gap-2 px-3 py-2 text-sm" 73 + > 74 + {{ i "download" "w-4 h-4" }} 75 + Download tar.gz 76 + </a> 77 + </div> 78 + 79 + </div> 80 + </div> 81 + </details> 82 + 83 + <script> 84 + function copyToClipboard(button, text) { 85 + navigator.clipboard.writeText(text).then(() => { 86 + const originalContent = button.innerHTML; 87 + button.innerHTML = `{{ i "check" "w-4 h-4" }}`; 88 + setTimeout(() => { 89 + button.innerHTML = originalContent; 90 + }, 2000); 91 + }); 92 + } 93 + 94 + // Close clone dropdown when clicking outside 95 + document.addEventListener('click', function(event) { 96 + const cloneDropdown = document.getElementById('clone-dropdown'); 97 + if (cloneDropdown && cloneDropdown.hasAttribute('open')) { 98 + if (!cloneDropdown.contains(event.target)) { 99 + cloneDropdown.removeAttribute('open'); 100 + } 101 + } 102 + }); 103 + </script> 104 + {{ end }}
-55
appview/pages/templates/repo/fragments/cloneInstructions.html
··· 1 - {{ define "repo/fragments/cloneInstructions" }} 2 - {{ $knot := .RepoInfo.Knot }} 3 - {{ if eq $knot "knot1.tangled.sh" }} 4 - {{ $knot = "tangled.sh" }} 5 - {{ end }} 6 - <section 7 - class="mt-4 p-6 rounded drop-shadow-sm bg-white dark:bg-gray-800 dark:text-white w-full mx-auto overflow-auto flex flex-col gap-4" 8 - > 9 - <div class="flex flex-col gap-2"> 10 - <strong>push</strong> 11 - <div class="md:pl-4 overflow-x-auto whitespace-nowrap"> 12 - <code class="dark:text-gray-100" 13 - >git remote add origin 14 - git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code 15 - > 16 - </div> 17 - </div> 18 - 19 - <div class="flex flex-col gap-2"> 20 - <strong>clone</strong> 21 - <div class="md:pl-4 flex flex-col gap-2"> 22 - <div class="flex items-center gap-3"> 23 - <span 24 - class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white" 25 - >HTTP</span 26 - > 27 - <div class="overflow-x-auto whitespace-nowrap flex-1"> 28 - <code class="dark:text-gray-100" 29 - >git clone 30 - https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code 31 - > 32 - </div> 33 - </div> 34 - 35 - <div class="flex items-center gap-3"> 36 - <span 37 - class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white" 38 - >SSH</span 39 - > 40 - <div class="overflow-x-auto whitespace-nowrap flex-1"> 41 - <code class="dark:text-gray-100" 42 - >git clone 43 - git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code 44 - > 45 - </div> 46 - </div> 47 - </div> 48 - </div> 49 - 50 - <p class="py-2 text-gray-500 dark:text-gray-400"> 51 - Note that for self-hosted knots, clone URLs may be different based 52 - on your setup. 53 - </p> 54 - </section> 55 - {{ end }}
+4 -4
appview/pages/templates/repo/fragments/fileTree.html
··· 3 3 <details open> 4 4 <summary class="cursor-pointer list-none pt-1"> 5 5 <span class="tree-directory inline-flex items-center gap-2 "> 6 - {{ i "folder" "size-4 fill-current" }} 7 - <span class="filename text-black dark:text-white">{{ .Name }}</span> 6 + {{ i "folder" "flex-shrink-0 size-4 fill-current" }} 7 + <span class="filename truncate text-black dark:text-white">{{ .Name }}</span> 8 8 </span> 9 9 </summary> 10 10 <div class="ml-1 pl-2 border-l border-gray-200 dark:border-gray-700"> ··· 15 15 </details> 16 16 {{ else if .Name }} 17 17 <div class="tree-file flex items-center gap-2 pt-1"> 18 - {{ i "file" "size-4" }} 19 - <a href="#file-{{ .Path }}" class="filename text-black dark:text-white no-underline hover:underline">{{ .Name }}</a> 18 + {{ i "file" "flex-shrink-0 size-4" }} 19 + <a href="#file-{{ .Path }}" class="filename truncate text-black dark:text-white no-underline hover:underline">{{ .Name }}</a> 20 20 </div> 21 21 {{ else }} 22 22 {{ range $child := .Children }}
+1 -1
appview/pages/templates/repo/fragments/interdiffFiles.html
··· 1 1 {{ define "repo/fragments/interdiffFiles" }} 2 2 {{ $fileTree := fileTree .AffectedFiles }} 3 - <section class="mt-4 px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm min-h-full text-sm"> 3 + <section class="px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm min-h-full text-sm"> 4 4 <div class="diff-stat"> 5 5 <div class="flex gap-2 items-center"> 6 6 <strong class="text-sm uppercase dark:text-gray-200">files</strong>
+1 -1
appview/pages/templates/repo/fragments/repoDescription.html
··· 1 1 {{ define "repo/fragments/repoDescription" }} 2 2 <span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML"> 3 3 {{ if .RepoInfo.Description }} 4 - {{ .RepoInfo.Description }} 4 + {{ .RepoInfo.Description | description }} 5 5 {{ else }} 6 6 <span class="italic">this repo has no description</span> 7 7 {{ end }}
+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
··· 2 2 {{ with .Comment }} 3 3 <div id="comment-container-{{.CommentId}}"> 4 4 <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 - {{ $owner := index $.DidHandleMap .OwnerDid }} 6 - {{ template "user/fragments/picHandleLink" $owner }} 5 + {{ template "user/fragments/picHandleLink" .OwnerDid }} 7 6 8 7 <!-- show user "hats" --> 9 8 {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
+3 -3
appview/pages/templates/repo/issues/issue.html
··· 11 11 {{ define "repoContent" }} 12 12 <header class="pb-4"> 13 13 <h1 class="text-2xl"> 14 - {{ .Issue.Title }} 14 + {{ .Issue.Title | description }} 15 15 <span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span> 16 16 </h1> 17 17 </header> ··· 54 54 "Kind" $kind 55 55 "Count" (index $.Reactions $kind) 56 56 "IsReacted" (index $.UserReacted $kind) 57 - "ThreadAt" $.Issue.IssueAt) 57 + "ThreadAt" $.Issue.AtUri) 58 58 }} 59 59 {{ end }} 60 60 </div> ··· 70 70 {{ if gt $index 0 }} 71 71 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 72 72 {{ end }} 73 - {{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "DidHandleMap" $.DidHandleMap "Issue" $.Issue "Comment" .)}} 73 + {{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "Issue" $.Issue "Comment" .)}} 74 74 </div> 75 75 {{ end }} 76 76 </section>
+2 -3
appview/pages/templates/repo/issues/issues.html
··· 45 45 href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 46 class="no-underline hover:underline" 47 47 > 48 - {{ .Title }} 48 + {{ .Title | description }} 49 49 <span class="text-gray-500">#{{ .IssueId }}</span> 50 50 </a> 51 51 </div> ··· 65 65 </span> 66 66 67 67 <span class="ml-1"> 68 - {{ $owner := index $.DidHandleMap .OwnerDid }} 69 - {{ template "user/fragments/picHandleLink" $owner }} 68 + {{ template "user/fragments/picHandleLink" .OwnerDid }} 70 69 </span> 71 70 72 71 <span class="before:content-['ยท']">
+2 -2
appview/pages/templates/repo/pipelines/fragments/pipelineSymbol.html
··· 23 23 </div> 24 24 {{ else if $allFail }} 25 25 <div class="flex gap-1 items-center"> 26 - {{ i "x" "size-4 text-red-600" }} 26 + {{ i "x" "size-4 text-red-500" }} 27 27 <span>0/{{ $total }}</span> 28 28 </div> 29 29 {{ else if $allTimeout }} 30 30 <div class="flex gap-1 items-center"> 31 - {{ i "clock-alert" "size-4 text-orange-400" }} 31 + {{ i "clock-alert" "size-4 text-orange-500" }} 32 32 <span>0/{{ $total }}</span> 33 33 </div> 34 34 {{ else }}
+1 -1
appview/pages/templates/repo/pipelines/fragments/workflowSymbol.html
··· 19 19 {{ $color = "text-gray-600 dark:text-gray-500" }} 20 20 {{ else if eq $kind "timeout" }} 21 21 {{ $icon = "clock-alert" }} 22 - {{ $color = "text-orange-400 dark:text-orange-300" }} 22 + {{ $color = "text-orange-400 dark:text-orange-500" }} 23 23 {{ else }} 24 24 {{ $icon = "x" }} 25 25 {{ $color = "text-red-600 dark:text-red-500" }}
+5 -1
appview/pages/templates/repo/pipelines/workflow.html
··· 19 19 20 20 {{ define "sidebar" }} 21 21 {{ $active := .Workflow }} 22 + 23 + {{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }} 24 + {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }} 25 + 22 26 {{ with .Pipeline }} 23 27 {{ $id := .Id }} 24 28 <div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 25 29 {{ range $name, $all := .Statuses }} 26 30 <a href="/{{ $.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 27 31 <div 28 - class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }}bg-gray-100/50 dark:bg-gray-700/50{{ end }}"> 32 + class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 29 33 {{ $lastStatus := $all.Latest }} 30 34 {{ $kind := $lastStatus.Status.String }} 31 35
+3 -3
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 1 1 {{ define "repo/pulls/fragments/pullHeader" }} 2 2 <header class="pb-4"> 3 3 <h1 class="text-2xl dark:text-white"> 4 - {{ .Pull.Title }} 4 + {{ .Pull.Title | description }} 5 5 <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span> 6 6 </h1> 7 7 </header> ··· 17 17 {{ $icon = "git-merge" }} 18 18 {{ end }} 19 19 20 + {{ $owner := resolve .Pull.OwnerDid }} 20 21 <section class="mt-2"> 21 22 <div class="flex items-center gap-2"> 22 23 <div ··· 28 29 </div> 29 30 <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 30 31 opened by 31 - {{ $owner := index $.DidHandleMap .Pull.OwnerDid }} 32 - {{ template "user/fragments/picHandleLink" $owner }} 32 + {{ template "user/fragments/picHandleLink" .Pull.OwnerDid }} 33 33 <span class="select-none before:content-['\00B7']"></span> 34 34 {{ template "repo/fragments/time" .Pull.Created }} 35 35
+1 -1
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
··· 9 9 </div> 10 10 <span class="truncate text-sm text-gray-800 dark:text-gray-200"> 11 11 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 12 - {{ .Title }} 12 + {{ .Title | description }} 13 13 </span> 14 14 </div> 15 15
+1 -1
appview/pages/templates/repo/pulls/interdiff.html
··· 68 68 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 69 69 {{ template "repo/fragments/diffOpts" .DiffOpts }} 70 70 </div> 71 - <div class="sticky top-0 flex-grow max-h-screen"> 71 + <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 72 72 {{ template "repo/fragments/interdiffFiles" .Interdiff }} 73 73 </div> 74 74 {{end}}
+1 -1
appview/pages/templates/repo/pulls/patch.html
··· 73 73 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 74 74 {{ template "repo/fragments/diffOpts" .DiffOpts }} 75 75 </div> 76 - <div class="sticky top-0 flex-grow max-h-screen"> 76 + <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 77 77 {{ template "repo/fragments/diffChangedFiles" .Diff }} 78 78 </div> 79 79 {{end}}
+4 -5
appview/pages/templates/repo/pulls/pull.html
··· 47 47 <!-- round summary --> 48 48 <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 49 49 <span class="gap-1 flex items-center"> 50 - {{ $owner := index $.DidHandleMap $.Pull.OwnerDid }} 50 + {{ $owner := resolve $.Pull.OwnerDid }} 51 51 {{ $re := "re" }} 52 52 {{ if eq .RoundNumber 0 }} 53 53 {{ $re = "" }} 54 54 {{ end }} 55 55 <span class="hidden md:inline">{{$re}}submitted</span> 56 - by {{ template "user/fragments/picHandleLink" $owner }} 56 + by {{ template "user/fragments/picHandleLink" $.Pull.OwnerDid }} 57 57 <span class="select-none before:content-['\00B7']"></span> 58 58 <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ .RoundNumber }}">{{ template "repo/fragments/shortTime" .Created }}</a> 59 59 <span class="select-none before:content-['ยท']"></span> ··· 122 122 {{ end }} 123 123 </div> 124 124 <div class="flex items-center"> 125 - <span>{{ .Title }}</span> 125 + <span>{{ .Title | description }}</span> 126 126 {{ if gt (len .Body) 0 }} 127 127 <button 128 128 class="py-1/2 px-1 mx-2 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600" ··· 151 151 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 152 152 {{ end }} 153 153 <div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1"> 154 - {{ $owner := index $.DidHandleMap $c.OwnerDid }} 155 - {{ template "user/fragments/picHandleLink" $owner }} 154 + {{ template "user/fragments/picHandleLink" $c.OwnerDid }} 156 155 <span class="before:content-['ยท']"></span> 157 156 <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}">{{ template "repo/fragments/time" $c.Created }}</a> 158 157 </div>
+2 -3
appview/pages/templates/repo/pulls/pulls.html
··· 50 50 <div class="px-6 py-4 z-5"> 51 51 <div class="pb-2"> 52 52 <a href="/{{ $.RepoInfo.FullName }}/pulls/{{ .PullId }}" class="dark:text-white"> 53 - {{ .Title }} 53 + {{ .Title | description }} 54 54 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 55 55 </a> 56 56 </div> 57 57 <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 58 - {{ $owner := index $.DidHandleMap .OwnerDid }} 59 58 {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 60 59 {{ $icon := "ban" }} 61 60 ··· 76 75 </span> 77 76 78 77 <span class="ml-1"> 79 - {{ template "user/fragments/picHandleLink" $owner }} 78 + {{ template "user/fragments/picHandleLink" .OwnerDid }} 80 79 </span> 81 80 82 81 <span class="before:content-['ยท']">
+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
··· 1 - {{ define "title" }}settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 - 3 - {{ define "repoContent" }} 4 - {{ template "collaboratorSettings" . }} 5 - {{ template "branchSettings" . }} 6 - {{ template "dangerZone" . }} 7 - {{ template "spindleSelector" . }} 8 - {{ template "spindleSecrets" . }} 9 - {{ end }} 10 - 11 - {{ define "collaboratorSettings" }} 12 - <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 13 - Collaborators 14 - </header> 15 - 16 - <div id="collaborator-list" class="flex flex-col gap-2 mb-2"> 17 - {{ range .Collaborators }} 18 - <div id="collaborator" class="mb-2"> 19 - <a 20 - href="/{{ didOrHandle .Did .Handle }}" 21 - class="no-underline hover:underline text-black dark:text-white" 22 - > 23 - {{ didOrHandle .Did .Handle }} 24 - </a> 25 - <div> 26 - <span class="text-sm text-gray-500 dark:text-gray-400"> 27 - {{ .Role }} 28 - </span> 29 - </div> 30 - </div> 31 - {{ end }} 32 - </div> 33 - 34 - {{ if .RepoInfo.Roles.CollaboratorInviteAllowed }} 35 - <form 36 - hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator" 37 - class="group" 38 - > 39 - <label for="collaborator" class="dark:text-white"> 40 - add collaborator 41 - </label> 42 - <input 43 - type="text" 44 - id="collaborator" 45 - name="collaborator" 46 - required 47 - class="dark:bg-gray-700 dark:text-white" 48 - placeholder="enter did or handle"> 49 - <button class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700" type="text"> 50 - <span>add</span> 51 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 52 - </button> 53 - </form> 54 - {{ end }} 55 - {{ end }} 56 - 57 - {{ define "dangerZone" }} 58 - {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 59 - <form 60 - hx-confirm="Are you sure you want to delete this repository?" 61 - hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 62 - class="mt-6" 63 - hx-indicator="#delete-repo-spinner"> 64 - <label for="branch">delete repository</label> 65 - <button class="btn my-2 flex items-center" type="text"> 66 - <span>delete</span> 67 - <span id="delete-repo-spinner" class="group"> 68 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 69 - </span> 70 - </button> 71 - <span> 72 - Deleting a repository is irreversible and permanent. 73 - </span> 74 - </form> 75 - {{ end }} 76 - {{ end }} 77 - 78 - {{ define "branchSettings" }} 79 - <form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="mt-6 group"> 80 - <label for="branch">default branch</label> 81 - <div class="flex gap-2 items-center"> 82 - <select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 83 - <option value="" disabled selected > 84 - Choose a default branch 85 - </option> 86 - {{ range .Branches }} 87 - <option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} > 88 - {{ .Name }} 89 - </option> 90 - {{ end }} 91 - </select> 92 - <button class="btn my-2 flex gap-2 items-center" type="submit"> 93 - <span>save</span> 94 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 95 - </button> 96 - </div> 97 - </form> 98 - {{ end }} 99 - 100 - {{ define "spindleSelector" }} 101 - {{ if .RepoInfo.Roles.IsOwner }} 102 - <form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="mt-6 group" > 103 - <label for="spindle">spindle</label> 104 - <div class="flex gap-2 items-center"> 105 - <select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 106 - <option value="" selected > 107 - None 108 - </option> 109 - {{ range .Spindles }} 110 - <option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}> 111 - {{ . }} 112 - </option> 113 - {{ end }} 114 - </select> 115 - <button class="btn my-2 flex gap-2 items-center" type="submit"> 116 - <span>save</span> 117 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 118 - </button> 119 - </div> 120 - </form> 121 - {{ end }} 122 - {{ end }} 123 - 124 - {{ define "spindleSecrets" }} 125 - {{ if $.CurrentSpindle }} 126 - <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 127 - Secrets 128 - </header> 129 - 130 - <div id="secret-list" class="flex flex-col gap-2 mb-2"> 131 - {{ range $idx, $secret := .Secrets }} 132 - {{ with $secret }} 133 - <div id="secret-{{$idx}}" class="mb-2"> 134 - {{ .Key }} created on {{ .CreatedAt }} by {{ .CreatedBy }} 135 - </div> 136 - {{ end }} 137 - {{ end }} 138 - </div> 139 - <form 140 - hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets" 141 - class="mt-6" 142 - hx-indicator="#add-secret-spinner"> 143 - <label for="key">secret key</label> 144 - <input 145 - type="text" 146 - id="key" 147 - name="key" 148 - required 149 - class="dark:bg-gray-700 dark:text-white" 150 - placeholder="SECRET_KEY" /> 151 - <label for="value">secret value</label> 152 - <input 153 - type="text" 154 - id="value" 155 - name="value" 156 - required 157 - class="dark:bg-gray-700 dark:text-white" 158 - placeholder="SECRET VALUE" /> 159 - 160 - <button class="btn my-2 flex items-center" type="text"> 161 - <span>add</span> 162 - <span id="add-secret-spinner" class="group"> 163 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 164 - </span> 165 - </button> 166 - </form> 167 - {{ end }} 168 - {{ end }}
+8 -2
appview/pages/templates/repo/tags.html
··· 97 97 {{ $isPushAllowed := $root.RepoInfo.Roles.IsPushAllowed }} 98 98 {{ $artifacts := index $root.ArtifactMap $tag.Tag.Hash }} 99 99 100 - {{ if or (gt (len $artifacts) 0) $isPushAllowed }} 101 100 <h2 class="my-4 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">artifacts</h2> 102 101 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700"> 103 102 {{ range $artifact := $artifacts }} 104 103 {{ $args := dict "LoggedInUser" $root.LoggedInUser "RepoInfo" $root.RepoInfo "Artifact" $artifact }} 105 104 {{ template "repo/fragments/artifact" $args }} 106 105 {{ end }} 106 + <div id="artifact-git-source" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 107 + <div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]"> 108 + {{ i "archive" "w-4 h-4" }} 109 + <a href="/{{ $root.RepoInfo.FullName }}/archive/{{ pathEscape (print "refs/tags/" $tag.Name) }}" class="no-underline hover:no-underline"> 110 + Source code (.tar.gz) 111 + </a> 112 + </div> 113 + </div> 107 114 {{ if $isPushAllowed }} 108 115 {{ block "uploadArtifact" (list $root $tag) }} {{ end }} 109 116 {{ end }} 110 117 </div> 111 - {{ end }} 112 118 {{ end }} 113 119 114 120 {{ define "uploadArtifact" }}
+5 -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
··· 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
··· 1 + {{ define "title" }}strings by {{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 + <meta property="og:type" content="profile" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" /> 7 + <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 + {{ end }} 9 + 10 + 11 + {{ define "content" }} 12 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 13 + <div class="md:col-span-3 order-1 md:order-1"> 14 + {{ template "user/fragments/profileCard" .Card }} 15 + </div> 16 + <div id="all-strings" class="md:col-span-8 order-2 md:order-2"> 17 + {{ block "allStrings" . }}{{ end }} 18 + </div> 19 + </div> 20 + {{ end }} 21 + 22 + {{ define "allStrings" }} 23 + <p class="text-sm font-bold p-2 dark:text-white">ALL STRINGS</p> 24 + <div id="strings" class="grid grid-cols-1 gap-4 mb-6"> 25 + {{ range .Strings }} 26 + {{ template "singleString" (list $ .) }} 27 + {{ else }} 28 + <p class="px-6 dark:text-white">This user does not have any strings yet.</p> 29 + {{ end }} 30 + </div> 31 + {{ end }} 32 + 33 + {{ define "singleString" }} 34 + {{ $root := index . 0 }} 35 + {{ $s := index . 1 }} 36 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 37 + <div class="font-medium dark:text-white flex gap-2 items-center"> 38 + <a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 39 + </div> 40 + {{ with $s.Description }} 41 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 42 + {{ . }} 43 + </div> 44 + {{ end }} 45 + 46 + {{ $stat := $s.Stats }} 47 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto"> 48 + <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 49 + <span class="select-none [&:before]:content-['ยท']"></span> 50 + {{ with $s.Edited }} 51 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 52 + {{ else }} 53 + {{ template "repo/fragments/shortTimeAgo" $s.Created }} 54 + {{ end }} 55 + </div> 56 + </div> 57 + {{ end }}
+90
appview/pages/templates/strings/fragments/form.html
··· 1 + {{ define "strings/fragments/form" }} 2 + <form 3 + {{ if eq .Action "new" }} 4 + hx-post="/strings/new" 5 + {{ else }} 6 + hx-post="/strings/{{.String.Did}}/{{.String.Rkey}}/edit" 7 + {{ end }} 8 + hx-indicator="#new-button" 9 + class="p-6 pb-4 dark:text-white flex flex-col gap-2 bg-white dark:bg-gray-800 drop-shadow-sm rounded" 10 + hx-swap="none"> 11 + <div class="flex flex-col md:flex-row md:items-center gap-2"> 12 + <input 13 + type="text" 14 + id="filename" 15 + name="filename" 16 + placeholder="Filename" 17 + required 18 + value="{{ .String.Filename }}" 19 + class="md:max-w-64 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" 20 + > 21 + <input 22 + type="text" 23 + id="description" 24 + name="description" 25 + value="{{ .String.Description }}" 26 + placeholder="Description ..." 27 + class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" 28 + > 29 + </div> 30 + <textarea 31 + name="content" 32 + id="content-textarea" 33 + wrap="off" 34 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 font-mono" 35 + rows="20" 36 + spellcheck="false" 37 + placeholder="Paste your string here!" 38 + required>{{ .String.Contents }}</textarea> 39 + <div class="flex justify-between items-center"> 40 + <div id="content-stats" class="text-sm text-gray-500 dark:text-gray-400"> 41 + <span id="line-count">0 lines</span> 42 + <span class="select-none px-1 [&:before]:content-['ยท']"></span> 43 + <span id="byte-count">0 bytes</span> 44 + </div> 45 + <div id="actions" class="flex gap-2 items-center"> 46 + {{ if eq .Action "edit" }} 47 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 " 48 + href="/strings/{{ .String.Did }}/{{ .String.Rkey }}"> 49 + {{ i "x" "size-4" }} 50 + <span class="hidden md:inline">cancel</span> 51 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 52 + </a> 53 + {{ end }} 54 + <button 55 + type="submit" 56 + id="new-button" 57 + class="w-fit btn-create rounded flex items-center py-0 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group" 58 + > 59 + <span class="inline-flex items-center gap-2"> 60 + {{ i "arrow-up" "w-4 h-4" }} 61 + publish 62 + </span> 63 + <span class="pl-2 hidden group-[.htmx-request]:inline"> 64 + {{ i "loader-circle" "w-4 h-4 animate-spin" }} 65 + </span> 66 + </button> 67 + </div> 68 + </div> 69 + <script> 70 + (function() { 71 + const textarea = document.getElementById('content-textarea'); 72 + const lineCount = document.getElementById('line-count'); 73 + const byteCount = document.getElementById('byte-count'); 74 + function updateStats() { 75 + const content = textarea.value; 76 + const lines = content === '' ? 0 : content.split('\n').length; 77 + const bytes = new TextEncoder().encode(content).length; 78 + lineCount.textContent = `${lines} line${lines !== 1 ? 's' : ''}`; 79 + byteCount.textContent = `${bytes} byte${bytes !== 1 ? 's' : ''}`; 80 + } 81 + textarea.addEventListener('input', updateStats); 82 + textarea.addEventListener('paste', () => { 83 + setTimeout(updateStats, 0); 84 + }); 85 + updateStats(); 86 + })(); 87 + </script> 88 + <div id="error" class="error dark:text-red-400"></div> 89 + </form> 90 + {{ end }}
+17
appview/pages/templates/strings/put.html
··· 1 + {{ define "title" }}publish a new string{{ end }} 2 + 3 + {{ define "topbar" }} 4 + {{ template "layouts/topbar" $ }} 5 + {{ end }} 6 + 7 + {{ define "content" }} 8 + <div class="px-6 py-2 mb-4"> 9 + {{ if eq .Action "new" }} 10 + <p class="text-xl font-bold dark:text-white">Create a new string</p> 11 + <p class="">Store and share code snippets with ease.</p> 12 + {{ else }} 13 + <p class="text-xl font-bold dark:text-white">Edit string</p> 14 + {{ end }} 15 + </div> 16 + {{ template "strings/fragments/form" . }} 17 + {{ end }}
+88
appview/pages/templates/strings/string.html
··· 1 + {{ define "title" }}{{ .String.Filename }} ยท by {{ didOrHandle .Owner.DID.String .Owner.Handle.String }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 5 + <meta property="og:title" content="{{ .String.Filename }} ยท by {{ $ownerId }}" /> 6 + <meta property="og:type" content="object" /> 7 + <meta property="og:url" content="https://tangled.sh/strings/{{ $ownerId }}/{{ .String.Rkey }}" /> 8 + <meta property="og:description" content="{{ .String.Description }}" /> 9 + {{ end }} 10 + 11 + {{ define "topbar" }} 12 + {{ template "layouts/topbar" $ }} 13 + {{ end }} 14 + 15 + {{ define "content" }} 16 + {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 17 + <section id="string-header" class="mb-4 py-2 px-6 dark:text-white"> 18 + <div class="text-lg flex items-center justify-between"> 19 + <div> 20 + <a href="/strings/{{ $ownerId }}">{{ $ownerId }}</a> 21 + <span class="select-none">/</span> 22 + <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 23 + </div> 24 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 25 + <div class="flex gap-2 text-base"> 26 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 27 + hx-boost="true" 28 + href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> 29 + {{ i "pencil" "size-4" }} 30 + <span class="hidden md:inline">edit</span> 31 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 32 + </a> 33 + <button 34 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group p-2" 35 + title="Delete string" 36 + hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/" 37 + hx-swap="none" 38 + hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?" 39 + > 40 + {{ i "trash-2" "size-4" }} 41 + <span class="hidden md:inline">delete</span> 42 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 43 + </button> 44 + </div> 45 + {{ end }} 46 + </div> 47 + <span> 48 + {{ with .String.Description }} 49 + {{ . }} 50 + {{ end }} 51 + </span> 52 + </section> 53 + <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white"> 54 + <div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 55 + <span> 56 + {{ .String.Filename }} 57 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 58 + <span> 59 + {{ with .String.Edited }} 60 + edited {{ template "repo/fragments/shortTimeAgo" . }} 61 + {{ else }} 62 + {{ template "repo/fragments/shortTimeAgo" .String.Created }} 63 + {{ end }} 64 + </span> 65 + </span> 66 + <div> 67 + <span>{{ .Stats.LineCount }} lines</span> 68 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 69 + <span>{{ byteFmt .Stats.ByteCount }}</span> 70 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 71 + <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}/raw">view raw</a> 72 + {{ if .RenderToggle }} 73 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 74 + <a href="?code={{ .ShowRendered }}" hx-boost="true"> 75 + view {{ if .ShowRendered }}code{{ else }}rendered{{ end }} 76 + </a> 77 + {{ end }} 78 + </div> 79 + </div> 80 + <div class="overflow-x-auto overflow-y-hidden relative"> 81 + {{ if .ShowRendered }} 82 + <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 83 + {{ else }} 84 + <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div> 85 + {{ end }} 86 + </div> 87 + </section> 88 + {{ end }}
+65
appview/pages/templates/strings/timeline.html
··· 1 + {{ define "title" }} all strings {{ end }} 2 + 3 + {{ define "topbar" }} 4 + {{ template "layouts/topbar" $ }} 5 + {{ end }} 6 + 7 + {{ define "content" }} 8 + {{ block "timeline" $ }}{{ end }} 9 + {{ end }} 10 + 11 + {{ define "timeline" }} 12 + <div> 13 + <div class="p-6"> 14 + <p class="text-xl font-bold dark:text-white">All strings</p> 15 + </div> 16 + 17 + <div class="flex flex-col gap-4"> 18 + {{ range $i, $s := .Strings }} 19 + <div class="relative"> 20 + {{ if ne $i 0 }} 21 + <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 22 + {{ end }} 23 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 24 + {{ template "stringCard" $s }} 25 + </div> 26 + </div> 27 + {{ end }} 28 + </div> 29 + </div> 30 + {{ end }} 31 + 32 + {{ define "stringCard" }} 33 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 34 + <div class="font-medium dark:text-white flex gap-2 items-center"> 35 + <a href="/strings/{{ resolve .Did.String }}/{{ .Rkey }}">{{ .Filename }}</a> 36 + </div> 37 + {{ with .Description }} 38 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 39 + {{ . }} 40 + </div> 41 + {{ end }} 42 + 43 + {{ template "stringCardInfo" . }} 44 + </div> 45 + {{ end }} 46 + 47 + {{ define "stringCardInfo" }} 48 + {{ $stat := .Stats }} 49 + {{ $resolved := resolve .Did.String }} 50 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto"> 51 + <a href="/strings/{{ $resolved }}" class="flex items-center"> 52 + {{ template "user/fragments/picHandle" $resolved }} 53 + </a> 54 + <span class="select-none [&:before]:content-['ยท']"></span> 55 + <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 56 + <span class="select-none [&:before]:content-['ยท']"></span> 57 + {{ with .Edited }} 58 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 59 + {{ else }} 60 + {{ template "repo/fragments/shortTimeAgo" .Created }} 61 + {{ end }} 62 + </div> 63 + {{ end }} 64 + 65 +
+183
appview/pages/templates/timeline/timeline.html
··· 1 + {{ define "title" }}timeline{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="timeline ยท tangled" /> 5 + <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.sh" /> 7 + <meta property="og:description" content="tightly-knit social coding" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + {{ if .LoggedInUser }} 12 + {{ else }} 13 + {{ block "hero" $ }}{{ end }} 14 + {{ end }} 15 + 16 + {{ block "trending" $ }}{{ end }} 17 + {{ block "timeline" $ }}{{ end }} 18 + {{ end }} 19 + 20 + {{ define "hero" }} 21 + <div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl"> 22 + <div class="font-bold text-4xl">tightly-knit<br>social coding.</div> 23 + 24 + <p class="text-lg"> 25 + tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>. 26 + </p> 27 + <p class="text-lg"> 28 + we envision a place where developers have complete ownership of their 29 + code, open source communities can freely self-govern and most 30 + importantly, coding can be social and fun again. 31 + </p> 32 + 33 + <div class="flex gap-6 items-center"> 34 + <a href="/signup" class="no-underline hover:no-underline "> 35 + <button class="btn-create flex gap-2 px-4 items-center"> 36 + join now {{ i "arrow-right" "size-4" }} 37 + </button> 38 + </a> 39 + </div> 40 + </div> 41 + {{ end }} 42 + 43 + {{ define "trending" }} 44 + <div class="w-full md:mx-0 py-4"> 45 + <div class="px-6 pb-4"> 46 + <h3 class="text-xl font-bold dark:text-white flex items-center gap-2"> 47 + Trending 48 + {{ i "trending-up" "size-4 flex-shrink-0" }} 49 + </h3> 50 + </div> 51 + <div class="flex gap-4 overflow-x-auto scrollbar-hide items-stretch"> 52 + {{ range $index, $repo := .Repos }} 53 + <div class="flex-none h-full border border-gray-200 dark:border-gray-700 rounded-sm w-96"> 54 + {{ template "user/fragments/repoCard" (list $ $repo true) }} 55 + </div> 56 + {{ else }} 57 + <div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm"> 58 + <div class="text-sm text-gray-500 dark:text-gray-400 text-center"> 59 + No trending repositories this week 60 + </div> 61 + </div> 62 + {{ end }} 63 + </div> 64 + </div> 65 + {{ end }} 66 + 67 + {{ define "timeline" }} 68 + <div class="py-4"> 69 + <div class="px-6 pb-4"> 70 + <p class="text-xl font-bold dark:text-white">Timeline</p> 71 + </div> 72 + 73 + <div class="flex flex-col gap-4"> 74 + {{ range $i, $e := .Timeline }} 75 + <div class="relative"> 76 + {{ if ne $i 0 }} 77 + <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 78 + {{ end }} 79 + {{ with $e }} 80 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 81 + {{ if .Repo }} 82 + {{ block "repoEvent" (list $ .Repo .Source) }} {{ end }} 83 + {{ else if .Star }} 84 + {{ block "starEvent" (list $ .Star) }} {{ end }} 85 + {{ else if .Follow }} 86 + {{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} {{ end }} 87 + {{ end }} 88 + </div> 89 + {{ end }} 90 + </div> 91 + {{ end }} 92 + </div> 93 + </div> 94 + {{ end }} 95 + 96 + {{ define "repoEvent" }} 97 + {{ $root := index . 0 }} 98 + {{ $repo := index . 1 }} 99 + {{ $source := index . 2 }} 100 + {{ $userHandle := resolve $repo.Did }} 101 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 102 + {{ template "user/fragments/picHandleLink" $repo.Did }} 103 + {{ with $source }} 104 + {{ $sourceDid := resolve .Did }} 105 + forked 106 + <a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline"> 107 + {{ $sourceDid }}/{{ .Name }} 108 + </a> 109 + to 110 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 111 + {{ else }} 112 + created 113 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline"> 114 + {{ $repo.Name }} 115 + </a> 116 + {{ end }} 117 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 118 + </div> 119 + {{ with $repo }} 120 + {{ template "user/fragments/repoCard" (list $root . true) }} 121 + {{ end }} 122 + {{ end }} 123 + 124 + {{ define "starEvent" }} 125 + {{ $root := index . 0 }} 126 + {{ $star := index . 1 }} 127 + {{ with $star }} 128 + {{ $starrerHandle := resolve .StarredByDid }} 129 + {{ $repoOwnerHandle := resolve .Repo.Did }} 130 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 131 + {{ template "user/fragments/picHandleLink" $starrerHandle }} 132 + starred 133 + <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 134 + {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 135 + </a> 136 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 137 + </div> 138 + {{ with .Repo }} 139 + {{ template "user/fragments/repoCard" (list $root . true) }} 140 + {{ end }} 141 + {{ end }} 142 + {{ end }} 143 + 144 + 145 + {{ define "followEvent" }} 146 + {{ $root := index . 0 }} 147 + {{ $follow := index . 1 }} 148 + {{ $profile := index . 2 }} 149 + {{ $stat := index . 3 }} 150 + 151 + {{ $userHandle := resolve $follow.UserDid }} 152 + {{ $subjectHandle := resolve $follow.SubjectDid }} 153 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 154 + {{ template "user/fragments/picHandleLink" $userHandle }} 155 + followed 156 + {{ template "user/fragments/picHandleLink" $subjectHandle }} 157 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 158 + </div> 159 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 160 + <div class="flex-shrink-0 max-h-full w-24 h-24"> 161 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 162 + </div> 163 + 164 + <div class="flex-1 min-h-0 justify-around flex flex-col"> 165 + <a href="/{{ $subjectHandle }}"> 166 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 167 + </a> 168 + {{ with $profile }} 169 + {{ with .Description }} 170 + <p class="text-sm pb-2 md:pb-2">{{.}}</p> 171 + {{ end }} 172 + {{ end }} 173 + {{ with $stat }} 174 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 &middot; tangled</title> 11 + <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 + <title>login &middot; tangled</title> 29 13 </head> 30 14 <body class="flex items-center justify-center min-h-screen"> 31 15 <main class="max-w-md px-6 -mt-4"> 32 - <h1 33 - class="text-center text-2xl font-semibold italic dark:text-white" 34 - > 16 + <h1 class="text-center text-2xl font-semibold italic dark:text-white" > 35 17 tangled 36 18 </h1> 37 19 <h2 class="text-center text-xl italic dark:text-white"> ··· 51 33 name="handle" 52 34 tabindex="1" 53 35 required 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
··· 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
··· 1 + {{ define "user/signup" }} 2 + <!doctype html> 3 + <html lang="en" class="dark:bg-gray-900"> 4 + <head> 5 + <meta charset="UTF-8" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 + <meta property="og:title" content="signup ยท tangled" /> 8 + <meta property="og:url" content="https://tangled.sh/signup" /> 9 + <meta property="og:description" content="sign up for tangled" /> 10 + <script src="/static/htmx.min.js"></script> 11 + <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 + <title>sign up &middot; tangled</title> 13 + </head> 14 + <body class="flex items-center justify-center min-h-screen"> 15 + <main class="max-w-md px-6 -mt-4"> 16 + <h1 class="text-center text-2xl font-semibold italic dark:text-white" >tangled</h1> 17 + <h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2> 18 + <form 19 + class="mt-4 max-w-sm mx-auto" 20 + hx-post="/signup" 21 + hx-swap="none" 22 + hx-disabled-elt="#signup-button" 23 + > 24 + <div class="flex flex-col mt-2"> 25 + <label for="email">email</label> 26 + <input 27 + type="email" 28 + id="email" 29 + name="email" 30 + tabindex="4" 31 + required 32 + placeholder="jason@bourne.co" 33 + /> 34 + </div> 35 + <span class="text-sm text-gray-500 mt-1"> 36 + You will receive an email with an invite code. Enter your 37 + invite code, desired username, and password in the next 38 + page to complete your registration. 39 + </span> 40 + <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 41 + <span>join now</span> 42 + </button> 43 + </form> 44 + <p class="text-sm text-gray-500"> 45 + Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>. 46 + </p> 47 + 48 + <p id="signup-msg" class="error w-full"></p> 49 + </main> 50 + </body> 51 + </html> 52 + {{ end }} 53 +
+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
··· 76 76 Artifact: uploadBlobResp.Blob, 77 77 CreatedAt: createdAt.Format(time.RFC3339), 78 78 Name: handler.Filename, 79 - Repo: f.RepoAt.String(), 79 + Repo: f.RepoAt().String(), 80 80 Tag: tag.Tag.Hash[:], 81 81 }, 82 82 }, ··· 100 100 artifact := db.Artifact{ 101 101 Did: user.Did, 102 102 Rkey: rkey, 103 - RepoAt: f.RepoAt, 103 + RepoAt: f.RepoAt(), 104 104 Tag: tag.Tag.Hash, 105 105 CreatedAt: createdAt, 106 106 BlobCid: cid.Cid(uploadBlobResp.Blob.Ref), ··· 155 155 156 156 artifacts, err := db.GetArtifact( 157 157 rp.db, 158 - db.FilterEq("repo_at", f.RepoAt), 158 + db.FilterEq("repo_at", f.RepoAt()), 159 159 db.FilterEq("tag", tag.Tag.Hash[:]), 160 160 db.FilterEq("name", filename), 161 161 ) ··· 197 197 198 198 artifacts, err := db.GetArtifact( 199 199 rp.db, 200 - db.FilterEq("repo_at", f.RepoAt), 200 + db.FilterEq("repo_at", f.RepoAt()), 201 201 db.FilterEq("tag", tag[:]), 202 202 db.FilterEq("name", filename), 203 203 ) ··· 239 239 defer tx.Rollback() 240 240 241 241 err = db.DeleteArtifact(tx, 242 - db.FilterEq("repo_at", f.RepoAt), 242 + db.FilterEq("repo_at", f.RepoAt()), 243 243 db.FilterEq("tag", artifact.Tag[:]), 244 244 db.FilterEq("name", filename), 245 245 ) ··· 270 270 return nil, err 271 271 } 272 272 273 - result, err := us.Tags(f.OwnerDid(), f.RepoName) 273 + result, err := us.Tags(f.OwnerDid(), f.Name) 274 274 if err != nil { 275 275 log.Println("failed to reach knotserver", err) 276 276 return nil, err
+165
appview/repo/feed.go
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log" 7 + "net/http" 8 + "slices" 9 + "time" 10 + 11 + "tangled.sh/tangled.sh/core/appview/db" 12 + "tangled.sh/tangled.sh/core/appview/reporesolver" 13 + 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + "github.com/gorilla/feeds" 16 + ) 17 + 18 + func (rp *Repo) getRepoFeed(ctx context.Context, f *reporesolver.ResolvedRepo) (*feeds.Feed, error) { 19 + const feedLimitPerType = 100 20 + 21 + pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt())) 22 + if err != nil { 23 + return nil, err 24 + } 25 + 26 + issues, err := db.GetIssuesWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt())) 27 + if err != nil { 28 + return nil, err 29 + } 30 + 31 + feed := &feeds.Feed{ 32 + Title: fmt.Sprintf("activity feed for %s", f.OwnerSlashRepo()), 33 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.AppviewHost, f.OwnerSlashRepo()), Type: "text/html", Rel: "alternate"}, 34 + Items: make([]*feeds.Item, 0), 35 + Updated: time.UnixMilli(0), 36 + } 37 + 38 + for _, pull := range pulls { 39 + items, err := rp.createPullItems(ctx, pull, f) 40 + if err != nil { 41 + return nil, err 42 + } 43 + feed.Items = append(feed.Items, items...) 44 + } 45 + 46 + for _, issue := range issues { 47 + item, err := rp.createIssueItem(ctx, issue, f) 48 + if err != nil { 49 + return nil, err 50 + } 51 + feed.Items = append(feed.Items, item) 52 + } 53 + 54 + slices.SortFunc(feed.Items, func(a, b *feeds.Item) int { 55 + if a.Created.After(b.Created) { 56 + return -1 57 + } 58 + return 1 59 + }) 60 + 61 + if len(feed.Items) > 0 { 62 + feed.Updated = feed.Items[0].Created 63 + } 64 + 65 + return feed, nil 66 + } 67 + 68 + func (rp *Repo) createPullItems(ctx context.Context, pull *db.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) { 69 + owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid) 70 + if err != nil { 71 + return nil, err 72 + } 73 + 74 + var items []*feeds.Item 75 + 76 + state := rp.getPullState(pull) 77 + description := rp.buildPullDescription(owner.Handle, state, pull, f.OwnerSlashRepo()) 78 + 79 + mainItem := &feeds.Item{ 80 + Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title), 81 + Description: description, 82 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId)}, 83 + Created: pull.Created, 84 + Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 85 + } 86 + items = append(items, mainItem) 87 + 88 + for _, round := range pull.Submissions { 89 + if round == nil || round.RoundNumber == 0 { 90 + continue 91 + } 92 + 93 + roundItem := &feeds.Item{ 94 + Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.RoundNumber), 95 + Description: fmt.Sprintf("@%s submitted changes (at round #%d) on PR #%d in %s", owner.Handle, round.RoundNumber, pull.PullId, f.OwnerSlashRepo()), 96 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId, round.RoundNumber)}, 97 + Created: round.Created, 98 + Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 99 + } 100 + items = append(items, roundItem) 101 + } 102 + 103 + return items, nil 104 + } 105 + 106 + func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { 107 + owner, err := rp.idResolver.ResolveIdent(ctx, issue.OwnerDid) 108 + if err != nil { 109 + return nil, err 110 + } 111 + 112 + state := "closed" 113 + if issue.Open { 114 + state = "opened" 115 + } 116 + 117 + return &feeds.Item{ 118 + Title: fmt.Sprintf("[Issue #%d] %s", issue.IssueId, issue.Title), 119 + Description: fmt.Sprintf("@%s %s issue #%d in %s", owner.Handle, state, issue.IssueId, f.OwnerSlashRepo()), 120 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), issue.IssueId)}, 121 + Created: issue.Created, 122 + Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 123 + }, nil 124 + } 125 + 126 + func (rp *Repo) getPullState(pull *db.Pull) string { 127 + if pull.State == db.PullOpen { 128 + return "opened" 129 + } 130 + return pull.State.String() 131 + } 132 + 133 + func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *db.Pull, repoName string) string { 134 + base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId) 135 + 136 + if pull.State == db.PullMerged { 137 + return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName) 138 + } 139 + 140 + return fmt.Sprintf("%s in %s", base, repoName) 141 + } 142 + 143 + func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) { 144 + f, err := rp.repoResolver.Resolve(r) 145 + if err != nil { 146 + log.Println("failed to fully resolve repo:", err) 147 + return 148 + } 149 + 150 + feed, err := rp.getRepoFeed(r.Context(), f) 151 + if err != nil { 152 + log.Println("failed to get repo feed:", err) 153 + rp.pages.Error500(w) 154 + return 155 + } 156 + 157 + atom, err := feed.ToAtom() 158 + if err != nil { 159 + rp.pages.Error500(w) 160 + return 161 + } 162 + 163 + w.Header().Set("content-type", "application/atom+xml") 164 + w.Write([]byte(atom)) 165 + }
+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
··· 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
··· 10 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 11 r := chi.NewRouter() 12 12 r.Get("/", rp.RepoIndex) 13 + r.Get("/feed.atom", rp.RepoAtomFeed) 13 14 r.Get("/commits/{ref}", rp.RepoLog) 14 15 r.Route("/tree/{ref}", func(r chi.Router) { 15 16 r.Get("/", rp.RepoIndex) ··· 37 38 }) 38 39 r.Get("/blob/{ref}/*", rp.RepoBlob) 39 40 r.Get("/raw/{ref}/*", rp.RepoBlobRaw) 41 + 42 + // intentionally doesn't use /* as this isn't 43 + // a file path 44 + r.Get("/archive/{ref}", rp.DownloadArchive) 40 45 41 46 r.Route("/fork", func(r chi.Router) { 42 47 r.Use(middleware.AuthMiddleware(rp.oauth))
+37 -104
appview/reporesolver/resolver.go
··· 7 7 "fmt" 8 8 "log" 9 9 "net/http" 10 - "net/url" 11 10 "path" 11 + "regexp" 12 12 "strings" 13 13 14 14 "github.com/bluesky-social/indigo/atproto/identity" 15 - "github.com/bluesky-social/indigo/atproto/syntax" 16 15 securejoin "github.com/cyphar/filepath-securejoin" 17 16 "github.com/go-chi/chi/v5" 18 17 "tangled.sh/tangled.sh/core/appview/config" ··· 21 20 "tangled.sh/tangled.sh/core/appview/pages" 22 21 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 23 22 "tangled.sh/tangled.sh/core/idresolver" 24 - "tangled.sh/tangled.sh/core/knotclient" 25 23 "tangled.sh/tangled.sh/core/rbac" 26 24 ) 27 25 28 26 type ResolvedRepo struct { 29 - Knot string 30 - OwnerId identity.Identity 31 - RepoName string 32 - RepoAt syntax.ATURI 33 - Description string 34 - Spindle string 35 - CreatedAt string 36 - Ref string 37 - CurrentDir string 27 + db.Repo 28 + OwnerId identity.Identity 29 + CurrentDir string 30 + Ref string 38 31 39 32 rr *RepoResolver 40 33 } ··· 51 44 } 52 45 53 46 func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) { 54 - repoName := chi.URLParam(r, "repo") 55 - knot, ok := r.Context().Value("knot").(string) 47 + repo, ok := r.Context().Value("repo").(*db.Repo) 56 48 if !ok { 57 - log.Println("malformed middleware") 49 + log.Println("malformed middleware: `repo` not exist in context") 58 50 return nil, fmt.Errorf("malformed middleware") 59 51 } 60 52 id, ok := r.Context().Value("resolvedId").(identity.Identity) ··· 63 55 return nil, fmt.Errorf("malformed middleware") 64 56 } 65 57 66 - repoAt, ok := r.Context().Value("repoAt").(string) 67 - if !ok { 68 - log.Println("malformed middleware") 69 - return nil, fmt.Errorf("malformed middleware") 70 - } 71 - 72 - parsedRepoAt, err := syntax.ParseATURI(repoAt) 73 - if err != nil { 74 - log.Println("malformed repo at-uri") 75 - return nil, fmt.Errorf("malformed middleware") 76 - } 77 - 58 + currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath())) 78 59 ref := chi.URLParam(r, "ref") 79 60 80 - if ref == "" { 81 - us, err := knotclient.NewUnsignedClient(knot, rr.config.Core.Dev) 82 - if err != nil { 83 - return nil, err 84 - } 85 - 86 - defaultBranch, err := us.DefaultBranch(id.DID.String(), repoName) 87 - if err != nil { 88 - return nil, err 89 - } 90 - 91 - ref = defaultBranch.Branch 92 - } 93 - 94 - currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath(), ref)) 95 - 96 - // pass through values from the middleware 97 - description, ok := r.Context().Value("repoDescription").(string) 98 - addedAt, ok := r.Context().Value("repoAddedAt").(string) 99 - spindle, ok := r.Context().Value("repoSpindle").(string) 100 - 101 61 return &ResolvedRepo{ 102 - Knot: knot, 103 - OwnerId: id, 104 - RepoName: repoName, 105 - RepoAt: parsedRepoAt, 106 - Description: description, 107 - CreatedAt: addedAt, 108 - Ref: ref, 109 - CurrentDir: currentDir, 110 - Spindle: spindle, 62 + Repo: *repo, 63 + OwnerId: id, 64 + CurrentDir: currentDir, 65 + Ref: ref, 111 66 112 67 rr: rr, 113 68 }, nil ··· 126 81 127 82 var p string 128 83 if handle != "" && !handle.IsInvalidHandle() { 129 - p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName) 84 + p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.Name) 130 85 } else { 131 - p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 86 + p, _ = securejoin.SecureJoin(f.OwnerDid(), f.Name) 132 87 } 133 88 134 - return p 135 - } 136 - 137 - func (f *ResolvedRepo) DidSlashRepo() string { 138 - p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 139 89 return p 140 90 } 141 91 ··· 187 137 // this function is a bit weird since it now returns RepoInfo from an entirely different 188 138 // package. we should refactor this or get rid of RepoInfo entirely. 189 139 func (f *ResolvedRepo) RepoInfo(user *oauth.User) repoinfo.RepoInfo { 140 + repoAt := f.RepoAt() 190 141 isStarred := false 191 142 if user != nil { 192 - isStarred = db.GetStarStatus(f.rr.execer, user.Did, syntax.ATURI(f.RepoAt)) 143 + isStarred = db.GetStarStatus(f.rr.execer, user.Did, repoAt) 193 144 } 194 145 195 - starCount, err := db.GetStarCount(f.rr.execer, f.RepoAt) 146 + starCount, err := db.GetStarCount(f.rr.execer, repoAt) 196 147 if err != nil { 197 - log.Println("failed to get star count for ", f.RepoAt) 148 + log.Println("failed to get star count for ", repoAt) 198 149 } 199 - issueCount, err := db.GetIssueCount(f.rr.execer, f.RepoAt) 150 + issueCount, err := db.GetIssueCount(f.rr.execer, repoAt) 200 151 if err != nil { 201 - log.Println("failed to get issue count for ", f.RepoAt) 152 + log.Println("failed to get issue count for ", repoAt) 202 153 } 203 - pullCount, err := db.GetPullCount(f.rr.execer, f.RepoAt) 154 + pullCount, err := db.GetPullCount(f.rr.execer, repoAt) 204 155 if err != nil { 205 - log.Println("failed to get issue count for ", f.RepoAt) 156 + log.Println("failed to get issue count for ", repoAt) 206 157 } 207 - source, err := db.GetRepoSource(f.rr.execer, f.RepoAt) 158 + source, err := db.GetRepoSource(f.rr.execer, repoAt) 208 159 if errors.Is(err, sql.ErrNoRows) { 209 160 source = "" 210 161 } else if err != nil { 211 - log.Println("failed to get repo source for ", f.RepoAt, err) 162 + log.Println("failed to get repo source for ", repoAt, err) 212 163 } 213 164 214 165 var sourceRepo *db.Repo ··· 228 179 } 229 180 230 181 knot := f.Knot 231 - var disableFork bool 232 - us, err := knotclient.NewUnsignedClient(knot, f.rr.config.Core.Dev) 233 - if err != nil { 234 - log.Printf("failed to create unsigned client for %s: %v", knot, err) 235 - } else { 236 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 237 - if err != nil { 238 - log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err) 239 - } 240 - 241 - if len(result.Branches) == 0 { 242 - disableFork = true 243 - } 244 - } 245 182 246 183 repoInfo := repoinfo.RepoInfo{ 247 184 OwnerDid: f.OwnerDid(), 248 185 OwnerHandle: f.OwnerHandle(), 249 - Name: f.RepoName, 250 - RepoAt: f.RepoAt, 186 + Name: f.Name, 187 + RepoAt: repoAt, 251 188 Description: f.Description, 252 - Ref: f.Ref, 253 189 IsStarred: isStarred, 254 190 Knot: knot, 255 191 Spindle: f.Spindle, ··· 259 195 IssueCount: issueCount, 260 196 PullCount: pullCount, 261 197 }, 262 - DisableFork: disableFork, 263 - CurrentDir: f.CurrentDir, 198 + CurrentDir: f.CurrentDir, 199 + Ref: f.Ref, 264 200 } 265 201 266 202 if sourceRepo != nil { ··· 284 220 // after the ref. for example: 285 221 // 286 222 // /@icyphox.sh/foorepo/blob/main/abc/xyz/ => abc/xyz/ 287 - func extractPathAfterRef(fullPath, ref string) string { 223 + func extractPathAfterRef(fullPath string) string { 288 224 fullPath = strings.TrimPrefix(fullPath, "/") 289 225 290 - ref = url.PathEscape(ref) 226 + // match blob/, tree/, or raw/ followed by any ref and then a slash 227 + // 228 + // captures everything after the final slash 229 + pattern := `(?:blob|tree|raw)/[^/]+/(.*)$` 291 230 292 - prefixes := []string{ 293 - fmt.Sprintf("blob/%s/", ref), 294 - fmt.Sprintf("tree/%s/", ref), 295 - fmt.Sprintf("raw/%s/", ref), 296 - } 231 + re := regexp.MustCompile(pattern) 232 + matches := re.FindStringSubmatch(fullPath) 297 233 298 - for _, prefix := range prefixes { 299 - idx := strings.Index(fullPath, prefix) 300 - if idx != -1 { 301 - return fullPath[idx+len(prefix):] 302 - } 234 + if len(matches) > 1 { 235 + return matches[1] 303 236 } 304 237 305 238 return ""
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 30 30 panic(err) 31 31 } 32 32 33 + if err := key.Set("use", "sig"); err != nil { 34 + panic(err) 35 + } 36 + 33 37 b, err := json.Marshal(key) 34 38 if err != nil { 35 39 panic(err)
+1 -1
cmd/punchcardPopulate/main.go
··· 11 11 ) 12 12 13 13 func main() { 14 - db, err := sql.Open("sqlite3", "./appview.db") 14 + db, err := sql.Open("sqlite3", "./appview.db?_foreign_keys=1") 15 15 if err != nil { 16 16 log.Fatal("Failed to open database:", err) 17 17 }
+14 -15
docs/contributing.md
··· 55 55 - Avoid noisy commit messages like "wip" or "final fix"โ€”rewrite history 56 56 before submitting if necessary. 57 57 58 + ## code formatting 59 + 60 + We use a variety of tools to format our code, and multiplex them with 61 + [`treefmt`](https://treefmt.com): all you need to do to format your changes 62 + is run `nix run .#fmt` (or just `treefmt` if you're in the devshell). 63 + 58 64 ## proposals for bigger changes 59 65 60 66 Small fixes like typos, minor bugs, or trivial refactors can be ··· 115 121 If you're submitting a PR with multiple commits, make sure each one is 116 122 signed. 117 123 118 - For [jj](https://jj-vcs.github.io/jj/latest/) users, you can add this to 119 - your jj config: 124 + For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command 125 + to make it sign off commits in the tangled repo: 120 126 121 - ``` 122 - ui.should-sign-off = true 123 - ``` 124 - 125 - and to your `templates.draft_commit_description`, add the following `if` 126 - block: 127 - 128 - ``` 129 - if( 130 - config("ui.should-sign-off").as_boolean() && !description.contains("Signed-off-by: " ++ author.name()), 131 - "\nSigned-off-by: " ++ author.name() ++ " <" ++ author.email() ++ ">", 132 - ), 127 + ```shell 128 + # Safety check, should say "No matching config key..." 129 + jj config list templates.commit_trailers 130 + # The command below may need to be adjusted if the command above returned something. 131 + jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)" 133 132 ``` 134 133 135 134 Refer to the [jj 136 - documentation](https://jj-vcs.github.io/jj/latest/config/#default-description) 135 + documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers) 137 136 for more information.
+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
··· 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
··· 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
··· 4 4 repo. Generally: 5 5 6 6 * Pipelines are defined in YAML. 7 - * Dependencies can be specified from 8 - [Nixpkgs](https://search.nixos.org) or custom registries. 9 - * Environment variables can be set globally or per-step. 7 + * Workflows can run using different *engines*. 8 + 9 + The most barebones workflow looks like this: 10 + 11 + ```yaml 12 + when: 13 + - event: ["push"] 14 + branch: ["main"] 15 + 16 + engine: "nixery" 17 + 18 + # optional 19 + clone: 20 + skip: false 21 + depth: 50 22 + submodules: true 23 + ``` 24 + 25 + The `when` and `engine` fields are required, while every other aspect 26 + of how the definition is parsed is up to the engine. Currently, a spindle 27 + provides at least one of these built-in engines: 28 + 29 + ## `nixery` 30 + 31 + The Nixery engine uses an instance of [Nixery](https://nixery.dev) to run 32 + steps that use dependencies from [Nixpkgs](https://github.com/NixOS/nixpkgs). 10 33 11 34 Here's an example that uses all fields: 12 35
+1 -1
eventconsumer/cursor/sqlite.go
··· 21 21 } 22 22 23 23 func NewSQLiteStore(dbPath string, opts ...SqliteStoreOpt) (*SqliteStore, error) { 24 - db, err := sql.Open("sqlite3", dbPath) 24 + db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1") 25 25 if err != nil { 26 26 return nil, fmt.Errorf("failed to open sqlite database: %w", err) 27 27 }
+10 -31
flake.lock
··· 1 1 { 2 2 "nodes": { 3 - "gitignore": { 4 - "inputs": { 5 - "nixpkgs": [ 6 - "nixpkgs" 7 - ] 8 - }, 9 - "locked": { 10 - "lastModified": 1709087332, 11 - "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", 12 - "owner": "hercules-ci", 13 - "repo": "gitignore.nix", 14 - "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", 15 - "type": "github" 16 - }, 17 - "original": { 18 - "owner": "hercules-ci", 19 - "repo": "gitignore.nix", 20 - "type": "github" 21 - } 22 - }, 23 3 "flake-utils": { 24 4 "inputs": { 25 5 "systems": "systems" ··· 46 26 ] 47 27 }, 48 28 "locked": { 49 - "lastModified": 1751702058, 50 - "narHash": "sha256-/GTdqFzFw/Y9DSNAfzvzyCMlKjUyRKMPO+apIuaTU4A=", 29 + "lastModified": 1754078208, 30 + "narHash": "sha256-YVoIFDCDpYuU3riaDEJ3xiGdPOtsx4sR5eTzHTytPV8=", 51 31 "owner": "nix-community", 52 32 "repo": "gomod2nix", 53 - "rev": "664ad7a2df4623037e315e4094346bff5c44e9ee", 33 + "rev": "7f963246a71626c7fc70b431a315c4388a0c95cf", 54 34 "type": "github" 55 35 }, 56 36 "original": { ··· 99 79 "indigo": { 100 80 "flake": false, 101 81 "locked": { 102 - "lastModified": 1745333930, 103 - "narHash": "sha256-83fIHqDE+dfnZ88HaNuwfKFO+R0RKAM1WxMfNh/Matk=", 82 + "lastModified": 1753693716, 83 + "narHash": "sha256-DMIKnCJRODQXEHUxA+7mLzRALmnZhkkbHlFT2rCQYrE=", 104 84 "owner": "oppiliappan", 105 85 "repo": "indigo", 106 - "rev": "e4e59280737b8676611fc077a228d47b3e8e9491", 86 + "rev": "5f170569da9360f57add450a278d73538092d8ca", 107 87 "type": "github" 108 88 }, 109 89 "original": { ··· 128 108 "lucide-src": { 129 109 "flake": false, 130 110 "locked": { 131 - "lastModified": 1742302029, 132 - "narHash": "sha256-OyPVtpnC4/AAmPq84Wt1r1Gcs48d9KG+UBCtZK87e9k=", 111 + "lastModified": 1754044466, 112 + "narHash": "sha256-+exBR2OToB1iv7ZQI2S4B0lXA/QRvC9n6U99UxGpJGs=", 133 113 "type": "tarball", 134 - "url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip" 114 + "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 135 115 }, 136 116 "original": { 137 117 "type": "tarball", 138 - "url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip" 118 + "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 139 119 } 140 120 }, 141 121 "nixpkgs": { ··· 156 136 }, 157 137 "root": { 158 138 "inputs": { 159 - "gitignore": "gitignore", 160 139 "gomod2nix": "gomod2nix", 161 140 "htmx-src": "htmx-src", 162 141 "htmx-ws-src": "htmx-ws-src",
+103 -29
flake.nix
··· 22 22 flake = false; 23 23 }; 24 24 lucide-src = { 25 - url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"; 25 + url = "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"; 26 26 flake = false; 27 27 }; 28 28 inter-fonts-src = { ··· 37 37 url = "https://sqlite.org/2024/sqlite-amalgamation-3450100.zip"; 38 38 flake = false; 39 39 }; 40 - gitignore = { 41 - url = "github:hercules-ci/gitignore.nix"; 42 - inputs.nixpkgs.follows = "nixpkgs"; 43 - }; 44 40 }; 45 41 46 42 outputs = { ··· 51 47 htmx-src, 52 48 htmx-ws-src, 53 49 lucide-src, 54 - gitignore, 55 50 inter-fonts-src, 56 51 sqlite-lib-src, 57 52 ibm-plex-mono-src, ··· 62 57 63 58 mkPackageSet = pkgs: 64 59 pkgs.lib.makeScope pkgs.newScope (self: { 65 - inherit (gitignore.lib) gitignoreSource; 60 + src = let 61 + fs = pkgs.lib.fileset; 62 + in 63 + fs.toSource { 64 + root = ./.; 65 + fileset = fs.difference (fs.intersection (fs.gitTracked ./.) (fs.fileFilter (file: !(file.hasExt "nix")) ./.)) (fs.maybeMissing ./.jj); 66 + }; 66 67 buildGoApplication = 67 68 (self.callPackage "${gomod2nix}/builder" { 68 69 gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix; ··· 74 75 }; 75 76 genjwks = self.callPackage ./nix/pkgs/genjwks.nix {}; 76 77 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 77 - appview = self.callPackage ./nix/pkgs/appview.nix { 78 + appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix { 78 79 inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src; 79 80 }; 81 + appview = self.callPackage ./nix/pkgs/appview.nix {}; 80 82 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 81 83 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 82 84 knot = self.callPackage ./nix/pkgs/knot.nix {}; ··· 92 94 staticPackages = mkPackageSet pkgs.pkgsStatic; 93 95 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 94 96 in { 95 - appview = packages.appview; 96 - lexgen = packages.lexgen; 97 - knot = packages.knot; 98 - knot-unwrapped = packages.knot-unwrapped; 99 - spindle = packages.spindle; 100 - genjwks = packages.genjwks; 101 - sqlite-lib = packages.sqlite-lib; 97 + inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib; 102 98 103 99 pkgsStatic-appview = staticPackages.appview; 104 100 pkgsStatic-knot = staticPackages.knot; ··· 110 106 pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot; 111 107 pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped; 112 108 pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle; 109 + 110 + treefmt-wrapper = pkgs.treefmt.withConfig { 111 + settings.formatter = { 112 + alejandra = { 113 + command = pkgs.lib.getExe pkgs.alejandra; 114 + includes = ["*.nix"]; 115 + }; 116 + 117 + gofmt = { 118 + command = pkgs.lib.getExe' pkgs.go "gofmt"; 119 + options = ["-w"]; 120 + includes = ["*.go"]; 121 + }; 122 + 123 + # prettier = let 124 + # wrapper = pkgs.runCommandLocal "prettier-wrapper" {nativeBuildInputs = [pkgs.makeWrapper];} '' 125 + # makeWrapper ${pkgs.prettier}/bin/prettier "$out" --add-flags "--plugin=${pkgs.prettier-plugin-go-template}/lib/node_modules/prettier-plugin-go-template/lib/index.js" 126 + # ''; 127 + # in { 128 + # command = wrapper; 129 + # options = ["-w"]; 130 + # includes = ["*.html"]; 131 + # # causes Go template plugin errors: https://github.com/NiklasPor/prettier-plugin-go-template/issues/120 132 + # excludes = ["appview/pages/templates/layouts/repobase.html" "appview/pages/templates/repo/tags.html"]; 133 + # }; 134 + }; 135 + }; 113 136 }); 114 137 defaultPackage = forAllSystems (system: self.packages.${system}.appview); 115 - formatter = forAllSystems (system: nixpkgsFor.${system}.alejandra); 116 138 devShells = forAllSystems (system: let 117 139 pkgs = nixpkgsFor.${system}; 118 140 packages' = self.packages.${system}; ··· 131 153 pkgs.tailwindcss 132 154 pkgs.nixos-shell 133 155 pkgs.redis 156 + pkgs.coreutils # for those of us who are on systems that use busybox (alpine) 134 157 packages'.lexgen 158 + packages'.treefmt-wrapper 135 159 ]; 136 160 shellHook = '' 137 - mkdir -p appview/pages/static/{fonts,icons} 138 - cp -f ${htmx-src} appview/pages/static/htmx.min.js 139 - cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js 140 - cp -rf ${lucide-src}/*.svg appview/pages/static/icons/ 141 - cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/ 142 - cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/ 143 - cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/ 161 + mkdir -p appview/pages/static 162 + # no preserve is needed because watch-tailwind will want to be able to overwrite 163 + cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 144 164 export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)" 145 165 ''; 146 166 env.CGO_ENABLED = 1; ··· 148 168 }); 149 169 apps = forAllSystems (system: let 150 170 pkgs = nixpkgsFor."${system}"; 171 + packages' = self.packages.${system}; 151 172 air-watcher = name: arg: 152 173 pkgs.writeShellScriptBin "run" 153 174 '' ··· 164 185 ${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css 165 186 ''; 166 187 in { 188 + fmt = { 189 + type = "app"; 190 + program = pkgs.lib.getExe packages'.treefmt-wrapper; 191 + }; 167 192 watch-appview = { 168 193 type = "app"; 169 - program = ''${air-watcher "appview" ""}/bin/run''; 194 + program = toString (pkgs.writeShellScript "watch-appview" '' 195 + echo "copying static files to appview/pages/static..." 196 + ${pkgs.coreutils}/bin/cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 197 + ${air-watcher "appview" ""}/bin/run 198 + ''); 170 199 }; 171 200 watch-knot = { 172 201 type = "app"; ··· 176 205 type = "app"; 177 206 program = ''${tailwind-watcher}/bin/run''; 178 207 }; 179 - vm = { 208 + vm = let 209 + guestSystem = 210 + if pkgs.stdenv.hostPlatform.isAarch64 211 + then "aarch64-linux" 212 + else "x86_64-linux"; 213 + in { 180 214 type = "app"; 181 - program = toString (pkgs.writeShellScript "vm" '' 182 - ${pkgs.nixos-shell}/bin/nixos-shell --flake .#vm 183 - ''); 215 + program = 216 + (pkgs.writeShellApplication { 217 + name = "launch-vm"; 218 + text = '' 219 + rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 220 + cd "$rootDir" 221 + 222 + mkdir -p nix/vm-data/{knot,repos,spindle,spindle-logs} 223 + 224 + export TANGLED_VM_DATA_DIR="$rootDir/nix/vm-data" 225 + exec ${pkgs.lib.getExe 226 + (import ./nix/vm.nix { 227 + inherit nixpkgs self; 228 + system = guestSystem; 229 + hostSystem = system; 230 + }).config.system.build.vm} 231 + ''; 232 + }) 233 + + /bin/launch-vm; 184 234 }; 185 235 gomod2nix = { 186 236 type = "app"; ··· 188 238 ${gomod2nix.legacyPackages.${system}.gomod2nix}/bin/gomod2nix generate --outdir ./nix 189 239 ''); 190 240 }; 241 + lexgen = { 242 + type = "app"; 243 + program = 244 + (pkgs.writeShellApplication { 245 + name = "lexgen"; 246 + text = '' 247 + if ! command -v lexgen > /dev/null; then 248 + echo "error: must be executed from devshell" 249 + exit 1 250 + fi 251 + 252 + rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 253 + cd "$rootDir" 254 + 255 + rm 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
··· 22 22 github.com/go-enry/go-enry/v2 v2.9.2 23 23 github.com/go-git/go-git/v5 v5.14.0 24 24 github.com/google/uuid v1.6.0 25 + github.com/gorilla/feeds v1.2.0 25 26 github.com/gorilla/sessions v1.4.0 26 27 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 27 28 github.com/hiddeco/sshsig v0.2.0 ··· 38 39 github.com/stretchr/testify v1.10.0 39 40 github.com/urfave/cli/v3 v3.3.3 40 41 github.com/whyrusleeping/cbor-gen v0.3.1 41 - github.com/yuin/goldmark v1.4.13 42 + github.com/yuin/goldmark v1.4.15 43 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 42 44 golang.org/x/crypto v0.40.0 43 45 golang.org/x/net v0.42.0 44 46 golang.org/x/sync v0.16.0
+7 -1
go.sum
··· 79 79 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 80 80 github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= 81 81 github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= 82 + github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 82 83 github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= 83 84 github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 84 85 github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw= ··· 173 174 github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 174 175 github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 175 176 github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 177 + github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc= 178 + github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y= 176 179 github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 177 180 github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 178 181 github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= ··· 427 430 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 428 431 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 429 432 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 430 - github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= 431 433 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 434 + github.com/yuin/goldmark v1.4.15 h1:CFa84T0goNn/UIXYS+dmjjVxMyTAvpOmzld40N/nfK0= 435 + github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 436 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 437 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 432 438 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 433 439 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 434 440 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
+83 -6
input.css
··· 13 13 @font-face { 14 14 font-family: "InterVariable"; 15 15 src: url("/static/fonts/InterVariable-Italic.woff2") format("woff2"); 16 - font-weight: 400; 16 + font-weight: normal; 17 17 font-style: italic; 18 18 font-display: swap; 19 19 } 20 20 21 21 @font-face { 22 22 font-family: "InterVariable"; 23 - src: url("/static/fonts/InterVariable.woff2") format("woff2"); 24 - font-weight: 600; 23 + src: url("/static/fonts/InterDisplay-Bold.woff2") format("woff2"); 24 + font-weight: bold; 25 25 font-style: normal; 26 26 font-display: swap; 27 27 } 28 28 29 29 @font-face { 30 + font-family: "InterVariable"; 31 + src: url("/static/fonts/InterDisplay-BoldItalic.woff2") format("woff2"); 32 + font-weight: bold; 33 + font-style: italic; 34 + font-display: swap; 35 + } 36 + 37 + @font-face { 30 38 font-family: "IBMPlexMono"; 31 39 src: url("/static/fonts/IBMPlexMono-Regular.woff2") format("woff2"); 40 + font-weight: normal; 41 + font-style: normal; 42 + font-display: swap; 43 + } 44 + 45 + @font-face { 46 + font-family: "IBMPlexMono"; 47 + src: url("/static/fonts/IBMPlexMono-Italic.woff2") format("woff2"); 32 48 font-weight: normal; 33 49 font-style: italic; 34 50 font-display: swap; 35 51 } 36 52 53 + @font-face { 54 + font-family: "IBMPlexMono"; 55 + src: url("/static/fonts/IBMPlexMono-Bold.woff2") format("woff2"); 56 + font-weight: bold; 57 + font-style: normal; 58 + font-display: swap; 59 + } 60 + 61 + @font-face { 62 + font-family: "IBMPlexMono"; 63 + src: url("/static/fonts/IBMPlexMono-BoldItalic.woff2") format("woff2"); 64 + font-weight: bold; 65 + font-style: italic; 66 + font-display: swap; 67 + } 68 + 37 69 ::selection { 38 70 @apply bg-yellow-400 text-black bg-opacity-30 dark:bg-yellow-600 dark:bg-opacity-50 dark:text-white; 39 71 } ··· 46 78 @supports (font-variation-settings: normal) { 47 79 html { 48 80 font-feature-settings: 49 - "ss01" 1, 50 81 "kern" 1, 51 82 "liga" 1, 52 83 "cv05" 1, ··· 70 101 details summary::-webkit-details-marker { 71 102 display: none; 72 103 } 104 + 105 + code { 106 + @apply font-mono rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white; 107 + } 73 108 } 74 109 75 110 @layer components { ··· 98 133 disabled:before:bg-green-400 dark:disabled:before:bg-green-600; 99 134 } 100 135 136 + .prose hr { 137 + @apply my-2; 138 + } 139 + 140 + .prose li:has(input) { 141 + @apply list-none; 142 + } 143 + 144 + .prose ul:has(input) { 145 + @apply pl-2; 146 + } 147 + 148 + .prose .heading .anchor { 149 + @apply no-underline mx-2 opacity-0; 150 + } 151 + 152 + .prose .heading:hover .anchor { 153 + @apply opacity-70; 154 + } 155 + 156 + .prose .heading .anchor:hover { 157 + @apply opacity-70; 158 + } 159 + 160 + .prose a.footnote-backref { 161 + @apply no-underline; 162 + } 163 + 164 + .prose li { 165 + @apply my-0 py-0; 166 + } 167 + 168 + .prose ul, .prose ol { 169 + @apply my-1 py-0; 170 + } 171 + 101 172 .prose img { 102 173 display: inline; 103 174 margin: 0; 104 175 vertical-align: middle; 176 + } 177 + 178 + .prose input { 179 + @apply inline-block my-0 mb-1 mx-1; 180 + } 181 + 182 + .prose input[type="checkbox"] { 183 + @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500; 105 184 } 106 185 } 107 186 @layer utilities { ··· 122 201 /* PreWrapper */ 123 202 .chroma { 124 203 color: #4c4f69; 125 - background-color: #eff1f5; 126 204 } 127 205 /* Error */ 128 206 .chroma .err { ··· 459 537 /* PreWrapper */ 460 538 .chroma { 461 539 color: #cad3f5; 462 - background-color: #24273a; 463 540 } 464 541 /* Error */ 465 542 .chroma .err {
+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
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "strings" 5 6 6 7 _ "github.com/mattn/go-sqlite3" 7 8 ) ··· 11 12 } 12 13 13 14 func Setup(dbPath string) (*DB, error) { 14 - db, err := sql.Open("sqlite3", dbPath) 15 + // https://github.com/mattn/go-sqlite3#connection-string 16 + opts := []string{ 17 + "_foreign_keys=1", 18 + "_journal_mode=WAL", 19 + "_synchronous=NORMAL", 20 + "_auto_vacuum=incremental", 21 + } 22 + 23 + db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 15 24 if err != nil { 16 25 return nil, err 17 26 } 18 27 19 - _, err = db.Exec(` 20 - pragma journal_mode = WAL; 21 - pragma synchronous = normal; 22 - pragma foreign_keys = on; 23 - pragma temp_store = memory; 24 - pragma mmap_size = 30000000000; 25 - pragma page_size = 32768; 26 - pragma auto_vacuum = incremental; 27 - pragma busy_timeout = 5000; 28 + // NOTE: If any other migration is added here, you MUST 29 + // copy the pattern in appview: use a single sql.Conn 30 + // for every migration. 28 31 32 + _, err = db.Exec(` 29 33 create table if not exists known_dids ( 30 34 did text primary key 31 35 );
-8
knotserver/file.go
··· 10 10 "tangled.sh/tangled.sh/core/types" 11 11 ) 12 12 13 - func (h *Handle) listFiles(files []types.NiceTree, data map[string]any, w http.ResponseWriter) { 14 - data["files"] = files 15 - 16 - writeJSON(w, data) 17 - return 18 - } 19 - 20 13 func countLines(r io.Reader) (int, error) { 21 14 buf := make([]byte, 32*1024) 22 15 bufLen := 0 ··· 52 45 53 46 resp.Lines = lc 54 47 writeJSON(w, resp) 55 - return 56 48 }
+8 -10
knotserver/git/fork.go
··· 10 10 ) 11 11 12 12 func Fork(repoPath, source string) error { 13 - _, err := git.PlainClone(repoPath, true, &git.CloneOptions{ 14 - URL: source, 15 - SingleBranch: false, 16 - }) 17 - 18 - if err != nil { 13 + cloneCmd := exec.Command("git", "clone", "--bare", source, repoPath) 14 + if err := cloneCmd.Run(); err != nil { 19 15 return fmt.Errorf("failed to bare clone repository: %w", err) 20 16 } 21 17 22 - err = exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden").Run() 23 - if err != nil { 18 + configureCmd := exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden") 19 + if err := configureCmd.Run(); err != nil { 24 20 return fmt.Errorf("failed to configure hidden refs: %w", err) 25 21 } 26 22 27 23 return nil 28 24 } 29 25 30 - func (g *GitRepo) Sync(branch string) error { 26 + func (g *GitRepo) Sync() error { 27 + branch := g.h.String() 28 + 31 29 fetchOpts := &git.FetchOptions{ 32 30 RefSpecs: []config.RefSpec{ 33 - config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/heads/%s", branch, branch)), 31 + config.RefSpec("+" + branch + ":" + branch), // +refs/heads/master:refs/heads/master 34 32 }, 35 33 } 36 34
+19 -12
knotserver/git/post_receive.go
··· 3 3 import ( 4 4 "bufio" 5 5 "context" 6 + "errors" 6 7 "fmt" 7 8 "io" 8 9 "strings" ··· 57 58 ByEmail map[string]int 58 59 } 59 60 60 - func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) RefUpdateMeta { 61 + func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) (RefUpdateMeta, error) { 62 + var errs error 63 + 61 64 commitCount, err := g.newCommitCount(line) 62 - if err != nil { 63 - // TODO: log this 64 - } 65 + errors.Join(errs, err) 65 66 66 67 isDefaultRef, err := g.isDefaultBranch(line) 67 - if err != nil { 68 - // TODO: log this 69 - } 68 + errors.Join(errs, err) 70 69 71 70 ctx, cancel := context.WithTimeout(context.Background(), time.Second*2) 72 71 defer cancel() 73 72 breakdown, err := g.AnalyzeLanguages(ctx) 74 - if err != nil { 75 - // TODO: log this 76 - } 73 + errors.Join(errs, err) 77 74 78 75 return RefUpdateMeta{ 79 76 CommitCount: commitCount, 80 77 IsDefaultRef: isDefaultRef, 81 78 LangBreakdown: breakdown, 82 - } 79 + }, errs 83 80 } 84 81 85 82 func (g *GitRepo) newCommitCount(line PostReceiveLine) (CommitCount, error) { ··· 95 92 args := []string{fmt.Sprintf("--max-count=%d", 100)} 96 93 97 94 if line.OldSha.IsZero() { 98 - // just git rev-list <newsha> 95 + // git rev-list <newsha> ^other-branches --not ^this-branch 99 96 args = append(args, line.NewSha.String()) 97 + 98 + branches, _ := g.Branches() 99 + for _, b := range branches { 100 + if !strings.Contains(line.Ref, b.Name) { 101 + args = append(args, fmt.Sprintf("^%s", b.Name)) 102 + } 103 + } 104 + 105 + args = append(args, "--not") 106 + args = append(args, fmt.Sprintf("^%s", line.Ref)) 100 107 } else { 101 108 // git rev-list <oldsha>..<newsha> 102 109 args = append(args, fmt.Sprintf("%s..%s", line.OldSha.String(), line.NewSha.String()))
+5
knotserver/git.go
··· 129 129 // If the appview gave us the repository owner's handle we can attempt to 130 130 // construct the correct ssh url. 131 131 ownerHandle := r.Header.Get("x-tangled-repo-owner-handle") 132 + ownerHandle = strings.TrimPrefix(ownerHandle, "@") 132 133 if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") { 133 134 hostname := d.c.Server.Hostname 134 135 if strings.Contains(hostname, ":") { 135 136 hostname = strings.Split(hostname, ":")[0] 137 + } 138 + 139 + if hostname == "knot1.tangled.sh" { 140 + hostname = "tangled.sh" 136 141 } 137 142 138 143 fmt.Fprintf(w, " Try:\ngit remote set-url --push origin git@%s:%s/%s\n\n... and push again.", hostname, ownerHandle, unqualifiedRepoName)
+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
··· 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
··· 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
··· 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
··· 76 76 tangled.PublicKeyNSID, 77 77 tangled.KnotMemberNSID, 78 78 tangled.RepoPullNSID, 79 + tangled.RepoCollaboratorNSID, 79 80 }, nil, logger, db, true, c.Server.LogDids) 80 81 if err != nil { 81 82 logger.Error("failed to setup jetstream", "error", err)
-37
lexicons/addSecret.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.addSecret", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "description": "Add a CI secret", 8 - "input": { 9 - "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": [ 13 - "repo", 14 - "key", 15 - "value" 16 - ], 17 - "properties": { 18 - "repo": { 19 - "type": "string", 20 - "format": "at-uri" 21 - }, 22 - "key": { 23 - "type": "string", 24 - "maxLength": 50, 25 - "minLength": 1 26 - }, 27 - "value": { 28 - "type": "string", 29 - "maxLength": 200, 30 - "minLength": 1 31 - } 32 - } 33 - } 34 - } 35 - } 36 - } 37 - }
-52
lexicons/artifact.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.artifact", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "name", 14 - "repo", 15 - "tag", 16 - "createdAt", 17 - "artifact" 18 - ], 19 - "properties": { 20 - "name": { 21 - "type": "string", 22 - "description": "name of the artifact" 23 - }, 24 - "repo": { 25 - "type": "string", 26 - "format": "at-uri", 27 - "description": "repo that this artifact is being uploaded to" 28 - }, 29 - "tag": { 30 - "type": "bytes", 31 - "description": "hash of the tag object that this artifact is attached to (only annotated tags are supported)", 32 - "minLength": 20, 33 - "maxLength": 20 34 - }, 35 - "createdAt": { 36 - "type": "string", 37 - "format": "datetime", 38 - "description": "time of creation of this artifact" 39 - }, 40 - "artifact": { 41 - "type": "blob", 42 - "description": "the artifact", 43 - "accept": [ 44 - "*/*" 45 - ], 46 - "maxSize": 52428800 47 - } 48 - } 49 - } 50 - } 51 - } 52 - }
-29
lexicons/defaultBranch.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.setDefaultBranch", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "description": "Set the default branch for a repository", 8 - "input": { 9 - "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": [ 13 - "repo", 14 - "defaultBranch" 15 - ], 16 - "properties": { 17 - "repo": { 18 - "type": "string", 19 - "format": "at-uri" 20 - }, 21 - "defaultBranch": { 22 - "type": "string" 23 - } 24 - } 25 - } 26 - } 27 - } 28 - } 29 - }
+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
··· 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
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.listSecrets", 4 - "defs": { 5 - "main": { 6 - "type": "query", 7 - "parameters": { 8 - "type": "params", 9 - "required": [ 10 - "repo" 11 - ], 12 - "properties": { 13 - "repo": { 14 - "type": "string", 15 - "format": "at-uri" 16 - } 17 - } 18 - }, 19 - "output": { 20 - "encoding": "application/json", 21 - "schema": { 22 - "type": "object", 23 - "required": [ 24 - "secrets" 25 - ], 26 - "properties": { 27 - "secrets": { 28 - "type": "array", 29 - "items": { 30 - "type": "ref", 31 - "ref": "#secret" 32 - } 33 - } 34 - } 35 - } 36 - } 37 - }, 38 - "secret": { 39 - "type": "object", 40 - "required": [ 41 - "repo", 42 - "key", 43 - "createdAt", 44 - "createdBy" 45 - ], 46 - "properties": { 47 - "repo": { 48 - "type": "string", 49 - "format": "at-uri" 50 - }, 51 - "key": { 52 - "type": "string", 53 - "maxLength": 50, 54 - "minLength": 1 55 - }, 56 - "createdAt": { 57 - "type": "string", 58 - "format": "datetime" 59 - }, 60 - "createdBy": { 61 - "type": "string", 62 - "format": "did" 63 - } 64 - } 65 - } 66 - } 67 - }
+207
lexicons/pipeline/pipeline.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.pipeline", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "triggerMetadata", 14 + "workflows" 15 + ], 16 + "properties": { 17 + "triggerMetadata": { 18 + "type": "ref", 19 + "ref": "#triggerMetadata" 20 + }, 21 + "workflows": { 22 + "type": "array", 23 + "items": { 24 + "type": "ref", 25 + "ref": "#workflow" 26 + } 27 + } 28 + } 29 + } 30 + }, 31 + "triggerMetadata": { 32 + "type": "object", 33 + "required": [ 34 + "kind", 35 + "repo" 36 + ], 37 + "properties": { 38 + "kind": { 39 + "type": "string", 40 + "enum": [ 41 + "push", 42 + "pull_request", 43 + "manual" 44 + ] 45 + }, 46 + "repo": { 47 + "type": "ref", 48 + "ref": "#triggerRepo" 49 + }, 50 + "push": { 51 + "type": "ref", 52 + "ref": "#pushTriggerData" 53 + }, 54 + "pullRequest": { 55 + "type": "ref", 56 + "ref": "#pullRequestTriggerData" 57 + }, 58 + "manual": { 59 + "type": "ref", 60 + "ref": "#manualTriggerData" 61 + } 62 + } 63 + }, 64 + "triggerRepo": { 65 + "type": "object", 66 + "required": [ 67 + "knot", 68 + "did", 69 + "repo", 70 + "defaultBranch" 71 + ], 72 + "properties": { 73 + "knot": { 74 + "type": "string" 75 + }, 76 + "did": { 77 + "type": "string", 78 + "format": "did" 79 + }, 80 + "repo": { 81 + "type": "string" 82 + }, 83 + "defaultBranch": { 84 + "type": "string" 85 + } 86 + } 87 + }, 88 + "pushTriggerData": { 89 + "type": "object", 90 + "required": [ 91 + "ref", 92 + "newSha", 93 + "oldSha" 94 + ], 95 + "properties": { 96 + "ref": { 97 + "type": "string" 98 + }, 99 + "newSha": { 100 + "type": "string", 101 + "minLength": 40, 102 + "maxLength": 40 103 + }, 104 + "oldSha": { 105 + "type": "string", 106 + "minLength": 40, 107 + "maxLength": 40 108 + } 109 + } 110 + }, 111 + "pullRequestTriggerData": { 112 + "type": "object", 113 + "required": [ 114 + "sourceBranch", 115 + "targetBranch", 116 + "sourceSha", 117 + "action" 118 + ], 119 + "properties": { 120 + "sourceBranch": { 121 + "type": "string" 122 + }, 123 + "targetBranch": { 124 + "type": "string" 125 + }, 126 + "sourceSha": { 127 + "type": "string", 128 + "minLength": 40, 129 + "maxLength": 40 130 + }, 131 + "action": { 132 + "type": "string" 133 + } 134 + } 135 + }, 136 + "manualTriggerData": { 137 + "type": "object", 138 + "properties": { 139 + "inputs": { 140 + "type": "array", 141 + "items": { 142 + "type": "ref", 143 + "ref": "#pair" 144 + } 145 + } 146 + } 147 + }, 148 + "workflow": { 149 + "type": "object", 150 + "required": [ 151 + "name", 152 + "engine", 153 + "clone", 154 + "raw" 155 + ], 156 + "properties": { 157 + "name": { 158 + "type": "string" 159 + }, 160 + "engine": { 161 + "type": "string" 162 + }, 163 + "clone": { 164 + "type": "ref", 165 + "ref": "#cloneOpts" 166 + }, 167 + "raw": { 168 + "type": "string" 169 + } 170 + } 171 + }, 172 + "cloneOpts": { 173 + "type": "object", 174 + "required": [ 175 + "skip", 176 + "depth", 177 + "submodules" 178 + ], 179 + "properties": { 180 + "skip": { 181 + "type": "boolean" 182 + }, 183 + "depth": { 184 + "type": "integer" 185 + }, 186 + "submodules": { 187 + "type": "boolean" 188 + } 189 + } 190 + }, 191 + "pair": { 192 + "type": "object", 193 + "required": [ 194 + "key", 195 + "value" 196 + ], 197 + "properties": { 198 + "key": { 199 + "type": "string" 200 + }, 201 + "value": { 202 + "type": "string" 203 + } 204 + } 205 + } 206 + } 207 + }
-263
lexicons/pipeline.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.pipeline", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "triggerMetadata", 14 - "workflows" 15 - ], 16 - "properties": { 17 - "triggerMetadata": { 18 - "type": "ref", 19 - "ref": "#triggerMetadata" 20 - }, 21 - "workflows": { 22 - "type": "array", 23 - "items": { 24 - "type": "ref", 25 - "ref": "#workflow" 26 - } 27 - } 28 - } 29 - } 30 - }, 31 - "triggerMetadata": { 32 - "type": "object", 33 - "required": [ 34 - "kind", 35 - "repo" 36 - ], 37 - "properties": { 38 - "kind": { 39 - "type": "string", 40 - "enum": [ 41 - "push", 42 - "pull_request", 43 - "manual" 44 - ] 45 - }, 46 - "repo": { 47 - "type": "ref", 48 - "ref": "#triggerRepo" 49 - }, 50 - "push": { 51 - "type": "ref", 52 - "ref": "#pushTriggerData" 53 - }, 54 - "pullRequest": { 55 - "type": "ref", 56 - "ref": "#pullRequestTriggerData" 57 - }, 58 - "manual": { 59 - "type": "ref", 60 - "ref": "#manualTriggerData" 61 - } 62 - } 63 - }, 64 - "triggerRepo": { 65 - "type": "object", 66 - "required": [ 67 - "knot", 68 - "did", 69 - "repo", 70 - "defaultBranch" 71 - ], 72 - "properties": { 73 - "knot": { 74 - "type": "string" 75 - }, 76 - "did": { 77 - "type": "string", 78 - "format": "did" 79 - }, 80 - "repo": { 81 - "type": "string" 82 - }, 83 - "defaultBranch": { 84 - "type": "string" 85 - } 86 - } 87 - }, 88 - "pushTriggerData": { 89 - "type": "object", 90 - "required": [ 91 - "ref", 92 - "newSha", 93 - "oldSha" 94 - ], 95 - "properties": { 96 - "ref": { 97 - "type": "string" 98 - }, 99 - "newSha": { 100 - "type": "string", 101 - "minLength": 40, 102 - "maxLength": 40 103 - }, 104 - "oldSha": { 105 - "type": "string", 106 - "minLength": 40, 107 - "maxLength": 40 108 - } 109 - } 110 - }, 111 - "pullRequestTriggerData": { 112 - "type": "object", 113 - "required": [ 114 - "sourceBranch", 115 - "targetBranch", 116 - "sourceSha", 117 - "action" 118 - ], 119 - "properties": { 120 - "sourceBranch": { 121 - "type": "string" 122 - }, 123 - "targetBranch": { 124 - "type": "string" 125 - }, 126 - "sourceSha": { 127 - "type": "string", 128 - "minLength": 40, 129 - "maxLength": 40 130 - }, 131 - "action": { 132 - "type": "string" 133 - } 134 - } 135 - }, 136 - "manualTriggerData": { 137 - "type": "object", 138 - "properties": { 139 - "inputs": { 140 - "type": "array", 141 - "items": { 142 - "type": "ref", 143 - "ref": "#pair" 144 - } 145 - } 146 - } 147 - }, 148 - "workflow": { 149 - "type": "object", 150 - "required": [ 151 - "name", 152 - "dependencies", 153 - "steps", 154 - "environment", 155 - "clone" 156 - ], 157 - "properties": { 158 - "name": { 159 - "type": "string" 160 - }, 161 - "dependencies": { 162 - "type": "array", 163 - "items": { 164 - "type": "ref", 165 - "ref": "#dependency" 166 - } 167 - }, 168 - "steps": { 169 - "type": "array", 170 - "items": { 171 - "type": "ref", 172 - "ref": "#step" 173 - } 174 - }, 175 - "environment": { 176 - "type": "array", 177 - "items": { 178 - "type": "ref", 179 - "ref": "#pair" 180 - } 181 - }, 182 - "clone": { 183 - "type": "ref", 184 - "ref": "#cloneOpts" 185 - } 186 - } 187 - }, 188 - "dependency": { 189 - "type": "object", 190 - "required": [ 191 - "registry", 192 - "packages" 193 - ], 194 - "properties": { 195 - "registry": { 196 - "type": "string" 197 - }, 198 - "packages": { 199 - "type": "array", 200 - "items": { 201 - "type": "string" 202 - } 203 - } 204 - } 205 - }, 206 - "cloneOpts": { 207 - "type": "object", 208 - "required": [ 209 - "skip", 210 - "depth", 211 - "submodules" 212 - ], 213 - "properties": { 214 - "skip": { 215 - "type": "boolean" 216 - }, 217 - "depth": { 218 - "type": "integer" 219 - }, 220 - "submodules": { 221 - "type": "boolean" 222 - } 223 - } 224 - }, 225 - "step": { 226 - "type": "object", 227 - "required": [ 228 - "name", 229 - "command" 230 - ], 231 - "properties": { 232 - "name": { 233 - "type": "string" 234 - }, 235 - "command": { 236 - "type": "string" 237 - }, 238 - "environment": { 239 - "type": "array", 240 - "items": { 241 - "type": "ref", 242 - "ref": "#pair" 243 - } 244 - } 245 - } 246 - }, 247 - "pair": { 248 - "type": "object", 249 - "required": [ 250 - "key", 251 - "value" 252 - ], 253 - "properties": { 254 - "key": { 255 - "type": "string" 256 - }, 257 - "value": { 258 - "type": "string" 259 - } 260 - } 261 - } 262 - } 263 - }
-31
lexicons/removeSecret.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.removeSecret", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "description": "Remove a CI secret", 8 - "input": { 9 - "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": [ 13 - "repo", 14 - "key" 15 - ], 16 - "properties": { 17 - "repo": { 18 - "type": "string", 19 - "format": "at-uri" 20 - }, 21 - "key": { 22 - "type": "string", 23 - "maxLength": 50, 24 - "minLength": 1 25 - } 26 - } 27 - } 28 - } 29 - } 30 - } 31 - }
+37
lexicons/repo/addSecret.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.addSecret", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Add a CI secret", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "key", 15 + "value" 16 + ], 17 + "properties": { 18 + "repo": { 19 + "type": "string", 20 + "format": "at-uri" 21 + }, 22 + "key": { 23 + "type": "string", 24 + "maxLength": 50, 25 + "minLength": 1 26 + }, 27 + "value": { 28 + "type": "string", 29 + "maxLength": 200, 30 + "minLength": 1 31 + } 32 + } 33 + } 34 + } 35 + } 36 + } 37 + }
+52
lexicons/repo/artifact.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.artifact", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "name", 14 + "repo", 15 + "tag", 16 + "createdAt", 17 + "artifact" 18 + ], 19 + "properties": { 20 + "name": { 21 + "type": "string", 22 + "description": "name of the artifact" 23 + }, 24 + "repo": { 25 + "type": "string", 26 + "format": "at-uri", 27 + "description": "repo that this artifact is being uploaded to" 28 + }, 29 + "tag": { 30 + "type": "bytes", 31 + "description": "hash of the tag object that this artifact is attached to (only annotated tags are supported)", 32 + "minLength": 20, 33 + "maxLength": 20 34 + }, 35 + "createdAt": { 36 + "type": "string", 37 + "format": "datetime", 38 + "description": "time of creation of this artifact" 39 + }, 40 + "artifact": { 41 + "type": "blob", 42 + "description": "the artifact", 43 + "accept": [ 44 + "*/*" 45 + ], 46 + "maxSize": 52428800 47 + } 48 + } 49 + } 50 + } 51 + } 52 + }
+36
lexicons/repo/collaborator.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.collaborator", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "subject", 14 + "repo", 15 + "createdAt" 16 + ], 17 + "properties": { 18 + "subject": { 19 + "type": "string", 20 + "format": "did" 21 + }, 22 + "repo": { 23 + "type": "string", 24 + "description": "repo to add this user to", 25 + "format": "at-uri" 26 + }, 27 + "createdAt": { 28 + "type": "string", 29 + "format": "datetime" 30 + } 31 + } 32 + } 33 + } 34 + } 35 + } 36 +
+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
··· 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
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.removeSecret", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Remove a CI secret", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "key" 15 + ], 16 + "properties": { 17 + "repo": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "key": { 22 + "type": "string", 23 + "maxLength": 50, 24 + "minLength": 1 25 + } 26 + } 27 + } 28 + } 29 + } 30 + } 31 + }
+54
lexicons/repo/repo.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "name", 14 + "knot", 15 + "owner", 16 + "createdAt" 17 + ], 18 + "properties": { 19 + "name": { 20 + "type": "string", 21 + "description": "name of the repo" 22 + }, 23 + "owner": { 24 + "type": "string", 25 + "format": "did" 26 + }, 27 + "knot": { 28 + "type": "string", 29 + "description": "knot where the repo was created" 30 + }, 31 + "spindle": { 32 + "type": "string", 33 + "description": "CI runner to send jobs to and receive results from" 34 + }, 35 + "description": { 36 + "type": "string", 37 + "format": "datetime", 38 + "minGraphemes": 1, 39 + "maxGraphemes": 140 40 + }, 41 + "source": { 42 + "type": "string", 43 + "format": "uri", 44 + "description": "source of the repo" 45 + }, 46 + "createdAt": { 47 + "type": "string", 48 + "format": "datetime" 49 + } 50 + } 51 + } 52 + } 53 + } 54 + }
-54
lexicons/repo.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "name", 14 - "knot", 15 - "owner", 16 - "createdAt" 17 - ], 18 - "properties": { 19 - "name": { 20 - "type": "string", 21 - "description": "name of the repo" 22 - }, 23 - "owner": { 24 - "type": "string", 25 - "format": "did" 26 - }, 27 - "knot": { 28 - "type": "string", 29 - "description": "knot where the repo was created" 30 - }, 31 - "spindle": { 32 - "type": "string", 33 - "description": "CI runner to send jobs to and receive results from" 34 - }, 35 - "description": { 36 - "type": "string", 37 - "format": "datetime", 38 - "minGraphemes": 1, 39 - "maxGraphemes": 140 40 - }, 41 - "source": { 42 - "type": "string", 43 - "format": "uri", 44 - "description": "source of the repo" 45 - }, 46 - "createdAt": { 47 - "type": "string", 48 - "format": "datetime" 49 - } 50 - } 51 - } 52 - } 53 - } 54 - }
+25
lexicons/spindle/spindle.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.spindle", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "any", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "createdAt" 14 + ], 15 + "properties": { 16 + "createdAt": { 17 + "type": "string", 18 + "format": "datetime" 19 + } 20 + } 21 + } 22 + } 23 + } 24 + } 25 +
-25
lexicons/spindle.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.spindle", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "any", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "createdAt" 14 - ], 15 - "properties": { 16 - "createdAt": { 17 - "type": "string", 18 - "format": "datetime" 19 - } 20 - } 21 - } 22 - } 23 - } 24 - } 25 -
+40
lexicons/string/string.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.string", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "filename", 14 + "description", 15 + "createdAt", 16 + "contents" 17 + ], 18 + "properties": { 19 + "filename": { 20 + "type": "string", 21 + "maxGraphemes": 140, 22 + "minGraphemes": 1 23 + }, 24 + "description": { 25 + "type": "string", 26 + "maxGraphemes": 280 27 + }, 28 + "createdAt": { 29 + "type": "string", 30 + "format": "datetime" 31 + }, 32 + "contents": { 33 + "type": "string", 34 + "minGraphemes": 1 35 + } 36 + } 37 + } 38 + } 39 + } 40 + }
+3 -1
log/log.go
··· 9 9 // NewHandler sets up a new slog.Handler with the service name 10 10 // as an attribute 11 11 func NewHandler(name string) slog.Handler { 12 - handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{}) 12 + handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ 13 + Level: slog.LevelDebug, 14 + }) 13 15 14 16 var attrs []slog.Attr 15 17 attrs = append(attrs, slog.Attr{Key: "service", Value: slog.StringValue(name)})
+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
··· 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
··· 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
··· 54 54 example = "did:plc:qfpnj4og54vl56wngdriaxug"; 55 55 description = "DID of owner (required)"; 56 56 }; 57 + 58 + secrets = { 59 + provider = mkOption { 60 + type = types.str; 61 + default = "sqlite"; 62 + description = "Backend to use for secret management, valid options are 'sqlite', and 'openbao'."; 63 + }; 64 + 65 + openbao = { 66 + proxyAddr = mkOption { 67 + type = types.str; 68 + default = "http://127.0.0.1:8200"; 69 + }; 70 + mount = mkOption { 71 + type = types.str; 72 + default = "spindle"; 73 + }; 74 + }; 75 + }; 57 76 }; 58 77 59 78 pipelines = { ··· 89 108 "SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}" 90 109 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 91 110 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 92 - "SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 93 - "SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 111 + "SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}" 112 + "SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}" 113 + "SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}" 114 + "SPINDLE_NIXERY_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 115 + "SPINDLE_NIXERY_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 94 116 ]; 95 117 ExecStart = "${cfg.package}/bin/spindle"; 96 118 Restart = "always";
+29
nix/pkgs/appview-static-files.nix
··· 1 + { 2 + runCommandLocal, 3 + htmx-src, 4 + htmx-ws-src, 5 + lucide-src, 6 + inter-fonts-src, 7 + ibm-plex-mono-src, 8 + sqlite-lib, 9 + tailwindcss, 10 + src, 11 + }: 12 + runCommandLocal "appview-static-files" { 13 + # TOOD(winter): figure out why this is even required after 14 + # changing the libraries that the tailwindcss binary loads 15 + sandboxProfile = '' 16 + (allow file-read* (subpath "/System/Library/OpenSSL")) 17 + ''; 18 + } '' 19 + mkdir -p $out/{fonts,icons} && cd $out 20 + cp -f ${htmx-src} htmx.min.js 21 + cp -f ${htmx-ws-src} htmx-ext-ws.min.js 22 + cp -rf ${lucide-src}/*.svg icons/ 23 + cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/ 24 + cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 25 + cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 26 + # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 27 + # for whatever reason (produces broken css), so we are doing this instead 28 + cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css 29 + ''
+5 -17
nix/pkgs/appview.nix
··· 1 1 { 2 2 buildGoApplication, 3 3 modules, 4 - htmx-src, 5 - htmx-ws-src, 6 - lucide-src, 7 - inter-fonts-src, 8 - ibm-plex-mono-src, 9 - tailwindcss, 4 + appview-static-files, 10 5 sqlite-lib, 11 - gitignoreSource, 6 + src, 12 7 }: 13 8 buildGoApplication { 14 9 pname = "appview"; 15 10 version = "0.1.0"; 16 - src = gitignoreSource ../..; 17 - inherit modules; 11 + inherit src modules; 18 12 19 13 postUnpack = '' 20 14 pushd source 21 - mkdir -p appview/pages/static/{fonts,icons} 22 - cp -f ${htmx-src} appview/pages/static/htmx.min.js 23 - cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js 24 - cp -rf ${lucide-src}/*.svg appview/pages/static/icons/ 25 - cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/ 26 - cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/ 27 - cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/ 28 - ${tailwindcss}/bin/tailwindcss -i input.css -o appview/pages/static/tw.css 15 + mkdir -p appview/pages/static 16 + cp -frv ${appview-static-files}/* appview/pages/static 29 17 popd 30 18 ''; 31 19
+7 -3
nix/pkgs/genjwks.nix
··· 1 1 { 2 - gitignoreSource, 3 2 buildGoApplication, 4 3 modules, 5 4 }: 6 5 buildGoApplication { 7 6 pname = "genjwks"; 8 7 version = "0.1.0"; 9 - src = gitignoreSource ../..; 8 + src = ../../cmd/genjwks; 9 + postPatch = '' 10 + ln -s ${../../go.mod} ./go.mod 11 + ''; 12 + postInstall = '' 13 + mv $out/bin/core $out/bin/genjwks 14 + ''; 10 15 inherit modules; 11 - subPackages = ["cmd/genjwks"]; 12 16 doCheck = false; 13 17 CGO_ENABLED = 0; 14 18 }
+2 -3
nix/pkgs/knot-unwrapped.nix
··· 2 2 buildGoApplication, 3 3 modules, 4 4 sqlite-lib, 5 - gitignoreSource, 5 + src, 6 6 }: 7 7 buildGoApplication { 8 8 pname = "knot"; 9 9 version = "0.1.0"; 10 - src = gitignoreSource ../..; 11 - inherit modules; 10 + inherit src modules; 12 11 13 12 doCheck = false; 14 13
+1 -1
nix/pkgs/lexgen.nix
··· 7 7 version = "0.1.0"; 8 8 src = indigo; 9 9 subPackages = ["cmd/lexgen"]; 10 - vendorHash = "sha256-pGc29fgJFq8LP7n/pY1cv6ExZl88PAeFqIbFEhB3xXs="; 10 + vendorHash = "sha256-VbDrcN4r5b7utRFQzVsKgDsVgdQLSXl7oZ5kdPA/huw="; 11 11 doCheck = false; 12 12 }
+2 -3
nix/pkgs/spindle.nix
··· 2 2 buildGoApplication, 3 3 modules, 4 4 sqlite-lib, 5 - gitignoreSource, 5 + src, 6 6 }: 7 7 buildGoApplication { 8 8 pname = "spindle"; 9 9 version = "0.1.0"; 10 - src = gitignoreSource ../..; 11 - inherit modules; 10 + inherit src modules; 12 11 13 12 doCheck = false; 14 13
+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
··· 43 43 return nil, err 44 44 } 45 45 46 - db, err := sql.Open("sqlite3", path) 46 + db, err := sql.Open("sqlite3", path+"?_foreign_keys=1") 47 47 if err != nil { 48 48 return nil, err 49 49 }
+1 -1
rbac/rbac_test.go
··· 14 14 ) 15 15 16 16 func setup(t *testing.T) *rbac.Enforcer { 17 - db, err := sql.Open("sqlite3", ":memory:") 17 + db, err := sql.Open("sqlite3", ":memory:?_foreign_keys=1") 18 18 assert.NoError(t, err) 19 19 20 20 a, err := adapter.NewAdapter(db, "sqlite3", "acl")
+4 -4
spindle/config/config.go
··· 16 16 Dev bool `env:"DEV, default=false"` 17 17 Owner string `env:"OWNER, required"` 18 18 Secrets Secrets `env:",prefix=SECRETS_"` 19 + LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 19 20 } 20 21 21 22 func (s Server) Did() syntax.DID { ··· 32 33 Mount string `env:"MOUNT, default=spindle"` 33 34 } 34 35 35 - type Pipelines struct { 36 + type NixeryPipelines struct { 36 37 Nixery string `env:"NIXERY, default=nixery.tangled.sh"` 37 38 WorkflowTimeout string `env:"WORKFLOW_TIMEOUT, default=5m"` 38 - LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 39 39 } 40 40 41 41 type Config struct { 42 - Server Server `env:",prefix=SPINDLE_SERVER_"` 43 - Pipelines Pipelines `env:",prefix=SPINDLE_PIPELINES_"` 42 + Server Server `env:",prefix=SPINDLE_SERVER_"` 43 + NixeryPipelines NixeryPipelines `env:",prefix=SPINDLE_NIXERY_PIPELINES_"` 44 44 } 45 45 46 46 func Load(ctx context.Context) (*Config, error) {
+29 -10
spindle/db/db.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "strings" 5 6 6 7 _ "github.com/mattn/go-sqlite3" 7 8 ) ··· 11 12 } 12 13 13 14 func Make(dbPath string) (*DB, error) { 14 - db, err := sql.Open("sqlite3", dbPath) 15 + // https://github.com/mattn/go-sqlite3#connection-string 16 + opts := []string{ 17 + "_foreign_keys=1", 18 + "_journal_mode=WAL", 19 + "_synchronous=NORMAL", 20 + "_auto_vacuum=incremental", 21 + } 22 + 23 + db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 15 24 if err != nil { 16 25 return nil, err 17 26 } 27 + 28 + // NOTE: If any other migration is added here, you MUST 29 + // copy the pattern in appview: use a single sql.Conn 30 + // for every migration. 18 31 19 32 _, err = db.Exec(` 20 - pragma journal_mode = WAL; 21 - pragma synchronous = normal; 22 - pragma foreign_keys = on; 23 - pragma temp_store = memory; 24 - pragma mmap_size = 30000000000; 25 - pragma page_size = 32768; 26 - pragma auto_vacuum = incremental; 27 - pragma busy_timeout = 5000; 28 - 29 33 create table if not exists _jetstream ( 30 34 id integer primary key autoincrement, 31 35 last_time_us integer not null ··· 43 47 addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 44 48 45 49 unique(owner, name) 50 + ); 51 + 52 + create table if not exists spindle_members ( 53 + -- identifiers for the record 54 + id integer primary key autoincrement, 55 + did text not null, 56 + rkey text not null, 57 + 58 + -- data 59 + instance text not null, 60 + subject text not null, 61 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 62 + 63 + -- constraints 64 + unique (did, instance, subject) 46 65 ); 47 66 48 67 -- status event for a single workflow
+59
spindle/db/member.go
··· 1 + package db 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type SpindleMember struct { 10 + Id int 11 + Did syntax.DID // owner of the record 12 + Rkey string // rkey of the record 13 + Instance string 14 + Subject syntax.DID // the member being added 15 + Created time.Time 16 + } 17 + 18 + func AddSpindleMember(db *DB, member SpindleMember) error { 19 + _, err := db.Exec( 20 + `insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`, 21 + member.Did, 22 + member.Rkey, 23 + member.Instance, 24 + member.Subject, 25 + ) 26 + return err 27 + } 28 + 29 + func RemoveSpindleMember(db *DB, owner_did, rkey string) error { 30 + _, err := db.Exec( 31 + "delete from spindle_members where did = ? and rkey = ?", 32 + owner_did, 33 + rkey, 34 + ) 35 + return err 36 + } 37 + 38 + func GetSpindleMember(db *DB, did, rkey string) (*SpindleMember, error) { 39 + query := 40 + `select id, did, rkey, instance, subject, created 41 + from spindle_members 42 + where did = ? and rkey = ?` 43 + 44 + var member SpindleMember 45 + var createdAt string 46 + err := db.QueryRow(query, did, rkey).Scan( 47 + &member.Id, 48 + &member.Did, 49 + &member.Rkey, 50 + &member.Instance, 51 + &member.Subject, 52 + &createdAt, 53 + ) 54 + if err != nil { 55 + return nil, err 56 + } 57 + 58 + return &member, nil 59 + }
-21
spindle/engine/ansi_stripper.go
··· 1 - package engine 2 - 3 - import ( 4 - "io" 5 - 6 - "regexp" 7 - ) 8 - 9 - // regex to match ANSI escape codes (e.g., color codes, cursor moves) 10 - const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" 11 - 12 - var re = regexp.MustCompile(ansi) 13 - 14 - type ansiStrippingWriter struct { 15 - underlying io.Writer 16 - } 17 - 18 - func (w *ansiStrippingWriter) Write(p []byte) (int, error) { 19 - clean := re.ReplaceAll(p, []byte{}) 20 - return w.underlying.Write(clean) 21 - }
+68 -415
spindle/engine/engine.go
··· 4 4 "context" 5 5 "errors" 6 6 "fmt" 7 - "io" 8 7 "log/slog" 9 - "os" 10 - "strings" 11 - "sync" 12 - "time" 13 8 14 9 securejoin "github.com/cyphar/filepath-securejoin" 15 - "github.com/docker/docker/api/types/container" 16 - "github.com/docker/docker/api/types/image" 17 - "github.com/docker/docker/api/types/mount" 18 - "github.com/docker/docker/api/types/network" 19 - "github.com/docker/docker/api/types/volume" 20 - "github.com/docker/docker/client" 21 - "github.com/docker/docker/pkg/stdcopy" 22 10 "golang.org/x/sync/errgroup" 23 - "tangled.sh/tangled.sh/core/log" 24 11 "tangled.sh/tangled.sh/core/notifier" 25 12 "tangled.sh/tangled.sh/core/spindle/config" 26 13 "tangled.sh/tangled.sh/core/spindle/db" ··· 28 15 "tangled.sh/tangled.sh/core/spindle/secrets" 29 16 ) 30 17 31 - const ( 32 - workspaceDir = "/tangled/workspace" 18 + var ( 19 + ErrTimedOut = errors.New("timed out") 20 + ErrWorkflowFailed = errors.New("workflow failed") 33 21 ) 34 22 35 - type cleanupFunc func(context.Context) error 36 - 37 - type Engine struct { 38 - docker client.APIClient 39 - l *slog.Logger 40 - db *db.DB 41 - n *notifier.Notifier 42 - cfg *config.Config 43 - vault secrets.Manager 44 - 45 - cleanupMu sync.Mutex 46 - cleanup map[string][]cleanupFunc 47 - } 48 - 49 - func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier, vault secrets.Manager) (*Engine, error) { 50 - dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 51 - if err != nil { 52 - return nil, err 53 - } 54 - 55 - l := log.FromContext(ctx).With("component", "spindle") 56 - 57 - e := &Engine{ 58 - docker: dcli, 59 - l: l, 60 - db: db, 61 - n: n, 62 - cfg: cfg, 63 - vault: vault, 64 - } 65 - 66 - e.cleanup = make(map[string][]cleanupFunc) 67 - 68 - return e, nil 69 - } 70 - 71 - func (e *Engine) StartWorkflows(ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) { 72 - e.l.Info("starting all workflows in parallel", "pipeline", pipelineId) 23 + func StartWorkflows(l *slog.Logger, vault secrets.Manager, cfg *config.Config, db *db.DB, n *notifier.Notifier, ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) { 24 + l.Info("starting all workflows in parallel", "pipeline", pipelineId) 73 25 74 26 // extract secrets 75 27 var allSecrets []secrets.UnlockedSecret 76 28 if didSlashRepo, err := securejoin.SecureJoin(pipeline.RepoOwner, pipeline.RepoName); err == nil { 77 - if res, err := e.vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)); err == nil { 29 + if res, err := vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)); err == nil { 78 30 allSecrets = res 79 31 } 80 32 } 81 33 82 - workflowTimeoutStr := e.cfg.Pipelines.WorkflowTimeout 83 - workflowTimeout, err := time.ParseDuration(workflowTimeoutStr) 84 - if err != nil { 85 - e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr) 86 - workflowTimeout = 5 * time.Minute 87 - } 88 - e.l.Info("using workflow timeout", "timeout", workflowTimeout) 89 - 90 34 eg, ctx := errgroup.WithContext(ctx) 91 - for _, w := range pipeline.Workflows { 92 - eg.Go(func() error { 93 - wid := models.WorkflowId{ 94 - PipelineId: pipelineId, 95 - Name: w.Name, 96 - } 97 - 98 - err := e.db.StatusRunning(wid, e.n) 99 - if err != nil { 100 - return err 101 - } 35 + for eng, wfs := range pipeline.Workflows { 36 + workflowTimeout := eng.WorkflowTimeout() 37 + l.Info("using workflow timeout", "timeout", workflowTimeout) 102 38 103 - err = e.SetupWorkflow(ctx, wid) 104 - if err != nil { 105 - e.l.Error("setting up worklow", "wid", wid, "err", err) 106 - return err 107 - } 108 - defer e.DestroyWorkflow(ctx, wid) 109 - 110 - reader, err := e.docker.ImagePull(ctx, w.Image, image.PullOptions{}) 111 - if err != nil { 112 - e.l.Error("pipeline image pull failed!", "image", w.Image, "workflowId", wid, "error", err.Error()) 39 + for _, w := range wfs { 40 + eg.Go(func() error { 41 + wid := models.WorkflowId{ 42 + PipelineId: pipelineId, 43 + Name: w.Name, 44 + } 113 45 114 - err := e.db.StatusFailed(wid, err.Error(), -1, e.n) 46 + err := db.StatusRunning(wid, n) 115 47 if err != nil { 116 48 return err 117 49 } 118 50 119 - return fmt.Errorf("pulling image: %w", err) 120 - } 121 - defer reader.Close() 122 - io.Copy(os.Stdout, reader) 123 - 124 - ctx, cancel := context.WithTimeout(ctx, workflowTimeout) 125 - defer cancel() 51 + err = eng.SetupWorkflow(ctx, wid, &w) 52 + if err != nil { 53 + // TODO(winter): Should this always set StatusFailed? 54 + // In the original, we only do in a subset of cases. 55 + l.Error("setting up worklow", "wid", wid, "err", err) 126 56 127 - err = e.StartSteps(ctx, wid, w, allSecrets) 128 - if err != nil { 129 - if errors.Is(err, ErrTimedOut) { 130 - dbErr := e.db.StatusTimeout(wid, e.n) 131 - if dbErr != nil { 132 - return dbErr 57 + destroyErr := eng.DestroyWorkflow(ctx, wid) 58 + if destroyErr != nil { 59 + l.Error("failed to destroy workflow after setup failure", "error", destroyErr) 133 60 } 134 - } else { 135 - dbErr := e.db.StatusFailed(wid, err.Error(), -1, e.n) 61 + 62 + dbErr := db.StatusFailed(wid, err.Error(), -1, n) 136 63 if dbErr != nil { 137 64 return dbErr 138 65 } 66 + return err 139 67 } 68 + defer eng.DestroyWorkflow(ctx, wid) 140 69 141 - return fmt.Errorf("starting steps image: %w", err) 142 - } 70 + wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid) 71 + if err != nil { 72 + l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 73 + wfLogger = nil 74 + } else { 75 + defer wfLogger.Close() 76 + } 143 77 144 - err = e.db.StatusSuccess(wid, e.n) 145 - if err != nil { 146 - return err 147 - } 78 + ctx, cancel := context.WithTimeout(ctx, workflowTimeout) 79 + defer cancel() 148 80 149 - return nil 150 - }) 151 - } 81 + for stepIdx, step := range w.Steps { 82 + if wfLogger != nil { 83 + ctl := wfLogger.ControlWriter(stepIdx, step) 84 + ctl.Write([]byte(step.Name())) 85 + } 152 86 153 - if err = eg.Wait(); err != nil { 154 - e.l.Error("failed to run one or more workflows", "err", err) 155 - } else { 156 - e.l.Error("successfully ran full pipeline") 157 - } 158 - } 87 + err = eng.RunStep(ctx, wid, &w, stepIdx, allSecrets, wfLogger) 88 + if err != nil { 89 + if errors.Is(err, ErrTimedOut) { 90 + dbErr := db.StatusTimeout(wid, n) 91 + if dbErr != nil { 92 + return dbErr 93 + } 94 + } else { 95 + dbErr := db.StatusFailed(wid, err.Error(), -1, n) 96 + if dbErr != nil { 97 + return dbErr 98 + } 99 + } 159 100 160 - // SetupWorkflow sets up a new network for the workflow and volumes for 161 - // the workspace and Nix store. These are persisted across steps and are 162 - // destroyed at the end of the workflow. 163 - func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId) error { 164 - e.l.Info("setting up workflow", "workflow", wid) 101 + return fmt.Errorf("starting steps image: %w", err) 102 + } 103 + } 165 104 166 - _, err := e.docker.VolumeCreate(ctx, volume.CreateOptions{ 167 - Name: workspaceVolume(wid), 168 - Driver: "local", 169 - }) 170 - if err != nil { 171 - return err 172 - } 173 - e.registerCleanup(wid, func(ctx context.Context) error { 174 - return e.docker.VolumeRemove(ctx, workspaceVolume(wid), true) 175 - }) 176 - 177 - _, err = e.docker.VolumeCreate(ctx, volume.CreateOptions{ 178 - Name: nixVolume(wid), 179 - Driver: "local", 180 - }) 181 - if err != nil { 182 - return err 183 - } 184 - e.registerCleanup(wid, func(ctx context.Context) error { 185 - return e.docker.VolumeRemove(ctx, nixVolume(wid), true) 186 - }) 187 - 188 - _, err = e.docker.NetworkCreate(ctx, networkName(wid), network.CreateOptions{ 189 - Driver: "bridge", 190 - }) 191 - if err != nil { 192 - return err 193 - } 194 - e.registerCleanup(wid, func(ctx context.Context) error { 195 - return e.docker.NetworkRemove(ctx, networkName(wid)) 196 - }) 105 + err = db.StatusSuccess(wid, n) 106 + if err != nil { 107 + return err 108 + } 197 109 198 - return nil 199 - } 200 - 201 - // StartSteps starts all steps sequentially with the same base image. 202 - // ONLY marks pipeline as failed if container's exit code is non-zero. 203 - // All other errors are bubbled up. 204 - // Fixed version of the step execution logic 205 - func (e *Engine) StartSteps(ctx context.Context, wid models.WorkflowId, w models.Workflow, secrets []secrets.UnlockedSecret) error { 206 - workflowEnvs := ConstructEnvs(w.Environment) 207 - for _, s := range secrets { 208 - workflowEnvs.AddEnv(s.Key, s.Value) 209 - } 210 - 211 - for stepIdx, step := range w.Steps { 212 - select { 213 - case <-ctx.Done(): 214 - return ctx.Err() 215 - default: 216 - } 217 - 218 - envs := append(EnvVars(nil), workflowEnvs...) 219 - for k, v := range step.Environment { 220 - envs.AddEnv(k, v) 221 - } 222 - envs.AddEnv("HOME", workspaceDir) 223 - e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice()) 224 - 225 - hostConfig := hostConfig(wid) 226 - resp, err := e.docker.ContainerCreate(ctx, &container.Config{ 227 - Image: w.Image, 228 - Cmd: []string{"bash", "-c", step.Command}, 229 - WorkingDir: workspaceDir, 230 - Tty: false, 231 - Hostname: "spindle", 232 - Env: envs.Slice(), 233 - }, hostConfig, nil, nil, "") 234 - defer e.DestroyStep(ctx, resp.ID) 235 - if err != nil { 236 - return fmt.Errorf("creating container: %w", err) 237 - } 238 - 239 - err = e.docker.NetworkConnect(ctx, networkName(wid), resp.ID, nil) 240 - if err != nil { 241 - return fmt.Errorf("connecting network: %w", err) 242 - } 243 - 244 - err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}) 245 - if err != nil { 246 - return err 247 - } 248 - e.l.Info("started container", "name", resp.ID, "step", step.Name) 249 - 250 - // start tailing logs in background 251 - tailDone := make(chan error, 1) 252 - go func() { 253 - tailDone <- e.TailStep(ctx, resp.ID, wid, stepIdx, step) 254 - }() 255 - 256 - // wait for container completion or timeout 257 - waitDone := make(chan struct{}) 258 - var state *container.State 259 - var waitErr error 260 - 261 - go func() { 262 - defer close(waitDone) 263 - state, waitErr = e.WaitStep(ctx, resp.ID) 264 - }() 265 - 266 - select { 267 - case <-waitDone: 268 - 269 - // wait for tailing to complete 270 - <-tailDone 271 - 272 - case <-ctx.Done(): 273 - e.l.Warn("step timed out; killing container", "container", resp.ID, "step", step.Name) 274 - err = e.DestroyStep(context.Background(), resp.ID) 275 - if err != nil { 276 - e.l.Error("failed to destroy step", "container", resp.ID, "error", err) 277 - } 278 - 279 - // wait for both goroutines to finish 280 - <-waitDone 281 - <-tailDone 282 - 283 - return ErrTimedOut 284 - } 285 - 286 - select { 287 - case <-ctx.Done(): 288 - return ctx.Err() 289 - default: 290 - } 291 - 292 - if waitErr != nil { 293 - return waitErr 294 - } 295 - 296 - err = e.DestroyStep(ctx, resp.ID) 297 - if err != nil { 298 - return err 299 - } 300 - 301 - if state.ExitCode != 0 { 302 - e.l.Error("workflow failed!", "workflow_id", wid.String(), "error", state.Error, "exit_code", state.ExitCode, "oom_killed", state.OOMKilled) 303 - if state.OOMKilled { 304 - return ErrOOMKilled 305 - } 306 - return ErrWorkflowFailed 110 + return nil 111 + }) 307 112 } 308 113 } 309 114 310 - return nil 311 - } 312 - 313 - func (e *Engine) WaitStep(ctx context.Context, containerID string) (*container.State, error) { 314 - wait, errCh := e.docker.ContainerWait(ctx, containerID, container.WaitConditionNotRunning) 315 - select { 316 - case err := <-errCh: 317 - if err != nil { 318 - return nil, err 319 - } 320 - case <-wait: 321 - } 322 - 323 - e.l.Info("waited for container", "name", containerID) 324 - 325 - info, err := e.docker.ContainerInspect(ctx, containerID) 326 - if err != nil { 327 - return nil, err 328 - } 329 - 330 - return info.State, nil 331 - } 332 - 333 - func (e *Engine) TailStep(ctx context.Context, containerID string, wid models.WorkflowId, stepIdx int, step models.Step) error { 334 - wfLogger, err := NewWorkflowLogger(e.cfg.Pipelines.LogDir, wid) 335 - if err != nil { 336 - e.l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 337 - return err 115 + if err := eg.Wait(); err != nil { 116 + l.Error("failed to run one or more workflows", "err", err) 117 + } else { 118 + l.Error("successfully ran full pipeline") 338 119 } 339 - defer wfLogger.Close() 340 - 341 - ctl := wfLogger.ControlWriter(stepIdx, step) 342 - ctl.Write([]byte(step.Name)) 343 - 344 - logs, err := e.docker.ContainerLogs(ctx, containerID, container.LogsOptions{ 345 - Follow: true, 346 - ShowStdout: true, 347 - ShowStderr: true, 348 - Details: false, 349 - Timestamps: false, 350 - }) 351 - if err != nil { 352 - return err 353 - } 354 - 355 - _, err = stdcopy.StdCopy( 356 - wfLogger.DataWriter("stdout"), 357 - wfLogger.DataWriter("stderr"), 358 - logs, 359 - ) 360 - if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) { 361 - return fmt.Errorf("failed to copy logs: %w", err) 362 - } 363 - 364 - return nil 365 - } 366 - 367 - func (e *Engine) DestroyStep(ctx context.Context, containerID string) error { 368 - err := e.docker.ContainerKill(ctx, containerID, "9") // SIGKILL 369 - if err != nil && !isErrContainerNotFoundOrNotRunning(err) { 370 - return err 371 - } 372 - 373 - if err := e.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{ 374 - RemoveVolumes: true, 375 - RemoveLinks: false, 376 - Force: false, 377 - }); err != nil && !isErrContainerNotFoundOrNotRunning(err) { 378 - return err 379 - } 380 - 381 - return nil 382 - } 383 - 384 - func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error { 385 - e.cleanupMu.Lock() 386 - key := wid.String() 387 - 388 - fns := e.cleanup[key] 389 - delete(e.cleanup, key) 390 - e.cleanupMu.Unlock() 391 - 392 - for _, fn := range fns { 393 - if err := fn(ctx); err != nil { 394 - e.l.Error("failed to cleanup workflow resource", "workflowId", wid, "error", err) 395 - } 396 - } 397 - return nil 398 - } 399 - 400 - func (e *Engine) registerCleanup(wid models.WorkflowId, fn cleanupFunc) { 401 - e.cleanupMu.Lock() 402 - defer e.cleanupMu.Unlock() 403 - 404 - key := wid.String() 405 - e.cleanup[key] = append(e.cleanup[key], fn) 406 - } 407 - 408 - func workspaceVolume(wid models.WorkflowId) string { 409 - return fmt.Sprintf("workspace-%s", wid) 410 - } 411 - 412 - func nixVolume(wid models.WorkflowId) string { 413 - return fmt.Sprintf("nix-%s", wid) 414 - } 415 - 416 - func networkName(wid models.WorkflowId) string { 417 - return fmt.Sprintf("workflow-network-%s", wid) 418 - } 419 - 420 - func hostConfig(wid models.WorkflowId) *container.HostConfig { 421 - hostConfig := &container.HostConfig{ 422 - Mounts: []mount.Mount{ 423 - { 424 - Type: mount.TypeVolume, 425 - Source: workspaceVolume(wid), 426 - Target: workspaceDir, 427 - }, 428 - { 429 - Type: mount.TypeVolume, 430 - Source: nixVolume(wid), 431 - Target: "/nix", 432 - }, 433 - { 434 - Type: mount.TypeTmpfs, 435 - Target: "/tmp", 436 - ReadOnly: false, 437 - TmpfsOptions: &mount.TmpfsOptions{ 438 - Mode: 0o1777, // world-writeable sticky bit 439 - Options: [][]string{ 440 - {"exec"}, 441 - }, 442 - }, 443 - }, 444 - { 445 - Type: mount.TypeVolume, 446 - Source: "etc-nix-" + wid.String(), 447 - Target: "/etc/nix", 448 - }, 449 - }, 450 - ReadonlyRootfs: false, 451 - CapDrop: []string{"ALL"}, 452 - CapAdd: []string{"CAP_DAC_OVERRIDE"}, 453 - SecurityOpt: []string{"no-new-privileges"}, 454 - ExtraHosts: []string{"host.docker.internal:host-gateway"}, 455 - } 456 - 457 - return hostConfig 458 - } 459 - 460 - // thanks woodpecker 461 - func isErrContainerNotFoundOrNotRunning(err error) bool { 462 - // Error response from daemon: Cannot kill container: ...: No such container: ... 463 - // Error response from daemon: Cannot kill container: ...: Container ... is not running" 464 - // Error response from podman daemon: can only kill running containers. ... is in state exited 465 - // Error: No such container: ... 466 - return err != nil && (strings.Contains(err.Error(), "No such container") || strings.Contains(err.Error(), "is not running") || strings.Contains(err.Error(), "can only kill running containers")) 467 120 }
-28
spindle/engine/envs.go
··· 1 - package engine 2 - 3 - import ( 4 - "fmt" 5 - ) 6 - 7 - type EnvVars []string 8 - 9 - // ConstructEnvs converts a tangled.Pipeline_Step_Environment_Elem.{Key,Value} 10 - // representation into a docker-friendly []string{"KEY=value", ...} slice. 11 - func ConstructEnvs(envs map[string]string) EnvVars { 12 - var dockerEnvs EnvVars 13 - for k, v := range envs { 14 - ev := fmt.Sprintf("%s=%s", k, v) 15 - dockerEnvs = append(dockerEnvs, ev) 16 - } 17 - return dockerEnvs 18 - } 19 - 20 - // Slice returns the EnvVar as a []string slice. 21 - func (ev EnvVars) Slice() []string { 22 - return ev 23 - } 24 - 25 - // AddEnv adds a key=value string to the EnvVar. 26 - func (ev *EnvVars) AddEnv(key, value string) { 27 - *ev = append(*ev, fmt.Sprintf("%s=%s", key, value)) 28 - }
-48
spindle/engine/envs_test.go
··· 1 - package engine 2 - 3 - import ( 4 - "testing" 5 - 6 - "github.com/stretchr/testify/assert" 7 - ) 8 - 9 - func TestConstructEnvs(t *testing.T) { 10 - tests := []struct { 11 - name string 12 - in map[string]string 13 - want EnvVars 14 - }{ 15 - { 16 - name: "empty input", 17 - in: make(map[string]string), 18 - want: EnvVars{}, 19 - }, 20 - { 21 - name: "single env var", 22 - in: map[string]string{"FOO": "bar"}, 23 - want: EnvVars{"FOO=bar"}, 24 - }, 25 - { 26 - name: "multiple env vars", 27 - in: map[string]string{"FOO": "bar", "BAZ": "qux"}, 28 - want: EnvVars{"FOO=bar", "BAZ=qux"}, 29 - }, 30 - } 31 - for _, tt := range tests { 32 - t.Run(tt.name, func(t *testing.T) { 33 - got := ConstructEnvs(tt.in) 34 - if got == nil { 35 - got = EnvVars{} 36 - } 37 - assert.ElementsMatch(t, tt.want, got) 38 - }) 39 - } 40 - } 41 - 42 - func TestAddEnv(t *testing.T) { 43 - ev := EnvVars{} 44 - ev.AddEnv("FOO", "bar") 45 - ev.AddEnv("BAZ", "qux") 46 - want := EnvVars{"FOO=bar", "BAZ=qux"} 47 - assert.ElementsMatch(t, want, ev) 48 - }
-9
spindle/engine/errors.go
··· 1 - package engine 2 - 3 - import "errors" 4 - 5 - var ( 6 - ErrOOMKilled = errors.New("oom killed") 7 - ErrTimedOut = errors.New("timed out") 8 - ErrWorkflowFailed = errors.New("workflow failed") 9 - )
-84
spindle/engine/logger.go
··· 1 - package engine 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - "io" 7 - "os" 8 - "path/filepath" 9 - "strings" 10 - 11 - "tangled.sh/tangled.sh/core/spindle/models" 12 - ) 13 - 14 - type WorkflowLogger struct { 15 - file *os.File 16 - encoder *json.Encoder 17 - } 18 - 19 - func NewWorkflowLogger(baseDir string, wid models.WorkflowId) (*WorkflowLogger, error) { 20 - path := LogFilePath(baseDir, wid) 21 - 22 - file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) 23 - if err != nil { 24 - return nil, fmt.Errorf("creating log file: %w", err) 25 - } 26 - 27 - return &WorkflowLogger{ 28 - file: file, 29 - encoder: json.NewEncoder(file), 30 - }, nil 31 - } 32 - 33 - func LogFilePath(baseDir string, workflowID models.WorkflowId) string { 34 - logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String())) 35 - return logFilePath 36 - } 37 - 38 - func (l *WorkflowLogger) Close() error { 39 - return l.file.Close() 40 - } 41 - 42 - func (l *WorkflowLogger) DataWriter(stream string) io.Writer { 43 - // TODO: emit stream 44 - return &dataWriter{ 45 - logger: l, 46 - stream: stream, 47 - } 48 - } 49 - 50 - func (l *WorkflowLogger) ControlWriter(idx int, step models.Step) io.Writer { 51 - return &controlWriter{ 52 - logger: l, 53 - idx: idx, 54 - step: step, 55 - } 56 - } 57 - 58 - type dataWriter struct { 59 - logger *WorkflowLogger 60 - stream string 61 - } 62 - 63 - func (w *dataWriter) Write(p []byte) (int, error) { 64 - line := strings.TrimRight(string(p), "\r\n") 65 - entry := models.NewDataLogLine(line, w.stream) 66 - if err := w.logger.encoder.Encode(entry); err != nil { 67 - return 0, err 68 - } 69 - return len(p), nil 70 - } 71 - 72 - type controlWriter struct { 73 - logger *WorkflowLogger 74 - idx int 75 - step models.Step 76 - } 77 - 78 - func (w *controlWriter) Write(_ []byte) (int, error) { 79 - entry := models.NewControlLogLine(w.idx, w.step) 80 - if err := w.logger.encoder.Encode(entry); err != nil { 81 - return 0, err 82 - } 83 - return len(w.step.Name), nil 84 - }
+21
spindle/engines/nixery/ansi_stripper.go
··· 1 + package nixery 2 + 3 + import ( 4 + "io" 5 + 6 + "regexp" 7 + ) 8 + 9 + // regex to match ANSI escape codes (e.g., color codes, cursor moves) 10 + const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" 11 + 12 + var re = regexp.MustCompile(ansi) 13 + 14 + type ansiStrippingWriter struct { 15 + underlying io.Writer 16 + } 17 + 18 + func (w *ansiStrippingWriter) Write(p []byte) (int, error) { 19 + clean := re.ReplaceAll(p, []byte{}) 20 + return w.underlying.Write(clean) 21 + }
+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
··· 1 + package nixery 2 + 3 + import ( 4 + "fmt" 5 + ) 6 + 7 + type EnvVars []string 8 + 9 + // ConstructEnvs converts a tangled.Pipeline_Step_Environment_Elem.{Key,Value} 10 + // representation into a docker-friendly []string{"KEY=value", ...} slice. 11 + func ConstructEnvs(envs map[string]string) EnvVars { 12 + var dockerEnvs EnvVars 13 + for k, v := range envs { 14 + ev := fmt.Sprintf("%s=%s", k, v) 15 + dockerEnvs = append(dockerEnvs, ev) 16 + } 17 + return dockerEnvs 18 + } 19 + 20 + // Slice returns the EnvVar as a []string slice. 21 + func (ev EnvVars) Slice() []string { 22 + return ev 23 + } 24 + 25 + // AddEnv adds a key=value string to the EnvVar. 26 + func (ev *EnvVars) AddEnv(key, value string) { 27 + *ev = append(*ev, fmt.Sprintf("%s=%s", key, value)) 28 + }
+48
spindle/engines/nixery/envs_test.go
··· 1 + package nixery 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + ) 8 + 9 + func TestConstructEnvs(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + in map[string]string 13 + want EnvVars 14 + }{ 15 + { 16 + name: "empty input", 17 + in: make(map[string]string), 18 + want: EnvVars{}, 19 + }, 20 + { 21 + name: "single env var", 22 + in: map[string]string{"FOO": "bar"}, 23 + want: EnvVars{"FOO=bar"}, 24 + }, 25 + { 26 + name: "multiple env vars", 27 + in: map[string]string{"FOO": "bar", "BAZ": "qux"}, 28 + want: EnvVars{"FOO=bar", "BAZ=qux"}, 29 + }, 30 + } 31 + for _, tt := range tests { 32 + t.Run(tt.name, func(t *testing.T) { 33 + got := ConstructEnvs(tt.in) 34 + if got == nil { 35 + got = EnvVars{} 36 + } 37 + assert.ElementsMatch(t, tt.want, got) 38 + }) 39 + } 40 + } 41 + 42 + func TestAddEnv(t *testing.T) { 43 + ev := EnvVars{} 44 + ev.AddEnv("FOO", "bar") 45 + ev.AddEnv("BAZ", "qux") 46 + want := EnvVars{"FOO=bar", "BAZ=qux"} 47 + assert.ElementsMatch(t, want, ev) 48 + }
+7
spindle/engines/nixery/errors.go
··· 1 + package nixery 2 + 3 + import "errors" 4 + 5 + var ( 6 + ErrOOMKilled = errors.New("oom killed") 7 + )
+126
spindle/engines/nixery/setup_steps.go
··· 1 + package nixery 2 + 3 + import ( 4 + "fmt" 5 + "path" 6 + "strings" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + "tangled.sh/tangled.sh/core/workflow" 10 + ) 11 + 12 + func nixConfStep() Step { 13 + setupCmd := `mkdir -p /etc/nix 14 + echo 'extra-experimental-features = nix-command flakes' >> /etc/nix/nix.conf 15 + echo 'build-users-group = ' >> /etc/nix/nix.conf` 16 + return Step{ 17 + command: setupCmd, 18 + name: "Configure Nix", 19 + } 20 + } 21 + 22 + // cloneOptsAsSteps processes clone options and adds corresponding steps 23 + // to the beginning of the workflow's step list if cloning is not skipped. 24 + // 25 + // the steps to do here are: 26 + // - git init 27 + // - git remote add origin <url> 28 + // - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha> 29 + // - git checkout FETCH_HEAD 30 + func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step { 31 + if twf.Clone.Skip { 32 + return Step{} 33 + } 34 + 35 + var commands []string 36 + 37 + // initialize git repo in workspace 38 + commands = append(commands, "git init") 39 + 40 + // add repo as git remote 41 + scheme := "https://" 42 + if dev { 43 + scheme = "http://" 44 + tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal") 45 + } 46 + url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo) 47 + commands = append(commands, fmt.Sprintf("git remote add origin %s", url)) 48 + 49 + // run git fetch 50 + { 51 + var fetchArgs []string 52 + 53 + // default clone depth is 1 54 + depth := 1 55 + if twf.Clone.Depth > 1 { 56 + depth = int(twf.Clone.Depth) 57 + } 58 + fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth)) 59 + 60 + // optionally recurse submodules 61 + if twf.Clone.Submodules { 62 + fetchArgs = append(fetchArgs, "--recurse-submodules=yes") 63 + } 64 + 65 + // set remote to fetch from 66 + fetchArgs = append(fetchArgs, "origin") 67 + 68 + // set revision to checkout 69 + switch workflow.TriggerKind(tr.Kind) { 70 + case workflow.TriggerKindManual: 71 + // TODO: unimplemented 72 + case workflow.TriggerKindPush: 73 + fetchArgs = append(fetchArgs, tr.Push.NewSha) 74 + case workflow.TriggerKindPullRequest: 75 + fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha) 76 + } 77 + 78 + commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " "))) 79 + } 80 + 81 + // run git checkout 82 + commands = append(commands, "git checkout FETCH_HEAD") 83 + 84 + cloneStep := Step{ 85 + command: strings.Join(commands, "\n"), 86 + name: "Clone repository into workspace", 87 + } 88 + return cloneStep 89 + } 90 + 91 + // dependencyStep processes dependencies defined in the workflow. 92 + // For dependencies using a custom registry (i.e. not nixpkgs), it collects 93 + // all packages and adds a single 'nix profile install' step to the 94 + // beginning of the workflow's step list. 95 + func dependencyStep(deps map[string][]string) *Step { 96 + var customPackages []string 97 + 98 + for registry, packages := range deps { 99 + if registry == "nixpkgs" { 100 + continue 101 + } 102 + 103 + if len(packages) == 0 { 104 + customPackages = append(customPackages, registry) 105 + } 106 + // collect packages from custom registries 107 + for _, pkg := range packages { 108 + customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg)) 109 + } 110 + } 111 + 112 + if len(customPackages) > 0 { 113 + installCmd := "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install" 114 + cmd := fmt.Sprintf("%s %s", installCmd, strings.Join(customPackages, " ")) 115 + installStep := Step{ 116 + command: cmd, 117 + name: "Install custom dependencies", 118 + environment: map[string]string{ 119 + "NIX_NO_COLOR": "1", 120 + "NIX_SHOW_DOWNLOAD_PROGRESS": "0", 121 + }, 122 + } 123 + return &installStep 124 + } 125 + return nil 126 + }
+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
··· 1 + package models 2 + 3 + import ( 4 + "context" 5 + "time" 6 + 7 + "tangled.sh/tangled.sh/core/api/tangled" 8 + "tangled.sh/tangled.sh/core/spindle/secrets" 9 + ) 10 + 11 + type Engine interface { 12 + InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*Workflow, error) 13 + SetupWorkflow(ctx context.Context, wid WorkflowId, wf *Workflow) error 14 + WorkflowTimeout() time.Duration 15 + DestroyWorkflow(ctx context.Context, wid WorkflowId) error 16 + RunStep(ctx context.Context, wid WorkflowId, w *Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *WorkflowLogger) error 17 + }
+82
spindle/models/logger.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "io" 7 + "os" 8 + "path/filepath" 9 + "strings" 10 + ) 11 + 12 + type WorkflowLogger struct { 13 + file *os.File 14 + encoder *json.Encoder 15 + } 16 + 17 + func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) { 18 + path := LogFilePath(baseDir, wid) 19 + 20 + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) 21 + if err != nil { 22 + return nil, fmt.Errorf("creating log file: %w", err) 23 + } 24 + 25 + return &WorkflowLogger{ 26 + file: file, 27 + encoder: json.NewEncoder(file), 28 + }, nil 29 + } 30 + 31 + func LogFilePath(baseDir string, workflowID WorkflowId) string { 32 + logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String())) 33 + return logFilePath 34 + } 35 + 36 + func (l *WorkflowLogger) Close() error { 37 + return l.file.Close() 38 + } 39 + 40 + func (l *WorkflowLogger) DataWriter(stream string) io.Writer { 41 + // TODO: emit stream 42 + return &dataWriter{ 43 + logger: l, 44 + stream: stream, 45 + } 46 + } 47 + 48 + func (l *WorkflowLogger) ControlWriter(idx int, step Step) io.Writer { 49 + return &controlWriter{ 50 + logger: l, 51 + idx: idx, 52 + step: step, 53 + } 54 + } 55 + 56 + type dataWriter struct { 57 + logger *WorkflowLogger 58 + stream string 59 + } 60 + 61 + func (w *dataWriter) Write(p []byte) (int, error) { 62 + line := strings.TrimRight(string(p), "\r\n") 63 + entry := NewDataLogLine(line, w.stream) 64 + if err := w.logger.encoder.Encode(entry); err != nil { 65 + return 0, err 66 + } 67 + return len(p), nil 68 + } 69 + 70 + type controlWriter struct { 71 + logger *WorkflowLogger 72 + idx int 73 + step Step 74 + } 75 + 76 + func (w *controlWriter) Write(_ []byte) (int, error) { 77 + entry := NewControlLogLine(w.idx, w.step) 78 + if err := w.logger.encoder.Encode(entry); err != nil { 79 + return 0, err 80 + } 81 + return len(w.step.Name()), nil 82 + }
+3 -3
spindle/models/models.go
··· 104 104 func NewControlLogLine(idx int, step Step) LogLine { 105 105 return LogLine{ 106 106 Kind: LogKindControl, 107 - Content: step.Name, 107 + Content: step.Name(), 108 108 StepId: idx, 109 - StepKind: step.Kind, 110 - StepCommand: step.Command, 109 + StepKind: step.Kind(), 110 + StepCommand: step.Command(), 111 111 } 112 112 }
+8 -103
spindle/models/pipeline.go
··· 1 1 package models 2 2 3 - import ( 4 - "path" 5 - 6 - "tangled.sh/tangled.sh/core/api/tangled" 7 - "tangled.sh/tangled.sh/core/spindle/config" 8 - ) 9 - 10 3 type Pipeline struct { 11 4 RepoOwner string 12 5 RepoName string 13 - Workflows []Workflow 6 + Workflows map[Engine][]Workflow 14 7 } 15 8 16 - type Step struct { 17 - Command string 18 - Name string 19 - Environment map[string]string 20 - Kind StepKind 9 + type Step interface { 10 + Name() string 11 + Command() string 12 + Kind() StepKind 21 13 } 22 14 23 15 type StepKind int ··· 30 22 ) 31 23 32 24 type Workflow struct { 33 - Steps []Step 34 - Environment map[string]string 35 - Name string 36 - Image string 37 - } 38 - 39 - // setupSteps get added to start of Steps 40 - type setupSteps []Step 41 - 42 - // addStep adds a step to the beginning of the workflow's steps. 43 - func (ss *setupSteps) addStep(step Step) { 44 - *ss = append(*ss, step) 45 - } 46 - 47 - // ToPipeline converts a tangled.Pipeline into a model.Pipeline. 48 - // In the process, dependencies are resolved: nixpkgs deps 49 - // are constructed atop nixery and set as the Workflow.Image, 50 - // and ones from custom registries 51 - func ToPipeline(pl tangled.Pipeline, cfg config.Config) *Pipeline { 52 - workflows := []Workflow{} 53 - 54 - for _, twf := range pl.Workflows { 55 - swf := &Workflow{} 56 - for _, tstep := range twf.Steps { 57 - sstep := Step{} 58 - sstep.Environment = stepEnvToMap(tstep.Environment) 59 - sstep.Command = tstep.Command 60 - sstep.Name = tstep.Name 61 - sstep.Kind = StepKindUser 62 - swf.Steps = append(swf.Steps, sstep) 63 - } 64 - swf.Name = twf.Name 65 - swf.Environment = workflowEnvToMap(twf.Environment) 66 - swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery) 67 - 68 - setup := &setupSteps{} 69 - 70 - setup.addStep(nixConfStep()) 71 - setup.addStep(cloneStep(*twf, *pl.TriggerMetadata, cfg.Server.Dev)) 72 - // this step could be empty 73 - if s := dependencyStep(*twf); s != nil { 74 - setup.addStep(*s) 75 - } 76 - 77 - // append setup steps in order to the start of workflow steps 78 - swf.Steps = append(*setup, swf.Steps...) 79 - 80 - workflows = append(workflows, *swf) 81 - } 82 - repoOwner := pl.TriggerMetadata.Repo.Did 83 - repoName := pl.TriggerMetadata.Repo.Repo 84 - return &Pipeline{ 85 - RepoOwner: repoOwner, 86 - RepoName: repoName, 87 - Workflows: workflows, 88 - } 89 - } 90 - 91 - func workflowEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string { 92 - envMap := map[string]string{} 93 - for _, env := range envs { 94 - if env != nil { 95 - envMap[env.Key] = env.Value 96 - } 97 - } 98 - return envMap 99 - } 100 - 101 - func stepEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string { 102 - envMap := map[string]string{} 103 - for _, env := range envs { 104 - if env != nil { 105 - envMap[env.Key] = env.Value 106 - } 107 - } 108 - return envMap 109 - } 110 - 111 - func workflowImage(deps []*tangled.Pipeline_Dependency, nixery string) string { 112 - var dependencies string 113 - for _, d := range deps { 114 - if d.Registry == "nixpkgs" { 115 - dependencies = path.Join(d.Packages...) 116 - } 117 - } 118 - 119 - // load defaults from somewhere else 120 - dependencies = path.Join(dependencies, "bash", "git", "coreutils", "nix") 121 - 122 - return path.Join(nixery, dependencies) 25 + Steps []Step 26 + Name string 27 + Data any 123 28 }
-128
spindle/models/setup_steps.go
··· 1 - package models 2 - 3 - import ( 4 - "fmt" 5 - "path" 6 - "strings" 7 - 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 - "tangled.sh/tangled.sh/core/workflow" 10 - ) 11 - 12 - func nixConfStep() Step { 13 - setupCmd := `echo 'extra-experimental-features = nix-command flakes' >> /etc/nix/nix.conf 14 - echo 'build-users-group = ' >> /etc/nix/nix.conf` 15 - return Step{ 16 - Command: setupCmd, 17 - Name: "Configure Nix", 18 - } 19 - } 20 - 21 - // cloneOptsAsSteps processes clone options and adds corresponding steps 22 - // to the beginning of the workflow's step list if cloning is not skipped. 23 - // 24 - // the steps to do here are: 25 - // - git init 26 - // - git remote add origin <url> 27 - // - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha> 28 - // - git checkout FETCH_HEAD 29 - func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step { 30 - if twf.Clone.Skip { 31 - return Step{} 32 - } 33 - 34 - var commands []string 35 - 36 - // initialize git repo in workspace 37 - commands = append(commands, "git init") 38 - 39 - // add repo as git remote 40 - scheme := "https://" 41 - if dev { 42 - scheme = "http://" 43 - tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal") 44 - } 45 - url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo) 46 - commands = append(commands, fmt.Sprintf("git remote add origin %s", url)) 47 - 48 - // run git fetch 49 - { 50 - var fetchArgs []string 51 - 52 - // default clone depth is 1 53 - depth := 1 54 - if twf.Clone.Depth > 1 { 55 - depth = int(twf.Clone.Depth) 56 - } 57 - fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth)) 58 - 59 - // optionally recurse submodules 60 - if twf.Clone.Submodules { 61 - fetchArgs = append(fetchArgs, "--recurse-submodules=yes") 62 - } 63 - 64 - // set remote to fetch from 65 - fetchArgs = append(fetchArgs, "origin") 66 - 67 - // set revision to checkout 68 - switch workflow.TriggerKind(tr.Kind) { 69 - case workflow.TriggerKindManual: 70 - // TODO: unimplemented 71 - case workflow.TriggerKindPush: 72 - fetchArgs = append(fetchArgs, tr.Push.NewSha) 73 - case workflow.TriggerKindPullRequest: 74 - fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha) 75 - } 76 - 77 - commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " "))) 78 - } 79 - 80 - // run git checkout 81 - commands = append(commands, "git checkout FETCH_HEAD") 82 - 83 - cloneStep := Step{ 84 - Command: strings.Join(commands, "\n"), 85 - Name: "Clone repository into workspace", 86 - } 87 - return cloneStep 88 - } 89 - 90 - // dependencyStep processes dependencies defined in the workflow. 91 - // For dependencies using a custom registry (i.e. not nixpkgs), it collects 92 - // all packages and adds a single 'nix profile install' step to the 93 - // beginning of the workflow's step list. 94 - func dependencyStep(twf tangled.Pipeline_Workflow) *Step { 95 - var customPackages []string 96 - 97 - for _, d := range twf.Dependencies { 98 - registry := d.Registry 99 - packages := d.Packages 100 - 101 - if registry == "nixpkgs" { 102 - continue 103 - } 104 - 105 - if len(packages) == 0 { 106 - customPackages = append(customPackages, registry) 107 - } 108 - // collect packages from custom registries 109 - for _, pkg := range packages { 110 - customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg)) 111 - } 112 - } 113 - 114 - if len(customPackages) > 0 { 115 - installCmd := "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install" 116 - cmd := fmt.Sprintf("%s %s", installCmd, strings.Join(customPackages, " ")) 117 - installStep := Step{ 118 - Command: cmd, 119 - Name: "Install custom dependencies", 120 - Environment: map[string]string{ 121 - "NIX_NO_COLOR": "1", 122 - "NIX_SHOW_DOWNLOAD_PROGRESS": "0", 123 - }, 124 - } 125 - return &installStep 126 - } 127 - return nil 128 - }
+1 -1
spindle/secrets/openbao.go
··· 132 132 return ErrKeyNotFound 133 133 } 134 134 135 - err = v.client.KVv2(v.mountPath).Delete(ctx, secretPath) 135 + err = v.client.KVv2(v.mountPath).DeleteMetadata(ctx, secretPath) 136 136 if err != nil { 137 137 return fmt.Errorf("failed to delete secret from openbao: %w", err) 138 138 }
+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
··· 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
··· 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
··· 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
··· 36 36 css: { 37 37 maxWidth: "none", 38 38 pre: { 39 - backgroundColor: colors.gray[100], 40 - color: colors.black, 41 - "@apply font-normal text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 dark:border": {}, 39 + "@apply font-normal text-black bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 border": {}, 42 40 }, 43 41 code: { 44 42 "@apply font-normal font-mono p-1 rounded text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700": {},
+62 -41
workflow/compile.go
··· 1 1 package workflow 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 6 6 7 "tangled.sh/tangled.sh/core/api/tangled" 7 8 ) 8 9 10 + type RawWorkflow struct { 11 + Name string 12 + Contents []byte 13 + } 14 + 15 + type RawPipeline = []RawWorkflow 16 + 9 17 type Compiler struct { 10 18 Trigger tangled.Pipeline_TriggerMetadata 11 19 Diagnostics Diagnostics 12 20 } 13 21 14 22 type Diagnostics struct { 15 - Errors []error 23 + Errors []Error 16 24 Warnings []Warning 17 25 } 18 26 27 + func (d *Diagnostics) IsEmpty() bool { 28 + return len(d.Errors) == 0 && len(d.Warnings) == 0 29 + } 30 + 19 31 func (d *Diagnostics) Combine(o Diagnostics) { 20 32 d.Errors = append(d.Errors, o.Errors...) 21 33 d.Warnings = append(d.Warnings, o.Warnings...) ··· 25 37 d.Warnings = append(d.Warnings, Warning{path, kind, reason}) 26 38 } 27 39 28 - func (d *Diagnostics) AddError(err error) { 29 - d.Errors = append(d.Errors, err) 40 + func (d *Diagnostics) AddError(path string, err error) { 41 + d.Errors = append(d.Errors, Error{path, err}) 30 42 } 31 43 32 44 func (d Diagnostics) IsErr() bool { 33 45 return len(d.Errors) != 0 34 46 } 35 47 48 + type Error struct { 49 + Path string 50 + Error error 51 + } 52 + 53 + func (e Error) String() string { 54 + return fmt.Sprintf("error: %s: %s", e.Path, e.Error.Error()) 55 + } 56 + 36 57 type Warning struct { 37 58 Path string 38 59 Type WarningKind 39 60 Reason string 40 61 } 41 62 63 + func (w Warning) String() string { 64 + return fmt.Sprintf("warning: %s: %s: %s", w.Path, w.Type, w.Reason) 65 + } 66 + 67 + var ( 68 + MissingEngine error = errors.New("missing engine") 69 + ) 70 + 42 71 type WarningKind string 43 72 44 73 var ( ··· 46 75 InvalidConfiguration WarningKind = "invalid configuration" 47 76 ) 48 77 78 + func (compiler *Compiler) Parse(p RawPipeline) Pipeline { 79 + var pp Pipeline 80 + 81 + for _, w := range p { 82 + wf, err := FromFile(w.Name, w.Contents) 83 + if err != nil { 84 + compiler.Diagnostics.AddError(w.Name, err) 85 + continue 86 + } 87 + 88 + pp = append(pp, wf) 89 + } 90 + 91 + return pp 92 + } 93 + 49 94 // convert a repositories' workflow files into a fully compiled pipeline that runners accept 50 95 func (compiler *Compiler) Compile(p Pipeline) tangled.Pipeline { 51 96 cp := tangled.Pipeline{ 52 97 TriggerMetadata: &compiler.Trigger, 53 98 } 54 99 55 - for _, w := range p { 56 - cw := compiler.compileWorkflow(w) 100 + for _, wf := range p { 101 + cw := compiler.compileWorkflow(wf) 57 102 58 - // empty workflows are not added to the pipeline 59 - if len(cw.Steps) == 0 { 103 + if cw == nil { 60 104 continue 61 105 } 62 106 63 - cp.Workflows = append(cp.Workflows, &cw) 107 + cp.Workflows = append(cp.Workflows, cw) 64 108 } 65 109 66 110 return cp 67 111 } 68 112 69 - func (compiler *Compiler) compileWorkflow(w Workflow) tangled.Pipeline_Workflow { 70 - cw := tangled.Pipeline_Workflow{} 113 + func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow { 114 + cw := &tangled.Pipeline_Workflow{} 71 115 72 116 if !w.Match(compiler.Trigger) { 73 117 compiler.Diagnostics.AddWarning( ··· 75 119 WorkflowSkipped, 76 120 fmt.Sprintf("did not match trigger %s", compiler.Trigger.Kind), 77 121 ) 78 - return cw 79 - } 80 - 81 - if len(w.Steps) == 0 { 82 - compiler.Diagnostics.AddWarning( 83 - w.Name, 84 - WorkflowSkipped, 85 - "empty workflow", 86 - ) 87 - return cw 122 + return nil 88 123 } 89 124 90 125 // validate clone options 91 126 compiler.analyzeCloneOptions(w) 92 127 93 128 cw.Name = w.Name 94 - cw.Dependencies = w.Dependencies.AsRecord() 95 - for _, s := range w.Steps { 96 - step := tangled.Pipeline_Step{ 97 - Command: s.Command, 98 - Name: s.Name, 99 - } 100 - for k, v := range s.Environment { 101 - e := &tangled.Pipeline_Pair{ 102 - Key: k, 103 - Value: v, 104 - } 105 - step.Environment = append(step.Environment, e) 106 - } 107 - cw.Steps = append(cw.Steps, &step) 129 + 130 + if w.Engine == "" { 131 + compiler.Diagnostics.AddError(w.Name, MissingEngine) 132 + return nil 108 133 } 109 - for k, v := range w.Environment { 110 - e := &tangled.Pipeline_Pair{ 111 - Key: k, 112 - Value: v, 113 - } 114 - cw.Environment = append(cw.Environment, e) 115 - } 134 + 135 + cw.Engine = w.Engine 136 + cw.Raw = w.Raw 116 137 117 138 o := w.CloneOpts.AsRecord() 118 139 cw.Clone = &o
+23 -29
workflow/compile_test.go
··· 26 26 27 27 func TestCompileWorkflow_MatchingWorkflowWithSteps(t *testing.T) { 28 28 wf := Workflow{ 29 - Name: ".tangled/workflows/test.yml", 30 - When: when, 31 - Steps: []Step{ 32 - {Name: "Test", Command: "go test ./..."}, 33 - }, 29 + Name: ".tangled/workflows/test.yml", 30 + Engine: "nixery", 31 + When: when, 34 32 CloneOpts: CloneOpts{}, // default true 35 33 } 36 34 ··· 43 41 assert.False(t, c.Diagnostics.IsErr()) 44 42 } 45 43 46 - func TestCompileWorkflow_EmptySteps(t *testing.T) { 47 - wf := Workflow{ 48 - Name: ".tangled/workflows/empty.yml", 49 - When: when, 50 - Steps: []Step{}, // no steps 51 - } 52 - 53 - c := Compiler{Trigger: trigger} 54 - cp := c.Compile([]Workflow{wf}) 55 - 56 - assert.Len(t, cp.Workflows, 0) 57 - assert.Len(t, c.Diagnostics.Warnings, 1) 58 - assert.Equal(t, WorkflowSkipped, c.Diagnostics.Warnings[0].Type) 59 - } 60 - 61 44 func TestCompileWorkflow_TriggerMismatch(t *testing.T) { 62 45 wf := Workflow{ 63 - Name: ".tangled/workflows/mismatch.yml", 46 + Name: ".tangled/workflows/mismatch.yml", 47 + Engine: "nixery", 64 48 When: []Constraint{ 65 49 { 66 50 Event: []string{"push"}, 67 51 Branch: []string{"master"}, // different branch 68 52 }, 69 53 }, 70 - Steps: []Step{ 71 - {Name: "Lint", Command: "golint ./..."}, 72 - }, 73 54 } 74 55 75 56 c := Compiler{Trigger: trigger} ··· 82 63 83 64 func TestCompileWorkflow_CloneFalseWithShallowTrue(t *testing.T) { 84 65 wf := Workflow{ 85 - Name: ".tangled/workflows/clone_skip.yml", 86 - When: when, 87 - Steps: []Step{ 88 - {Name: "Skip", Command: "echo skip"}, 89 - }, 66 + Name: ".tangled/workflows/clone_skip.yml", 67 + Engine: "nixery", 68 + When: when, 90 69 CloneOpts: CloneOpts{ 91 70 Skip: true, 92 71 Depth: 1, ··· 101 80 assert.Len(t, c.Diagnostics.Warnings, 1) 102 81 assert.Equal(t, InvalidConfiguration, c.Diagnostics.Warnings[0].Type) 103 82 } 83 + 84 + func TestCompileWorkflow_MissingEngine(t *testing.T) { 85 + wf := Workflow{ 86 + Name: ".tangled/workflows/missing_engine.yml", 87 + When: when, 88 + Engine: "", 89 + } 90 + 91 + c := Compiler{Trigger: trigger} 92 + cp := c.Compile([]Workflow{wf}) 93 + 94 + assert.Len(t, cp.Workflows, 0) 95 + assert.Len(t, c.Diagnostics.Errors, 1) 96 + assert.Equal(t, MissingEngine, c.Diagnostics.Errors[0].Error) 97 + }
+6 -33
workflow/def.go
··· 24 24 25 25 // this is simply a structural representation of the workflow file 26 26 Workflow struct { 27 - Name string `yaml:"-"` // name of the workflow file 28 - When []Constraint `yaml:"when"` 29 - Dependencies Dependencies `yaml:"dependencies"` 30 - Steps []Step `yaml:"steps"` 31 - Environment map[string]string `yaml:"environment"` 32 - CloneOpts CloneOpts `yaml:"clone"` 27 + Name string `yaml:"-"` // name of the workflow file 28 + Engine string `yaml:"engine"` 29 + When []Constraint `yaml:"when"` 30 + CloneOpts CloneOpts `yaml:"clone"` 31 + Raw string `yaml:"-"` 33 32 } 34 33 35 34 Constraint struct { 36 35 Event StringList `yaml:"event"` 37 36 Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events 38 37 } 39 - 40 - Dependencies map[string][]string 41 38 42 39 CloneOpts struct { 43 40 Skip bool `yaml:"skip"` 44 41 Depth int `yaml:"depth"` 45 42 IncludeSubmodules bool `yaml:"submodules"` 46 - } 47 - 48 - Step struct { 49 - Name string `yaml:"name"` 50 - Command string `yaml:"command"` 51 - Environment map[string]string `yaml:"environment"` 52 43 } 53 44 54 45 StringList []string ··· 77 68 } 78 69 79 70 wf.Name = name 71 + wf.Raw = string(contents) 80 72 81 73 return wf, nil 82 74 } ··· 173 165 } 174 166 175 167 return errors.New("failed to unmarshal StringOrSlice") 176 - } 177 - 178 - // conversion utilities to atproto records 179 - func (d Dependencies) AsRecord() []*tangled.Pipeline_Dependency { 180 - var deps []*tangled.Pipeline_Dependency 181 - for registry, packages := range d { 182 - deps = append(deps, &tangled.Pipeline_Dependency{ 183 - Registry: registry, 184 - Packages: packages, 185 - }) 186 - } 187 - return deps 188 - } 189 - 190 - func (s Step) AsRecord() tangled.Pipeline_Step { 191 - return tangled.Pipeline_Step{ 192 - Command: s.Command, 193 - Name: s.Name, 194 - } 195 168 } 196 169 197 170 func (c CloneOpts) AsRecord() tangled.Pipeline_CloneOpts {
+1 -86
workflow/def_test.go
··· 10 10 yamlData := ` 11 11 when: 12 12 - event: ["push", "pull_request"] 13 - branch: ["main", "develop"] 14 - 15 - dependencies: 16 - nixpkgs: 17 - - go 18 - - git 19 - - curl 20 - 21 - steps: 22 - - name: "Test" 23 - command: | 24 - go test ./...` 13 + branch: ["main", "develop"]` 25 14 26 15 wf, err := FromFile("test.yml", []byte(yamlData)) 27 16 assert.NoError(t, err, "YAML should unmarshal without error") ··· 30 19 assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch) 31 20 assert.ElementsMatch(t, []string{"push", "pull_request"}, wf.When[0].Event) 32 21 33 - assert.Len(t, wf.Steps, 1) 34 - assert.Equal(t, "Test", wf.Steps[0].Name) 35 - assert.Equal(t, "go test ./...", wf.Steps[0].Command) 36 - 37 - pkgs, ok := wf.Dependencies["nixpkgs"] 38 - assert.True(t, ok, "`nixpkgs` should be present in dependencies") 39 - assert.ElementsMatch(t, []string{"go", "git", "curl"}, pkgs) 40 - 41 22 assert.False(t, wf.CloneOpts.Skip, "Skip should default to false") 42 23 } 43 24 44 - func TestUnmarshalCustomRegistry(t *testing.T) { 45 - yamlData := ` 46 - when: 47 - - event: push 48 - branch: main 49 - 50 - dependencies: 51 - git+https://tangled.sh/@oppi.li/tbsp: 52 - - tbsp 53 - git+https://git.peppe.rs/languages/statix: 54 - - statix 55 - 56 - steps: 57 - - name: "Check" 58 - command: | 59 - statix check` 60 - 61 - wf, err := FromFile("test.yml", []byte(yamlData)) 62 - assert.NoError(t, err, "YAML should unmarshal without error") 63 - 64 - assert.ElementsMatch(t, []string{"push"}, wf.When[0].Event) 65 - assert.ElementsMatch(t, []string{"main"}, wf.When[0].Branch) 66 - 67 - assert.ElementsMatch(t, []string{"tbsp"}, wf.Dependencies["git+https://tangled.sh/@oppi.li/tbsp"]) 68 - assert.ElementsMatch(t, []string{"statix"}, wf.Dependencies["git+https://git.peppe.rs/languages/statix"]) 69 - } 70 - 71 25 func TestUnmarshalCloneFalse(t *testing.T) { 72 26 yamlData := ` 73 27 when: ··· 75 29 76 30 clone: 77 31 skip: true 78 - 79 - dependencies: 80 - nixpkgs: 81 - - python3 82 - 83 - steps: 84 - - name: Notify 85 - command: | 86 - python3 ./notify.py 87 32 ` 88 33 89 34 wf, err := FromFile("test.yml", []byte(yamlData)) ··· 93 38 94 39 assert.True(t, wf.CloneOpts.Skip, "Skip should be false") 95 40 } 96 - 97 - func TestUnmarshalEnv(t *testing.T) { 98 - yamlData := ` 99 - when: 100 - - event: ["pull_request_close"] 101 - 102 - clone: 103 - skip: false 104 - 105 - environment: 106 - HOME: /home/foo bar/baz 107 - CGO_ENABLED: 1 108 - 109 - steps: 110 - - name: Something 111 - command: echo "hello" 112 - environment: 113 - FOO: bar 114 - BAZ: qux 115 - ` 116 - 117 - wf, err := FromFile("test.yml", []byte(yamlData)) 118 - assert.NoError(t, err) 119 - 120 - assert.Len(t, wf.Environment, 2) 121 - assert.Equal(t, "/home/foo bar/baz", wf.Environment["HOME"]) 122 - assert.Equal(t, "1", wf.Environment["CGO_ENABLED"]) 123 - assert.Equal(t, "bar", wf.Steps[0].Environment["FOO"]) 124 - assert.Equal(t, "qux", wf.Steps[0].Environment["BAZ"]) 125 - }