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

Compare changes

Choose any two refs to compare.

Changed files
+8452 -6359
.tangled
.zed
api
appview
config
db
issues
knots
middleware
oauth
pages
pulls
repo
reporesolver
serververify
settings
spindles
spindleverify
state
strings
xrpcclient
cmd
punchcardPopulate
docs
eventconsumer
cursor
jetstream
knotclient
knotserver
lexicons
log
nix
rbac
spindle
workflow
xrpc
errors
serviceauth
+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 - event: ["push", "pull_request"] 3 branch: ["master"] 4 5 dependencies: 6 nixpkgs: 7 - go
··· 2 - event: ["push", "pull_request"] 3 branch: ["master"] 4 5 + engine: nixery 6 + 7 dependencies: 8 nixpkgs: 9 - go
+3 -12
.tangled/workflows/fmt.yml
··· 2 - event: ["push", "pull_request"] 3 branch: ["master"] 4 5 - dependencies: 6 - nixpkgs: 7 - - go 8 - - alejandra 9 10 steps: 11 - - name: "nix fmt" 12 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 -
··· 2 - event: ["push", "pull_request"] 3 branch: ["master"] 4 5 + engine: nixery 6 7 steps: 8 + - name: "Check formatting" 9 command: | 10 + nix run .#fmt -- --ci
+2
.tangled/workflows/test.yml
··· 2 - event: ["push", "pull_request"] 3 branch: ["master"] 4 5 dependencies: 6 nixpkgs: 7 - go
··· 2 - event: ["push", "pull_request"] 3 branch: ["master"] 4 5 + engine: nixery 6 + 7 dependencies: 8 nixpkgs: 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 - }
···
+183 -600
api/tangled/cbor_gen.go
··· 2141 2142 return nil 2143 } 2144 func (t *KnotMember) MarshalCBOR(w io.Writer) error { 2145 if t == nil { 2146 _, err := w.Write(cbg.CborNull) ··· 2716 t.Submodules = true 2717 default: 2718 return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra) 2719 - } 2720 - 2721 - default: 2722 - // Field doesn't exist on this type, so ignore it 2723 - if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 2724 - return err 2725 - } 2726 - } 2727 - } 2728 - 2729 - return nil 2730 - } 2731 - func (t *Pipeline_Dependency) MarshalCBOR(w io.Writer) error { 2732 - if t == nil { 2733 - _, err := w.Write(cbg.CborNull) 2734 - return err 2735 - } 2736 - 2737 - cw := cbg.NewCborWriter(w) 2738 - 2739 - if _, err := cw.Write([]byte{162}); err != nil { 2740 - return err 2741 - } 2742 - 2743 - // t.Packages ([]string) (slice) 2744 - if len("packages") > 1000000 { 2745 - return xerrors.Errorf("Value in field \"packages\" was too long") 2746 - } 2747 - 2748 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("packages"))); err != nil { 2749 - return err 2750 - } 2751 - if _, err := cw.WriteString(string("packages")); err != nil { 2752 - return err 2753 - } 2754 - 2755 - if len(t.Packages) > 8192 { 2756 - return xerrors.Errorf("Slice value in field t.Packages was too long") 2757 - } 2758 - 2759 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Packages))); err != nil { 2760 - return err 2761 - } 2762 - for _, v := range t.Packages { 2763 - if len(v) > 1000000 { 2764 - return xerrors.Errorf("Value in field v was too long") 2765 - } 2766 - 2767 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 2768 - return err 2769 - } 2770 - if _, err := cw.WriteString(string(v)); err != nil { 2771 - return err 2772 - } 2773 - 2774 - } 2775 - 2776 - // t.Registry (string) (string) 2777 - if len("registry") > 1000000 { 2778 - return xerrors.Errorf("Value in field \"registry\" was too long") 2779 - } 2780 - 2781 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("registry"))); err != nil { 2782 - return err 2783 - } 2784 - if _, err := cw.WriteString(string("registry")); err != nil { 2785 - return err 2786 - } 2787 - 2788 - if len(t.Registry) > 1000000 { 2789 - return xerrors.Errorf("Value in field t.Registry was too long") 2790 - } 2791 - 2792 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Registry))); err != nil { 2793 - return err 2794 - } 2795 - if _, err := cw.WriteString(string(t.Registry)); err != nil { 2796 - return err 2797 - } 2798 - return nil 2799 - } 2800 - 2801 - func (t *Pipeline_Dependency) UnmarshalCBOR(r io.Reader) (err error) { 2802 - *t = Pipeline_Dependency{} 2803 - 2804 - cr := cbg.NewCborReader(r) 2805 - 2806 - maj, extra, err := cr.ReadHeader() 2807 - if err != nil { 2808 - return err 2809 - } 2810 - defer func() { 2811 - if err == io.EOF { 2812 - err = io.ErrUnexpectedEOF 2813 - } 2814 - }() 2815 - 2816 - if maj != cbg.MajMap { 2817 - return fmt.Errorf("cbor input should be of type map") 2818 - } 2819 - 2820 - if extra > cbg.MaxLength { 2821 - return fmt.Errorf("Pipeline_Dependency: map struct too large (%d)", extra) 2822 - } 2823 - 2824 - n := extra 2825 - 2826 - nameBuf := make([]byte, 8) 2827 - for i := uint64(0); i < n; i++ { 2828 - nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 2829 - if err != nil { 2830 - return err 2831 - } 2832 - 2833 - if !ok { 2834 - // Field doesn't exist on this type, so ignore it 2835 - if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 2836 - return err 2837 - } 2838 - continue 2839 - } 2840 - 2841 - switch string(nameBuf[:nameLen]) { 2842 - // t.Packages ([]string) (slice) 2843 - case "packages": 2844 - 2845 - maj, extra, err = cr.ReadHeader() 2846 - if err != nil { 2847 - return err 2848 - } 2849 - 2850 - if extra > 8192 { 2851 - return fmt.Errorf("t.Packages: array too large (%d)", extra) 2852 - } 2853 - 2854 - if maj != cbg.MajArray { 2855 - return fmt.Errorf("expected cbor array") 2856 - } 2857 - 2858 - if extra > 0 { 2859 - t.Packages = make([]string, extra) 2860 - } 2861 - 2862 - for i := 0; i < int(extra); i++ { 2863 - { 2864 - var maj byte 2865 - var extra uint64 2866 - var err error 2867 - _ = maj 2868 - _ = extra 2869 - _ = err 2870 - 2871 - { 2872 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 2873 - if err != nil { 2874 - return err 2875 - } 2876 - 2877 - t.Packages[i] = string(sval) 2878 - } 2879 - 2880 - } 2881 - } 2882 - // t.Registry (string) (string) 2883 - case "registry": 2884 - 2885 - { 2886 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 2887 - if err != nil { 2888 - return err 2889 - } 2890 - 2891 - t.Registry = string(sval) 2892 } 2893 2894 default: ··· 3916 3917 return nil 3918 } 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 func (t *Pipeline_TriggerMetadata) MarshalCBOR(w io.Writer) error { 4137 if t == nil { 4138 _, err := w.Write(cbg.CborNull) ··· 4609 4610 cw := cbg.NewCborWriter(w) 4611 4612 - if _, err := cw.Write([]byte{165}); err != nil { 4613 return err 4614 } 4615 ··· 4652 return err 4653 } 4654 4655 - // t.Steps ([]*tangled.Pipeline_Step) (slice) 4656 - if len("steps") > 1000000 { 4657 - return xerrors.Errorf("Value in field \"steps\" was too long") 4658 } 4659 4660 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("steps"))); err != nil { 4661 - return err 4662 - } 4663 - if _, err := cw.WriteString(string("steps")); err != nil { 4664 return err 4665 } 4666 - 4667 - if len(t.Steps) > 8192 { 4668 - return xerrors.Errorf("Slice value in field t.Steps was too long") 4669 - } 4670 - 4671 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Steps))); err != nil { 4672 - return err 4673 - } 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 { 4713 return err 4714 } 4715 - if _, err := cw.WriteString(string("dependencies")); err != nil { 4716 return err 4717 - } 4718 - 4719 - if len(t.Dependencies) > 8192 { 4720 - return xerrors.Errorf("Slice value in field t.Dependencies was too long") 4721 - } 4722 - 4723 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Dependencies))); err != nil { 4724 - return err 4725 - } 4726 - for _, v := range t.Dependencies { 4727 - if err := v.MarshalCBOR(cw); err != nil { 4728 - return err 4729 - } 4730 - 4731 } 4732 return nil 4733 } ··· 4757 4758 n := extra 4759 4760 - nameBuf := make([]byte, 12) 4761 for i := uint64(0); i < n; i++ { 4762 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 4763 if err != nil { ··· 4773 } 4774 4775 switch string(nameBuf[:nameLen]) { 4776 - // t.Name (string) (string) 4777 case "name": 4778 4779 { ··· 4804 } 4805 4806 } 4807 - // t.Steps ([]*tangled.Pipeline_Step) (slice) 4808 - case "steps": 4809 4810 - maj, extra, err = cr.ReadHeader() 4811 - if err != nil { 4812 - return err 4813 - } 4814 - 4815 - if extra > 8192 { 4816 - return fmt.Errorf("t.Steps: array too large (%d)", extra) 4817 - } 4818 - 4819 - if maj != cbg.MajArray { 4820 - return fmt.Errorf("expected cbor array") 4821 - } 4822 - 4823 - if extra > 0 { 4824 - t.Steps = make([]*Pipeline_Step, extra) 4825 - } 4826 - 4827 - for i := 0; i < int(extra); i++ { 4828 - { 4829 - var maj byte 4830 - var extra uint64 4831 - var err error 4832 - _ = maj 4833 - _ = extra 4834 - _ = err 4835 - 4836 - { 4837 - 4838 - b, err := cr.ReadByte() 4839 - if err != nil { 4840 - return err 4841 - } 4842 - if b != cbg.CborNull[0] { 4843 - if err := cr.UnreadByte(); err != nil { 4844 - return err 4845 - } 4846 - t.Steps[i] = new(Pipeline_Step) 4847 - if err := t.Steps[i].UnmarshalCBOR(cr); err != nil { 4848 - return xerrors.Errorf("unmarshaling t.Steps[i] pointer: %w", err) 4849 - } 4850 - } 4851 - 4852 - } 4853 - 4854 } 4855 - } 4856 - // t.Environment ([]*tangled.Pipeline_Pair) (slice) 4857 - case "environment": 4858 4859 - maj, extra, err = cr.ReadHeader() 4860 - if err != nil { 4861 - return err 4862 - } 4863 - 4864 - if extra > 8192 { 4865 - return fmt.Errorf("t.Environment: array too large (%d)", extra) 4866 - } 4867 - 4868 - if maj != cbg.MajArray { 4869 - return fmt.Errorf("expected cbor array") 4870 - } 4871 - 4872 - if extra > 0 { 4873 - t.Environment = make([]*Pipeline_Pair, extra) 4874 - } 4875 - 4876 - for i := 0; i < int(extra); i++ { 4877 - { 4878 - var maj byte 4879 - var extra uint64 4880 - var err error 4881 - _ = maj 4882 - _ = extra 4883 - _ = err 4884 - 4885 - { 4886 - 4887 - b, err := cr.ReadByte() 4888 - if err != nil { 4889 - return err 4890 - } 4891 - if b != cbg.CborNull[0] { 4892 - if err := cr.UnreadByte(); err != nil { 4893 - return err 4894 - } 4895 - t.Environment[i] = new(Pipeline_Pair) 4896 - if err := t.Environment[i].UnmarshalCBOR(cr); err != nil { 4897 - return xerrors.Errorf("unmarshaling t.Environment[i] pointer: %w", err) 4898 - } 4899 - } 4900 - 4901 - } 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 - } 4953 } 4954 4955 default:
··· 2141 2142 return nil 2143 } 2144 + func (t *Knot) MarshalCBOR(w io.Writer) error { 2145 + if t == nil { 2146 + _, err := w.Write(cbg.CborNull) 2147 + return err 2148 + } 2149 + 2150 + cw := cbg.NewCborWriter(w) 2151 + 2152 + if _, err := cw.Write([]byte{162}); err != nil { 2153 + return err 2154 + } 2155 + 2156 + // t.LexiconTypeID (string) (string) 2157 + if len("$type") > 1000000 { 2158 + return xerrors.Errorf("Value in field \"$type\" was too long") 2159 + } 2160 + 2161 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 2162 + return err 2163 + } 2164 + if _, err := cw.WriteString(string("$type")); err != nil { 2165 + return err 2166 + } 2167 + 2168 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.knot"))); err != nil { 2169 + return err 2170 + } 2171 + if _, err := cw.WriteString(string("sh.tangled.knot")); err != nil { 2172 + return err 2173 + } 2174 + 2175 + // t.CreatedAt (string) (string) 2176 + if len("createdAt") > 1000000 { 2177 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 2178 + } 2179 + 2180 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 2181 + return err 2182 + } 2183 + if _, err := cw.WriteString(string("createdAt")); err != nil { 2184 + return err 2185 + } 2186 + 2187 + if len(t.CreatedAt) > 1000000 { 2188 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 2189 + } 2190 + 2191 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 2192 + return err 2193 + } 2194 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 2195 + return err 2196 + } 2197 + return nil 2198 + } 2199 + 2200 + func (t *Knot) UnmarshalCBOR(r io.Reader) (err error) { 2201 + *t = Knot{} 2202 + 2203 + cr := cbg.NewCborReader(r) 2204 + 2205 + maj, extra, err := cr.ReadHeader() 2206 + if err != nil { 2207 + return err 2208 + } 2209 + defer func() { 2210 + if err == io.EOF { 2211 + err = io.ErrUnexpectedEOF 2212 + } 2213 + }() 2214 + 2215 + if maj != cbg.MajMap { 2216 + return fmt.Errorf("cbor input should be of type map") 2217 + } 2218 + 2219 + if extra > cbg.MaxLength { 2220 + return fmt.Errorf("Knot: map struct too large (%d)", extra) 2221 + } 2222 + 2223 + n := extra 2224 + 2225 + nameBuf := make([]byte, 9) 2226 + for i := uint64(0); i < n; i++ { 2227 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 2228 + if err != nil { 2229 + return err 2230 + } 2231 + 2232 + if !ok { 2233 + // Field doesn't exist on this type, so ignore it 2234 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 2235 + return err 2236 + } 2237 + continue 2238 + } 2239 + 2240 + switch string(nameBuf[:nameLen]) { 2241 + // t.LexiconTypeID (string) (string) 2242 + case "$type": 2243 + 2244 + { 2245 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 2246 + if err != nil { 2247 + return err 2248 + } 2249 + 2250 + t.LexiconTypeID = string(sval) 2251 + } 2252 + // t.CreatedAt (string) (string) 2253 + case "createdAt": 2254 + 2255 + { 2256 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 2257 + if err != nil { 2258 + return err 2259 + } 2260 + 2261 + t.CreatedAt = string(sval) 2262 + } 2263 + 2264 + default: 2265 + // Field doesn't exist on this type, so ignore it 2266 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 2267 + return err 2268 + } 2269 + } 2270 + } 2271 + 2272 + return nil 2273 + } 2274 func (t *KnotMember) MarshalCBOR(w io.Writer) error { 2275 if t == nil { 2276 _, err := w.Write(cbg.CborNull) ··· 2846 t.Submodules = true 2847 default: 2848 return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra) 2849 } 2850 2851 default: ··· 3873 3874 return nil 3875 } 3876 func (t *Pipeline_TriggerMetadata) MarshalCBOR(w io.Writer) error { 3877 if t == nil { 3878 _, err := w.Write(cbg.CborNull) ··· 4349 4350 cw := cbg.NewCborWriter(w) 4351 4352 + if _, err := cw.Write([]byte{164}); err != nil { 4353 + return err 4354 + } 4355 + 4356 + // t.Raw (string) (string) 4357 + if len("raw") > 1000000 { 4358 + return xerrors.Errorf("Value in field \"raw\" was too long") 4359 + } 4360 + 4361 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("raw"))); err != nil { 4362 + return err 4363 + } 4364 + if _, err := cw.WriteString(string("raw")); err != nil { 4365 + return err 4366 + } 4367 + 4368 + if len(t.Raw) > 1000000 { 4369 + return xerrors.Errorf("Value in field t.Raw was too long") 4370 + } 4371 + 4372 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Raw))); err != nil { 4373 + return err 4374 + } 4375 + if _, err := cw.WriteString(string(t.Raw)); err != nil { 4376 return err 4377 } 4378 ··· 4415 return err 4416 } 4417 4418 + // t.Engine (string) (string) 4419 + if len("engine") > 1000000 { 4420 + return xerrors.Errorf("Value in field \"engine\" was too long") 4421 } 4422 4423 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("engine"))); err != nil { 4424 return err 4425 } 4426 + if _, err := cw.WriteString(string("engine")); err != nil { 4427 return err 4428 } 4429 4430 + if len(t.Engine) > 1000000 { 4431 + return xerrors.Errorf("Value in field t.Engine was too long") 4432 } 4433 4434 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Engine))); err != nil { 4435 return err 4436 } 4437 + if _, err := cw.WriteString(string(t.Engine)); err != nil { 4438 return err 4439 } 4440 return nil 4441 } ··· 4465 4466 n := extra 4467 4468 + nameBuf := make([]byte, 6) 4469 for i := uint64(0); i < n; i++ { 4470 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 4471 if err != nil { ··· 4481 } 4482 4483 switch string(nameBuf[:nameLen]) { 4484 + // t.Raw (string) (string) 4485 + case "raw": 4486 + 4487 + { 4488 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4489 + if err != nil { 4490 + return err 4491 + } 4492 + 4493 + t.Raw = string(sval) 4494 + } 4495 + // t.Name (string) (string) 4496 case "name": 4497 4498 { ··· 4523 } 4524 4525 } 4526 + // t.Engine (string) (string) 4527 + case "engine": 4528 4529 + { 4530 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4531 + if err != nil { 4532 + return err 4533 } 4534 4535 + t.Engine = string(sval) 4536 } 4537 4538 default:
+34
api/tangled/repocreate.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.create 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoCreateNSID = "sh.tangled.repo.create" 15 + ) 16 + 17 + // RepoCreate_Input is the input argument to a sh.tangled.repo.create call. 18 + type RepoCreate_Input struct { 19 + // defaultBranch: Default branch to push to 20 + DefaultBranch *string `json:"defaultBranch,omitempty" cborgen:"defaultBranch,omitempty"` 21 + // rkey: Rkey of the repository record 22 + Rkey string `json:"rkey" cborgen:"rkey"` 23 + // source: A source URL to clone from, populate this when forking or importing a repository. 24 + Source *string `json:"source,omitempty" cborgen:"source,omitempty"` 25 + } 26 + 27 + // RepoCreate calls the XRPC method "sh.tangled.repo.create". 28 + func RepoCreate(ctx context.Context, c util.LexClient, input *RepoCreate_Input) error { 29 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.create", nil, input, nil); err != nil { 30 + return err 31 + } 32 + 33 + return nil 34 + }
+34
api/tangled/repodelete.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.delete 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoDeleteNSID = "sh.tangled.repo.delete" 15 + ) 16 + 17 + // RepoDelete_Input is the input argument to a sh.tangled.repo.delete call. 18 + type RepoDelete_Input struct { 19 + // did: DID of the repository owner 20 + Did string `json:"did" cborgen:"did"` 21 + // name: Name of the repository to delete 22 + Name string `json:"name" cborgen:"name"` 23 + // rkey: Rkey of the repository record 24 + Rkey string `json:"rkey" cborgen:"rkey"` 25 + } 26 + 27 + // RepoDelete calls the XRPC method "sh.tangled.repo.delete". 28 + func RepoDelete(ctx context.Context, c util.LexClient, input *RepoDelete_Input) error { 29 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.delete", nil, input, nil); err != nil { 30 + return err 31 + } 32 + 33 + return nil 34 + }
+45
api/tangled/repoforkStatus.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.forkStatus 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoForkStatusNSID = "sh.tangled.repo.forkStatus" 15 + ) 16 + 17 + // RepoForkStatus_Input is the input argument to a sh.tangled.repo.forkStatus call. 18 + type RepoForkStatus_Input struct { 19 + // branch: Branch to check status for 20 + Branch string `json:"branch" cborgen:"branch"` 21 + // did: DID of the fork owner 22 + Did string `json:"did" cborgen:"did"` 23 + // hiddenRef: Hidden ref to use for comparison 24 + HiddenRef string `json:"hiddenRef" cborgen:"hiddenRef"` 25 + // name: Name of the forked repository 26 + Name string `json:"name" cborgen:"name"` 27 + // source: Source repository URL 28 + Source string `json:"source" cborgen:"source"` 29 + } 30 + 31 + // RepoForkStatus_Output is the output of a sh.tangled.repo.forkStatus call. 32 + type RepoForkStatus_Output struct { 33 + // status: Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch 34 + Status int64 `json:"status" cborgen:"status"` 35 + } 36 + 37 + // RepoForkStatus calls the XRPC method "sh.tangled.repo.forkStatus". 38 + func RepoForkStatus(ctx context.Context, c util.LexClient, input *RepoForkStatus_Input) (*RepoForkStatus_Output, error) { 39 + var out RepoForkStatus_Output 40 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.forkStatus", nil, input, &out); err != nil { 41 + return nil, err 42 + } 43 + 44 + return &out, nil 45 + }
+36
api/tangled/repoforkSync.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.forkSync 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoForkSyncNSID = "sh.tangled.repo.forkSync" 15 + ) 16 + 17 + // RepoForkSync_Input is the input argument to a sh.tangled.repo.forkSync call. 18 + type RepoForkSync_Input struct { 19 + // branch: Branch to sync 20 + Branch string `json:"branch" cborgen:"branch"` 21 + // did: DID of the fork owner 22 + Did string `json:"did" cborgen:"did"` 23 + // name: Name of the forked repository 24 + Name string `json:"name" cborgen:"name"` 25 + // source: AT-URI of the source repository 26 + Source string `json:"source" cborgen:"source"` 27 + } 28 + 29 + // RepoForkSync calls the XRPC method "sh.tangled.repo.forkSync". 30 + func RepoForkSync(ctx context.Context, c util.LexClient, input *RepoForkSync_Input) error { 31 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.forkSync", nil, input, nil); err != nil { 32 + return err 33 + } 34 + 35 + return nil 36 + }
+45
api/tangled/repohiddenRef.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.hiddenRef 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoHiddenRefNSID = "sh.tangled.repo.hiddenRef" 15 + ) 16 + 17 + // RepoHiddenRef_Input is the input argument to a sh.tangled.repo.hiddenRef call. 18 + type RepoHiddenRef_Input struct { 19 + // forkRef: Fork reference name 20 + ForkRef string `json:"forkRef" cborgen:"forkRef"` 21 + // remoteRef: Remote reference name 22 + RemoteRef string `json:"remoteRef" cborgen:"remoteRef"` 23 + // repo: AT-URI of the repository 24 + Repo string `json:"repo" cborgen:"repo"` 25 + } 26 + 27 + // RepoHiddenRef_Output is the output of a sh.tangled.repo.hiddenRef call. 28 + type RepoHiddenRef_Output struct { 29 + // error: Error message if creation failed 30 + Error *string `json:"error,omitempty" cborgen:"error,omitempty"` 31 + // ref: The created hidden ref name 32 + Ref *string `json:"ref,omitempty" cborgen:"ref,omitempty"` 33 + // success: Whether the hidden ref was created successfully 34 + Success bool `json:"success" cborgen:"success"` 35 + } 36 + 37 + // RepoHiddenRef calls the XRPC method "sh.tangled.repo.hiddenRef". 38 + func RepoHiddenRef(ctx context.Context, c util.LexClient, input *RepoHiddenRef_Input) (*RepoHiddenRef_Output, error) { 39 + var out RepoHiddenRef_Output 40 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.hiddenRef", nil, input, &out); err != nil { 41 + return nil, err 42 + } 43 + 44 + return &out, nil 45 + }
+44
api/tangled/repomerge.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.merge 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoMergeNSID = "sh.tangled.repo.merge" 15 + ) 16 + 17 + // RepoMerge_Input is the input argument to a sh.tangled.repo.merge call. 18 + type RepoMerge_Input struct { 19 + // authorEmail: Author email for the merge commit 20 + AuthorEmail *string `json:"authorEmail,omitempty" cborgen:"authorEmail,omitempty"` 21 + // authorName: Author name for the merge commit 22 + AuthorName *string `json:"authorName,omitempty" cborgen:"authorName,omitempty"` 23 + // branch: Target branch to merge into 24 + Branch string `json:"branch" cborgen:"branch"` 25 + // commitBody: Additional commit message body 26 + CommitBody *string `json:"commitBody,omitempty" cborgen:"commitBody,omitempty"` 27 + // commitMessage: Merge commit message 28 + CommitMessage *string `json:"commitMessage,omitempty" cborgen:"commitMessage,omitempty"` 29 + // did: DID of the repository owner 30 + Did string `json:"did" cborgen:"did"` 31 + // name: Name of the repository 32 + Name string `json:"name" cborgen:"name"` 33 + // patch: Patch content to merge 34 + Patch string `json:"patch" cborgen:"patch"` 35 + } 36 + 37 + // RepoMerge calls the XRPC method "sh.tangled.repo.merge". 38 + func RepoMerge(ctx context.Context, c util.LexClient, input *RepoMerge_Input) error { 39 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.merge", nil, input, nil); err != nil { 40 + return err 41 + } 42 + 43 + return nil 44 + }
+57
api/tangled/repomergeCheck.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.mergeCheck 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoMergeCheckNSID = "sh.tangled.repo.mergeCheck" 15 + ) 16 + 17 + // RepoMergeCheck_ConflictInfo is a "conflictInfo" in the sh.tangled.repo.mergeCheck schema. 18 + type RepoMergeCheck_ConflictInfo struct { 19 + // filename: Name of the conflicted file 20 + Filename string `json:"filename" cborgen:"filename"` 21 + // reason: Reason for the conflict 22 + Reason string `json:"reason" cborgen:"reason"` 23 + } 24 + 25 + // RepoMergeCheck_Input is the input argument to a sh.tangled.repo.mergeCheck call. 26 + type RepoMergeCheck_Input struct { 27 + // branch: Target branch to merge into 28 + Branch string `json:"branch" cborgen:"branch"` 29 + // did: DID of the repository owner 30 + Did string `json:"did" cborgen:"did"` 31 + // name: Name of the repository 32 + Name string `json:"name" cborgen:"name"` 33 + // patch: Patch or pull request to check for merge conflicts 34 + Patch string `json:"patch" cborgen:"patch"` 35 + } 36 + 37 + // RepoMergeCheck_Output is the output of a sh.tangled.repo.mergeCheck call. 38 + type RepoMergeCheck_Output struct { 39 + // conflicts: List of files with merge conflicts 40 + Conflicts []*RepoMergeCheck_ConflictInfo `json:"conflicts,omitempty" cborgen:"conflicts,omitempty"` 41 + // error: Error message if check failed 42 + Error *string `json:"error,omitempty" cborgen:"error,omitempty"` 43 + // is_conflicted: Whether the merge has conflicts 44 + Is_conflicted bool `json:"is_conflicted" cborgen:"is_conflicted"` 45 + // message: Additional message about the merge check 46 + Message *string `json:"message,omitempty" cborgen:"message,omitempty"` 47 + } 48 + 49 + // RepoMergeCheck calls the XRPC method "sh.tangled.repo.mergeCheck". 50 + func RepoMergeCheck(ctx context.Context, c util.LexClient, input *RepoMergeCheck_Input) (*RepoMergeCheck_Output, error) { 51 + var out RepoMergeCheck_Output 52 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.mergeCheck", nil, input, &out); err != nil { 53 + return nil, err 54 + } 55 + 56 + return &out, nil 57 + }
+22
api/tangled/tangledknot.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.knot 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + KnotNSID = "sh.tangled.knot" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.knot", &Knot{}) 17 + } // 18 + // RECORDTYPE: Knot 19 + type Knot struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.knot" cborgen:"$type,const=sh.tangled.knot"` 21 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 22 + }
+4 -18
api/tangled/tangledpipeline.go
··· 29 Submodules bool `json:"submodules" cborgen:"submodules"` 30 } 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 // Pipeline_ManualTriggerData is a "manualTriggerData" in the sh.tangled.pipeline schema. 39 type Pipeline_ManualTriggerData struct { 40 Inputs []*Pipeline_Pair `json:"inputs,omitempty" cborgen:"inputs,omitempty"` ··· 61 Ref string `json:"ref" cborgen:"ref"` 62 } 63 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 // Pipeline_TriggerMetadata is a "triggerMetadata" in the sh.tangled.pipeline schema. 72 type Pipeline_TriggerMetadata struct { 73 Kind string `json:"kind" cborgen:"kind"` ··· 87 88 // Pipeline_Workflow is a "workflow" in the sh.tangled.pipeline schema. 89 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"` 95 }
··· 29 Submodules bool `json:"submodules" cborgen:"submodules"` 30 } 31 32 // Pipeline_ManualTriggerData is a "manualTriggerData" in the sh.tangled.pipeline schema. 33 type Pipeline_ManualTriggerData struct { 34 Inputs []*Pipeline_Pair `json:"inputs,omitempty" cborgen:"inputs,omitempty"` ··· 55 Ref string `json:"ref" cborgen:"ref"` 56 } 57 58 // Pipeline_TriggerMetadata is a "triggerMetadata" in the sh.tangled.pipeline schema. 59 type Pipeline_TriggerMetadata struct { 60 Kind string `json:"kind" cborgen:"kind"` ··· 74 75 // Pipeline_Workflow is a "workflow" in the sh.tangled.pipeline schema. 76 type Pipeline_Workflow struct { 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"` 81 }
+1 -1
appview/config/config.go
··· 17 Dev bool `env:"DEV, default=false"` 18 DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` 19 20 - // temporarily, to add users to default spindle 21 AppPassword string `env:"APP_PASSWORD"` 22 } 23
··· 17 Dev bool `env:"DEV, default=false"` 18 DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` 19 20 + // temporarily, to add users to default knot and spindle 21 AppPassword string `env:"APP_PASSWORD"` 22 } 23
+71 -23
appview/db/db.go
··· 27 } 28 29 func Make(dbPath string) (*DB, error) { 30 - db, err := sql.Open("sqlite3", dbPath) 31 if err != nil { 32 return nil, err 33 } 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; 43 44 create table if not exists registrations ( 45 id integer primary key autoincrement, 46 domain text not null unique, ··· 462 id integer primary key autoincrement, 463 name text unique 464 ); 465 `) 466 if err != nil { 467 return nil, err 468 } 469 470 // run migrations 471 - runMigration(db, "add-description-to-repos", func(tx *sql.Tx) error { 472 tx.Exec(` 473 alter table repos add column description text check (length(description) <= 200); 474 `) 475 return nil 476 }) 477 478 - runMigration(db, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 479 // add unconstrained column 480 _, err := tx.Exec(` 481 alter table public_keys ··· 498 return nil 499 }) 500 501 - runMigration(db, "add-rkey-to-comments", func(tx *sql.Tx) error { 502 _, err := tx.Exec(` 503 alter table comments drop column comment_at; 504 alter table comments add column rkey text; ··· 506 return err 507 }) 508 509 - runMigration(db, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 510 _, err := tx.Exec(` 511 alter table comments add column deleted text; -- timestamp 512 alter table comments add column edited text; -- timestamp ··· 514 return err 515 }) 516 517 - runMigration(db, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 518 _, err := tx.Exec(` 519 alter table pulls add column source_branch text; 520 alter table pulls add column source_repo_at text; ··· 523 return err 524 }) 525 526 - runMigration(db, "add-source-to-repos", func(tx *sql.Tx) error { 527 _, err := tx.Exec(` 528 alter table repos add column source text; 529 `) ··· 534 // NOTE: this cannot be done in a transaction, so it is run outside [0] 535 // 536 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys 537 - db.Exec("pragma foreign_keys = off;") 538 - runMigration(db, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 539 _, err := tx.Exec(` 540 create table pulls_new ( 541 -- identifiers ··· 590 `) 591 return err 592 }) 593 - db.Exec("pragma foreign_keys = on;") 594 595 // run migrations 596 - runMigration(db, "add-spindle-to-repos", func(tx *sql.Tx) error { 597 tx.Exec(` 598 alter table repos add column spindle text; 599 `) 600 return nil 601 }) 602 603 // recreate and add rkey + created columns with default constraint 604 - runMigration(db, "rework-collaborators-table", func(tx *sql.Tx) error { 605 // create new table 606 // - repo_at instead of repo integer 607 // - rkey field ··· 655 return err 656 }) 657 658 return &DB{db}, nil 659 } 660 661 type migrationFn = func(*sql.Tx) error 662 663 - func runMigration(d *sql.DB, name string, migrationFn migrationFn) error { 664 - tx, err := d.Begin() 665 if err != nil { 666 return err 667 }
··· 27 } 28 29 func Make(dbPath string) (*DB, error) { 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, "&")) 39 if err != nil { 40 return nil, err 41 } 42 + 43 + ctx := context.Background() 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, ` 52 create table if not exists registrations ( 53 id integer primary key autoincrement, 54 domain text not null unique, ··· 470 id integer primary key autoincrement, 471 name text unique 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); 477 `) 478 if err != nil { 479 return nil, err 480 } 481 482 // run migrations 483 + runMigration(conn, "add-description-to-repos", func(tx *sql.Tx) error { 484 tx.Exec(` 485 alter table repos add column description text check (length(description) <= 200); 486 `) 487 return nil 488 }) 489 490 + runMigration(conn, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 491 // add unconstrained column 492 _, err := tx.Exec(` 493 alter table public_keys ··· 510 return nil 511 }) 512 513 + runMigration(conn, "add-rkey-to-comments", func(tx *sql.Tx) error { 514 _, err := tx.Exec(` 515 alter table comments drop column comment_at; 516 alter table comments add column rkey text; ··· 518 return err 519 }) 520 521 + runMigration(conn, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 522 _, err := tx.Exec(` 523 alter table comments add column deleted text; -- timestamp 524 alter table comments add column edited text; -- timestamp ··· 526 return err 527 }) 528 529 + runMigration(conn, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 530 _, err := tx.Exec(` 531 alter table pulls add column source_branch text; 532 alter table pulls add column source_repo_at text; ··· 535 return err 536 }) 537 538 + runMigration(conn, "add-source-to-repos", func(tx *sql.Tx) error { 539 _, err := tx.Exec(` 540 alter table repos add column source text; 541 `) ··· 546 // NOTE: this cannot be done in a transaction, so it is run outside [0] 547 // 548 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys 549 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 550 + runMigration(conn, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 551 _, err := tx.Exec(` 552 create table pulls_new ( 553 -- identifiers ··· 602 `) 603 return err 604 }) 605 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 606 607 // run migrations 608 + runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error { 609 tx.Exec(` 610 alter table repos add column spindle text; 611 `) 612 return nil 613 }) 614 615 + // drop all knot secrets, add unique constraint to knots 616 + // 617 + // knots will henceforth use service auth for signed requests 618 + runMigration(conn, "no-more-secrets", func(tx *sql.Tx) error { 619 + _, err := tx.Exec(` 620 + create table registrations_new ( 621 + id integer primary key autoincrement, 622 + domain text not null, 623 + did text not null, 624 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 625 + registered text, 626 + read_only integer not null default 0, 627 + unique(domain, did) 628 + ); 629 + 630 + insert into registrations_new (id, domain, did, created, registered, read_only) 631 + select id, domain, did, created, registered, 1 from registrations 632 + where registered is not null; 633 + 634 + drop table registrations; 635 + alter table registrations_new rename to registrations; 636 + `) 637 + return err 638 + }) 639 + 640 // recreate and add rkey + created columns with default constraint 641 + runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error { 642 // create new table 643 // - repo_at instead of repo integer 644 // - rkey field ··· 692 return err 693 }) 694 695 + runMigration(conn, "add-rkey-to-issues", func(tx *sql.Tx) error { 696 + _, err := tx.Exec(` 697 + alter table issues add column rkey text not null default ''; 698 + 699 + -- get last url section from issue_at and save to rkey column 700 + update issues 701 + set rkey = replace(issue_at, rtrim(issue_at, replace(issue_at, '/', '')), ''); 702 + `) 703 + return err 704 + }) 705 + 706 return &DB{db}, nil 707 } 708 709 type migrationFn = func(*sql.Tx) error 710 711 + func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error { 712 + tx, err := c.BeginTx(context.Background(), nil) 713 if err != nil { 714 return err 715 }
+144 -41
appview/db/follow.go
··· 1 package db 2 3 import ( 4 "log" 5 "time" 6 ) 7 ··· 53 return err 54 } 55 56 - func GetFollowerFollowingCount(e Execer, did string) (int, int, error) { 57 followers, following := 0, 0 58 err := e.QueryRow( 59 - `SELECT 60 COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers, 61 COUNT(CASE WHEN user_did = ? THEN 1 END) AS following 62 FROM follows;`, did, did).Scan(&followers, &following) 63 if err != nil { 64 - return 0, 0, err 65 } 66 - return followers, following, nil 67 } 68 69 - type FollowStatus int 70 71 - const ( 72 - IsNotFollowing FollowStatus = iota 73 - IsFollowing 74 - IsSelf 75 - ) 76 77 - func (s FollowStatus) String() string { 78 - switch s { 79 - case IsNotFollowing: 80 - return "IsNotFollowing" 81 - case IsFollowing: 82 - return "IsFollowing" 83 - case IsSelf: 84 - return "IsSelf" 85 - default: 86 - return "IsNotFollowing" 87 } 88 - } 89 90 - func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus { 91 - if userDid == subjectDid { 92 - return IsSelf 93 - } else if _, err := GetFollow(e, userDid, subjectDid); err != nil { 94 - return IsNotFollowing 95 - } else { 96 - return IsFollowing 97 } 98 } 99 100 - func GetAllFollows(e Execer, limit int) ([]Follow, error) { 101 var follows []Follow 102 103 - rows, err := e.Query(` 104 - select user_did, subject_did, followed_at, rkey 105 from follows 106 order by followed_at desc 107 - limit ?`, limit, 108 - ) 109 if err != nil { 110 return nil, err 111 } 112 - defer rows.Close() 113 - 114 for rows.Next() { 115 var follow Follow 116 var followedAt string 117 - if err := rows.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey); err != nil { 118 return nil, err 119 } 120 - 121 followedAtTime, err := time.Parse(time.RFC3339, followedAt) 122 if err != nil { 123 log.Println("unable to determine followed at time") ··· 125 } else { 126 follow.FollowedAt = followedAtTime 127 } 128 - 129 follows = append(follows, follow) 130 } 131 132 - if err := rows.Err(); err != nil { 133 - return nil, err 134 } 135 136 - return follows, nil 137 }
··· 1 package db 2 3 import ( 4 + "fmt" 5 "log" 6 + "strings" 7 "time" 8 ) 9 ··· 55 return err 56 } 57 58 + type FollowStats struct { 59 + Followers int 60 + Following int 61 + } 62 + 63 + func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) { 64 followers, following := 0, 0 65 err := e.QueryRow( 66 + `SELECT 67 COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers, 68 COUNT(CASE WHEN user_did = ? THEN 1 END) AS following 69 FROM follows;`, did, did).Scan(&followers, &following) 70 if err != nil { 71 + return FollowStats{}, err 72 } 73 + return FollowStats{ 74 + Followers: followers, 75 + Following: following, 76 + }, nil 77 } 78 79 + func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]FollowStats, error) { 80 + if len(dids) == 0 { 81 + return nil, nil 82 + } 83 + 84 + placeholders := make([]string, len(dids)) 85 + for i := range placeholders { 86 + placeholders[i] = "?" 87 + } 88 + placeholderStr := strings.Join(placeholders, ",") 89 + 90 + args := make([]any, len(dids)*2) 91 + for i, did := range dids { 92 + args[i] = did 93 + args[i+len(dids)] = did 94 + } 95 + 96 + query := fmt.Sprintf(` 97 + select 98 + coalesce(f.did, g.did) as did, 99 + coalesce(f.followers, 0) as followers, 100 + coalesce(g.following, 0) as following 101 + from ( 102 + select subject_did as did, count(*) as followers 103 + from follows 104 + where subject_did in (%s) 105 + group by subject_did 106 + ) f 107 + full outer join ( 108 + select user_did as did, count(*) as following 109 + from follows 110 + where user_did in (%s) 111 + group by user_did 112 + ) g on f.did = g.did`, 113 + placeholderStr, placeholderStr) 114 115 + result := make(map[string]FollowStats) 116 117 + rows, err := e.Query(query, args...) 118 + if err != nil { 119 + return nil, err 120 } 121 + defer rows.Close() 122 + 123 + for rows.Next() { 124 + var did string 125 + var followers, following int 126 + if err := rows.Scan(&did, &followers, &following); err != nil { 127 + return nil, err 128 + } 129 + result[did] = FollowStats{ 130 + Followers: followers, 131 + Following: following, 132 + } 133 + } 134 135 + for _, did := range dids { 136 + if _, exists := result[did]; !exists { 137 + result[did] = FollowStats{ 138 + Followers: 0, 139 + Following: 0, 140 + } 141 + } 142 } 143 + 144 + return result, nil 145 } 146 147 + func GetFollows(e Execer, limit int, filters ...filter) ([]Follow, error) { 148 var follows []Follow 149 150 + var conditions []string 151 + var args []any 152 + for _, filter := range filters { 153 + conditions = append(conditions, filter.Condition()) 154 + args = append(args, filter.Arg()...) 155 + } 156 + 157 + whereClause := "" 158 + if conditions != nil { 159 + whereClause = " where " + strings.Join(conditions, " and ") 160 + } 161 + limitClause := "" 162 + if limit > 0 { 163 + limitClause = " limit ?" 164 + args = append(args, limit) 165 + } 166 + 167 + query := fmt.Sprintf( 168 + `select user_did, subject_did, followed_at, rkey 169 from follows 170 + %s 171 order by followed_at desc 172 + %s 173 + `, whereClause, limitClause) 174 + 175 + rows, err := e.Query(query, args...) 176 if err != nil { 177 return nil, err 178 } 179 for rows.Next() { 180 var follow Follow 181 var followedAt string 182 + err := rows.Scan( 183 + &follow.UserDid, 184 + &follow.SubjectDid, 185 + &followedAt, 186 + &follow.Rkey, 187 + ) 188 + if err != nil { 189 return nil, err 190 } 191 followedAtTime, err := time.Parse(time.RFC3339, followedAt) 192 if err != nil { 193 log.Println("unable to determine followed at time") ··· 195 } else { 196 follow.FollowedAt = followedAtTime 197 } 198 follows = append(follows, follow) 199 } 200 + return follows, nil 201 + } 202 + 203 + func GetFollowers(e Execer, did string) ([]Follow, error) { 204 + return GetFollows(e, 0, FilterEq("subject_did", did)) 205 + } 206 207 + func GetFollowing(e Execer, did string) ([]Follow, error) { 208 + return GetFollows(e, 0, FilterEq("user_did", did)) 209 + } 210 + 211 + type FollowStatus int 212 + 213 + const ( 214 + IsNotFollowing FollowStatus = iota 215 + IsFollowing 216 + IsSelf 217 + ) 218 + 219 + func (s FollowStatus) String() string { 220 + switch s { 221 + case IsNotFollowing: 222 + return "IsNotFollowing" 223 + case IsFollowing: 224 + return "IsFollowing" 225 + case IsSelf: 226 + return "IsSelf" 227 + default: 228 + return "IsNotFollowing" 229 } 230 + } 231 232 + func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus { 233 + if userDid == subjectDid { 234 + return IsSelf 235 + } else if _, err := GetFollow(e, userDid, subjectDid); err != nil { 236 + return IsNotFollowing 237 + } else { 238 + return IsFollowing 239 + } 240 }
+103 -17
appview/db/issues.go
··· 2 3 import ( 4 "database/sql" 5 "time" 6 7 "github.com/bluesky-social/indigo/atproto/syntax" 8 "tangled.sh/tangled.sh/core/appview/pagination" 9 ) 10 ··· 13 RepoAt syntax.ATURI 14 OwnerDid string 15 IssueId int 16 - IssueAt string 17 Created time.Time 18 Title string 19 Body string ··· 42 Edited *time.Time 43 } 44 45 func NewIssue(tx *sql.Tx, issue *Issue) error { 46 defer tx.Rollback() 47 ··· 67 issue.IssueId = nextId 68 69 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) 73 if err != nil { 74 return err 75 } ··· 87 return nil 88 } 89 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 func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 96 var issueAt string 97 err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt) ··· 104 return ownerDid, err 105 } 106 107 - func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { 108 var issues []Issue 109 openValue := 0 110 if isOpen { ··· 117 select 118 i.id, 119 i.owner_did, 120 i.issue_id, 121 i.created, 122 i.title, ··· 136 select 137 id, 138 owner_did, 139 issue_id, 140 created, 141 title, 142 body, 143 open, 144 comment_count 145 - from 146 numbered_issue 147 - where 148 row_num between ? and ?`, 149 repoAt, openValue, page.Offset+1, page.Offset+page.Limit) 150 if err != nil { ··· 156 var issue Issue 157 var createdAt string 158 var metadata IssueMetadata 159 - err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 160 if err != nil { 161 return nil, err 162 } ··· 178 return issues, nil 179 } 180 181 // timeframe here is directly passed into the sql query filter, and any 182 // timeframe in the past should be negative; e.g.: "-3 months" 183 func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) { ··· 187 `select 188 i.id, 189 i.owner_did, 190 i.repo_at, 191 i.issue_id, 192 i.created, ··· 219 err := rows.Scan( 220 &issue.ID, 221 &issue.OwnerDid, 222 &issue.RepoAt, 223 &issue.IssueId, 224 &issueCreatedAt, ··· 262 } 263 264 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 = ?` 266 row := e.QueryRow(query, repoAt, issueId) 267 268 var issue Issue 269 var createdAt string 270 - err := row.Scan(&issue.ID, &issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open) 271 if err != nil { 272 return nil, err 273 } ··· 282 } 283 284 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 = ?` 286 row := e.QueryRow(query, repoAt, issueId) 287 288 var issue Issue 289 var createdAt string 290 - err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &issue.IssueAt) 291 if err != nil { 292 return nil, nil, err 293 }
··· 2 3 import ( 4 "database/sql" 5 + "fmt" 6 + "strings" 7 "time" 8 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.sh/tangled.sh/core/api/tangled" 11 "tangled.sh/tangled.sh/core/appview/pagination" 12 ) 13 ··· 16 RepoAt syntax.ATURI 17 OwnerDid string 18 IssueId int 19 + Rkey string 20 Created time.Time 21 Title string 22 Body string ··· 45 Edited *time.Time 46 } 47 48 + func (i *Issue) AtUri() syntax.ATURI { 49 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.OwnerDid, tangled.RepoIssueNSID, i.Rkey)) 50 + } 51 + 52 func NewIssue(tx *sql.Tx, issue *Issue) error { 53 defer tx.Rollback() 54 ··· 74 issue.IssueId = nextId 75 76 res, err := tx.Exec(` 77 + insert into issues (repo_at, owner_did, rkey, issue_at, issue_id, title, body) 78 + values (?, ?, ?, ?, ?, ?, ?) 79 + `, issue.RepoAt, issue.OwnerDid, issue.Rkey, issue.AtUri(), issue.IssueId, issue.Title, issue.Body) 80 if err != nil { 81 return err 82 } ··· 94 return nil 95 } 96 97 func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 98 var issueAt string 99 err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt) ··· 106 return ownerDid, err 107 } 108 109 + func GetIssuesPaginated(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { 110 var issues []Issue 111 openValue := 0 112 if isOpen { ··· 119 select 120 i.id, 121 i.owner_did, 122 + i.rkey, 123 i.issue_id, 124 i.created, 125 i.title, ··· 139 select 140 id, 141 owner_did, 142 + rkey, 143 issue_id, 144 created, 145 title, 146 body, 147 open, 148 comment_count 149 + from 150 numbered_issue 151 + where 152 row_num between ? and ?`, 153 repoAt, openValue, page.Offset+1, page.Offset+page.Limit) 154 if err != nil { ··· 160 var issue Issue 161 var createdAt string 162 var metadata IssueMetadata 163 + err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 164 if err != nil { 165 return nil, err 166 } ··· 182 return issues, nil 183 } 184 185 + func GetIssuesWithLimit(e Execer, limit int, filters ...filter) ([]Issue, error) { 186 + issues := make([]Issue, 0, limit) 187 + 188 + var conditions []string 189 + var args []any 190 + for _, filter := range filters { 191 + conditions = append(conditions, filter.Condition()) 192 + args = append(args, filter.Arg()...) 193 + } 194 + 195 + whereClause := "" 196 + if conditions != nil { 197 + whereClause = " where " + strings.Join(conditions, " and ") 198 + } 199 + limitClause := "" 200 + if limit != 0 { 201 + limitClause = fmt.Sprintf(" limit %d ", limit) 202 + } 203 + 204 + query := fmt.Sprintf( 205 + `select 206 + i.id, 207 + i.owner_did, 208 + i.repo_at, 209 + i.issue_id, 210 + i.created, 211 + i.title, 212 + i.body, 213 + i.open 214 + from 215 + issues i 216 + %s 217 + order by 218 + i.created desc 219 + %s`, 220 + whereClause, limitClause) 221 + 222 + rows, err := e.Query(query, args...) 223 + if err != nil { 224 + return nil, err 225 + } 226 + defer rows.Close() 227 + 228 + for rows.Next() { 229 + var issue Issue 230 + var issueCreatedAt string 231 + err := rows.Scan( 232 + &issue.ID, 233 + &issue.OwnerDid, 234 + &issue.RepoAt, 235 + &issue.IssueId, 236 + &issueCreatedAt, 237 + &issue.Title, 238 + &issue.Body, 239 + &issue.Open, 240 + ) 241 + if err != nil { 242 + return nil, err 243 + } 244 + 245 + issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt) 246 + if err != nil { 247 + return nil, err 248 + } 249 + issue.Created = issueCreatedTime 250 + 251 + issues = append(issues, issue) 252 + } 253 + 254 + if err := rows.Err(); err != nil { 255 + return nil, err 256 + } 257 + 258 + return issues, nil 259 + } 260 + 261 + func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 262 + return GetIssuesWithLimit(e, 0, filters...) 263 + } 264 + 265 // timeframe here is directly passed into the sql query filter, and any 266 // timeframe in the past should be negative; e.g.: "-3 months" 267 func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) { ··· 271 `select 272 i.id, 273 i.owner_did, 274 + i.rkey, 275 i.repo_at, 276 i.issue_id, 277 i.created, ··· 304 err := rows.Scan( 305 &issue.ID, 306 &issue.OwnerDid, 307 + &issue.Rkey, 308 &issue.RepoAt, 309 &issue.IssueId, 310 &issueCreatedAt, ··· 348 } 349 350 func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { 351 + query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 352 row := e.QueryRow(query, repoAt, issueId) 353 354 var issue Issue 355 var createdAt string 356 + err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 357 if err != nil { 358 return nil, err 359 } ··· 368 } 369 370 func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) { 371 + query := `select id, owner_did, rkey, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?` 372 row := e.QueryRow(query, repoAt, issueId) 373 374 var issue Issue 375 var createdAt string 376 + err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open) 377 if err != nil { 378 return nil, nil, err 379 }
+2 -7
appview/db/profile.go
··· 348 return tx.Commit() 349 } 350 351 - func GetProfiles(e Execer, filters ...filter) ([]Profile, error) { 352 var conditions []string 353 var args []any 354 for _, filter := range filters { ··· 448 idxs[did] = idx + 1 449 } 450 451 - var profiles []Profile 452 - for _, p := range profileMap { 453 - profiles = append(profiles, *p) 454 - } 455 - 456 - return profiles, nil 457 } 458 459 func GetProfile(e Execer, did string) (*Profile, error) {
··· 348 return tx.Commit() 349 } 350 351 + func GetProfiles(e Execer, filters ...filter) (map[string]*Profile, error) { 352 var conditions []string 353 var args []any 354 for _, filter := range filters { ··· 448 idxs[did] = idx + 1 449 } 450 451 + return profileMap, nil 452 } 453 454 func GetProfile(e Execer, did string) (*Profile, error) {
+22 -3
appview/db/pulls.go
··· 310 return pullId - 1, err 311 } 312 313 - func GetPulls(e Execer, filters ...filter) ([]*Pull, error) { 314 pulls := make(map[int]*Pull) 315 316 var conditions []string ··· 323 whereClause := "" 324 if conditions != nil { 325 whereClause = " where " + strings.Join(conditions, " and ") 326 } 327 328 query := fmt.Sprintf(` ··· 344 from 345 pulls 346 %s 347 - `, whereClause) 348 349 rows, err := e.Query(query, args...) 350 if err != nil { ··· 412 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 413 submissionsQuery := fmt.Sprintf(` 414 select 415 - id, pull_id, round_number, patch, source_rev 416 from 417 pull_submissions 418 where ··· 438 for submissionsRows.Next() { 439 var s PullSubmission 440 var sourceRev sql.NullString 441 err := submissionsRows.Scan( 442 &s.ID, 443 &s.PullId, 444 &s.RoundNumber, 445 &s.Patch, 446 &sourceRev, 447 ) 448 if err != nil { 449 return nil, err 450 } 451 452 if sourceRev.Valid { 453 s.SourceRev = sourceRev.String ··· 511 }) 512 513 return orderedByPullId, nil 514 } 515 516 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) {
··· 310 return pullId - 1, err 311 } 312 313 + func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*Pull, error) { 314 pulls := make(map[int]*Pull) 315 316 var conditions []string ··· 323 whereClause := "" 324 if conditions != nil { 325 whereClause = " where " + strings.Join(conditions, " and ") 326 + } 327 + limitClause := "" 328 + if limit != 0 { 329 + limitClause = fmt.Sprintf(" limit %d ", limit) 330 } 331 332 query := fmt.Sprintf(` ··· 348 from 349 pulls 350 %s 351 + order by 352 + created desc 353 + %s 354 + `, whereClause, limitClause) 355 356 rows, err := e.Query(query, args...) 357 if err != nil { ··· 419 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 420 submissionsQuery := fmt.Sprintf(` 421 select 422 + id, pull_id, round_number, patch, created, source_rev 423 from 424 pull_submissions 425 where ··· 445 for submissionsRows.Next() { 446 var s PullSubmission 447 var sourceRev sql.NullString 448 + var createdAt string 449 err := submissionsRows.Scan( 450 &s.ID, 451 &s.PullId, 452 &s.RoundNumber, 453 &s.Patch, 454 + &createdAt, 455 &sourceRev, 456 ) 457 if err != nil { 458 return nil, err 459 } 460 + 461 + createdTime, err := time.Parse(time.RFC3339, createdAt) 462 + if err != nil { 463 + return nil, err 464 + } 465 + s.Created = createdTime 466 467 if sourceRev.Valid { 468 s.SourceRev = sourceRev.String ··· 526 }) 527 528 return orderedByPullId, nil 529 + } 530 + 531 + func GetPulls(e Execer, filters ...filter) ([]*Pull, error) { 532 + return GetPullsWithLimit(e, 0, filters...) 533 } 534 535 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) {
+89 -125
appview/db/registration.go
··· 1 package db 2 3 import ( 4 - "crypto/rand" 5 "database/sql" 6 - "encoding/hex" 7 "fmt" 8 - "log" 9 "time" 10 ) 11 12 type Registration struct { 13 Id int64 14 Domain string 15 ByDid string 16 Created *time.Time 17 Registered *time.Time 18 } 19 20 func (r *Registration) Status() Status { 21 - if r.Registered != nil { 22 return Registered 23 } else { 24 return Pending 25 } 26 } 27 28 type Status uint32 29 30 const ( 31 Registered Status = iota 32 Pending 33 ) 34 35 - // returns registered status, did of owner, error 36 - func RegistrationsByDid(e Execer, did string) ([]Registration, error) { 37 var registrations []Registration 38 39 - rows, err := e.Query(` 40 - select id, domain, did, created, registered from registrations 41 - where did = ? 42 - `, did) 43 if err != nil { 44 return nil, err 45 } 46 47 for rows.Next() { 48 - var createdAt *string 49 - var registeredAt *string 50 - var registration Registration 51 - err = rows.Scan(&registration.Id, &registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 52 53 if err != nil { 54 - log.Println(err) 55 - } else { 56 - createdAtTime, _ := time.Parse(time.RFC3339, *createdAt) 57 - var registeredAtTime *time.Time 58 - if registeredAt != nil { 59 - x, _ := time.Parse(time.RFC3339, *registeredAt) 60 - registeredAtTime = &x 61 - } 62 63 - registration.Created = &createdAtTime 64 - registration.Registered = registeredAtTime 65 - registrations = append(registrations, registration) 66 } 67 - } 68 69 - return registrations, nil 70 - } 71 - 72 - // returns registered status, did of owner, error 73 - func RegistrationByDomain(e Execer, domain string) (*Registration, error) { 74 - var createdAt *string 75 - var registeredAt *string 76 - var registration Registration 77 - 78 - err := e.QueryRow(` 79 - select id, domain, did, created, registered from registrations 80 - where domain = ? 81 - `, domain).Scan(&registration.Id, &registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 82 83 - if err != nil { 84 - if err == sql.ErrNoRows { 85 - return nil, nil 86 - } else { 87 - return nil, err 88 } 89 - } 90 91 - createdAtTime, _ := time.Parse(time.RFC3339, *createdAt) 92 - var registeredAtTime *time.Time 93 - if registeredAt != nil { 94 - x, _ := time.Parse(time.RFC3339, *registeredAt) 95 - registeredAtTime = &x 96 } 97 98 - registration.Created = &createdAtTime 99 - registration.Registered = registeredAtTime 100 - 101 - return &registration, nil 102 - } 103 - 104 - func genSecret() string { 105 - key := make([]byte, 32) 106 - rand.Read(key) 107 - return hex.EncodeToString(key) 108 } 109 110 - func GenerateRegistrationKey(e Execer, domain, did string) (string, error) { 111 - // sanity check: does this domain already have a registration? 112 - reg, err := RegistrationByDomain(e, domain) 113 - if err != nil { 114 - return "", err 115 - } 116 - 117 - // registration is open 118 - if reg != nil { 119 - switch reg.Status() { 120 - case Registered: 121 - // already registered by `owner` 122 - return "", fmt.Errorf("%s already registered by %s", domain, reg.ByDid) 123 - case Pending: 124 - // TODO: be loud about this 125 - log.Printf("%s registered by %s, status pending", domain, reg.ByDid) 126 - } 127 } 128 129 - secret := genSecret() 130 - 131 - _, err = e.Exec(` 132 - insert into registrations (domain, did, secret) 133 - values (?, ?, ?) 134 - on conflict(domain) do update set did = excluded.did, secret = excluded.secret, created = excluded.created 135 - `, domain, did, secret) 136 - 137 - if err != nil { 138 - return "", err 139 } 140 141 - return secret, nil 142 } 143 144 - func GetRegistrationKey(e Execer, domain string) (string, error) { 145 - res := e.QueryRow(`select secret from registrations where domain = ?`, domain) 146 - 147 - var secret string 148 - err := res.Scan(&secret) 149 - if err != nil || secret == "" { 150 - return "", err 151 - } 152 - 153 - return secret, nil 154 } 155 156 - func GetCompletedRegistrations(e Execer) ([]string, error) { 157 - rows, err := e.Query(`select domain from registrations where registered not null`) 158 - if err != nil { 159 - return nil, err 160 } 161 162 - var domains []string 163 - for rows.Next() { 164 - var domain string 165 - err = rows.Scan(&domain) 166 - 167 - if err != nil { 168 - log.Println(err) 169 - } else { 170 - domains = append(domains, domain) 171 - } 172 - } 173 - 174 - if err = rows.Err(); err != nil { 175 - return nil, err 176 } 177 178 - return domains, nil 179 - } 180 181 - func Register(e Execer, domain string) error { 182 - _, err := e.Exec(` 183 - update registrations 184 - set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 185 - where domain = ?; 186 - `, domain) 187 - 188 return err 189 }
··· 1 package db 2 3 import ( 4 "database/sql" 5 "fmt" 6 + "strings" 7 "time" 8 ) 9 10 + // Registration represents a knot registration. Knot would've been a better 11 + // name but we're stuck with this for historical reasons. 12 type Registration struct { 13 Id int64 14 Domain string 15 ByDid string 16 Created *time.Time 17 Registered *time.Time 18 + ReadOnly bool 19 } 20 21 func (r *Registration) Status() Status { 22 + if r.ReadOnly { 23 + return ReadOnly 24 + } else if r.Registered != nil { 25 return Registered 26 } else { 27 return Pending 28 } 29 } 30 31 + func (r *Registration) IsRegistered() bool { 32 + return r.Status() == Registered 33 + } 34 + 35 + func (r *Registration) IsReadOnly() bool { 36 + return r.Status() == ReadOnly 37 + } 38 + 39 + func (r *Registration) IsPending() bool { 40 + return r.Status() == Pending 41 + } 42 + 43 type Status uint32 44 45 const ( 46 Registered Status = iota 47 Pending 48 + ReadOnly 49 ) 50 51 + func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) { 52 var registrations []Registration 53 54 + var conditions []string 55 + var args []any 56 + for _, filter := range filters { 57 + conditions = append(conditions, filter.Condition()) 58 + args = append(args, filter.Arg()...) 59 + } 60 + 61 + whereClause := "" 62 + if conditions != nil { 63 + whereClause = " where " + strings.Join(conditions, " and ") 64 + } 65 + 66 + query := fmt.Sprintf(` 67 + select id, domain, did, created, registered, read_only 68 + from registrations 69 + %s 70 + order by created 71 + `, 72 + whereClause, 73 + ) 74 + 75 + rows, err := e.Query(query, args...) 76 if err != nil { 77 return nil, err 78 } 79 80 for rows.Next() { 81 + var createdAt string 82 + var registeredAt sql.Null[string] 83 + var readOnly int 84 + var reg Registration 85 86 + err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &readOnly) 87 if err != nil { 88 + return nil, err 89 + } 90 91 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 92 + reg.Created = &t 93 } 94 95 + if registeredAt.Valid { 96 + if t, err := time.Parse(time.RFC3339, registeredAt.V); err == nil { 97 + reg.Registered = &t 98 + } 99 + } 100 101 + if readOnly != 0 { 102 + reg.ReadOnly = true 103 } 104 105 + registrations = append(registrations, reg) 106 } 107 108 + return registrations, nil 109 } 110 111 + func MarkRegistered(e Execer, filters ...filter) error { 112 + var conditions []string 113 + var args []any 114 + for _, filter := range filters { 115 + conditions = append(conditions, filter.Condition()) 116 + args = append(args, filter.Arg()...) 117 } 118 119 + query := "update registrations set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), read_only = 0" 120 + if len(conditions) > 0 { 121 + query += " where " + strings.Join(conditions, " and ") 122 } 123 124 + _, err := e.Exec(query, args...) 125 + return err 126 } 127 128 + func AddKnot(e Execer, domain, did string) error { 129 + _, err := e.Exec(` 130 + insert into registrations (domain, did) 131 + values (?, ?) 132 + `, domain, did) 133 + return err 134 } 135 136 + func DeleteKnot(e Execer, filters ...filter) error { 137 + var conditions []string 138 + var args []any 139 + for _, filter := range filters { 140 + conditions = append(conditions, filter.Condition()) 141 + args = append(args, filter.Arg()...) 142 } 143 144 + whereClause := "" 145 + if conditions != nil { 146 + whereClause = " where " + strings.Join(conditions, " and ") 147 } 148 149 + query := fmt.Sprintf(`delete from registrations %s`, whereClause) 150 151 + _, err := e.Exec(query, args...) 152 return err 153 }
+9 -10
appview/db/repos.go
··· 19 Knot string 20 Rkey string 21 Created time.Time 22 - AtUri string 23 Description string 24 Spindle string 25 ··· 391 var description, spindle sql.NullString 392 393 row := e.QueryRow(` 394 - select did, name, knot, created, at_uri, description, spindle 395 from repos 396 where did = ? and name = ? 397 `, ··· 400 ) 401 402 var createdAt string 403 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &description, &spindle); err != nil { 404 return nil, err 405 } 406 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 421 var repo Repo 422 var nullableDescription sql.NullString 423 424 - row := e.QueryRow(`select did, name, knot, created, at_uri, description from repos where at_uri = ?`, atUri) 425 426 var createdAt string 427 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &nullableDescription); err != nil { 428 return nil, err 429 } 430 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 444 `insert into repos 445 (did, name, knot, rkey, at_uri, description, source) 446 values (?, ?, ?, ?, ?, ?, ?)`, 447 - repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri, repo.Description, repo.Source, 448 ) 449 return err 450 } ··· 467 var repos []Repo 468 469 rows, err := e.Query( 470 - `select did, name, knot, rkey, description, created, at_uri, source 471 from repos 472 where did = ? and source is not null and source != '' 473 order by created desc`, ··· 484 var nullableDescription sql.NullString 485 var nullableSource sql.NullString 486 487 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource) 488 if err != nil { 489 return nil, err 490 } ··· 521 var nullableSource sql.NullString 522 523 row := e.QueryRow( 524 - `select did, name, knot, rkey, description, created, at_uri, source 525 from repos 526 where did = ? and name = ? and source is not null and source != ''`, 527 did, name, 528 ) 529 530 - err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource) 531 if err != nil { 532 return nil, err 533 }
··· 19 Knot string 20 Rkey string 21 Created time.Time 22 Description string 23 Spindle string 24 ··· 390 var description, spindle sql.NullString 391 392 row := e.QueryRow(` 393 + select did, name, knot, created, description, spindle, rkey 394 from repos 395 where did = ? and name = ? 396 `, ··· 399 ) 400 401 var createdAt string 402 + if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &description, &spindle, &repo.Rkey); err != nil { 403 return nil, err 404 } 405 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 420 var repo Repo 421 var nullableDescription sql.NullString 422 423 + row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 424 425 var createdAt string 426 + if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 427 return nil, err 428 } 429 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 443 `insert into repos 444 (did, name, knot, rkey, at_uri, description, source) 445 values (?, ?, ?, ?, ?, ?, ?)`, 446 + repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, 447 ) 448 return err 449 } ··· 466 var repos []Repo 467 468 rows, err := e.Query( 469 + `select did, name, knot, rkey, description, created, source 470 from repos 471 where did = ? and source is not null and source != '' 472 order by created desc`, ··· 483 var nullableDescription sql.NullString 484 var nullableSource sql.NullString 485 486 + err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 487 if err != nil { 488 return nil, err 489 } ··· 520 var nullableSource sql.NullString 521 522 row := e.QueryRow( 523 + `select did, name, knot, rkey, description, created, source 524 from repos 525 where did = ? and name = ? and source is not null and source != ''`, 526 did, name, 527 ) 528 529 + err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 530 if err != nil { 531 return nil, err 532 }
+73 -6
appview/db/star.go
··· 47 // Get a star record 48 func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) { 49 query := ` 50 - select starred_by_did, repo_at, created, rkey 51 from stars 52 where starred_by_did = ? and repo_at = ?` 53 row := e.QueryRow(query, starredByDid, repoAt) ··· 119 } 120 121 repoQuery := fmt.Sprintf( 122 - `select starred_by_did, repo_at, created, rkey 123 from stars 124 %s 125 order by created desc ··· 187 var stars []Star 188 189 rows, err := e.Query(` 190 - select 191 s.starred_by_did, 192 s.repo_at, 193 s.rkey, ··· 196 r.name, 197 r.knot, 198 r.rkey, 199 - r.created, 200 - r.at_uri 201 from stars s 202 join repos r on s.repo_at = r.at_uri 203 `) ··· 222 &repo.Knot, 223 &repo.Rkey, 224 &repoCreatedAt, 225 - &repo.AtUri, 226 ); err != nil { 227 return nil, err 228 } ··· 246 247 return stars, nil 248 }
··· 47 // Get a star record 48 func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) { 49 query := ` 50 + select starred_by_did, repo_at, created, rkey 51 from stars 52 where starred_by_did = ? and repo_at = ?` 53 row := e.QueryRow(query, starredByDid, repoAt) ··· 119 } 120 121 repoQuery := fmt.Sprintf( 122 + `select starred_by_did, repo_at, created, rkey 123 from stars 124 %s 125 order by created desc ··· 187 var stars []Star 188 189 rows, err := e.Query(` 190 + select 191 s.starred_by_did, 192 s.repo_at, 193 s.rkey, ··· 196 r.name, 197 r.knot, 198 r.rkey, 199 + r.created 200 from stars s 201 join repos r on s.repo_at = r.at_uri 202 `) ··· 221 &repo.Knot, 222 &repo.Rkey, 223 &repoCreatedAt, 224 ); err != nil { 225 return nil, err 226 } ··· 244 245 return stars, nil 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 + }
+12 -11
appview/db/strings.go
··· 50 func (s String) Validate() error { 51 var err error 52 53 - if !strings.Contains(s.Filename, ".") { 54 - err = errors.Join(err, fmt.Errorf("missing filename extension")) 55 - } 56 - 57 - if strings.HasSuffix(s.Filename, ".") { 58 - err = errors.Join(err, fmt.Errorf("filename ends with `.`")) 59 - } 60 - 61 if utf8.RuneCountInString(s.Filename) > 140 { 62 err = errors.Join(err, fmt.Errorf("filename too long")) 63 } ··· 113 filename = excluded.filename, 114 description = excluded.description, 115 content = excluded.content, 116 - edited = case 117 when 118 strings.content != excluded.content 119 or strings.filename != excluded.filename ··· 131 return err 132 } 133 134 - func GetStrings(e Execer, filters ...filter) ([]String, error) { 135 var all []String 136 137 var conditions []string ··· 146 whereClause = " where " + strings.Join(conditions, " and ") 147 } 148 149 query := fmt.Sprintf(`select 150 did, 151 rkey, ··· 154 content, 155 created, 156 edited 157 - from strings %s`, 158 whereClause, 159 ) 160 161 rows, err := e.Query(query, args...)
··· 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 } ··· 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 ··· 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 ··· 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, ··· 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...)
+6 -22
appview/db/timeline.go
··· 20 *FollowStats 21 } 22 23 - type FollowStats struct { 24 - Followers int 25 - Following int 26 - } 27 - 28 const Limit = 50 29 30 // TODO: this gathers heterogenous events from different sources and aggregates ··· 137 } 138 139 func getTimelineFollows(e Execer) ([]TimelineEvent, error) { 140 - follows, err := GetAllFollows(e, Limit) 141 if err != nil { 142 return nil, err 143 } ··· 151 return nil, nil 152 } 153 154 - profileMap := make(map[string]Profile) 155 profiles, err := GetProfiles(e, FilterIn("did", subjects)) 156 if err != nil { 157 return nil, err 158 } 159 - for _, p := range profiles { 160 - profileMap[p.Did] = p 161 - } 162 163 - followStatMap := make(map[string]FollowStats) 164 - for _, s := range subjects { 165 - followers, following, err := GetFollowerFollowingCount(e, s) 166 - if err != nil { 167 - return nil, err 168 - } 169 - followStatMap[s] = FollowStats{ 170 - Followers: followers, 171 - Following: following, 172 - } 173 } 174 175 var events []TimelineEvent 176 for _, f := range follows { 177 - profile, _ := profileMap[f.SubjectDid] 178 followStatMap, _ := followStatMap[f.SubjectDid] 179 180 events = append(events, TimelineEvent{ 181 Follow: &f, 182 - Profile: &profile, 183 FollowStats: &followStatMap, 184 EventAt: f.FollowedAt, 185 })
··· 20 *FollowStats 21 } 22 23 const Limit = 50 24 25 // TODO: this gathers heterogenous events from different sources and aggregates ··· 132 } 133 134 func getTimelineFollows(e Execer) ([]TimelineEvent, error) { 135 + follows, err := GetFollows(e, Limit) 136 if err != nil { 137 return nil, err 138 } ··· 146 return nil, nil 147 } 148 149 profiles, err := GetProfiles(e, FilterIn("did", subjects)) 150 if err != nil { 151 return nil, err 152 } 153 154 + followStatMap, err := GetFollowerFollowingCounts(e, subjects) 155 + if err != nil { 156 + return nil, err 157 } 158 159 var events []TimelineEvent 160 for _, f := range follows { 161 + profile, _ := profiles[f.SubjectDid] 162 followStatMap, _ := followStatMap[f.SubjectDid] 163 164 events = append(events, TimelineEvent{ 165 Follow: &f, 166 + Profile: profile, 167 FollowStats: &followStatMap, 168 EventAt: f.FollowedAt, 169 })
+165 -5
appview/ingester.go
··· 14 "tangled.sh/tangled.sh/core/api/tangled" 15 "tangled.sh/tangled.sh/core/appview/config" 16 "tangled.sh/tangled.sh/core/appview/db" 17 - "tangled.sh/tangled.sh/core/appview/spindleverify" 18 "tangled.sh/tangled.sh/core/idresolver" 19 "tangled.sh/tangled.sh/core/rbac" 20 ) ··· 64 err = i.ingestSpindleMember(e) 65 case tangled.SpindleNSID: 66 err = i.ingestSpindle(e) 67 case tangled.StringNSID: 68 err = i.ingestString(e) 69 } ··· 71 } 72 73 if err != nil { 74 - l.Error("error ingesting record", "err", err) 75 } 76 77 - return err 78 } 79 } 80 ··· 475 return err 476 } 477 478 - err = spindleverify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev) 479 if err != nil { 480 l.Error("failed to add spindle to db", "err", err, "instance", instance) 481 return err 482 } 483 484 - _, err = spindleverify.MarkVerified(ddb, i.Enforcer, instance, did) 485 if err != nil { 486 return fmt.Errorf("failed to mark verified: %w", err) 487 } ··· 609 610 return nil 611 }
··· 14 "tangled.sh/tangled.sh/core/api/tangled" 15 "tangled.sh/tangled.sh/core/appview/config" 16 "tangled.sh/tangled.sh/core/appview/db" 17 + "tangled.sh/tangled.sh/core/appview/serververify" 18 "tangled.sh/tangled.sh/core/idresolver" 19 "tangled.sh/tangled.sh/core/rbac" 20 ) ··· 64 err = i.ingestSpindleMember(e) 65 case tangled.SpindleNSID: 66 err = i.ingestSpindle(e) 67 + case tangled.KnotMemberNSID: 68 + err = i.ingestKnotMember(e) 69 + case tangled.KnotNSID: 70 + err = i.ingestKnot(e) 71 case tangled.StringNSID: 72 err = i.ingestString(e) 73 } ··· 75 } 76 77 if err != nil { 78 + l.Debug("error ingesting record", "err", err) 79 } 80 81 + return nil 82 } 83 } 84 ··· 479 return err 480 } 481 482 + err = serververify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev) 483 if err != nil { 484 l.Error("failed to add spindle to db", "err", err, "instance", instance) 485 return err 486 } 487 488 + _, err = serververify.MarkSpindleVerified(ddb, i.Enforcer, instance, did) 489 if err != nil { 490 return fmt.Errorf("failed to mark verified: %w", err) 491 } ··· 613 614 return nil 615 } 616 + 617 + func (i *Ingester) ingestKnotMember(e *models.Event) error { 618 + did := e.Did 619 + var err error 620 + 621 + l := i.Logger.With("handler", "ingestKnotMember") 622 + l = l.With("nsid", e.Commit.Collection) 623 + 624 + switch e.Commit.Operation { 625 + case models.CommitOperationCreate: 626 + raw := json.RawMessage(e.Commit.Record) 627 + record := tangled.KnotMember{} 628 + err = json.Unmarshal(raw, &record) 629 + if err != nil { 630 + l.Error("invalid record", "err", err) 631 + return err 632 + } 633 + 634 + // only knot owner can invite to knots 635 + ok, err := i.Enforcer.IsKnotInviteAllowed(did, record.Domain) 636 + if err != nil || !ok { 637 + return fmt.Errorf("failed to enforce permissions: %w", err) 638 + } 639 + 640 + memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject) 641 + if err != nil { 642 + return err 643 + } 644 + 645 + if memberId.Handle.IsInvalidHandle() { 646 + return err 647 + } 648 + 649 + err = i.Enforcer.AddKnotMember(record.Domain, memberId.DID.String()) 650 + if err != nil { 651 + return fmt.Errorf("failed to update ACLs: %w", err) 652 + } 653 + 654 + l.Info("added knot member") 655 + case models.CommitOperationDelete: 656 + // we don't store knot members in a table (like we do for spindle) 657 + // and we can't remove this just yet. possibly fixed if we switch 658 + // to either: 659 + // 1. a knot_members table like with spindle and store the rkey 660 + // 2. use the knot host as the rkey 661 + // 662 + // TODO: implement member deletion 663 + l.Info("skipping knot member delete", "did", did, "rkey", e.Commit.RKey) 664 + } 665 + 666 + return nil 667 + } 668 + 669 + func (i *Ingester) ingestKnot(e *models.Event) error { 670 + did := e.Did 671 + var err error 672 + 673 + l := i.Logger.With("handler", "ingestKnot") 674 + l = l.With("nsid", e.Commit.Collection) 675 + 676 + switch e.Commit.Operation { 677 + case models.CommitOperationCreate: 678 + raw := json.RawMessage(e.Commit.Record) 679 + record := tangled.Knot{} 680 + err = json.Unmarshal(raw, &record) 681 + if err != nil { 682 + l.Error("invalid record", "err", err) 683 + return err 684 + } 685 + 686 + domain := e.Commit.RKey 687 + 688 + ddb, ok := i.Db.Execer.(*db.DB) 689 + if !ok { 690 + return fmt.Errorf("failed to index profile record, invalid db cast") 691 + } 692 + 693 + err := db.AddKnot(ddb, domain, did) 694 + if err != nil { 695 + l.Error("failed to add knot to db", "err", err, "domain", domain) 696 + return err 697 + } 698 + 699 + err = serververify.RunVerification(context.Background(), domain, did, i.Config.Core.Dev) 700 + if err != nil { 701 + l.Error("failed to verify knot", "err", err, "domain", domain) 702 + return err 703 + } 704 + 705 + err = serververify.MarkKnotVerified(ddb, i.Enforcer, domain, did) 706 + if err != nil { 707 + return fmt.Errorf("failed to mark verified: %w", err) 708 + } 709 + 710 + return nil 711 + 712 + case models.CommitOperationDelete: 713 + domain := e.Commit.RKey 714 + 715 + ddb, ok := i.Db.Execer.(*db.DB) 716 + if !ok { 717 + return fmt.Errorf("failed to index knot record, invalid db cast") 718 + } 719 + 720 + // get record from db first 721 + registrations, err := db.GetRegistrations( 722 + ddb, 723 + db.FilterEq("domain", domain), 724 + db.FilterEq("did", did), 725 + ) 726 + if err != nil { 727 + return fmt.Errorf("failed to get registration: %w", err) 728 + } 729 + if len(registrations) != 1 { 730 + return fmt.Errorf("got incorret number of registrations: %d, expected 1", len(registrations)) 731 + } 732 + registration := registrations[0] 733 + 734 + tx, err := ddb.Begin() 735 + if err != nil { 736 + return err 737 + } 738 + defer func() { 739 + tx.Rollback() 740 + i.Enforcer.E.LoadPolicy() 741 + }() 742 + 743 + err = db.DeleteKnot( 744 + tx, 745 + db.FilterEq("did", did), 746 + db.FilterEq("domain", domain), 747 + ) 748 + if err != nil { 749 + return err 750 + } 751 + 752 + if registration.Registered != nil { 753 + err = i.Enforcer.RemoveKnot(domain) 754 + if err != nil { 755 + return err 756 + } 757 + } 758 + 759 + err = tx.Commit() 760 + if err != nil { 761 + return err 762 + } 763 + 764 + err = i.Enforcer.E.SavePolicy() 765 + if err != nil { 766 + return err 767 + } 768 + } 769 + 770 + return nil 771 + }
+37 -32
appview/issues/issues.go
··· 7 "net/http" 8 "slices" 9 "strconv" 10 "time" 11 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 "github.com/bluesky-social/indigo/atproto/data" 14 - "github.com/bluesky-social/indigo/atproto/syntax" 15 lexutil "github.com/bluesky-social/indigo/lex/util" 16 "github.com/go-chi/chi/v5" 17 ··· 21 "tangled.sh/tangled.sh/core/appview/notify" 22 "tangled.sh/tangled.sh/core/appview/oauth" 23 "tangled.sh/tangled.sh/core/appview/pages" 24 "tangled.sh/tangled.sh/core/appview/pagination" 25 "tangled.sh/tangled.sh/core/appview/reporesolver" 26 "tangled.sh/tangled.sh/core/idresolver" ··· 73 return 74 } 75 76 - issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt, issueIdInt) 77 if err != nil { 78 log.Println("failed to get issue and comments", err) 79 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 80 return 81 } 82 83 - reactionCountMap, err := db.GetReactionCountMap(rp.db, syntax.ATURI(issue.IssueAt)) 84 if err != nil { 85 log.Println("failed to get issue reactions") 86 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") ··· 88 89 userReactions := map[db.ReactionKind]bool{} 90 if user != nil { 91 - userReactions = db.GetReactionStatusMap(rp.db, user.Did, syntax.ATURI(issue.IssueAt)) 92 } 93 94 issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) ··· 99 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 100 LoggedInUser: user, 101 RepoInfo: f.RepoInfo(user), 102 - Issue: *issue, 103 Comments: comments, 104 105 IssueOwnerHandle: issueOwnerIdent.Handle.String(), ··· 127 return 128 } 129 130 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 131 if err != nil { 132 log.Println("failed to get issue", err) 133 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") ··· 159 Rkey: tid.TID(), 160 Record: &lexutil.LexiconTypeDecoder{ 161 Val: &tangled.RepoIssueState{ 162 - Issue: issue.IssueAt, 163 State: closed, 164 }, 165 }, ··· 171 return 172 } 173 174 - err = db.CloseIssue(rp.db, f.RepoAt, issueIdInt) 175 if err != nil { 176 log.Println("failed to close issue", err) 177 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") ··· 203 return 204 } 205 206 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 207 if err != nil { 208 log.Println("failed to get issue", err) 209 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") ··· 220 isIssueOwner := user.Did == issue.OwnerDid 221 222 if isCollaborator || isIssueOwner { 223 - err := db.ReopenIssue(rp.db, f.RepoAt, issueIdInt) 224 if err != nil { 225 log.Println("failed to reopen issue", err) 226 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") ··· 264 265 err := db.NewIssueComment(rp.db, &db.Comment{ 266 OwnerDid: user.Did, 267 - RepoAt: f.RepoAt, 268 Issue: issueIdInt, 269 CommentId: commentId, 270 Body: body, ··· 279 createdAt := time.Now().Format(time.RFC3339) 280 commentIdInt64 := int64(commentId) 281 ownerDid := user.Did 282 - issueAt, err := db.GetIssueAt(rp.db, f.RepoAt, issueIdInt) 283 if err != nil { 284 log.Println("failed to get issue at", err) 285 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 286 return 287 } 288 289 - atUri := f.RepoAt.String() 290 client, err := rp.oauth.AuthorizedClient(r) 291 if err != nil { 292 log.Println("failed to get authorized client", err) ··· 343 return 344 } 345 346 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 347 if err != nil { 348 log.Println("failed to get issue", err) 349 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 350 return 351 } 352 353 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 354 if err != nil { 355 http.Error(w, "bad comment id", http.StatusBadRequest) 356 return ··· 388 return 389 } 390 391 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 392 if err != nil { 393 log.Println("failed to get issue", err) 394 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 395 return 396 } 397 398 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 399 if err != nil { 400 http.Error(w, "bad comment id", http.StatusBadRequest) 401 return ··· 506 return 507 } 508 509 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 510 if err != nil { 511 log.Println("failed to get issue", err) 512 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") ··· 521 return 522 } 523 524 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 525 if err != nil { 526 http.Error(w, "bad comment id", http.StatusBadRequest) 527 return ··· 539 540 // optimistic deletion 541 deleted := time.Now() 542 - err = db.DeleteComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 543 if err != nil { 544 log.Println("failed to delete comment") 545 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") ··· 603 return 604 } 605 606 - issues, err := db.GetIssues(rp.db, f.RepoAt, isOpen, page) 607 if err != nil { 608 log.Println("failed to get issues", err) 609 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") ··· 643 return 644 } 645 646 tx, err := rp.db.BeginTx(r.Context(), nil) 647 if err != nil { 648 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") ··· 650 } 651 652 issue := &db.Issue{ 653 - RepoAt: f.RepoAt, 654 Title: title, 655 Body: body, 656 OwnerDid: user.Did, ··· 668 rp.pages.Notice(w, "issues", "Failed to create issue.") 669 return 670 } 671 - atUri := f.RepoAt.String() 672 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 673 Collection: tangled.RepoIssueNSID, 674 Repo: user.Did, 675 - Rkey: tid.TID(), 676 Record: &lexutil.LexiconTypeDecoder{ 677 Val: &tangled.RepoIssue{ 678 Repo: atUri, ··· 685 }) 686 if err != nil { 687 log.Println("failed to create issue", err) 688 - rp.pages.Notice(w, "issues", "Failed to create issue.") 689 - return 690 - } 691 - 692 - err = db.SetIssueAt(rp.db, f.RepoAt, issue.IssueId, resp.Uri) 693 - if err != nil { 694 - log.Println("failed to set issue at", err) 695 rp.pages.Notice(w, "issues", "Failed to create issue.") 696 return 697 }
··· 7 "net/http" 8 "slices" 9 "strconv" 10 + "strings" 11 "time" 12 13 comatproto "github.com/bluesky-social/indigo/api/atproto" 14 "github.com/bluesky-social/indigo/atproto/data" 15 lexutil "github.com/bluesky-social/indigo/lex/util" 16 "github.com/go-chi/chi/v5" 17 ··· 21 "tangled.sh/tangled.sh/core/appview/notify" 22 "tangled.sh/tangled.sh/core/appview/oauth" 23 "tangled.sh/tangled.sh/core/appview/pages" 24 + "tangled.sh/tangled.sh/core/appview/pages/markup" 25 "tangled.sh/tangled.sh/core/appview/pagination" 26 "tangled.sh/tangled.sh/core/appview/reporesolver" 27 "tangled.sh/tangled.sh/core/idresolver" ··· 74 return 75 } 76 77 + issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt(), issueIdInt) 78 if err != nil { 79 log.Println("failed to get issue and comments", err) 80 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 81 return 82 } 83 84 + reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 85 if err != nil { 86 log.Println("failed to get issue reactions") 87 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") ··· 89 90 userReactions := map[db.ReactionKind]bool{} 91 if user != nil { 92 + userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 93 } 94 95 issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) ··· 100 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 101 LoggedInUser: user, 102 RepoInfo: f.RepoInfo(user), 103 + Issue: issue, 104 Comments: comments, 105 106 IssueOwnerHandle: issueOwnerIdent.Handle.String(), ··· 128 return 129 } 130 131 + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 132 if err != nil { 133 log.Println("failed to get issue", err) 134 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") ··· 160 Rkey: tid.TID(), 161 Record: &lexutil.LexiconTypeDecoder{ 162 Val: &tangled.RepoIssueState{ 163 + Issue: issue.AtUri().String(), 164 State: closed, 165 }, 166 }, ··· 172 return 173 } 174 175 + err = db.CloseIssue(rp.db, f.RepoAt(), issueIdInt) 176 if err != nil { 177 log.Println("failed to close issue", err) 178 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") ··· 204 return 205 } 206 207 + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 208 if err != nil { 209 log.Println("failed to get issue", err) 210 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") ··· 221 isIssueOwner := user.Did == issue.OwnerDid 222 223 if isCollaborator || isIssueOwner { 224 + err := db.ReopenIssue(rp.db, f.RepoAt(), issueIdInt) 225 if err != nil { 226 log.Println("failed to reopen issue", err) 227 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") ··· 265 266 err := db.NewIssueComment(rp.db, &db.Comment{ 267 OwnerDid: user.Did, 268 + RepoAt: f.RepoAt(), 269 Issue: issueIdInt, 270 CommentId: commentId, 271 Body: body, ··· 280 createdAt := time.Now().Format(time.RFC3339) 281 commentIdInt64 := int64(commentId) 282 ownerDid := user.Did 283 + issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt) 284 if err != nil { 285 log.Println("failed to get issue at", err) 286 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 287 return 288 } 289 290 + atUri := f.RepoAt().String() 291 client, err := rp.oauth.AuthorizedClient(r) 292 if err != nil { 293 log.Println("failed to get authorized client", err) ··· 344 return 345 } 346 347 + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 348 if err != nil { 349 log.Println("failed to get issue", err) 350 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 351 return 352 } 353 354 + comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 355 if err != nil { 356 http.Error(w, "bad comment id", http.StatusBadRequest) 357 return ··· 389 return 390 } 391 392 + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 393 if err != nil { 394 log.Println("failed to get issue", err) 395 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 396 return 397 } 398 399 + comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 400 if err != nil { 401 http.Error(w, "bad comment id", http.StatusBadRequest) 402 return ··· 507 return 508 } 509 510 + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 511 if err != nil { 512 log.Println("failed to get issue", err) 513 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") ··· 522 return 523 } 524 525 + comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 526 if err != nil { 527 http.Error(w, "bad comment id", http.StatusBadRequest) 528 return ··· 540 541 // optimistic deletion 542 deleted := time.Now() 543 + err = db.DeleteComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 544 if err != nil { 545 log.Println("failed to delete comment") 546 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") ··· 604 return 605 } 606 607 + issues, err := db.GetIssuesPaginated(rp.db, f.RepoAt(), isOpen, page) 608 if err != nil { 609 log.Println("failed to get issues", err) 610 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") ··· 644 return 645 } 646 647 + sanitizer := markup.NewSanitizer() 648 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" { 649 + rp.pages.Notice(w, "issues", "Title is empty after HTML sanitization") 650 + return 651 + } 652 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" { 653 + rp.pages.Notice(w, "issues", "Body is empty after HTML sanitization") 654 + return 655 + } 656 + 657 tx, err := rp.db.BeginTx(r.Context(), nil) 658 if err != nil { 659 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") ··· 661 } 662 663 issue := &db.Issue{ 664 + RepoAt: f.RepoAt(), 665 + Rkey: tid.TID(), 666 Title: title, 667 Body: body, 668 OwnerDid: user.Did, ··· 680 rp.pages.Notice(w, "issues", "Failed to create issue.") 681 return 682 } 683 + atUri := f.RepoAt().String() 684 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 685 Collection: tangled.RepoIssueNSID, 686 Repo: user.Did, 687 + Rkey: issue.Rkey, 688 Record: &lexutil.LexiconTypeDecoder{ 689 Val: &tangled.RepoIssue{ 690 Repo: atUri, ··· 697 }) 698 if err != nil { 699 log.Println("failed to create issue", err) 700 rp.pages.Notice(w, "issues", "Failed to create issue.") 701 return 702 }
+444 -217
appview/knots/knots.go
··· 1 package knots 2 3 import ( 4 - "context" 5 - "crypto/hmac" 6 - "crypto/sha256" 7 - "encoding/hex" 8 "fmt" 9 "log/slog" 10 "net/http" 11 - "strings" 12 "time" 13 14 "github.com/go-chi/chi/v5" ··· 18 "tangled.sh/tangled.sh/core/appview/middleware" 19 "tangled.sh/tangled.sh/core/appview/oauth" 20 "tangled.sh/tangled.sh/core/appview/pages" 21 "tangled.sh/tangled.sh/core/eventconsumer" 22 "tangled.sh/tangled.sh/core/idresolver" 23 - "tangled.sh/tangled.sh/core/knotclient" 24 "tangled.sh/tangled.sh/core/rbac" 25 "tangled.sh/tangled.sh/core/tid" 26 ··· 39 Knotstream *eventconsumer.Consumer 40 } 41 42 - func (k *Knots) Router(mw *middleware.Middleware) http.Handler { 43 r := chi.NewRouter() 44 45 - r.Use(middleware.AuthMiddleware(k.OAuth)) 46 47 - r.Get("/", k.index) 48 - r.Post("/key", k.generateKey) 49 50 - r.Route("/{domain}", func(r chi.Router) { 51 - r.Post("/init", k.init) 52 - r.Get("/", k.dashboard) 53 - r.Route("/member", func(r chi.Router) { 54 - r.Use(mw.KnotOwner()) 55 - r.Get("/", k.members) 56 - r.Put("/", k.addMember) 57 - r.Delete("/", k.removeMember) 58 - }) 59 - }) 60 61 return r 62 } 63 64 - // get knots registered by this user 65 - func (k *Knots) index(w http.ResponseWriter, r *http.Request) { 66 - l := k.Logger.With("handler", "index") 67 - 68 user := k.OAuth.GetUser(r) 69 - registrations, err := db.RegistrationsByDid(k.Db, user.Did) 70 if err != nil { 71 - l.Error("failed to get registrations by did", "err", err) 72 } 73 74 k.Pages.Knots(w, pages.KnotsParams{ ··· 77 }) 78 } 79 80 - // requires auth 81 - func (k *Knots) generateKey(w http.ResponseWriter, r *http.Request) { 82 - l := k.Logger.With("handler", "generateKey") 83 84 user := k.OAuth.GetUser(r) 85 - did := user.Did 86 - l = l.With("did", did) 87 88 - // check if domain is valid url, and strip extra bits down to just host 89 - domain := r.FormValue("domain") 90 if domain == "" { 91 - l.Error("empty domain") 92 - http.Error(w, "Invalid form", http.StatusBadRequest) 93 return 94 } 95 l = l.With("domain", domain) 96 97 - noticeId := "registration-error" 98 - fail := func() { 99 - k.Pages.Notice(w, noticeId, "Failed to generate registration key.") 100 } 101 102 - key, err := db.GenerateRegistrationKey(k.Db, domain, did) 103 if err != nil { 104 - l.Error("failed to generate registration key", "err", err) 105 - fail() 106 return 107 } 108 109 - allRegs, err := db.RegistrationsByDid(k.Db, did) 110 if err != nil { 111 - l.Error("failed to generate registration key", "err", err) 112 - fail() 113 return 114 } 115 116 - k.Pages.KnotListingFull(w, pages.KnotListingFullParams{ 117 - Registrations: allRegs, 118 - }) 119 - k.Pages.KnotSecret(w, pages.KnotSecretParams{ 120 - Secret: key, 121 }) 122 } 123 124 - // create a signed request and check if a node responds to that 125 - func (k *Knots) init(w http.ResponseWriter, r *http.Request) { 126 - l := k.Logger.With("handler", "init") 127 user := k.OAuth.GetUser(r) 128 129 - noticeId := "operation-error" 130 - defaultErr := "Failed to initialize knot. Try again later." 131 fail := func() { 132 k.Pages.Notice(w, noticeId, defaultErr) 133 } 134 135 - domain := chi.URLParam(r, "domain") 136 if domain == "" { 137 - http.Error(w, "malformed url", http.StatusBadRequest) 138 return 139 } 140 l = l.With("domain", domain) 141 142 - l.Info("checking domain") 143 144 - registration, err := db.RegistrationByDomain(k.Db, domain) 145 if err != nil { 146 - l.Error("failed to get registration for domain", "err", err) 147 fail() 148 return 149 } 150 - if registration.ByDid != user.Did { 151 - l.Error("unauthorized", "wantedDid", registration.ByDid, "gotDid", user.Did) 152 - w.WriteHeader(http.StatusUnauthorized) 153 return 154 } 155 156 - secret, err := db.GetRegistrationKey(k.Db, domain) 157 if err != nil { 158 - l.Error("failed to get registration key for domain", "err", err) 159 fail() 160 return 161 } 162 163 - client, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev) 164 if err != nil { 165 - l.Error("failed to create knotclient", "err", err) 166 fail() 167 return 168 } 169 170 - resp, err := client.Init(user.Did) 171 if err != nil { 172 - k.Pages.Notice(w, noticeId, fmt.Sprintf("Failed to make request: %s", err.Error())) 173 - l.Error("failed to make init request", "err", err) 174 return 175 } 176 177 - if resp.StatusCode == http.StatusConflict { 178 - k.Pages.Notice(w, noticeId, "This knot is already registered") 179 - l.Error("knot already registered", "statuscode", resp.StatusCode) 180 return 181 } 182 183 - if resp.StatusCode != http.StatusNoContent { 184 - k.Pages.Notice(w, noticeId, fmt.Sprintf("Received status %d from knot, expected %d", resp.StatusCode, http.StatusNoContent)) 185 - l.Error("incorrect statuscode returned", "statuscode", resp.StatusCode, "expected", http.StatusNoContent) 186 return 187 } 188 189 - // verify response mac 190 - signature := resp.Header.Get("X-Signature") 191 - signatureBytes, err := hex.DecodeString(signature) 192 if err != nil { 193 return 194 } 195 196 - expectedMac := hmac.New(sha256.New, []byte(secret)) 197 - expectedMac.Write([]byte("ok")) 198 199 - if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) { 200 - k.Pages.Notice(w, noticeId, "Response signature mismatch, consider regenerating the secret and retrying.") 201 - l.Error("signature mismatch", "bytes", signatureBytes) 202 return 203 } 204 205 - tx, err := k.Db.BeginTx(r.Context(), nil) 206 if err != nil { 207 - l.Error("failed to start tx", "err", err) 208 fail() 209 return 210 } 211 defer func() { 212 tx.Rollback() 213 - err = k.Enforcer.E.LoadPolicy() 214 - if err != nil { 215 - l.Error("rollback failed", "err", err) 216 - } 217 }() 218 219 - // mark as registered 220 - err = db.Register(tx, domain) 221 if err != nil { 222 - l.Error("failed to register domain", "err", err) 223 fail() 224 return 225 } 226 227 - // set permissions for this did as owner 228 - reg, err := db.RegistrationByDomain(tx, domain) 229 - if err != nil { 230 - l.Error("failed get registration by domain", "err", err) 231 - fail() 232 - return 233 } 234 235 - // add basic acls for this domain 236 - err = k.Enforcer.AddKnot(domain) 237 if err != nil { 238 - l.Error("failed to add knot to enforcer", "err", err) 239 fail() 240 return 241 } 242 243 - // add this did as owner of this domain 244 - err = k.Enforcer.AddKnotOwner(domain, reg.ByDid) 245 if err != nil { 246 - l.Error("failed to add knot owner to enforcer", "err", err) 247 - fail() 248 - return 249 } 250 251 err = tx.Commit() 252 if err != nil { 253 - l.Error("failed to commit changes", "err", err) 254 fail() 255 return 256 } 257 258 err = k.Enforcer.E.SavePolicy() 259 if err != nil { 260 - l.Error("failed to update ACLs", "err", err) 261 - fail() 262 return 263 } 264 265 - // add this knot to knotstream 266 - go k.Knotstream.AddSource( 267 - context.Background(), 268 - eventconsumer.NewKnotSource(domain), 269 - ) 270 271 - k.Pages.KnotListing(w, pages.KnotListingParams{ 272 - Registration: *reg, 273 - }) 274 } 275 276 - func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { 277 - l := k.Logger.With("handler", "dashboard") 278 fail := func() { 279 - w.WriteHeader(http.StatusInternalServerError) 280 } 281 282 domain := chi.URLParam(r, "domain") 283 if domain == "" { 284 - http.Error(w, "malformed url", http.StatusBadRequest) 285 return 286 } 287 l = l.With("domain", domain) 288 289 - user := k.OAuth.GetUser(r) 290 - l = l.With("did", user.Did) 291 - 292 - // dashboard is only available to owners 293 - ok, err := k.Enforcer.IsKnotOwner(user.Did, domain) 294 if err != nil { 295 - l.Error("failed to query enforcer", "err", err) 296 fail() 297 } 298 - if !ok { 299 - http.Error(w, "only owners can view dashboards", http.StatusUnauthorized) 300 return 301 } 302 303 - reg, err := db.RegistrationByDomain(k.Db, domain) 304 if err != nil { 305 - l.Error("failed to get registration by domain", "err", err) 306 fail() 307 return 308 } 309 310 - var members []string 311 - if reg.Registered != nil { 312 - members, err = k.Enforcer.GetUserByRole("server:member", domain) 313 if err != nil { 314 - l.Error("failed to get members list", "err", err) 315 fail() 316 return 317 } 318 } 319 320 - repos, err := db.GetRepos( 321 k.Db, 322 - 0, 323 - db.FilterEq("knot", domain), 324 - db.FilterIn("did", members), 325 ) 326 if err != nil { 327 - l.Error("failed to get repos list", "err", err) 328 fail() 329 return 330 } 331 - // convert to map 332 - repoByMember := make(map[string][]db.Repo) 333 - for _, r := range repos { 334 - repoByMember[r.Did] = append(repoByMember[r.Did], r) 335 - } 336 - 337 - k.Pages.Knot(w, pages.KnotParams{ 338 - LoggedInUser: user, 339 - Registration: reg, 340 - Members: members, 341 - Repos: repoByMember, 342 - IsOwner: true, 343 - }) 344 - } 345 - 346 - // list members of domain, requires auth and requires owner status 347 - func (k *Knots) members(w http.ResponseWriter, r *http.Request) { 348 - l := k.Logger.With("handler", "members") 349 - 350 - domain := chi.URLParam(r, "domain") 351 - if domain == "" { 352 - http.Error(w, "malformed url", http.StatusBadRequest) 353 return 354 } 355 - l = l.With("domain", domain) 356 357 - // list all members for this domain 358 - memberDids, err := k.Enforcer.GetUserByRole("server:member", domain) 359 - if err != nil { 360 - w.Write([]byte("failed to fetch member list")) 361 - return 362 - } 363 364 - w.Write([]byte(strings.Join(memberDids, "\n"))) 365 } 366 367 - // add member to domain, requires auth and requires invite access 368 func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { 369 - l := k.Logger.With("handler", "members") 370 371 domain := chi.URLParam(r, "domain") 372 if domain == "" { 373 - http.Error(w, "malformed url", http.StatusBadRequest) 374 return 375 } 376 l = l.With("domain", domain) 377 378 - reg, err := db.RegistrationByDomain(k.Db, domain) 379 if err != nil { 380 - l.Error("failed to get registration by domain", "err", err) 381 - http.Error(w, "malformed url", http.StatusBadRequest) 382 return 383 } 384 385 - noticeId := fmt.Sprintf("add-member-error-%d", reg.Id) 386 - l = l.With("notice-id", noticeId) 387 defaultErr := "Failed to add member. Try again later." 388 fail := func() { 389 k.Pages.Notice(w, noticeId, defaultErr) 390 } 391 392 - subjectIdentifier := r.FormValue("subject") 393 - if subjectIdentifier == "" { 394 - http.Error(w, "malformed form", http.StatusBadRequest) 395 return 396 } 397 - l = l.With("subjectIdentifier", subjectIdentifier) 398 399 - subjectIdentity, err := k.IdResolver.ResolveIdent(r.Context(), subjectIdentifier) 400 if err != nil { 401 - l.Error("failed to resolve identity", "err", err) 402 k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 403 return 404 } 405 - l = l.With("subjectDid", subjectIdentity.DID) 406 - 407 - l.Info("adding member to knot") 408 409 - // announce this relation into the firehose, store into owners' pds 410 client, err := k.OAuth.AuthorizedClient(r) 411 if err != nil { 412 - l.Error("failed to create client", "err", err) 413 fail() 414 return 415 } 416 417 - currentUser := k.OAuth.GetUser(r) 418 - createdAt := time.Now().Format(time.RFC3339) 419 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 420 Collection: tangled.KnotMemberNSID, 421 - Repo: currentUser.Did, 422 - Rkey: tid.TID(), 423 Record: &lexutil.LexiconTypeDecoder{ 424 Val: &tangled.KnotMember{ 425 - Subject: subjectIdentity.DID.String(), 426 Domain: domain, 427 - CreatedAt: createdAt, 428 - }}, 429 }) 430 - // invalid record 431 if err != nil { 432 - l.Error("failed to write to PDS", "err", err) 433 fail() 434 return 435 } 436 - l = l.With("at-uri", resp.Uri) 437 - l.Info("wrote record to PDS") 438 439 - secret, err := db.GetRegistrationKey(k.Db, domain) 440 if err != nil { 441 - l.Error("failed to get registration key", "err", err) 442 fail() 443 return 444 } 445 446 - ksClient, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev) 447 if err != nil { 448 - l.Error("failed to create client", "err", err) 449 - fail() 450 return 451 } 452 453 - ksResp, err := ksClient.AddMember(subjectIdentity.DID.String()) 454 if err != nil { 455 - l.Error("failed to reach knotserver", "err", err) 456 - k.Pages.Notice(w, noticeId, "Failed to reach to knotserver.") 457 return 458 } 459 460 - if ksResp.StatusCode != http.StatusNoContent { 461 - l.Error("status mismatch", "got", ksResp.StatusCode, "expected", http.StatusNoContent) 462 - k.Pages.Notice(w, noticeId, fmt.Sprintf("Unexpected status code from knotserver %d, expected %d", ksResp.StatusCode, http.StatusNoContent)) 463 return 464 } 465 466 - err = k.Enforcer.AddKnotMember(domain, subjectIdentity.DID.String()) 467 if err != nil { 468 - l.Error("failed to add member to enforcer", "err", err) 469 fail() 470 return 471 } 472 473 - // success 474 - k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) 475 } 476 477 - func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 478 }
··· 1 package knots 2 3 import ( 4 + "errors" 5 "fmt" 6 + "log" 7 "log/slog" 8 "net/http" 9 + "slices" 10 "time" 11 12 "github.com/go-chi/chi/v5" ··· 16 "tangled.sh/tangled.sh/core/appview/middleware" 17 "tangled.sh/tangled.sh/core/appview/oauth" 18 "tangled.sh/tangled.sh/core/appview/pages" 19 + "tangled.sh/tangled.sh/core/appview/serververify" 20 "tangled.sh/tangled.sh/core/eventconsumer" 21 "tangled.sh/tangled.sh/core/idresolver" 22 "tangled.sh/tangled.sh/core/rbac" 23 "tangled.sh/tangled.sh/core/tid" 24 ··· 37 Knotstream *eventconsumer.Consumer 38 } 39 40 + func (k *Knots) Router() http.Handler { 41 r := chi.NewRouter() 42 43 + r.With(middleware.AuthMiddleware(k.OAuth)).Get("/", k.knots) 44 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/register", k.register) 45 46 + r.With(middleware.AuthMiddleware(k.OAuth)).Get("/{domain}", k.dashboard) 47 + r.With(middleware.AuthMiddleware(k.OAuth)).Delete("/{domain}", k.delete) 48 49 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/retry", k.retry) 50 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/add", k.addMember) 51 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/remove", k.removeMember) 52 + 53 + r.With(middleware.AuthMiddleware(k.OAuth)).Get("/upgradeBanner", k.banner) 54 55 return r 56 } 57 58 + func (k *Knots) knots(w http.ResponseWriter, r *http.Request) { 59 user := k.OAuth.GetUser(r) 60 + registrations, err := db.GetRegistrations( 61 + k.Db, 62 + db.FilterEq("did", user.Did), 63 + ) 64 if err != nil { 65 + k.Logger.Error("failed to fetch knot registrations", "err", err) 66 + w.WriteHeader(http.StatusInternalServerError) 67 + return 68 } 69 70 k.Pages.Knots(w, pages.KnotsParams{ ··· 73 }) 74 } 75 76 + func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { 77 + l := k.Logger.With("handler", "dashboard") 78 79 user := k.OAuth.GetUser(r) 80 + l = l.With("user", user.Did) 81 82 + domain := chi.URLParam(r, "domain") 83 if domain == "" { 84 return 85 } 86 l = l.With("domain", domain) 87 88 + registrations, err := db.GetRegistrations( 89 + k.Db, 90 + db.FilterEq("did", user.Did), 91 + db.FilterEq("domain", domain), 92 + ) 93 + if err != nil { 94 + l.Error("failed to get registrations", "err", err) 95 + http.Error(w, "Not found", http.StatusNotFound) 96 + return 97 } 98 + if len(registrations) != 1 { 99 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 100 + return 101 + } 102 + registration := registrations[0] 103 104 + members, err := k.Enforcer.GetUserByRole("server:member", domain) 105 if err != nil { 106 + l.Error("failed to get knot members", "err", err) 107 + http.Error(w, "Not found", http.StatusInternalServerError) 108 return 109 } 110 + slices.Sort(members) 111 112 + repos, err := db.GetRepos( 113 + k.Db, 114 + 0, 115 + db.FilterEq("knot", domain), 116 + ) 117 if err != nil { 118 + l.Error("failed to get knot repos", "err", err) 119 + http.Error(w, "Not found", http.StatusInternalServerError) 120 return 121 } 122 123 + // organize repos by did 124 + repoMap := make(map[string][]db.Repo) 125 + for _, r := range repos { 126 + repoMap[r.Did] = append(repoMap[r.Did], r) 127 + } 128 + 129 + k.Pages.Knot(w, pages.KnotParams{ 130 + LoggedInUser: user, 131 + Registration: &registration, 132 + Members: members, 133 + Repos: repoMap, 134 + IsOwner: true, 135 }) 136 } 137 138 + func (k *Knots) register(w http.ResponseWriter, r *http.Request) { 139 user := k.OAuth.GetUser(r) 140 + l := k.Logger.With("handler", "register") 141 142 + noticeId := "register-error" 143 + defaultErr := "Failed to register knot. Try again later." 144 fail := func() { 145 k.Pages.Notice(w, noticeId, defaultErr) 146 } 147 148 + domain := r.FormValue("domain") 149 if domain == "" { 150 + k.Pages.Notice(w, noticeId, "Incomplete form.") 151 return 152 } 153 l = l.With("domain", domain) 154 + l = l.With("user", user.Did) 155 156 + tx, err := k.Db.Begin() 157 + if err != nil { 158 + l.Error("failed to start transaction", "err", err) 159 + fail() 160 + return 161 + } 162 + defer func() { 163 + tx.Rollback() 164 + k.Enforcer.E.LoadPolicy() 165 + }() 166 167 + err = db.AddKnot(tx, domain, user.Did) 168 if err != nil { 169 + l.Error("failed to insert", "err", err) 170 fail() 171 return 172 } 173 + 174 + err = k.Enforcer.AddKnot(domain) 175 + if err != nil { 176 + l.Error("failed to create knot", "err", err) 177 + fail() 178 return 179 } 180 181 + // create record on pds 182 + client, err := k.OAuth.AuthorizedClient(r) 183 if err != nil { 184 + l.Error("failed to authorize client", "err", err) 185 fail() 186 return 187 } 188 189 + ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 190 + var exCid *string 191 + if ex != nil { 192 + exCid = ex.Cid 193 + } 194 + 195 + // re-announce by registering under same rkey 196 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 197 + Collection: tangled.KnotNSID, 198 + Repo: user.Did, 199 + Rkey: domain, 200 + Record: &lexutil.LexiconTypeDecoder{ 201 + Val: &tangled.Knot{ 202 + CreatedAt: time.Now().Format(time.RFC3339), 203 + }, 204 + }, 205 + SwapRecord: exCid, 206 + }) 207 + 208 if err != nil { 209 + l.Error("failed to put record", "err", err) 210 fail() 211 return 212 } 213 214 + err = tx.Commit() 215 if err != nil { 216 + l.Error("failed to commit transaction", "err", err) 217 + fail() 218 return 219 } 220 221 + err = k.Enforcer.E.SavePolicy() 222 + if err != nil { 223 + l.Error("failed to update ACL", "err", err) 224 + k.Pages.HxRefresh(w) 225 return 226 } 227 228 + // begin verification 229 + err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 230 + if err != nil { 231 + l.Error("verification failed", "err", err) 232 + k.Pages.HxRefresh(w) 233 return 234 } 235 236 + err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 237 if err != nil { 238 + l.Error("failed to mark verified", "err", err) 239 + k.Pages.HxRefresh(w) 240 return 241 } 242 243 + // add this knot to knotstream 244 + go k.Knotstream.AddSource( 245 + r.Context(), 246 + eventconsumer.NewKnotSource(domain), 247 + ) 248 + 249 + // ok 250 + k.Pages.HxRefresh(w) 251 + } 252 + 253 + func (k *Knots) delete(w http.ResponseWriter, r *http.Request) { 254 + user := k.OAuth.GetUser(r) 255 + l := k.Logger.With("handler", "delete") 256 + 257 + noticeId := "operation-error" 258 + defaultErr := "Failed to delete knot. Try again later." 259 + fail := func() { 260 + k.Pages.Notice(w, noticeId, defaultErr) 261 + } 262 + 263 + domain := chi.URLParam(r, "domain") 264 + if domain == "" { 265 + l.Error("empty domain") 266 + fail() 267 + return 268 + } 269 270 + // get record from db first 271 + registrations, err := db.GetRegistrations( 272 + k.Db, 273 + db.FilterEq("did", user.Did), 274 + db.FilterEq("domain", domain), 275 + ) 276 + if err != nil { 277 + l.Error("failed to get registration", "err", err) 278 + fail() 279 return 280 } 281 + if len(registrations) != 1 { 282 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 283 + fail() 284 + return 285 + } 286 + registration := registrations[0] 287 288 + tx, err := k.Db.Begin() 289 if err != nil { 290 + l.Error("failed to start txn", "err", err) 291 fail() 292 return 293 } 294 defer func() { 295 tx.Rollback() 296 + k.Enforcer.E.LoadPolicy() 297 }() 298 299 + err = db.DeleteKnot( 300 + tx, 301 + db.FilterEq("did", user.Did), 302 + db.FilterEq("domain", domain), 303 + ) 304 if err != nil { 305 + l.Error("failed to delete registration", "err", err) 306 fail() 307 return 308 } 309 310 + // delete from enforcer if it was registered 311 + if registration.Registered != nil { 312 + err = k.Enforcer.RemoveKnot(domain) 313 + if err != nil { 314 + l.Error("failed to update ACL", "err", err) 315 + fail() 316 + return 317 + } 318 } 319 320 + client, err := k.OAuth.AuthorizedClient(r) 321 if err != nil { 322 + l.Error("failed to authorize client", "err", err) 323 fail() 324 return 325 } 326 327 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 328 + Collection: tangled.KnotNSID, 329 + Repo: user.Did, 330 + Rkey: domain, 331 + }) 332 if err != nil { 333 + // non-fatal 334 + l.Error("failed to delete record", "err", err) 335 } 336 337 err = tx.Commit() 338 if err != nil { 339 + l.Error("failed to delete knot", "err", err) 340 fail() 341 return 342 } 343 344 err = k.Enforcer.E.SavePolicy() 345 if err != nil { 346 + l.Error("failed to update ACL", "err", err) 347 + k.Pages.HxRefresh(w) 348 return 349 } 350 351 + shouldRedirect := r.Header.Get("shouldRedirect") 352 + if shouldRedirect == "true" { 353 + k.Pages.HxRedirect(w, "/knots") 354 + return 355 + } 356 357 + w.Write([]byte{}) 358 } 359 360 + func (k *Knots) retry(w http.ResponseWriter, r *http.Request) { 361 + user := k.OAuth.GetUser(r) 362 + l := k.Logger.With("handler", "retry") 363 + 364 + noticeId := "operation-error" 365 + defaultErr := "Failed to verify knot. Try again later." 366 fail := func() { 367 + k.Pages.Notice(w, noticeId, defaultErr) 368 } 369 370 domain := chi.URLParam(r, "domain") 371 if domain == "" { 372 + l.Error("empty domain") 373 + fail() 374 return 375 } 376 l = l.With("domain", domain) 377 + l = l.With("user", user.Did) 378 379 + // get record from db first 380 + registrations, err := db.GetRegistrations( 381 + k.Db, 382 + db.FilterEq("did", user.Did), 383 + db.FilterEq("domain", domain), 384 + ) 385 if err != nil { 386 + l.Error("failed to get registration", "err", err) 387 fail() 388 + return 389 } 390 + if len(registrations) != 1 { 391 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 392 + fail() 393 return 394 } 395 + registration := registrations[0] 396 397 + // begin verification 398 + err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 399 if err != nil { 400 + l.Error("verification failed", "err", err) 401 + 402 + if errors.Is(err, serververify.FetchError) { 403 + k.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.") 404 + return 405 + } 406 + 407 + if e, ok := err.(*serververify.OwnerMismatch); ok { 408 + k.Pages.Notice(w, noticeId, e.Error()) 409 + return 410 + } 411 + 412 fail() 413 return 414 } 415 416 + err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 417 + if err != nil { 418 + l.Error("failed to mark verified", "err", err) 419 + k.Pages.Notice(w, noticeId, err.Error()) 420 + return 421 + } 422 + 423 + // if this knot was previously read-only, then emit a record too 424 + // 425 + // this is part of migrating from the old knot system to the new one 426 + if registration.ReadOnly { 427 + // re-announce by registering under same rkey 428 + client, err := k.OAuth.AuthorizedClient(r) 429 if err != nil { 430 + l.Error("failed to authorize client", "err", err) 431 fail() 432 return 433 } 434 + 435 + ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 436 + var exCid *string 437 + if ex != nil { 438 + exCid = ex.Cid 439 + } 440 + 441 + // ignore the error here 442 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 443 + Collection: tangled.KnotNSID, 444 + Repo: user.Did, 445 + Rkey: domain, 446 + Record: &lexutil.LexiconTypeDecoder{ 447 + Val: &tangled.Knot{ 448 + CreatedAt: time.Now().Format(time.RFC3339), 449 + }, 450 + }, 451 + SwapRecord: exCid, 452 + }) 453 + if err != nil { 454 + l.Error("non-fatal: failed to reannouce knot", "err", err) 455 + } 456 } 457 458 + // add this knot to knotstream 459 + go k.Knotstream.AddSource( 460 + r.Context(), 461 + eventconsumer.NewKnotSource(domain), 462 + ) 463 + 464 + shouldRefresh := r.Header.Get("shouldRefresh") 465 + if shouldRefresh == "true" { 466 + k.Pages.HxRefresh(w) 467 + return 468 + } 469 + 470 + // Get updated registration to show 471 + registrations, err = db.GetRegistrations( 472 k.Db, 473 + db.FilterEq("did", user.Did), 474 + db.FilterEq("domain", domain), 475 ) 476 if err != nil { 477 + l.Error("failed to get registration", "err", err) 478 fail() 479 return 480 } 481 + if len(registrations) != 1 { 482 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 483 + fail() 484 return 485 } 486 + updatedRegistration := registrations[0] 487 488 + log.Println(updatedRegistration) 489 490 + w.Header().Set("HX-Reswap", "outerHTML") 491 + k.Pages.KnotListing(w, pages.KnotListingParams{ 492 + Registration: &updatedRegistration, 493 + }) 494 } 495 496 func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { 497 + user := k.OAuth.GetUser(r) 498 + l := k.Logger.With("handler", "addMember") 499 500 domain := chi.URLParam(r, "domain") 501 if domain == "" { 502 + l.Error("empty domain") 503 + http.Error(w, "Not found", http.StatusNotFound) 504 return 505 } 506 l = l.With("domain", domain) 507 + l = l.With("user", user.Did) 508 509 + registrations, err := db.GetRegistrations( 510 + k.Db, 511 + db.FilterEq("did", user.Did), 512 + db.FilterEq("domain", domain), 513 + db.FilterIsNot("registered", "null"), 514 + ) 515 if err != nil { 516 + l.Error("failed to get registration", "err", err) 517 + return 518 + } 519 + if len(registrations) != 1 { 520 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 521 return 522 } 523 + registration := registrations[0] 524 525 + noticeId := fmt.Sprintf("add-member-error-%d", registration.Id) 526 defaultErr := "Failed to add member. Try again later." 527 fail := func() { 528 k.Pages.Notice(w, noticeId, defaultErr) 529 } 530 531 + member := r.FormValue("member") 532 + if member == "" { 533 + l.Error("empty member") 534 + k.Pages.Notice(w, noticeId, "Failed to add member, empty form.") 535 return 536 } 537 + l = l.With("member", member) 538 539 + memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 540 if err != nil { 541 + l.Error("failed to resolve member identity to handle", "err", err) 542 k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 543 return 544 } 545 + if memberId.Handle.IsInvalidHandle() { 546 + l.Error("failed to resolve member identity to handle") 547 + k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 548 + return 549 + } 550 551 + // write to pds 552 client, err := k.OAuth.AuthorizedClient(r) 553 if err != nil { 554 + l.Error("failed to authorize client", "err", err) 555 fail() 556 return 557 } 558 559 + rkey := tid.TID() 560 + 561 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 562 Collection: tangled.KnotMemberNSID, 563 + Repo: user.Did, 564 + Rkey: rkey, 565 Record: &lexutil.LexiconTypeDecoder{ 566 Val: &tangled.KnotMember{ 567 + CreatedAt: time.Now().Format(time.RFC3339), 568 Domain: domain, 569 + Subject: memberId.DID.String(), 570 + }, 571 + }, 572 }) 573 + if err != nil { 574 + l.Error("failed to add record to PDS", "err", err) 575 + k.Pages.Notice(w, noticeId, "Failed to add record to PDS, try again later.") 576 + return 577 + } 578 + 579 + err = k.Enforcer.AddKnotMember(domain, memberId.DID.String()) 580 if err != nil { 581 + l.Error("failed to add member to ACLs", "err", err) 582 fail() 583 return 584 } 585 586 + err = k.Enforcer.E.SavePolicy() 587 if err != nil { 588 + l.Error("failed to save ACL policy", "err", err) 589 + fail() 590 + return 591 + } 592 + 593 + // success 594 + k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) 595 + } 596 + 597 + func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 598 + user := k.OAuth.GetUser(r) 599 + l := k.Logger.With("handler", "removeMember") 600 + 601 + noticeId := "operation-error" 602 + defaultErr := "Failed to remove member. Try again later." 603 + fail := func() { 604 + k.Pages.Notice(w, noticeId, defaultErr) 605 + } 606 + 607 + domain := chi.URLParam(r, "domain") 608 + if domain == "" { 609 + l.Error("empty domain") 610 fail() 611 return 612 } 613 + l = l.With("domain", domain) 614 + l = l.With("user", user.Did) 615 616 + registrations, err := db.GetRegistrations( 617 + k.Db, 618 + db.FilterEq("did", user.Did), 619 + db.FilterEq("domain", domain), 620 + db.FilterIsNot("registered", "null"), 621 + ) 622 if err != nil { 623 + l.Error("failed to get registration", "err", err) 624 + return 625 + } 626 + if len(registrations) != 1 { 627 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 628 + return 629 + } 630 + 631 + member := r.FormValue("member") 632 + if member == "" { 633 + l.Error("empty member") 634 + k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") 635 return 636 } 637 + l = l.With("member", member) 638 639 + memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 640 if err != nil { 641 + l.Error("failed to resolve member identity to handle", "err", err) 642 + k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 643 + return 644 + } 645 + if memberId.Handle.IsInvalidHandle() { 646 + l.Error("failed to resolve member identity to handle") 647 + k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 648 return 649 } 650 651 + // remove from enforcer 652 + err = k.Enforcer.RemoveKnotMember(domain, memberId.DID.String()) 653 + if err != nil { 654 + l.Error("failed to update ACLs", "err", err) 655 + fail() 656 return 657 } 658 659 + client, err := k.OAuth.AuthorizedClient(r) 660 if err != nil { 661 + l.Error("failed to authorize client", "err", err) 662 fail() 663 return 664 } 665 666 + // TODO: We need to track the rkey for knot members to delete the record 667 + // For now, just remove from ACLs 668 + _ = client 669 + 670 + // commit everything 671 + err = k.Enforcer.E.SavePolicy() 672 + if err != nil { 673 + l.Error("failed to save ACLs", "err", err) 674 + fail() 675 + return 676 + } 677 + 678 + // ok 679 + k.Pages.HxRefresh(w) 680 } 681 682 + func (k *Knots) banner(w http.ResponseWriter, r *http.Request) { 683 + user := k.OAuth.GetUser(r) 684 + l := k.Logger.With("handler", "removeMember") 685 + l = l.With("did", user.Did) 686 + l = l.With("handle", user.Handle) 687 + 688 + registrations, err := db.GetRegistrations( 689 + k.Db, 690 + db.FilterEq("did", user.Did), 691 + db.FilterEq("read_only", 1), 692 + ) 693 + if err != nil { 694 + l.Error("non-fatal: failed to get registrations") 695 + return 696 + } 697 + 698 + if registrations == nil { 699 + return 700 + } 701 + 702 + k.Pages.KnotBanner(w, pages.KnotBannerParams{ 703 + Registrations: registrations, 704 + }) 705 }
+6 -11
appview/middleware/middleware.go
··· 9 "slices" 10 "strconv" 11 "strings" 12 - "time" 13 14 "github.com/bluesky-social/indigo/atproto/identity" 15 "github.com/go-chi/chi/v5" ··· 218 if err != nil { 219 // invalid did or handle 220 log.Println("failed to resolve repo") 221 - mw.pages.Error404(w) 222 return 223 } 224 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)) 230 next.ServeHTTP(w, req.WithContext(ctx)) 231 }) 232 } ··· 239 f, err := mw.repoResolver.Resolve(r) 240 if err != nil { 241 log.Println("failed to fully resolve repo", err) 242 - http.Error(w, "invalid repo url", http.StatusNotFound) 243 return 244 } 245 ··· 251 return 252 } 253 254 - pr, err := db.GetPull(mw.db, f.RepoAt, prIdInt) 255 if err != nil { 256 log.Println("failed to get pull and comments", err) 257 return ··· 288 f, err := mw.repoResolver.Resolve(r) 289 if err != nil { 290 log.Println("failed to fully resolve repo", err) 291 - http.Error(w, "invalid repo url", http.StatusNotFound) 292 return 293 } 294 295 - fullName := f.OwnerHandle() + "/" + f.RepoName 296 297 if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 298 if r.URL.Query().Get("go-get") == "1" {
··· 9 "slices" 10 "strconv" 11 "strings" 12 13 "github.com/bluesky-social/indigo/atproto/identity" 14 "github.com/go-chi/chi/v5" ··· 217 if err != nil { 218 // invalid did or handle 219 log.Println("failed to resolve repo") 220 + mw.pages.ErrorKnot404(w) 221 return 222 } 223 224 + ctx := context.WithValue(req.Context(), "repo", repo) 225 next.ServeHTTP(w, req.WithContext(ctx)) 226 }) 227 } ··· 234 f, err := mw.repoResolver.Resolve(r) 235 if err != nil { 236 log.Println("failed to fully resolve repo", err) 237 + mw.pages.ErrorKnot404(w) 238 return 239 } 240 ··· 246 return 247 } 248 249 + pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt) 250 if err != nil { 251 log.Println("failed to get pull and comments", err) 252 return ··· 283 f, err := mw.repoResolver.Resolve(r) 284 if err != nil { 285 log.Println("failed to fully resolve repo", err) 286 + mw.pages.ErrorKnot404(w) 287 return 288 } 289 290 + fullName := f.OwnerHandle() + "/" + f.Name 291 292 if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 293 if r.URL.Query().Get("go-get") == "1" {
+104 -82
appview/oauth/handler/handler.go
··· 8 "log" 9 "net/http" 10 "net/url" 11 "strings" 12 "time" 13 ··· 25 "tangled.sh/tangled.sh/core/appview/oauth/client" 26 "tangled.sh/tangled.sh/core/appview/pages" 27 "tangled.sh/tangled.sh/core/idresolver" 28 - "tangled.sh/tangled.sh/core/knotclient" 29 "tangled.sh/tangled.sh/core/rbac" 30 "tangled.sh/tangled.sh/core/tid" 31 ) ··· 253 return 254 } 255 256 self := o.oauth.ClientMetadata() 257 258 oauthClient, err := client.NewClient( ··· 347 return pubKey, nil 348 } 349 350 func (o *OAuthHandler) addToDefaultSpindle(did string) { 351 // use the tangled.sh app password to get an accessJwt 352 // and create an sh.tangled.spindle.member record with that 353 - 354 - defaultSpindle := "spindle.tangled.sh" 355 - appPassword := o.config.Core.AppPassword 356 - 357 spindleMembers, err := db.GetSpindleMembers( 358 o.db, 359 db.FilterEq("instance", "spindle.tangled.sh"), ··· 369 return 370 } 371 372 - // TODO: hardcoded tangled handle and did for now 373 - tangledHandle := "tangled.sh" 374 - tangledDid := "did:plc:wshs7t2adsemcrrd4snkeqli" 375 376 - if appPassword == "" { 377 - log.Println("no app password configured, skipping spindle member addition") 378 return 379 } 380 381 - log.Printf("adding %s to default spindle", did) 382 383 - resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid) 384 if err != nil { 385 - log.Printf("failed to resolve tangled.sh DID %s: %v", tangledDid, err) 386 return 387 } 388 389 pdsEndpoint := resolved.PDSEndpoint() 390 if pdsEndpoint == "" { 391 - log.Printf("no PDS endpoint found for tangled.sh DID %s", tangledDid) 392 - return 393 } 394 395 sessionPayload := map[string]string{ ··· 398 } 399 sessionBytes, err := json.Marshal(sessionPayload) 400 if err != nil { 401 - log.Printf("failed to marshal session payload: %v", err) 402 - return 403 } 404 405 sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 406 sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 407 if err != nil { 408 - log.Printf("failed to create session request: %v", err) 409 - return 410 } 411 sessionReq.Header.Set("Content-Type", "application/json") 412 413 client := &http.Client{Timeout: 30 * time.Second} 414 sessionResp, err := client.Do(sessionReq) 415 if err != nil { 416 - log.Printf("failed to create session: %v", err) 417 - return 418 } 419 defer sessionResp.Body.Close() 420 421 if sessionResp.StatusCode != http.StatusOK { 422 - log.Printf("failed to create session: HTTP %d", sessionResp.StatusCode) 423 - return 424 } 425 426 - var session struct { 427 - AccessJwt string `json:"accessJwt"` 428 - } 429 if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 430 - log.Printf("failed to decode session response: %v", err) 431 - return 432 } 433 434 - record := tangled.SpindleMember{ 435 - LexiconTypeID: "sh.tangled.spindle.member", 436 - Subject: did, 437 - Instance: defaultSpindle, 438 - CreatedAt: time.Now().Format(time.RFC3339), 439 - } 440 441 recordBytes, err := json.Marshal(record) 442 if err != nil { 443 - log.Printf("failed to marshal spindle member record: %v", err) 444 - return 445 } 446 447 - payload := map[string]interface{}{ 448 "repo": tangledDid, 449 - "collection": tangled.SpindleMemberNSID, 450 "rkey": tid.TID(), 451 "record": json.RawMessage(recordBytes), 452 } 453 454 payloadBytes, err := json.Marshal(payload) 455 if err != nil { 456 - log.Printf("failed to marshal request payload: %v", err) 457 - return 458 } 459 460 - url := pdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 461 req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 462 if err != nil { 463 - log.Printf("failed to create HTTP request: %v", err) 464 - return 465 } 466 467 req.Header.Set("Content-Type", "application/json") 468 - req.Header.Set("Authorization", "Bearer "+session.AccessJwt) 469 470 resp, err := client.Do(req) 471 if err != nil { 472 - log.Printf("failed to add user to default spindle: %v", err) 473 - return 474 } 475 defer resp.Body.Close() 476 477 if resp.StatusCode != http.StatusOK { 478 - log.Printf("failed to add user to default spindle: HTTP %d", resp.StatusCode) 479 - return 480 - } 481 - 482 - log.Printf("successfully added %s to default spindle", did) 483 - } 484 - 485 - func (o *OAuthHandler) addToDefaultKnot(did string) { 486 - defaultKnot := "knot1.tangled.sh" 487 - 488 - log.Printf("adding %s to default knot", did) 489 - err := o.enforcer.AddKnotMember(defaultKnot, did) 490 - if err != nil { 491 - log.Println("failed to add user to knot1.tangled.sh: ", err) 492 - return 493 - } 494 - err = o.enforcer.E.SavePolicy() 495 - if err != nil { 496 - log.Println("failed to add user to knot1.tangled.sh: ", err) 497 - return 498 } 499 500 - secret, err := db.GetRegistrationKey(o.db, defaultKnot) 501 - if err != nil { 502 - log.Println("failed to get registration key for knot1.tangled.sh") 503 - return 504 - } 505 - signedClient, err := knotclient.NewSignedClient(defaultKnot, secret, o.config.Core.Dev) 506 - resp, err := signedClient.AddMember(did) 507 - if err != nil { 508 - log.Println("failed to add user to knot1.tangled.sh: ", err) 509 - return 510 - } 511 - 512 - if resp.StatusCode != http.StatusNoContent { 513 - log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode) 514 - return 515 - } 516 }
··· 8 "log" 9 "net/http" 10 "net/url" 11 + "slices" 12 "strings" 13 "time" 14 ··· 26 "tangled.sh/tangled.sh/core/appview/oauth/client" 27 "tangled.sh/tangled.sh/core/appview/pages" 28 "tangled.sh/tangled.sh/core/idresolver" 29 "tangled.sh/tangled.sh/core/rbac" 30 "tangled.sh/tangled.sh/core/tid" 31 ) ··· 253 return 254 } 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 + 262 self := o.oauth.ClientMetadata() 263 264 oauthClient, err := client.NewClient( ··· 353 return pubKey, nil 354 } 355 356 + var ( 357 + tangledHandle = "tangled.sh" 358 + tangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli" 359 + defaultSpindle = "spindle.tangled.sh" 360 + defaultKnot = "knot1.tangled.sh" 361 + ) 362 + 363 func (o *OAuthHandler) addToDefaultSpindle(did string) { 364 // use the tangled.sh app password to get an accessJwt 365 // and create an sh.tangled.spindle.member record with that 366 spindleMembers, err := db.GetSpindleMembers( 367 o.db, 368 db.FilterEq("instance", "spindle.tangled.sh"), ··· 378 return 379 } 380 381 + log.Printf("adding %s to default spindle", did) 382 + session, err := o.createAppPasswordSession() 383 + if err != nil { 384 + log.Printf("failed to create session: %s", err) 385 + return 386 + } 387 388 + record := tangled.SpindleMember{ 389 + LexiconTypeID: "sh.tangled.spindle.member", 390 + Subject: did, 391 + Instance: defaultSpindle, 392 + CreatedAt: time.Now().Format(time.RFC3339), 393 + } 394 + 395 + if err := session.putRecord(record); err != nil { 396 + log.Printf("failed to add member to default knot: %s", err) 397 + return 398 + } 399 + 400 + log.Printf("successfully added %s to default spindle", did) 401 + } 402 + 403 + func (o *OAuthHandler) addToDefaultKnot(did string) { 404 + // use the tangled.sh app password to get an accessJwt 405 + // and create an sh.tangled.spindle.member record with that 406 + 407 + allKnots, err := o.enforcer.GetKnotsForUser(did) 408 + if err != nil { 409 + log.Printf("failed to get knot members for did %s: %v", did, err) 410 return 411 } 412 413 + if slices.Contains(allKnots, defaultKnot) { 414 + log.Printf("did %s is already a member of the default knot", did) 415 + return 416 + } 417 418 + log.Printf("adding %s to default knot", did) 419 + session, err := o.createAppPasswordSession() 420 if err != nil { 421 + log.Printf("failed to create session: %s", err) 422 + return 423 + } 424 + 425 + record := tangled.KnotMember{ 426 + LexiconTypeID: "sh.tangled.knot.member", 427 + Subject: did, 428 + Domain: defaultKnot, 429 + CreatedAt: time.Now().Format(time.RFC3339), 430 + } 431 + 432 + if err := session.putRecord(record); err != nil { 433 + log.Printf("failed to add member to default knot: %s", err) 434 return 435 } 436 437 + log.Printf("successfully added %s to default Knot", did) 438 + } 439 + 440 + // create a session using apppasswords 441 + type session struct { 442 + AccessJwt string `json:"accessJwt"` 443 + PdsEndpoint string 444 + } 445 + 446 + func (o *OAuthHandler) createAppPasswordSession() (*session, error) { 447 + appPassword := o.config.Core.AppPassword 448 + if appPassword == "" { 449 + return nil, fmt.Errorf("no app password configured, skipping member addition") 450 + } 451 + 452 + resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid) 453 + if err != nil { 454 + return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", tangledDid, err) 455 + } 456 + 457 pdsEndpoint := resolved.PDSEndpoint() 458 if pdsEndpoint == "" { 459 + return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", tangledDid) 460 } 461 462 sessionPayload := map[string]string{ ··· 465 } 466 sessionBytes, err := json.Marshal(sessionPayload) 467 if err != nil { 468 + return nil, fmt.Errorf("failed to marshal session payload: %v", err) 469 } 470 471 sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 472 sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 473 if err != nil { 474 + return nil, fmt.Errorf("failed to create session request: %v", err) 475 } 476 sessionReq.Header.Set("Content-Type", "application/json") 477 478 client := &http.Client{Timeout: 30 * time.Second} 479 sessionResp, err := client.Do(sessionReq) 480 if err != nil { 481 + return nil, fmt.Errorf("failed to create session: %v", err) 482 } 483 defer sessionResp.Body.Close() 484 485 if sessionResp.StatusCode != http.StatusOK { 486 + return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 487 } 488 489 + var session session 490 if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 491 + return nil, fmt.Errorf("failed to decode session response: %v", err) 492 } 493 494 + session.PdsEndpoint = pdsEndpoint 495 496 + return &session, nil 497 + } 498 + 499 + func (s *session) putRecord(record any) error { 500 recordBytes, err := json.Marshal(record) 501 if err != nil { 502 + return fmt.Errorf("failed to marshal knot member record: %w", err) 503 } 504 505 + payload := map[string]any{ 506 "repo": tangledDid, 507 + "collection": tangled.KnotMemberNSID, 508 "rkey": tid.TID(), 509 "record": json.RawMessage(recordBytes), 510 } 511 512 payloadBytes, err := json.Marshal(payload) 513 if err != nil { 514 + return fmt.Errorf("failed to marshal request payload: %w", err) 515 } 516 517 + url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 518 req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 519 if err != nil { 520 + return fmt.Errorf("failed to create HTTP request: %w", err) 521 } 522 523 req.Header.Set("Content-Type", "application/json") 524 + req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 525 526 + client := &http.Client{Timeout: 30 * time.Second} 527 resp, err := client.Do(req) 528 if err != nil { 529 + return fmt.Errorf("failed to add user to default Knot: %w", err) 530 } 531 defer resp.Body.Close() 532 533 if resp.StatusCode != http.StatusOK { 534 + return fmt.Errorf("failed to add user to default Knot: HTTP %d", resp.StatusCode) 535 } 536 537 + return nil 538 }
+3
appview/oauth/oauth.go
··· 286 AccessJwt: resp.Token, 287 }, 288 Host: opts.Host(), 289 }, nil 290 } 291
··· 286 AccessJwt: resp.Token, 287 }, 288 Host: opts.Host(), 289 + Client: &http.Client{ 290 + Timeout: time.Second * 5, 291 + }, 292 }, nil 293 } 294
+23 -4
appview/pages/funcmap.go
··· 19 20 "github.com/dustin/go-humanize" 21 "github.com/go-enry/go-enry/v2" 22 - "github.com/microcosm-cc/bluemonday" 23 "tangled.sh/tangled.sh/core/appview/filetree" 24 "tangled.sh/tangled.sh/core/appview/pages/markup" 25 ) 26 27 func (p *Pages) funcMap() template.FuncMap { ··· 207 } 208 return v.Slice(0, min(n, v.Len())).Interface() 209 }, 210 - 211 "markdown": func(text string) template.HTML { 212 - rctx := &markup.RenderContext{RendererType: markup.RendererTypeDefault} 213 - return template.HTML(bluemonday.UGCPolicy().Sanitize(rctx.RenderMarkdown(text))) 214 }, 215 "isNil": func(t any) bool { 216 // returns false for other "zero" values ··· 270 }, 271 "layoutCenter": func() string { 272 return "col-span-1 md:col-span-8 lg:col-span-6" 273 }, 274 } 275 }
··· 19 20 "github.com/dustin/go-humanize" 21 "github.com/go-enry/go-enry/v2" 22 "tangled.sh/tangled.sh/core/appview/filetree" 23 "tangled.sh/tangled.sh/core/appview/pages/markup" 24 + "tangled.sh/tangled.sh/core/crypto" 25 ) 26 27 func (p *Pages) funcMap() template.FuncMap { ··· 207 } 208 return v.Slice(0, min(n, v.Len())).Interface() 209 }, 210 "markdown": func(text string) template.HTML { 211 + p.rctx.RendererType = markup.RendererTypeDefault 212 + htmlString := p.rctx.RenderMarkdown(text) 213 + sanitized := p.rctx.SanitizeDefault(htmlString) 214 + return template.HTML(sanitized) 215 + }, 216 + "description": func(text string) template.HTML { 217 + p.rctx.RendererType = markup.RendererTypeDefault 218 + htmlString := p.rctx.RenderMarkdown(text) 219 + sanitized := p.rctx.SanitizeDescription(htmlString) 220 + return template.HTML(sanitized) 221 }, 222 "isNil": func(t any) bool { 223 // returns false for other "zero" values ··· 277 }, 278 "layoutCenter": func() string { 279 return "col-span-1 md:col-span-8 lg:col-span-6" 280 + }, 281 + 282 + "normalizeForHtmlId": func(s string) string { 283 + // TODO: extend this to handle other cases? 284 + return strings.ReplaceAll(s, ":", "_") 285 + }, 286 + "sshFingerprint": func(pubKey string) string { 287 + fp, err := crypto.SSHFingerprint(pubKey) 288 + if err != nil { 289 + return "error" 290 + } 291 + return fp 292 }, 293 } 294 }
+5 -1
appview/pages/markup/markdown.go
··· 161 } 162 163 func (rctx *RenderContext) SanitizeDefault(html string) string { 164 - return rctx.Sanitizer.defaultPolicy.Sanitize(html) 165 } 166 167 type MarkdownTransformer struct {
··· 161 } 162 163 func (rctx *RenderContext) SanitizeDefault(html string) string { 164 + return rctx.Sanitizer.SanitizeDefault(html) 165 + } 166 + 167 + func (rctx *RenderContext) SanitizeDescription(html string) string { 168 + return rctx.Sanitizer.SanitizeDescription(html) 169 } 170 171 type MarkdownTransformer struct {
+27 -2
appview/pages/markup/sanitizer.go
··· 11 ) 12 13 type Sanitizer struct { 14 - defaultPolicy *bluemonday.Policy 15 } 16 17 func NewSanitizer() Sanitizer { 18 return Sanitizer{ 19 - defaultPolicy: defaultPolicy(), 20 } 21 } 22 23 func defaultPolicy() *bluemonday.Policy { ··· 90 91 return policy 92 }
··· 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 { ··· 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 + }
+115 -61
appview/pages/pages.go
··· 299 type TimelineParams struct { 300 LoggedInUser *oauth.User 301 Timeline []db.TimelineEvent 302 } 303 304 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 305 - return p.execute("timeline", w, params) 306 } 307 308 - type SettingsParams struct { 309 LoggedInUser *oauth.User 310 PubKeys []db.PublicKey 311 Emails []db.Email 312 } 313 314 - func (p *Pages) Settings(w io.Writer, params SettingsParams) error { 315 - return p.execute("settings", w, params) 316 } 317 318 type KnotsParams struct { ··· 337 } 338 339 type KnotListingParams struct { 340 - db.Registration 341 } 342 343 func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error { 344 return p.executePlain("knots/fragments/knotListing", w, params) 345 } 346 347 - type KnotListingFullParams struct { 348 - Registrations []db.Registration 349 - } 350 - 351 - func (p *Pages) KnotListingFull(w io.Writer, params KnotListingFullParams) error { 352 - return p.executePlain("knots/fragments/knotListingFull", w, params) 353 - } 354 - 355 - type KnotSecretParams struct { 356 - Secret string 357 - } 358 - 359 - func (p *Pages) KnotSecret(w io.Writer, params KnotSecretParams) error { 360 - return p.executePlain("knots/fragments/secret", w, params) 361 - } 362 - 363 type SpindlesParams struct { 364 LoggedInUser *oauth.User 365 Spindles []db.Spindle ··· 407 return p.execute("repo/fork", w, params) 408 } 409 410 - type ProfilePageParams struct { 411 LoggedInUser *oauth.User 412 Repos []db.Repo 413 CollaboratingRepos []db.Repo ··· 417 } 418 419 type ProfileCard struct { 420 - UserDid string 421 - UserHandle string 422 - FollowStatus db.FollowStatus 423 - Followers int 424 - Following int 425 426 Profile *db.Profile 427 } 428 429 - func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error { 430 return p.execute("user/profile", w, params) 431 } 432 ··· 440 return p.execute("user/repos", w, params) 441 } 442 443 type FollowFragmentParams struct { 444 UserDid string 445 FollowStatus db.FollowStatus ··· 496 } 497 498 type RepoIndexParams struct { 499 - LoggedInUser *oauth.User 500 - RepoInfo repoinfo.RepoInfo 501 - Active string 502 - TagMap map[string][]string 503 - CommitsTrunc []*object.Commit 504 - TagsTrunc []*types.TagReference 505 - BranchesTrunc []types.Branch 506 - ForkInfo *types.ForkInfo 507 HTMLReadme template.HTML 508 Raw bool 509 EmailToDidOrHandle map[string]string ··· 520 } 521 522 p.rctx.RepoInfo = params.RepoInfo 523 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 524 525 if params.ReadmeFileName != "" { ··· 673 } 674 } 675 676 - if params.Lines < 5000 { 677 - c := params.Contents 678 - formatter := chromahtml.New( 679 - chromahtml.InlineCode(false), 680 - chromahtml.WithLineNumbers(true), 681 - chromahtml.WithLinkableLineNumbers(true, "L"), 682 - chromahtml.Standalone(false), 683 - chromahtml.WithClasses(true), 684 - ) 685 - 686 - lexer := lexers.Get(filepath.Base(params.Path)) 687 - if lexer == nil { 688 - lexer = lexers.Fallback 689 - } 690 691 - iterator, err := lexer.Tokenise(nil, c) 692 - if err != nil { 693 - return fmt.Errorf("chroma tokenize: %w", err) 694 - } 695 696 - var code bytes.Buffer 697 - err = formatter.Format(&code, style, iterator) 698 - if err != nil { 699 - return fmt.Errorf("chroma format: %w", err) 700 - } 701 702 - params.Contents = code.String() 703 } 704 705 params.Active = "overview" 706 return p.executeRepo("repo/blob", w, params) 707 } ··· 793 LoggedInUser *oauth.User 794 RepoInfo repoinfo.RepoInfo 795 Active string 796 - Issue db.Issue 797 Comments []db.Comment 798 IssueOwnerHandle string 799 ··· 1160 return p.execute("strings/dashboard", w, params) 1161 } 1162 1163 type SingleStringParams struct { 1164 LoggedInUser *oauth.User 1165 ShowRendered bool ··· 1262 1263 func (p *Pages) Error404(w io.Writer) error { 1264 return p.execute("errors/404", w, nil) 1265 } 1266 1267 func (p *Pages) Error503(w io.Writer) error {
··· 299 type TimelineParams struct { 300 LoggedInUser *oauth.User 301 Timeline []db.TimelineEvent 302 + Repos []db.Repo 303 } 304 305 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 306 + return p.execute("timeline/timeline", w, params) 307 } 308 309 + type UserProfileSettingsParams struct { 310 + LoggedInUser *oauth.User 311 + Tabs []map[string]any 312 + Tab string 313 + } 314 + 315 + func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error { 316 + return p.execute("user/settings/profile", w, params) 317 + } 318 + 319 + type UserKeysSettingsParams struct { 320 LoggedInUser *oauth.User 321 PubKeys []db.PublicKey 322 + Tabs []map[string]any 323 + Tab string 324 + } 325 + 326 + func (p *Pages) UserKeysSettings(w io.Writer, params UserKeysSettingsParams) error { 327 + return p.execute("user/settings/keys", w, params) 328 + } 329 + 330 + type UserEmailsSettingsParams struct { 331 + LoggedInUser *oauth.User 332 Emails []db.Email 333 + Tabs []map[string]any 334 + Tab string 335 } 336 337 + func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error { 338 + return p.execute("user/settings/emails", w, params) 339 + } 340 + 341 + type KnotBannerParams struct { 342 + Registrations []db.Registration 343 + } 344 + 345 + func (p *Pages) KnotBanner(w io.Writer, params KnotBannerParams) error { 346 + return p.executePlain("knots/fragments/banner", w, params) 347 } 348 349 type KnotsParams struct { ··· 368 } 369 370 type KnotListingParams struct { 371 + *db.Registration 372 } 373 374 func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error { 375 return p.executePlain("knots/fragments/knotListing", w, params) 376 } 377 378 type SpindlesParams struct { 379 LoggedInUser *oauth.User 380 Spindles []db.Spindle ··· 422 return p.execute("repo/fork", w, params) 423 } 424 425 + type ProfileHomePageParams struct { 426 LoggedInUser *oauth.User 427 Repos []db.Repo 428 CollaboratingRepos []db.Repo ··· 432 } 433 434 type ProfileCard struct { 435 + UserDid string 436 + UserHandle string 437 + FollowStatus db.FollowStatus 438 + FollowersCount int 439 + FollowingCount int 440 441 Profile *db.Profile 442 } 443 444 + func (p *Pages) ProfileHomePage(w io.Writer, params ProfileHomePageParams) error { 445 return p.execute("user/profile", w, params) 446 } 447 ··· 455 return p.execute("user/repos", w, params) 456 } 457 458 + type FollowCard struct { 459 + UserDid string 460 + FollowStatus db.FollowStatus 461 + FollowersCount int 462 + FollowingCount int 463 + Profile *db.Profile 464 + } 465 + 466 + type FollowersPageParams struct { 467 + LoggedInUser *oauth.User 468 + Followers []FollowCard 469 + Card ProfileCard 470 + } 471 + 472 + func (p *Pages) FollowersPage(w io.Writer, params FollowersPageParams) error { 473 + return p.execute("user/followers", w, params) 474 + } 475 + 476 + type FollowingPageParams struct { 477 + LoggedInUser *oauth.User 478 + Following []FollowCard 479 + Card ProfileCard 480 + } 481 + 482 + func (p *Pages) FollowingPage(w io.Writer, params FollowingPageParams) error { 483 + return p.execute("user/following", w, params) 484 + } 485 + 486 type FollowFragmentParams struct { 487 UserDid string 488 FollowStatus db.FollowStatus ··· 539 } 540 541 type RepoIndexParams struct { 542 + LoggedInUser *oauth.User 543 + RepoInfo repoinfo.RepoInfo 544 + Active string 545 + TagMap map[string][]string 546 + CommitsTrunc []*object.Commit 547 + TagsTrunc []*types.TagReference 548 + BranchesTrunc []types.Branch 549 + // ForkInfo *types.ForkInfo 550 HTMLReadme template.HTML 551 Raw bool 552 EmailToDidOrHandle map[string]string ··· 563 } 564 565 p.rctx.RepoInfo = params.RepoInfo 566 + p.rctx.RepoInfo.Ref = params.Ref 567 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 568 569 if params.ReadmeFileName != "" { ··· 717 } 718 } 719 720 + c := params.Contents 721 + formatter := chromahtml.New( 722 + chromahtml.InlineCode(false), 723 + chromahtml.WithLineNumbers(true), 724 + chromahtml.WithLinkableLineNumbers(true, "L"), 725 + chromahtml.Standalone(false), 726 + chromahtml.WithClasses(true), 727 + ) 728 729 + lexer := lexers.Get(filepath.Base(params.Path)) 730 + if lexer == nil { 731 + lexer = lexers.Fallback 732 + } 733 734 + iterator, err := lexer.Tokenise(nil, c) 735 + if err != nil { 736 + return fmt.Errorf("chroma tokenize: %w", err) 737 + } 738 739 + var code bytes.Buffer 740 + err = formatter.Format(&code, style, iterator) 741 + if err != nil { 742 + return fmt.Errorf("chroma format: %w", err) 743 } 744 745 + params.Contents = code.String() 746 params.Active = "overview" 747 return p.executeRepo("repo/blob", w, params) 748 } ··· 834 LoggedInUser *oauth.User 835 RepoInfo repoinfo.RepoInfo 836 Active string 837 + Issue *db.Issue 838 Comments []db.Comment 839 IssueOwnerHandle string 840 ··· 1201 return p.execute("strings/dashboard", w, params) 1202 } 1203 1204 + type StringTimelineParams struct { 1205 + LoggedInUser *oauth.User 1206 + Strings []db.String 1207 + } 1208 + 1209 + func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error { 1210 + return p.execute("strings/timeline", w, params) 1211 + } 1212 + 1213 type SingleStringParams struct { 1214 LoggedInUser *oauth.User 1215 ShowRendered bool ··· 1312 1313 func (p *Pages) Error404(w io.Writer) error { 1314 return p.execute("errors/404", w, nil) 1315 + } 1316 + 1317 + func (p *Pages) ErrorKnot404(w io.Writer) error { 1318 + return p.execute("errors/knot404", w, nil) 1319 } 1320 1321 func (p *Pages) Error503(w io.Writer) error {
+24 -4
appview/pages/templates/errors/404.html
··· 1 {{ define "title" }}404 &middot; tangled{{ end }} 2 3 {{ define "content" }} 4 - <h1>404 &mdash; nothing like that here!</h1> 5 - <p> 6 - It seems we couldn't find what you were looking for. Sorry about that! 7 - </p> 8 {{ end }}
··· 1 {{ define "title" }}404 &middot; tangled{{ end }} 2 3 {{ define "content" }} 4 + <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 + <div class="mb-6"> 7 + <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center"> 8 + {{ i "search-x" "w-8 h-8 text-gray-400 dark:text-gray-500" }} 9 + </div> 10 + </div> 11 + 12 + <div class="space-y-4"> 13 + <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 + 404 &mdash; page not found 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-300"> 17 + The page you're looking for doesn't exist. It may have been moved, deleted, or you have the wrong URL. 18 + </p> 19 + <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 + <a href="javascript:history.back()" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"> 21 + {{ i "arrow-left" "w-4 h-4" }} 22 + go back 23 + </a> 24 + </div> 25 + </div> 26 + </div> 27 + </div> 28 {{ end }}
+36 -3
appview/pages/templates/errors/500.html
··· 1 {{ define "title" }}500 &middot; tangled{{ end }} 2 3 {{ define "content" }} 4 - <h1>500 &mdash; something broke!</h1> 5 - <p>We're working on getting service back up. Hang tight!</p> 6 - {{ end }}
··· 1 {{ define "title" }}500 &middot; tangled{{ end }} 2 3 {{ define "content" }} 4 + <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 + <div class="mb-6"> 7 + <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"> 8 + {{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }} 9 + </div> 10 + </div> 11 + 12 + <div class="space-y-4"> 13 + <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 + 500 &mdash; internal server error 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-300"> 17 + Something went wrong on our end. We've been notified and are working to fix the issue. 18 + </p> 19 + <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200"> 20 + <div class="flex items-center gap-2"> 21 + {{ i "info" "w-4 h-4" }} 22 + <span class="font-medium">we're on it!</span> 23 + </div> 24 + <p class="mt-1">Our team has been automatically notified about this error.</p> 25 + </div> 26 + <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 27 + <button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white"> 28 + {{ i "refresh-cw" "w-4 h-4" }} 29 + try again 30 + </button> 31 + <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"> 32 + {{ i "home" "w-4 h-4" }} 33 + back to home 34 + </a> 35 + </div> 36 + </div> 37 + </div> 38 + </div> 39 + {{ end }}
+28 -5
appview/pages/templates/errors/503.html
··· 1 {{ define "title" }}503 &middot; tangled{{ end }} 2 3 {{ define "content" }} 4 - <h1>503 &mdash; unable to reach knot</h1> 5 - <p> 6 - We were unable to reach the knot hosting this repository. Try again 7 - later. 8 - </p> 9 {{ end }}
··· 1 {{ define "title" }}503 &middot; tangled{{ end }} 2 3 {{ define "content" }} 4 + <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 + <div class="mb-6"> 7 + <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center"> 8 + {{ i "server-off" "w-8 h-8 text-blue-500 dark:text-blue-400" }} 9 + </div> 10 + </div> 11 + 12 + <div class="space-y-4"> 13 + <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 + 503 &mdash; service unavailable 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-300"> 17 + We were unable to reach the knot hosting this repository. The service may be temporarily unavailable. 18 + </p> 19 + <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 + <button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white"> 21 + {{ i "refresh-cw" "w-4 h-4" }} 22 + try again 23 + </button> 24 + <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"> 25 + {{ i "arrow-left" "w-4 h-4" }} 26 + back to timeline 27 + </a> 28 + </div> 29 + </div> 30 + </div> 31 + </div> 32 {{ end }}
+28
appview/pages/templates/errors/knot404.html
···
··· 1 + {{ define "title" }}404 &middot; tangled{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 + <div class="mb-6"> 7 + <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center"> 8 + {{ i "book-x" "w-8 h-8 text-orange-500 dark:text-orange-400" }} 9 + </div> 10 + </div> 11 + 12 + <div class="space-y-4"> 13 + <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 + 404 &mdash; repository not found 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-300"> 17 + The repository you were looking for could not be found. The knot serving the repository may be unavailable. 18 + </p> 19 + <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 + <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline"> 21 + {{ i "arrow-left" "w-4 h-4" }} 22 + back to timeline 23 + </a> 24 + </div> 25 + </div> 26 + </div> 27 + </div> 28 + {{ end }}
+93 -28
appview/pages/templates/knots/dashboard.html
··· 1 - {{ define "title" }}{{ .Registration.Domain }}{{ end }} 2 3 {{ define "content" }} 4 - <div class="px-6 py-4"> 5 - <div class="flex justify-between items-center"> 6 - <div id="left-side" class="flex gap-2 items-center"> 7 - <h1 class="text-xl font-bold dark:text-white"> 8 - {{ .Registration.Domain }} 9 - </h1> 10 - <span class="text-gray-500 text-base"> 11 - {{ template "repo/fragments/shortTimeAgo" .Registration.Created }} 12 </span> 13 - </div> 14 - <div id="right-side" class="flex gap-2"> 15 - {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 16 - {{ if .Registration.Registered }} 17 - <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 18 - {{ template "knots/fragments/addMemberModal" .Registration }} 19 - {{ else }} 20 - <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} pending</span> 21 {{ end }} 22 - </div> 23 </div> 24 - <div id="operation-error" class="dark:text-red-400"></div> 25 </div> 26 27 - {{ if .Members }} 28 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 29 - <div class="flex flex-col gap-2"> 30 - {{ block "knotMember" . }} {{ end }} 31 - </div> 32 - </section> 33 - {{ end }} 34 {{ end }} 35 36 - {{ define "knotMember" }} 37 {{ range .Members }} 38 <div> 39 <div class="flex justify-between items-center"> ··· 41 {{ template "user/fragments/picHandleLink" . }} 42 <span class="ml-2 font-mono text-gray-500">{{.}}</span> 43 </div> 44 </div> 45 <div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700"> 46 {{ $repos := index $.Repos . }} ··· 53 </div> 54 {{ else }} 55 <div class="text-gray-500 dark:text-gray-400"> 56 - No repositories created yet. 57 </div> 58 {{ end }} 59 </div> 60 </div> 61 {{ end }} 62 {{ end }}
··· 1 + {{ define "title" }}{{ .Registration.Domain }} &middot; knots{{ end }} 2 3 {{ define "content" }} 4 + <div class="px-6 py-4"> 5 + <div class="flex justify-between items-center"> 6 + <h1 class="text-xl font-bold dark:text-white">{{ .Registration.Domain }}</h1> 7 + <div id="right-side" class="flex gap-2"> 8 + {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 9 + {{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Registration.ByDid) }} 10 + {{ if .Registration.IsRegistered }} 11 + <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 12 + {{ if $isOwner }} 13 + {{ template "knots/fragments/addMemberModal" .Registration }} 14 + {{ end }} 15 + {{ else if .Registration.IsReadOnly }} 16 + <span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> 17 + {{ i "shield-alert" "w-4 h-4" }} read-only 18 </span> 19 + {{ if $isOwner }} 20 + {{ block "retryButton" .Registration }} {{ end }} 21 + {{ end }} 22 + {{ else }} 23 + <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span> 24 + {{ if $isOwner }} 25 + {{ block "retryButton" .Registration }} {{ end }} 26 {{ end }} 27 + {{ end }} 28 + 29 + {{ if $isOwner }} 30 + {{ block "deleteButton" .Registration }} {{ end }} 31 + {{ end }} 32 </div> 33 </div> 34 + <div id="operation-error" class="dark:text-red-400"></div> 35 + </div> 36 37 + {{ if .Members }} 38 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 39 + <div class="flex flex-col gap-2"> 40 + {{ block "member" . }} {{ end }} 41 + </div> 42 + </section> 43 + {{ end }} 44 {{ end }} 45 46 + 47 + {{ define "member" }} 48 {{ range .Members }} 49 <div> 50 <div class="flex justify-between items-center"> ··· 52 {{ template "user/fragments/picHandleLink" . }} 53 <span class="ml-2 font-mono text-gray-500">{{.}}</span> 54 </div> 55 + {{ if ne $.LoggedInUser.Did . }} 56 + {{ block "removeMemberButton" (list $ . ) }} {{ end }} 57 + {{ end }} 58 </div> 59 <div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700"> 60 {{ $repos := index $.Repos . }} ··· 67 </div> 68 {{ else }} 69 <div class="text-gray-500 dark:text-gray-400"> 70 + No repositories configured yet. 71 </div> 72 {{ end }} 73 </div> 74 </div> 75 {{ end }} 76 {{ end }} 77 + 78 + {{ define "deleteButton" }} 79 + <button 80 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 81 + title="Delete knot" 82 + hx-delete="/knots/{{ .Domain }}" 83 + hx-swap="outerHTML" 84 + hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?" 85 + hx-headers='{"shouldRedirect": "true"}' 86 + > 87 + {{ i "trash-2" "w-5 h-5" }} 88 + <span class="hidden md:inline">delete</span> 89 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 90 + </button> 91 + {{ end }} 92 + 93 + 94 + {{ define "retryButton" }} 95 + <button 96 + class="btn gap-2 group" 97 + title="Retry knot verification" 98 + hx-post="/knots/{{ .Domain }}/retry" 99 + hx-swap="none" 100 + hx-headers='{"shouldRefresh": "true"}' 101 + > 102 + {{ i "rotate-ccw" "w-5 h-5" }} 103 + <span class="hidden md:inline">retry</span> 104 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 105 + </button> 106 + {{ end }} 107 + 108 + 109 + {{ define "removeMemberButton" }} 110 + {{ $root := index . 0 }} 111 + {{ $member := index . 1 }} 112 + {{ $memberHandle := resolve $member }} 113 + <button 114 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 115 + title="Remove member" 116 + hx-post="/knots/{{ $root.Registration.Domain }}/remove" 117 + hx-swap="none" 118 + hx-vals='{"member": "{{$member}}" }' 119 + hx-confirm="Are you sure you want to remove {{ $memberHandle }} from this knot?" 120 + > 121 + {{ i "user-minus" "w-4 h-4" }} 122 + remove 123 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 124 + </button> 125 + {{ end }} 126 + 127 +
+6 -7
appview/pages/templates/knots/fragments/addMemberModal.html
··· 1 {{ define "knots/fragments/addMemberModal" }} 2 <button 3 class="btn gap-2 group" 4 - title="Add member to this spindle" 5 popovertarget="add-member-{{ .Id }}" 6 popovertargetaction="toggle" 7 > ··· 20 21 {{ define "addKnotMemberPopover" }} 22 <form 23 - hx-put="/knots/{{ .Domain }}/member" 24 hx-indicator="#spinner" 25 hx-swap="none" 26 class="flex flex-col gap-2" ··· 28 <label for="member-did-{{ .Id }}" class="uppercase p-0"> 29 ADD MEMBER 30 </label> 31 - <p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories on this knot.</p> 32 <input 33 type="text" 34 id="member-did-{{ .Id }}" 35 - name="subject" 36 required 37 placeholder="@foo.bsky.social" 38 /> 39 <div class="flex gap-2 pt-2"> 40 - <button 41 type="button" 42 popovertarget="add-member-{{ .Id }}" 43 popovertargetaction="hide" ··· 54 </div> 55 <div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div> 56 </form> 57 - {{ end }} 58 -
··· 1 {{ define "knots/fragments/addMemberModal" }} 2 <button 3 class="btn gap-2 group" 4 + title="Add member to this knot" 5 popovertarget="add-member-{{ .Id }}" 6 popovertargetaction="toggle" 7 > ··· 20 21 {{ define "addKnotMemberPopover" }} 22 <form 23 + hx-post="/knots/{{ .Domain }}/add" 24 hx-indicator="#spinner" 25 hx-swap="none" 26 class="flex flex-col gap-2" ··· 28 <label for="member-did-{{ .Id }}" class="uppercase p-0"> 29 ADD MEMBER 30 </label> 31 + <p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p> 32 <input 33 type="text" 34 id="member-did-{{ .Id }}" 35 + name="member" 36 required 37 placeholder="@foo.bsky.social" 38 /> 39 <div class="flex gap-2 pt-2"> 40 + <button 41 type="button" 42 popovertarget="add-member-{{ .Id }}" 43 popovertargetaction="hide" ··· 54 </div> 55 <div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div> 56 </form> 57 + {{ end }}
+9
appview/pages/templates/knots/fragments/banner.html
···
··· 1 + {{ define "knots/fragments/banner" }} 2 + <div class="w-full px-6 py-2 -z-15 bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 border border-yellow-200 dark:border-yellow-800 rounded-b drop-shadow-sm"> 3 + A knot ({{range $i, $r := .Registrations}}{{if ne $i 0}}, {{end}}{{ $r.Domain }}{{ end }}) 4 + that you administer is presently read-only. Consider upgrading this knot to 5 + continue creating repositories on it. 6 + <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/migrations/knot-1.7.0.md">Click to read the upgrade guide</a>. 7 + </div> 8 + {{ end }} 9 +
+57 -25
appview/pages/templates/knots/fragments/knotListing.html
··· 1 {{ define "knots/fragments/knotListing" }} 2 - <div 3 - id="knot-{{.Id}}" 4 - hx-swap-oob="true" 5 - class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 6 - {{ block "listLeftSide" . }} {{ end }} 7 - {{ block "listRightSide" . }} {{ end }} 8 </div> 9 {{ end }} 10 11 - {{ define "listLeftSide" }} 12 <div class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 13 {{ i "hard-drive" "w-4 h-4" }} 14 - {{ if .Registered }} 15 - <a href="/knots/{{ .Domain }}"> 16 - {{ .Domain }} 17 - </a> 18 - {{ else }} 19 - {{ .Domain }} 20 - {{ end }} 21 <span class="text-gray-500"> 22 {{ template "repo/fragments/shortTimeAgo" .Created }} 23 </span> 24 </div> 25 {{ end }} 26 27 - {{ define "listRightSide" }} 28 <div id="right-side" class="flex gap-2"> 29 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 30 - {{ if .Registered }} 31 - <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 32 {{ template "knots/fragments/addMemberModal" . }} 33 {{ else }} 34 - <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} pending</span> 35 - {{ block "initializeButton" . }} {{ end }} 36 {{ end }} 37 </div> 38 {{ end }} 39 40 - {{ define "initializeButton" }} 41 <button 42 - class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center group" 43 - hx-post="/knots/{{ .Domain }}/init" 44 hx-swap="none" 45 > 46 - {{ i "square-play" "w-5 h-5" }} 47 - <span class="hidden md:inline">initialize</span> 48 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 49 </button> 50 {{ end }} 51 -
··· 1 {{ define "knots/fragments/knotListing" }} 2 + <div id="knot-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 3 + {{ block "knotLeftSide" . }} {{ end }} 4 + {{ block "knotRightSide" . }} {{ end }} 5 </div> 6 {{ end }} 7 8 + {{ define "knotLeftSide" }} 9 + {{ if .Registered }} 10 + <a href="/knots/{{ .Domain }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 + {{ i "hard-drive" "w-4 h-4" }} 12 + <span class="hover:underline"> 13 + {{ .Domain }} 14 + </span> 15 + <span class="text-gray-500"> 16 + {{ template "repo/fragments/shortTimeAgo" .Created }} 17 + </span> 18 + </a> 19 + {{ else }} 20 <div class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 21 {{ i "hard-drive" "w-4 h-4" }} 22 + {{ .Domain }} 23 <span class="text-gray-500"> 24 {{ template "repo/fragments/shortTimeAgo" .Created }} 25 </span> 26 </div> 27 + {{ end }} 28 {{ end }} 29 30 + {{ define "knotRightSide" }} 31 <div id="right-side" class="flex gap-2"> 32 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 33 + {{ if .IsRegistered }} 34 + <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}"> 35 + {{ i "shield-check" "w-4 h-4" }} verified 36 + </span> 37 {{ template "knots/fragments/addMemberModal" . }} 38 + {{ block "knotDeleteButton" . }} {{ end }} 39 + {{ else if .IsReadOnly }} 40 + <span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> 41 + {{ i "shield-alert" "w-4 h-4" }} read-only 42 + </span> 43 + {{ block "knotRetryButton" . }} {{ end }} 44 + {{ block "knotDeleteButton" . }} {{ end }} 45 {{ else }} 46 + <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}"> 47 + {{ i "shield-off" "w-4 h-4" }} unverified 48 + </span> 49 + {{ block "knotRetryButton" . }} {{ end }} 50 + {{ block "knotDeleteButton" . }} {{ end }} 51 {{ end }} 52 </div> 53 {{ end }} 54 55 + {{ define "knotDeleteButton" }} 56 + <button 57 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 58 + title="Delete knot" 59 + hx-delete="/knots/{{ .Domain }}" 60 + hx-swap="outerHTML" 61 + hx-target="#knot-{{.Id}}" 62 + hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?" 63 + > 64 + {{ i "trash-2" "w-5 h-5" }} 65 + <span class="hidden md:inline">delete</span> 66 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 67 + </button> 68 + {{ end }} 69 + 70 + 71 + {{ define "knotRetryButton" }} 72 <button 73 + class="btn gap-2 group" 74 + title="Retry knot verification" 75 + hx-post="/knots/{{ .Domain }}/retry" 76 hx-swap="none" 77 + hx-target="#knot-{{.Id}}" 78 > 79 + {{ i "rotate-ccw" "w-5 h-5" }} 80 + <span class="hidden md:inline">retry</span> 81 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 82 </button> 83 {{ end }}
-18
appview/pages/templates/knots/fragments/knotListingFull.html
··· 1 - {{ define "knots/fragments/knotListingFull" }} 2 - <section 3 - id="knot-listing-full" 4 - hx-swap-oob="true" 5 - class="rounded w-full flex flex-col gap-2"> 6 - <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your knots</h2> 7 - <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full"> 8 - {{ range $knot := .Registrations }} 9 - {{ template "knots/fragments/knotListing" . }} 10 - {{ else }} 11 - <div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500"> 12 - no knots registered yet 13 - </div> 14 - {{ end }} 15 - </div> 16 - <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 17 - </section> 18 - {{ end }}
···
-10
appview/pages/templates/knots/fragments/secret.html
··· 1 - {{ define "knots/fragments/secret" }} 2 - <div 3 - id="secret" 4 - hx-swap-oob="true" 5 - class="bg-gray-50 dark:bg-gray-700 border border-black dark:border-gray-500 rounded px-6 py-2 w-full lg:w-3xl"> 6 - <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">generated secret</h2> 7 - <p class="pb-2">Configure your knot to use this secret, and then hit initialize.</p> 8 - <span class="font-mono overflow-x">{{ .Secret }}</span> 9 - </div> 10 - {{ end }}
···
+23 -8
appview/pages/templates/knots/index.html
··· 8 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 9 <div class="flex flex-col gap-6"> 10 {{ block "about" . }} {{ end }} 11 - {{ template "knots/fragments/knotListingFull" . }} 12 {{ block "register" . }} {{ end }} 13 </div> 14 </section> ··· 27 </section> 28 {{ end }} 29 30 {{ define "register" }} 31 - <section class="rounded max-w-2xl flex flex-col gap-2"> 32 <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a knot</h2> 33 - <p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to generate a key.</p> 34 <form 35 - hx-post="/knots/key" 36 - class="space-y-4" 37 hx-indicator="#register-button" 38 hx-swap="none" 39 > ··· 53 > 54 <span class="inline-flex items-center gap-2"> 55 {{ i "plus" "w-4 h-4" }} 56 - generate 57 </span> 58 <span class="pl-2 hidden group-[.htmx-request]:inline"> 59 {{ i "loader-circle" "w-4 h-4 animate-spin" }} ··· 61 </button> 62 </div> 63 64 - <div id="registration-error" class="error dark:text-red-400"></div> 65 </form> 66 67 - <div id="secret"></div> 68 </section> 69 {{ end }}
··· 8 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 9 <div class="flex flex-col gap-6"> 10 {{ block "about" . }} {{ end }} 11 + {{ block "list" . }} {{ end }} 12 {{ block "register" . }} {{ end }} 13 </div> 14 </section> ··· 27 </section> 28 {{ end }} 29 30 + {{ define "list" }} 31 + <section class="rounded w-full flex flex-col gap-2"> 32 + <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your knots</h2> 33 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full"> 34 + {{ range $registration := .Registrations }} 35 + {{ template "knots/fragments/knotListing" . }} 36 + {{ else }} 37 + <div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500"> 38 + no knots registered yet 39 + </div> 40 + {{ end }} 41 + </div> 42 + <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 43 + </section> 44 + {{ end }} 45 + 46 {{ define "register" }} 47 + <section class="rounded w-full lg:w-fit flex flex-col gap-2"> 48 <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a knot</h2> 49 + <p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to get started.</p> 50 <form 51 + hx-post="/knots/register" 52 + class="max-w-2xl mb-2 space-y-4" 53 hx-indicator="#register-button" 54 hx-swap="none" 55 > ··· 69 > 70 <span class="inline-flex items-center gap-2"> 71 {{ i "plus" "w-4 h-4" }} 72 + register 73 </span> 74 <span class="pl-2 hidden group-[.htmx-request]:inline"> 75 {{ i "loader-circle" "w-4 h-4 animate-spin" }} ··· 77 </button> 78 </div> 79 80 + <div id="register-error" class="error dark:text-red-400"></div> 81 </form> 82 83 </section> 84 {{ end }}
-12
appview/pages/templates/layouts/base.html
··· 24 {{ block "mainLayout" . }} 25 <div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4"> 26 {{ block "contentLayout" . }} 27 - <div class="col-span-1 md:col-span-2"> 28 - {{ block "contentLeft" . }} {{ end }} 29 - </div> 30 <main class="col-span-1 md:col-span-8"> 31 {{ block "content" . }}{{ end }} 32 </main> 33 - <div class="col-span-1 md:col-span-2"> 34 - {{ block "contentRight" . }} {{ end }} 35 - </div> 36 {{ end }} 37 38 {{ block "contentAfterLayout" . }} 39 - <div class="col-span-1 md:col-span-2"> 40 - {{ block "contentAfterLeft" . }} {{ end }} 41 - </div> 42 <main class="col-span-1 md:col-span-8"> 43 {{ block "contentAfter" . }}{{ end }} 44 </main> 45 - <div class="col-span-1 md:col-span-2"> 46 - {{ block "contentAfterRight" . }} {{ end }} 47 - </div> 48 {{ end }} 49 </div> 50 {{ end }}
··· 24 {{ block "mainLayout" . }} 25 <div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4"> 26 {{ block "contentLayout" . }} 27 <main class="col-span-1 md:col-span-8"> 28 {{ block "content" . }}{{ end }} 29 </main> 30 {{ end }} 31 32 {{ block "contentAfterLayout" . }} 33 <main class="col-span-1 md:col-span-8"> 34 {{ block "contentAfter" . }}{{ end }} 35 </main> 36 {{ end }} 37 </div> 38 {{ end }}
+15 -20
appview/pages/templates/layouts/repobase.html
··· 20 </div> 21 22 <div class="flex items-center gap-2 z-auto"> 23 {{ 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 }} 44 </div> 45 </div> 46 {{ template "repo/fragments/repoDescription" . }}
··· 20 </div> 21 22 <div class="flex items-center gap-2 z-auto"> 23 + <a 24 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 25 + href="/{{ .RepoInfo.FullName }}/feed.atom" 26 + > 27 + {{ i "rss" "size-4" }} 28 + </a> 29 {{ template "repo/fragments/repoStar" .RepoInfo }} 30 + <a 31 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 32 + hx-boost="true" 33 + href="/{{ .RepoInfo.FullName }}/fork" 34 + > 35 + {{ i "git-fork" "w-4 h-4" }} 36 + fork 37 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 38 + </a> 39 </div> 40 </div> 41 {{ template "repo/fragments/repoDescription" . }}
+7
appview/pages/templates/layouts/topbar.html
··· 21 </div> 22 </div> 23 </nav> 24 {{ end }} 25 26 {{ define "newButton" }}
··· 21 </div> 22 </div> 23 </nav> 24 + {{ if .LoggedInUser }} 25 + <div id="upgrade-banner" 26 + hx-get="/knots/upgradeBanner" 27 + hx-trigger="load" 28 + hx-swap="innerHTML"> 29 + </div> 30 + {{ end }} 31 {{ end }} 32 33 {{ define "newButton" }}
+1 -1
appview/pages/templates/repo/commit.html
··· 118 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 119 {{ template "repo/fragments/diffOpts" .DiffOpts }} 120 </div> 121 - <div class="sticky top-0 flex-grow max-h-screen"> 122 {{ template "repo/fragments/diffChangedFiles" .Diff }} 123 </div> 124 {{end}}
··· 118 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 119 {{ template "repo/fragments/diffOpts" .DiffOpts }} 120 </div> 121 + <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 122 {{ template "repo/fragments/diffChangedFiles" .Diff }} 123 </div> 124 {{end}}
+1 -1
appview/pages/templates/repo/compare/compare.html
··· 49 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 50 {{ template "repo/fragments/diffOpts" .DiffOpts }} 51 </div> 52 - <div class="sticky top-0 flex-grow max-h-screen"> 53 {{ template "repo/fragments/diffChangedFiles" .Diff }} 54 </div> 55 {{end}}
··· 49 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 50 {{ template "repo/fragments/diffOpts" .DiffOpts }} 51 </div> 52 + <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 53 {{ template "repo/fragments/diffChangedFiles" .Diff }} 54 </div> 55 {{end}}
-4
appview/pages/templates/repo/empty.html
··· 44 {{ end }} 45 </main> 46 {{ end }} 47 - 48 - {{ define "repoAfter" }} 49 - {{ template "repo/fragments/cloneInstructions" . }} 50 - {{ end }}
··· 44 {{ end }} 45 </main> 46 {{ end }}
+8 -2
appview/pages/templates/repo/fork.html
··· 5 <p class="text-xl font-bold dark:text-white">Fork {{ .RepoInfo.FullName }}</p> 6 </div> 7 <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 - <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none"> 9 <fieldset class="space-y-3"> 10 <legend class="dark:text-white">Select a knot to fork into</legend> 11 <div class="space-y-2"> ··· 30 </fieldset> 31 32 <div class="space-y-2"> 33 - <button type="submit" class="btn">fork repo</button> 34 <div id="repo" class="error"></div> 35 </div> 36 </form>
··· 5 <p class="text-xl font-bold dark:text-white">Fork {{ .RepoInfo.FullName }}</p> 6 </div> 7 <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 + <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 <fieldset class="space-y-3"> 10 <legend class="dark:text-white">Select a knot to fork into</legend> 11 <div class="space-y-2"> ··· 30 </fieldset> 31 32 <div class="space-y-2"> 33 + <button type="submit" class="btn-create flex items-center gap-2"> 34 + {{ i "git-fork" "w-4 h-4" }} 35 + fork repo 36 + <span id="spinner" class="group"> 37 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 38 + </span> 39 + </button> 40 <div id="repo" class="error"></div> 41 </div> 42 </form>
+104
appview/pages/templates/repo/fragments/cloneDropdown.html
···
··· 1 + {{ define "repo/fragments/cloneDropdown" }} 2 + {{ $knot := .RepoInfo.Knot }} 3 + {{ if eq $knot "knot1.tangled.sh" }} 4 + {{ $knot = "tangled.sh" }} 5 + {{ end }} 6 + 7 + <details id="clone-dropdown" class="relative inline-block text-left group"> 8 + <summary class="btn-create cursor-pointer list-none flex items-center gap-2"> 9 + {{ i "download" "w-4 h-4" }} 10 + <span class="hidden md:inline">code</span> 11 + <span class="group-open:hidden"> 12 + {{ i "chevron-down" "w-4 h-4" }} 13 + </span> 14 + <span class="hidden group-open:flex"> 15 + {{ i "chevron-up" "w-4 h-4" }} 16 + </span> 17 + </summary> 18 + 19 + <div class="absolute right-0 mt-2 w-96 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 drop-shadow-sm dark:text-white z-[9999]"> 20 + <div class="p-4"> 21 + <div class="mb-3"> 22 + <h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">Clone this repository</h3> 23 + </div> 24 + 25 + <!-- HTTPS Clone --> 26 + <div class="mb-3"> 27 + <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">HTTPS</label> 28 + <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 29 + <code 30 + class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 31 + onclick="window.getSelection().selectAllChildren(this)" 32 + data-url="https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}" 33 + >https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code> 34 + <button 35 + onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 36 + class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" 37 + title="Copy to clipboard" 38 + > 39 + {{ i "copy" "w-4 h-4" }} 40 + </button> 41 + </div> 42 + </div> 43 + 44 + <!-- SSH Clone --> 45 + <div class="mb-3"> 46 + <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">SSH</label> 47 + <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 48 + <code 49 + class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 50 + onclick="window.getSelection().selectAllChildren(this)" 51 + data-url="git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}" 52 + >git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code> 53 + <button 54 + onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 55 + class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" 56 + title="Copy to clipboard" 57 + > 58 + {{ i "copy" "w-4 h-4" }} 59 + </button> 60 + </div> 61 + </div> 62 + 63 + <!-- Note for self-hosted --> 64 + <p class="text-xs text-gray-500 dark:text-gray-400"> 65 + For self-hosted knots, clone URLs may differ based on your setup. 66 + </p> 67 + 68 + <!-- Download Archive --> 69 + <div class="pt-2 mt-2 border-t border-gray-200 dark:border-gray-700"> 70 + <a 71 + href="/{{ .RepoInfo.FullName }}/archive/{{ .Ref | urlquery }}" 72 + class="flex items-center gap-2 px-3 py-2 text-sm" 73 + > 74 + {{ i "download" "w-4 h-4" }} 75 + Download tar.gz 76 + </a> 77 + </div> 78 + 79 + </div> 80 + </div> 81 + </details> 82 + 83 + <script> 84 + function copyToClipboard(button, text) { 85 + navigator.clipboard.writeText(text).then(() => { 86 + const originalContent = button.innerHTML; 87 + button.innerHTML = `{{ i "check" "w-4 h-4" }}`; 88 + setTimeout(() => { 89 + button.innerHTML = originalContent; 90 + }, 2000); 91 + }); 92 + } 93 + 94 + // Close clone dropdown when clicking outside 95 + document.addEventListener('click', function(event) { 96 + const cloneDropdown = document.getElementById('clone-dropdown'); 97 + if (cloneDropdown && cloneDropdown.hasAttribute('open')) { 98 + if (!cloneDropdown.contains(event.target)) { 99 + cloneDropdown.removeAttribute('open'); 100 + } 101 + } 102 + }); 103 + </script> 104 + {{ end }}
-55
appview/pages/templates/repo/fragments/cloneInstructions.html
··· 1 - {{ define "repo/fragments/cloneInstructions" }} 2 - {{ $knot := .RepoInfo.Knot }} 3 - {{ if eq $knot "knot1.tangled.sh" }} 4 - {{ $knot = "tangled.sh" }} 5 - {{ end }} 6 - <section 7 - class="mt-4 p-6 rounded drop-shadow-sm bg-white dark:bg-gray-800 dark:text-white w-full mx-auto overflow-auto flex flex-col gap-4" 8 - > 9 - <div class="flex flex-col gap-2"> 10 - <strong>push</strong> 11 - <div class="md:pl-4 overflow-x-auto whitespace-nowrap"> 12 - <code class="dark:text-gray-100" 13 - >git remote add origin 14 - git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code 15 - > 16 - </div> 17 - </div> 18 - 19 - <div class="flex flex-col gap-2"> 20 - <strong>clone</strong> 21 - <div class="md:pl-4 flex flex-col gap-2"> 22 - <div class="flex items-center gap-3"> 23 - <span 24 - class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white" 25 - >HTTP</span 26 - > 27 - <div class="overflow-x-auto whitespace-nowrap flex-1"> 28 - <code class="dark:text-gray-100" 29 - >git clone 30 - https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code 31 - > 32 - </div> 33 - </div> 34 - 35 - <div class="flex items-center gap-3"> 36 - <span 37 - class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white" 38 - >SSH</span 39 - > 40 - <div class="overflow-x-auto whitespace-nowrap flex-1"> 41 - <code class="dark:text-gray-100" 42 - >git clone 43 - git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code 44 - > 45 - </div> 46 - </div> 47 - </div> 48 - </div> 49 - 50 - <p class="py-2 text-gray-500 dark:text-gray-400"> 51 - Note that for self-hosted knots, clone URLs may be different based 52 - on your setup. 53 - </p> 54 - </section> 55 - {{ end }}
···
+1 -1
appview/pages/templates/repo/fragments/interdiffFiles.html
··· 1 {{ define "repo/fragments/interdiffFiles" }} 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"> 4 <div class="diff-stat"> 5 <div class="flex gap-2 items-center"> 6 <strong class="text-sm uppercase dark:text-gray-200">files</strong>
··· 1 {{ define "repo/fragments/interdiffFiles" }} 2 {{ $fileTree := fileTree .AffectedFiles }} 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 <div class="diff-stat"> 5 <div class="flex gap-2 items-center"> 6 <strong class="text-sm uppercase dark:text-gray-200">files</strong>
+1 -1
appview/pages/templates/repo/fragments/repoDescription.html
··· 1 {{ define "repo/fragments/repoDescription" }} 2 <span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML"> 3 {{ if .RepoInfo.Description }} 4 - {{ .RepoInfo.Description }} 5 {{ else }} 6 <span class="italic">this repo has no description</span> 7 {{ end }}
··· 1 {{ define "repo/fragments/repoDescription" }} 2 <span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML"> 3 {{ if .RepoInfo.Description }} 4 + {{ .RepoInfo.Description | description }} 5 {{ else }} 6 <span class="italic">this repo has no description</span> 7 {{ end }}
+79 -98
appview/pages/templates/repo/index.html
··· 14 {{ end }} 15 <div class="flex items-center justify-between pb-5"> 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"> 19 {{ i "git-commit-horizontal" "w-4" "h-4" }} {{ .TotalCommits }} 20 </a> 21 - <a href="/{{ .RepoInfo.FullName }}/branches" class="inline-flex items-center text-sm gap-1"> 22 {{ i "git-branch" "w-4" "h-4" }} {{ len .Branches }} 23 </a> 24 - <a href="/{{ .RepoInfo.FullName }}/tags" class="inline-flex items-center text-sm gap-1"> 25 {{ i "tags" "w-4" "h-4" }} {{ len .Tags }} 26 </a> 27 </div> 28 </div> 29 <div class="grid grid-cols-1 md:grid-cols-2 gap-2"> ··· 47 48 49 {{ define "branchSelector" }} 50 - <div class="flex gap-2 items-center items-stretch justify-center"> 51 - <select 52 - onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)" 53 - class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700" 54 - > 55 - <optgroup label="branches ({{len .Branches}})" class="bold text-sm"> 56 - {{ range .Branches }} 57 - <option 58 - value="{{ .Reference.Name }}" 59 - class="py-1" 60 - {{ if eq .Reference.Name $.Ref }} 61 - selected 62 - {{ end }} 63 - > 64 - {{ .Reference.Name }} 65 - </option> 66 - {{ end }} 67 - </optgroup> 68 - <optgroup label="tags ({{len .Tags}})" class="bold text-sm"> 69 - {{ range .Tags }} 70 - <option 71 - value="{{ .Reference.Name }}" 72 - class="py-1" 73 - {{ if eq .Reference.Name $.Ref }} 74 - selected 75 - {{ end }} 76 - > 77 - {{ .Reference.Name }} 78 - </option> 79 - {{ else }} 80 - <option class="py-1" disabled>no tags found</option> 81 - {{ end }} 82 - </optgroup> 83 - </select> 84 - <div class="flex items-center gap-2"> 85 - {{ $isOwner := and .LoggedInUser .RepoInfo.Roles.IsOwner }} 86 - {{ $isCollaborator := and .LoggedInUser .RepoInfo.Roles.IsCollaborator }} 87 - {{ if and (or $isOwner $isCollaborator) .ForkInfo .ForkInfo.IsFork }} 88 - {{ $disabled := "" }} 89 - {{ $title := "" }} 90 - {{ if eq .ForkInfo.Status 0 }} 91 - {{ $disabled = "disabled" }} 92 - {{ $title = "This branch is not behind the upstream" }} 93 - {{ else if eq .ForkInfo.Status 2 }} 94 - {{ $disabled = "disabled" }} 95 - {{ $title = "This branch has conflicts that must be resolved" }} 96 - {{ else if eq .ForkInfo.Status 3 }} 97 - {{ $disabled = "disabled" }} 98 - {{ $title = "This branch does not exist on the upstream" }} 99 - {{ end }} 100 101 - <button 102 - id="syncBtn" 103 - {{ $disabled }} 104 - {{ if $title }}title="{{ $title }}"{{ end }} 105 - class="btn flex gap-2 items-center disabled:opacity-50 disabled:cursor-not-allowed" 106 - hx-post="/{{ .RepoInfo.FullName }}/fork/sync" 107 - hx-trigger="click" 108 - hx-swap="none" 109 - > 110 - {{ if $disabled }} 111 - {{ i "refresh-cw-off" "w-4 h-4" }} 112 - {{ else }} 113 - {{ i "refresh-cw" "w-4 h-4" }} 114 - {{ end }} 115 - <span>sync</span> 116 - </button> 117 - {{ end }} 118 - <a 119 - href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}" 120 - class="btn flex items-center gap-2 no-underline hover:no-underline" 121 - title="Compare branches or tags" 122 - > 123 - {{ i "git-compare" "w-4 h-4" }} 124 - </a> 125 </div> 126 - </div> 127 {{ end }} 128 129 {{ define "fileTree" }} ··· 131 {{ $linkstyle := "no-underline hover:underline dark:text-white" }} 132 133 {{ range .Files }} 134 - <div class="grid grid-cols-2 gap-4 items-center py-1"> 135 - <div class="col-span-1"> 136 {{ $link := printf "/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) .Name }} 137 {{ $icon := "folder" }} 138 {{ $iconStyle := "size-4 fill-current" }} ··· 150 </a> 151 </div> 152 153 - <div class="text-xs col-span-1 text-right"> 154 {{ with .LastCommit }} 155 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a> 156 {{ end }} ··· 348 349 {{ define "repoAfter" }} 350 {{- if or .HTMLReadme .Readme -}} 351 - <section 352 - class="p-6 mt-4 rounded-br rounded-bl bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm w-full mx-auto overflow-auto {{ if not .Raw }} 353 - prose dark:prose-invert dark:[&_pre]:bg-gray-900 354 - dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 355 - dark:[&_pre]:border dark:[&_pre]:border-gray-700 356 - {{ end }}" 357 - > 358 - <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto"> 359 - {{- .Readme -}} 360 - </pre> 361 - {{- else -}} 362 - {{ .HTMLReadme }} 363 - {{- end -}}</article> 364 - </section> 365 {{- end -}} 366 - 367 - {{ template "repo/fragments/cloneInstructions" . }} 368 {{ end }}
··· 14 {{ end }} 15 <div class="flex items-center justify-between pb-5"> 16 {{ block "branchSelector" . }}{{ end }} 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 {{ i "git-commit-horizontal" "w-4" "h-4" }} {{ .TotalCommits }} 20 </a> 21 + <a href="/{{ .RepoInfo.FullName }}/branches" class="inline-flex items-center text-sm gap-1 font-bold"> 22 {{ i "git-branch" "w-4" "h-4" }} {{ len .Branches }} 23 </a> 24 + <a href="/{{ .RepoInfo.FullName }}/tags" class="inline-flex items-center text-sm gap-1 font-bold"> 25 {{ i "tags" "w-4" "h-4" }} {{ len .Tags }} 26 </a> 27 + {{ template "repo/fragments/cloneDropdown" . }} 28 </div> 29 </div> 30 <div class="grid grid-cols-1 md:grid-cols-2 gap-2"> ··· 48 49 50 {{ define "branchSelector" }} 51 + <div class="flex gap-2 items-center justify-between w-full"> 52 + <div class="flex gap-2 items-center"> 53 + <select 54 + onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)" 55 + class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700" 56 + > 57 + <optgroup label="branches ({{len .Branches}})" class="bold text-sm"> 58 + {{ range .Branches }} 59 + <option 60 + value="{{ .Reference.Name }}" 61 + class="py-1" 62 + {{ if eq .Reference.Name $.Ref }} 63 + selected 64 + {{ end }} 65 + > 66 + {{ .Reference.Name }} 67 + </option> 68 + {{ end }} 69 + </optgroup> 70 + <optgroup label="tags ({{len .Tags}})" class="bold text-sm"> 71 + {{ range .Tags }} 72 + <option 73 + value="{{ .Reference.Name }}" 74 + class="py-1" 75 + {{ if eq .Reference.Name $.Ref }} 76 + selected 77 + {{ end }} 78 + > 79 + {{ .Reference.Name }} 80 + </option> 81 + {{ else }} 82 + <option class="py-1" disabled>no tags found</option> 83 + {{ end }} 84 + </optgroup> 85 + </select> 86 + <div class="flex items-center gap-2"> 87 + <a 88 + href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}" 89 + class="btn flex items-center gap-2 no-underline hover:no-underline" 90 + title="Compare branches or tags" 91 + > 92 + {{ i "git-compare" "w-4 h-4" }} 93 + </a> 94 + </div> 95 + </div> 96 97 + <!-- Clone dropdown in top right --> 98 + <div class="hidden md:flex items-center "> 99 + {{ template "repo/fragments/cloneDropdown" . }} 100 </div> 101 + </div> 102 {{ end }} 103 104 {{ define "fileTree" }} ··· 106 {{ $linkstyle := "no-underline hover:underline dark:text-white" }} 107 108 {{ range .Files }} 109 + <div class="grid grid-cols-3 gap-4 items-center py-1"> 110 + <div class="col-span-2"> 111 {{ $link := printf "/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) .Name }} 112 {{ $icon := "folder" }} 113 {{ $iconStyle := "size-4 fill-current" }} ··· 125 </a> 126 </div> 127 128 + <div class="text-sm col-span-1 text-right"> 129 {{ with .LastCommit }} 130 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a> 131 {{ end }} ··· 323 324 {{ define "repoAfter" }} 325 {{- if or .HTMLReadme .Readme -}} 326 + <div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden"> 327 + {{- if .ReadmeFileName -}} 328 + <div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2"> 329 + {{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }} 330 + <span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span> 331 + </div> 332 + {{- end -}} 333 + <section 334 + class="p-6 overflow-auto {{ if not .Raw }} 335 + prose dark:prose-invert dark:[&_pre]:bg-gray-900 336 + dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 337 + dark:[&_pre]:border dark:[&_pre]:border-gray-700 338 + {{ end }}" 339 + > 340 + <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto"> 341 + {{- .Readme -}} 342 + </pre> 343 + {{- else -}} 344 + {{ .HTMLReadme }} 345 + {{- end -}}</article> 346 + </section> 347 + </div> 348 {{- end -}} 349 {{ end }}
+2 -2
appview/pages/templates/repo/issues/issue.html
··· 11 {{ define "repoContent" }} 12 <header class="pb-4"> 13 <h1 class="text-2xl"> 14 - {{ .Issue.Title }} 15 <span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span> 16 </h1> 17 </header> ··· 54 "Kind" $kind 55 "Count" (index $.Reactions $kind) 56 "IsReacted" (index $.UserReacted $kind) 57 - "ThreadAt" $.Issue.IssueAt) 58 }} 59 {{ end }} 60 </div>
··· 11 {{ define "repoContent" }} 12 <header class="pb-4"> 13 <h1 class="text-2xl"> 14 + {{ .Issue.Title | description }} 15 <span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span> 16 </h1> 17 </header> ··· 54 "Kind" $kind 55 "Count" (index $.Reactions $kind) 56 "IsReacted" (index $.UserReacted $kind) 57 + "ThreadAt" $.Issue.AtUri) 58 }} 59 {{ end }} 60 </div>
+1 -1
appview/pages/templates/repo/issues/issues.html
··· 45 href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 class="no-underline hover:underline" 47 > 48 - {{ .Title }} 49 <span class="text-gray-500">#{{ .IssueId }}</span> 50 </a> 51 </div>
··· 45 href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 class="no-underline hover:underline" 47 > 48 + {{ .Title | description }} 49 <span class="text-gray-500">#{{ .IssueId }}</span> 50 </a> 51 </div>
+1 -1
appview/pages/templates/repo/new.html
··· 63 <button type="submit" class="btn-create flex items-center gap-2"> 64 {{ i "book-plus" "w-4 h-4" }} 65 create repo 66 - <span id="create-pull-spinner" class="group"> 67 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 68 </span> 69 </button>
··· 63 <button type="submit" class="btn-create flex items-center gap-2"> 64 {{ i "book-plus" "w-4 h-4" }} 65 create repo 66 + <span id="spinner" class="group"> 67 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 68 </span> 69 </button>
+1 -1
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 1 {{ define "repo/pulls/fragments/pullHeader" }} 2 <header class="pb-4"> 3 <h1 class="text-2xl dark:text-white"> 4 - {{ .Pull.Title }} 5 <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span> 6 </h1> 7 </header>
··· 1 {{ define "repo/pulls/fragments/pullHeader" }} 2 <header class="pb-4"> 3 <h1 class="text-2xl dark:text-white"> 4 + {{ .Pull.Title | description }} 5 <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span> 6 </h1> 7 </header>
+1 -1
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
··· 9 </div> 10 <span class="truncate text-sm text-gray-800 dark:text-gray-200"> 11 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 12 - {{ .Title }} 13 </span> 14 </div> 15
··· 9 </div> 10 <span class="truncate text-sm text-gray-800 dark:text-gray-200"> 11 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 12 + {{ .Title | description }} 13 </span> 14 </div> 15
+1 -1
appview/pages/templates/repo/pulls/interdiff.html
··· 68 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 69 {{ template "repo/fragments/diffOpts" .DiffOpts }} 70 </div> 71 - <div class="sticky top-0 flex-grow max-h-screen"> 72 {{ template "repo/fragments/interdiffFiles" .Interdiff }} 73 </div> 74 {{end}}
··· 68 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 69 {{ template "repo/fragments/diffOpts" .DiffOpts }} 70 </div> 71 + <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 72 {{ template "repo/fragments/interdiffFiles" .Interdiff }} 73 </div> 74 {{end}}
+1 -1
appview/pages/templates/repo/pulls/patch.html
··· 73 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 74 {{ template "repo/fragments/diffOpts" .DiffOpts }} 75 </div> 76 - <div class="sticky top-0 flex-grow max-h-screen"> 77 {{ template "repo/fragments/diffChangedFiles" .Diff }} 78 </div> 79 {{end}}
··· 73 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 74 {{ template "repo/fragments/diffOpts" .DiffOpts }} 75 </div> 76 + <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 77 {{ template "repo/fragments/diffChangedFiles" .Diff }} 78 </div> 79 {{end}}
+1 -1
appview/pages/templates/repo/pulls/pull.html
··· 122 {{ end }} 123 </div> 124 <div class="flex items-center"> 125 - <span>{{ .Title }}</span> 126 {{ if gt (len .Body) 0 }} 127 <button 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"
··· 122 {{ end }} 123 </div> 124 <div class="flex items-center"> 125 + <span>{{ .Title | description }}</span> 126 {{ if gt (len .Body) 0 }} 127 <button 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"
+1 -1
appview/pages/templates/repo/pulls/pulls.html
··· 50 <div class="px-6 py-4 z-5"> 51 <div class="pb-2"> 52 <a href="/{{ $.RepoInfo.FullName }}/pulls/{{ .PullId }}" class="dark:text-white"> 53 - {{ .Title }} 54 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 55 </a> 56 </div>
··· 50 <div class="px-6 py-4 z-5"> 51 <div class="pb-2"> 52 <a href="/{{ $.RepoInfo.FullName }}/pulls/{{ .PullId }}" class="dark:text-white"> 53 + {{ .Title | description }} 54 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 55 </a> 56 </div>
+3 -1
appview/pages/templates/repo/settings/general.html
··· 8 <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 {{ template "branchSettings" . }} 10 {{ template "deleteRepo" . }} 11 </div> 12 </section> 13 {{ end }} ··· 22 unless you specify a different branch. 23 </p> 24 </div> 25 - <form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 26 <select id="branch" name="branch" required class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 27 <option value="" disabled selected > 28 Choose a default branch ··· 54 <button 55 class="btn group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center" 56 type="button" 57 hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 58 hx-confirm="Are you sure you want to delete {{ $.RepoInfo.FullName }}?"> 59 {{ i "trash-2" "size-4" }}
··· 8 <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 {{ template "branchSettings" . }} 10 {{ template "deleteRepo" . }} 11 + <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 12 </div> 13 </section> 14 {{ end }} ··· 23 unless you specify a different branch. 24 </p> 25 </div> 26 + <form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" hx-swap="none" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 27 <select id="branch" name="branch" required class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 28 <option value="" disabled selected > 29 Choose a default branch ··· 55 <button 56 class="btn group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center" 57 type="button" 58 + hx-swap="none" 59 hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 60 hx-confirm="Are you sure you want to delete {{ $.RepoInfo.FullName }}?"> 61 {{ i "trash-2" "size-4" }}
+2 -2
appview/pages/templates/repo/tree.html
··· 54 55 {{ range .Files }} 56 <div class="grid grid-cols-12 gap-4 items-center py-1"> 57 - <div class="col-span-6 md:col-span-4"> 58 {{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) $.TreePath .Name }} 59 {{ $icon := "folder" }} 60 {{ $iconStyle := "size-4 fill-current" }} ··· 77 {{ end }} 78 </div> 79 80 - <div class="col-span-6 md:col-span-2 text-right"> 81 {{ with .LastCommit }} 82 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a> 83 {{ end }}
··· 54 55 {{ range .Files }} 56 <div class="grid grid-cols-12 gap-4 items-center py-1"> 57 + <div class="col-span-8 md:col-span-4"> 58 {{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) $.TreePath .Name }} 59 {{ $icon := "folder" }} 60 {{ $iconStyle := "size-4 fill-current" }} ··· 77 {{ end }} 78 </div> 79 80 + <div class="col-span-4 md:col-span-2 text-sm text-right"> 81 {{ with .LastCommit }} 82 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a> 83 {{ end }}
-192
appview/pages/templates/settings.html
··· 1 - {{ define "title" }}settings{{ end }} 2 - 3 - {{ define "content" }} 4 - <div class="p-6"> 5 - <p class="text-xl font-bold dark:text-white">Settings</p> 6 - </div> 7 - <div class="flex flex-col"> 8 - {{ block "profile" . }} {{ end }} 9 - {{ block "keys" . }} {{ end }} 10 - {{ block "emails" . }} {{ end }} 11 - </div> 12 - {{ end }} 13 - 14 - {{ define "profile" }} 15 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">profile</h2> 16 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 17 - <dl class="grid grid-cols-[auto_1fr] gap-x-4 dark:text-gray-200"> 18 - {{ if .LoggedInUser.Handle }} 19 - <dt class="font-bold">handle</dt> 20 - <dd>@{{ .LoggedInUser.Handle }}</dd> 21 - {{ end }} 22 - <dt class="font-bold">did</dt> 23 - <dd>{{ .LoggedInUser.Did }}</dd> 24 - <dt class="font-bold">pds</dt> 25 - <dd>{{ .LoggedInUser.Pds }}</dd> 26 - </dl> 27 - </section> 28 - {{ end }} 29 - 30 - {{ define "keys" }} 31 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">ssh keys</h2> 32 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 33 - <p class="mb-8 dark:text-gray-300">SSH public keys added here will be broadcasted to knots that you are a member of, <br> allowing you to push to repositories there.</p> 34 - <div id="key-list" class="flex flex-col gap-6 mb-8"> 35 - {{ range $index, $key := .PubKeys }} 36 - <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 37 - <div class="flex flex-col gap-1"> 38 - <div class="inline-flex items-center gap-4"> 39 - {{ i "key" "w-3 h-3 dark:text-gray-300" }} 40 - <p class="font-bold dark:text-white">{{ .Name }}</p> 41 - </div> 42 - <p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .Created }}</p> 43 - <div class="overflow-x-auto whitespace-nowrap flex-1 max-w-full"> 44 - <code class="text-sm text-gray-500 dark:text-gray-400">{{ .Key }}</code> 45 - </div> 46 - </div> 47 - <button 48 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 49 - title="Delete key" 50 - hx-delete="/settings/keys?name={{urlquery .Name}}&rkey={{urlquery .Rkey}}&key={{urlquery .Key}}" 51 - hx-confirm="Are you sure you want to delete the key '{{ .Name }}'?" 52 - > 53 - {{ i "trash-2" "w-5 h-5" }} 54 - <span class="hidden md:inline">delete</span> 55 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 56 - </button> 57 - </div> 58 - {{ end }} 59 - </div> 60 - <form 61 - hx-put="/settings/keys" 62 - hx-indicator="#add-sshkey-spinner" 63 - hx-swap="none" 64 - class="max-w-2xl mb-8 space-y-4" 65 - > 66 - <input 67 - type="text" 68 - id="name" 69 - name="name" 70 - placeholder="key name" 71 - required 72 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/> 73 - 74 - <input 75 - id="key" 76 - name="key" 77 - placeholder="ssh-rsa AAAAAA..." 78 - required 79 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/> 80 - 81 - <button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center" type="submit"> 82 - <span>add key</span> 83 - <span id="add-sshkey-spinner" class="group"> 84 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 85 - </span> 86 - </button> 87 - 88 - <div id="settings-keys" class="error dark:text-red-400"></div> 89 - </form> 90 - </section> 91 - {{ end }} 92 - 93 - {{ define "emails" }} 94 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">email addresses</h2> 95 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 96 - <p class="mb-8 dark:text-gray-300">Commits authored using emails listed here will be associated with your Tangled profile.</p> 97 - <div id="email-list" class="flex flex-col gap-6 mb-8"> 98 - {{ range $index, $email := .Emails }} 99 - <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 100 - <div class="flex flex-col gap-2"> 101 - <div class="inline-flex items-center gap-4"> 102 - {{ i "mail" "w-3 h-3 dark:text-gray-300" }} 103 - <p class="font-bold dark:text-white">{{ .Address }}</p> 104 - <div class="inline-flex items-center gap-1"> 105 - {{ if .Verified }} 106 - <span class="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">verified</span> 107 - {{ else }} 108 - <span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">unverified</span> 109 - {{ end }} 110 - {{ if .Primary }} 111 - <span class="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded">primary</span> 112 - {{ end }} 113 - </div> 114 - </div> 115 - <p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .CreatedAt }}</p> 116 - </div> 117 - <div class="flex gap-2 items-center"> 118 - {{ if not .Verified }} 119 - <button 120 - class="btn flex gap-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" 121 - hx-post="/settings/emails/verify/resend" 122 - hx-swap="none" 123 - href="#" 124 - hx-vals='{"email": "{{ .Address }}"}'> 125 - {{ i "rotate-cw" "w-5 h-5" }} 126 - <span class="hidden md:inline">resend</span> 127 - </button> 128 - {{ end }} 129 - {{ if and (not .Primary) .Verified }} 130 - <a 131 - class="text-sm dark:text-blue-400 dark:hover:text-blue-300" 132 - hx-post="/settings/emails/primary" 133 - hx-swap="none" 134 - href="#" 135 - hx-vals='{"email": "{{ .Address }}"}'> 136 - set as primary 137 - </a> 138 - {{ end }} 139 - {{ if not .Primary }} 140 - <form 141 - hx-delete="/settings/emails" 142 - hx-confirm="Are you sure you wish to delete the email '{{ .Address }}'?" 143 - hx-indicator="#delete-email-{{ $index }}-spinner" 144 - > 145 - <input type="hidden" name="email" value="{{ .Address }}"> 146 - <button 147 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center" 148 - title="Delete email" 149 - type="submit" 150 - > 151 - {{ i "trash-2" "w-5 h-5" }} 152 - <span class="hidden md:inline">delete</span> 153 - <span id="delete-email-{{ $index }}-spinner" class="group"> 154 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 155 - </span> 156 - </button> 157 - </form> 158 - {{ end }} 159 - </div> 160 - </div> 161 - {{ end }} 162 - </div> 163 - <form 164 - hx-put="/settings/emails" 165 - hx-swap="none" 166 - class="max-w-2xl mb-8 space-y-4" 167 - hx-indicator="#add-email-spinner" 168 - > 169 - <input 170 - type="email" 171 - id="email" 172 - name="email" 173 - placeholder="your@email.com" 174 - required 175 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 176 - > 177 - 178 - <button 179 - class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center" 180 - type="submit" 181 - > 182 - <span>add email</span> 183 - <span id="add-email-spinner" class="group"> 184 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 185 - </span> 186 - </button> 187 - 188 - <div id="settings-emails-error" class="error dark:text-red-400"></div> 189 - <div id="settings-emails-success" class="success dark:text-green-400"></div> 190 - </form> 191 - </section> 192 - {{ end }}
···
+2 -2
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 14 id="add-member-{{ .Instance }}" 15 popover 16 class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 17 - {{ block "addMemberPopover" . }} {{ end }} 18 </div> 19 {{ end }} 20 21 - {{ define "addMemberPopover" }} 22 <form 23 hx-post="/spindles/{{ .Instance }}/add" 24 hx-indicator="#spinner"
··· 14 id="add-member-{{ .Instance }}" 15 popover 16 class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 17 + {{ block "addSpindleMemberPopover" . }} {{ end }} 18 </div> 19 {{ end }} 20 21 + {{ define "addSpindleMemberPopover" }} 22 <form 23 hx-post="/spindles/{{ .Instance }}/add" 24 hx-indicator="#spinner"
+11 -9
appview/pages/templates/spindles/fragments/spindleListing.html
··· 1 {{ define "spindles/fragments/spindleListing" }} 2 <div id="spindle-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 3 - {{ block "leftSide" . }} {{ end }} 4 - {{ block "rightSide" . }} {{ end }} 5 </div> 6 {{ end }} 7 8 - {{ define "leftSide" }} 9 {{ if .Verified }} 10 <a href="/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 {{ i "hard-drive" "w-4 h-4" }} 12 - {{ .Instance }} 13 <span class="text-gray-500"> 14 {{ template "repo/fragments/shortTimeAgo" .Created }} 15 </span> ··· 25 {{ end }} 26 {{ end }} 27 28 - {{ define "rightSide" }} 29 <div id="right-side" class="flex gap-2"> 30 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 31 {{ if .Verified }} ··· 33 {{ template "spindles/fragments/addMemberModal" . }} 34 {{ else }} 35 <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span> 36 - {{ block "retryButton" . }} {{ end }} 37 {{ end }} 38 - {{ block "deleteButton" . }} {{ end }} 39 </div> 40 {{ end }} 41 42 - {{ define "deleteButton" }} 43 <button 44 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 45 title="Delete spindle" ··· 55 {{ end }} 56 57 58 - {{ define "retryButton" }} 59 <button 60 class="btn gap-2 group" 61 title="Retry spindle verification"
··· 1 {{ define "spindles/fragments/spindleListing" }} 2 <div id="spindle-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 3 + {{ block "spindleLeftSide" . }} {{ end }} 4 + {{ block "spindleRightSide" . }} {{ end }} 5 </div> 6 {{ end }} 7 8 + {{ define "spindleLeftSide" }} 9 {{ if .Verified }} 10 <a href="/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 {{ i "hard-drive" "w-4 h-4" }} 12 + <span class="hover:underline"> 13 + {{ .Instance }} 14 + </span> 15 <span class="text-gray-500"> 16 {{ template "repo/fragments/shortTimeAgo" .Created }} 17 </span> ··· 27 {{ end }} 28 {{ end }} 29 30 + {{ define "spindleRightSide" }} 31 <div id="right-side" class="flex gap-2"> 32 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 33 {{ if .Verified }} ··· 35 {{ template "spindles/fragments/addMemberModal" . }} 36 {{ else }} 37 <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span> 38 + {{ block "spindleRetryButton" . }} {{ end }} 39 {{ end }} 40 + {{ block "spindleDeleteButton" . }} {{ end }} 41 </div> 42 {{ end }} 43 44 + {{ define "spindleDeleteButton" }} 45 <button 46 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 47 title="Delete spindle" ··· 57 {{ end }} 58 59 60 + {{ define "spindleRetryButton" }} 61 <button 62 class="btn gap-2 group" 63 title="Retry spindle verification"
+3 -2
appview/pages/templates/strings/fragments/form.html
··· 13 type="text" 14 id="filename" 15 name="filename" 16 - placeholder="Filename with extension" 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" ··· 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" 35 rows="20" 36 placeholder="Paste your string here!" 37 required>{{ .String.Contents }}</textarea> 38 <div class="flex justify-between items-center">
··· 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" ··· 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">
+2 -2
appview/pages/templates/strings/string.html
··· 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 gist `{{ .String.Filename }}`?" 39 > 40 {{ i "trash-2" "size-4" }} 41 <span class="hidden md:inline">delete</span> ··· 77 {{ end }} 78 </div> 79 </div> 80 - <div class="overflow-auto relative"> 81 {{ if .ShowRendered }} 82 <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 83 {{ else }}
··· 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> ··· 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 }}
+65
appview/pages/templates/strings/timeline.html
···
··· 1 + {{ define "title" }} all strings {{ end }} 2 + 3 + {{ define "topbar" }} 4 + {{ template "layouts/topbar" $ }} 5 + {{ end }} 6 + 7 + {{ define "content" }} 8 + {{ block "timeline" $ }}{{ end }} 9 + {{ end }} 10 + 11 + {{ define "timeline" }} 12 + <div> 13 + <div class="p-6"> 14 + <p class="text-xl font-bold dark:text-white">All strings</p> 15 + </div> 16 + 17 + <div class="flex flex-col gap-4"> 18 + {{ range $i, $s := .Strings }} 19 + <div class="relative"> 20 + {{ if ne $i 0 }} 21 + <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 22 + {{ end }} 23 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 24 + {{ template "stringCard" $s }} 25 + </div> 26 + </div> 27 + {{ end }} 28 + </div> 29 + </div> 30 + {{ end }} 31 + 32 + {{ define "stringCard" }} 33 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 34 + <div class="font-medium dark:text-white flex gap-2 items-center"> 35 + <a href="/strings/{{ resolve .Did.String }}/{{ .Rkey }}">{{ .Filename }}</a> 36 + </div> 37 + {{ with .Description }} 38 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 39 + {{ . }} 40 + </div> 41 + {{ end }} 42 + 43 + {{ template "stringCardInfo" . }} 44 + </div> 45 + {{ end }} 46 + 47 + {{ define "stringCardInfo" }} 48 + {{ $stat := .Stats }} 49 + {{ $resolved := resolve .Did.String }} 50 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto"> 51 + <a href="/strings/{{ $resolved }}" class="flex items-center"> 52 + {{ template "user/fragments/picHandle" $resolved }} 53 + </a> 54 + <span class="select-none [&:before]:content-['ยท']"></span> 55 + <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 56 + <span class="select-none [&:before]:content-['ยท']"></span> 57 + {{ with .Edited }} 58 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 59 + {{ else }} 60 + {{ template "repo/fragments/shortTimeAgo" .Created }} 61 + {{ end }} 62 + </div> 63 + {{ end }} 64 + 65 +
+183
appview/pages/templates/timeline/timeline.html
···
··· 1 + {{ define "title" }}timeline{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="timeline ยท tangled" /> 5 + <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.sh" /> 7 + <meta property="og:description" content="tightly-knit social coding" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + {{ if .LoggedInUser }} 12 + {{ else }} 13 + {{ block "hero" $ }}{{ end }} 14 + {{ end }} 15 + 16 + {{ block "trending" $ }}{{ end }} 17 + {{ block "timeline" $ }}{{ end }} 18 + {{ end }} 19 + 20 + {{ define "hero" }} 21 + <div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl"> 22 + <div class="font-bold text-4xl">tightly-knit<br>social coding.</div> 23 + 24 + <p class="text-lg"> 25 + tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>. 26 + </p> 27 + <p class="text-lg"> 28 + we envision a place where developers have complete ownership of their 29 + code, open source communities can freely self-govern and most 30 + importantly, coding can be social and fun again. 31 + </p> 32 + 33 + <div class="flex gap-6 items-center"> 34 + <a href="/signup" class="no-underline hover:no-underline "> 35 + <button class="btn-create flex gap-2 px-4 items-center"> 36 + join now {{ i "arrow-right" "size-4" }} 37 + </button> 38 + </a> 39 + </div> 40 + </div> 41 + {{ end }} 42 + 43 + {{ define "trending" }} 44 + <div class="w-full md:mx-0 py-4"> 45 + <div class="px-6 pb-4"> 46 + <h3 class="text-xl font-bold dark:text-white flex items-center gap-2"> 47 + Trending 48 + {{ i "trending-up" "size-4 flex-shrink-0" }} 49 + </h3> 50 + </div> 51 + <div class="flex gap-4 overflow-x-auto scrollbar-hide items-stretch"> 52 + {{ range $index, $repo := .Repos }} 53 + <div class="flex-none h-full border border-gray-200 dark:border-gray-700 rounded-sm w-96"> 54 + {{ template "user/fragments/repoCard" (list $ $repo true) }} 55 + </div> 56 + {{ else }} 57 + <div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm"> 58 + <div class="text-sm text-gray-500 dark:text-gray-400 text-center"> 59 + No trending repositories this week 60 + </div> 61 + </div> 62 + {{ end }} 63 + </div> 64 + </div> 65 + {{ end }} 66 + 67 + {{ define "timeline" }} 68 + <div class="py-4"> 69 + <div class="px-6 pb-4"> 70 + <p class="text-xl font-bold dark:text-white">Timeline</p> 71 + </div> 72 + 73 + <div class="flex flex-col gap-4"> 74 + {{ range $i, $e := .Timeline }} 75 + <div class="relative"> 76 + {{ if ne $i 0 }} 77 + <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 78 + {{ end }} 79 + {{ with $e }} 80 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 81 + {{ if .Repo }} 82 + {{ block "repoEvent" (list $ .Repo .Source) }} {{ end }} 83 + {{ else if .Star }} 84 + {{ block "starEvent" (list $ .Star) }} {{ end }} 85 + {{ else if .Follow }} 86 + {{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} {{ end }} 87 + {{ end }} 88 + </div> 89 + {{ end }} 90 + </div> 91 + {{ end }} 92 + </div> 93 + </div> 94 + {{ end }} 95 + 96 + {{ define "repoEvent" }} 97 + {{ $root := index . 0 }} 98 + {{ $repo := index . 1 }} 99 + {{ $source := index . 2 }} 100 + {{ $userHandle := resolve $repo.Did }} 101 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 102 + {{ template "user/fragments/picHandleLink" $repo.Did }} 103 + {{ with $source }} 104 + {{ $sourceDid := resolve .Did }} 105 + forked 106 + <a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline"> 107 + {{ $sourceDid }}/{{ .Name }} 108 + </a> 109 + to 110 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 111 + {{ else }} 112 + created 113 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline"> 114 + {{ $repo.Name }} 115 + </a> 116 + {{ end }} 117 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 118 + </div> 119 + {{ with $repo }} 120 + {{ template "user/fragments/repoCard" (list $root . true) }} 121 + {{ end }} 122 + {{ end }} 123 + 124 + {{ define "starEvent" }} 125 + {{ $root := index . 0 }} 126 + {{ $star := index . 1 }} 127 + {{ with $star }} 128 + {{ $starrerHandle := resolve .StarredByDid }} 129 + {{ $repoOwnerHandle := resolve .Repo.Did }} 130 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 131 + {{ template "user/fragments/picHandleLink" $starrerHandle }} 132 + starred 133 + <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 134 + {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 135 + </a> 136 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 137 + </div> 138 + {{ with .Repo }} 139 + {{ template "user/fragments/repoCard" (list $root . true) }} 140 + {{ end }} 141 + {{ end }} 142 + {{ end }} 143 + 144 + 145 + {{ define "followEvent" }} 146 + {{ $root := index . 0 }} 147 + {{ $follow := index . 1 }} 148 + {{ $profile := index . 2 }} 149 + {{ $stat := index . 3 }} 150 + 151 + {{ $userHandle := resolve $follow.UserDid }} 152 + {{ $subjectHandle := resolve $follow.SubjectDid }} 153 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 154 + {{ template "user/fragments/picHandleLink" $userHandle }} 155 + followed 156 + {{ template "user/fragments/picHandleLink" $subjectHandle }} 157 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 158 + </div> 159 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 160 + <div class="flex-shrink-0 max-h-full w-24 h-24"> 161 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 162 + </div> 163 + 164 + <div class="flex-1 min-h-0 justify-around flex flex-col"> 165 + <a href="/{{ $subjectHandle }}"> 166 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 167 + </a> 168 + {{ with $profile }} 169 + {{ with .Description }} 170 + <p class="text-sm pb-2 md:pb-2">{{.}}</p> 171 + {{ end }} 172 + {{ end }} 173 + {{ with $stat }} 174 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 175 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 176 + <span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span> 177 + <span class="select-none after:content-['ยท']"></span> 178 + <span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span> 179 + </div> 180 + {{ end }} 181 + </div> 182 + </div> 183 + {{ end }}
-162
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="/signup" class="no-underline hover:no-underline "> 38 - <button class="btn-create 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 := resolve $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" $repo.Did }} 82 - {{ with $source }} 83 - {{ $sourceDid := resolve .Did }} 84 - forked 85 - <a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline"> 86 - {{ $sourceDid }}/{{ .Name }} 87 - </a> 88 - to 89 - <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 90 - {{ else }} 91 - created 92 - <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline"> 93 - {{ $repo.Name }} 94 - </a> 95 - {{ end }} 96 - <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 97 - </div> 98 - {{ with $repo }} 99 - {{ template "user/fragments/repoCard" (list $root . true) }} 100 - {{ end }} 101 - {{ end }} 102 - 103 - {{ define "starEvent" }} 104 - {{ $root := index . 0 }} 105 - {{ $star := index . 1 }} 106 - {{ with $star }} 107 - {{ $starrerHandle := resolve .StarredByDid }} 108 - {{ $repoOwnerHandle := resolve .Repo.Did }} 109 - <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"> 110 - {{ template "user/fragments/picHandleLink" $starrerHandle }} 111 - starred 112 - <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 113 - {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 114 - </a> 115 - <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 116 - </div> 117 - {{ with .Repo }} 118 - {{ template "user/fragments/repoCard" (list $root . true) }} 119 - {{ end }} 120 - {{ end }} 121 - {{ end }} 122 - 123 - 124 - {{ define "followEvent" }} 125 - {{ $root := index . 0 }} 126 - {{ $follow := index . 1 }} 127 - {{ $profile := index . 2 }} 128 - {{ $stat := index . 3 }} 129 - 130 - {{ $userHandle := resolve $follow.UserDid }} 131 - {{ $subjectHandle := resolve $follow.SubjectDid }} 132 - <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"> 133 - {{ template "user/fragments/picHandleLink" $userHandle }} 134 - followed 135 - {{ template "user/fragments/picHandleLink" $subjectHandle }} 136 - <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 137 - </div> 138 - <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 139 - <div class="flex-shrink-0 max-h-full w-24 h-24"> 140 - <img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 141 - </div> 142 - 143 - <div class="flex-1 min-h-0 justify-around flex flex-col"> 144 - <a href="/{{ $subjectHandle }}"> 145 - <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 146 - </a> 147 - {{ with $profile }} 148 - {{ with .Description }} 149 - <p class="text-sm pb-2 md:pb-2">{{.}}</p> 150 - {{ end }} 151 - {{ end }} 152 - {{ with $stat }} 153 - <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 154 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 155 - <span id="followers">{{ .Followers }} followers</span> 156 - <span class="select-none after:content-['ยท']"></span> 157 - <span id="following">{{ .Following }} following</span> 158 - </div> 159 - {{ end }} 160 - </div> 161 - </div> 162 - {{ end }}
···
+30
appview/pages/templates/user/followers.html
···
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท followers {{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s followers" /> 5 + <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=followers" /> 7 + <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 + <div class="md:col-span-3 order-1 md:order-1"> 13 + {{ template "user/fragments/profileCard" .Card }} 14 + </div> 15 + <div id="all-followers" class="md:col-span-8 order-2 md:order-2"> 16 + {{ block "followers" . }}{{ end }} 17 + </div> 18 + </div> 19 + {{ end }} 20 + 21 + {{ define "followers" }} 22 + <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p> 23 + <div id="followers" class="grid grid-cols-1 gap-4 mb-6"> 24 + {{ range .Followers }} 25 + {{ template "user/fragments/followCard" . }} 26 + {{ else }} 27 + <p class="px-6 dark:text-white">This user does not have any followers yet.</p> 28 + {{ end }} 29 + </div> 30 + {{ end }}
+30
appview/pages/templates/user/following.html
···
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท following {{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s following" /> 5 + <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=following" /> 7 + <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 + <div class="md:col-span-3 order-1 md:order-1"> 13 + {{ template "user/fragments/profileCard" .Card }} 14 + </div> 15 + <div id="all-following" class="md:col-span-8 order-2 md:order-2"> 16 + {{ block "following" . }}{{ end }} 17 + </div> 18 + </div> 19 + {{ end }} 20 + 21 + {{ define "following" }} 22 + <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p> 23 + <div id="following" class="grid grid-cols-1 gap-4 mb-6"> 24 + {{ range .Following }} 25 + {{ template "user/fragments/followCard" . }} 26 + {{ else }} 27 + <p class="px-6 dark:text-white">This user does not follow anyone yet.</p> 28 + {{ end }} 29 + </div> 30 + {{ end }}
+2 -2
appview/pages/templates/user/fragments/follow.html
··· 1 {{ define "user/fragments/follow" }} 2 - <button id="followBtn" 3 class="btn mt-2 w-full flex gap-2 items-center group" 4 5 {{ if eq .FollowStatus.String "IsNotFollowing" }} ··· 9 {{ end }} 10 11 hx-trigger="click" 12 - hx-target="#followBtn" 13 hx-swap="outerHTML" 14 > 15 {{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }}
··· 1 {{ define "user/fragments/follow" }} 2 + <button id="{{ normalizeForHtmlId .UserDid }}" 3 class="btn mt-2 w-full flex gap-2 items-center group" 4 5 {{ if eq .FollowStatus.String "IsNotFollowing" }} ··· 9 {{ end }} 10 11 hx-trigger="click" 12 + hx-target="#{{ normalizeForHtmlId .UserDid }}" 13 hx-swap="outerHTML" 14 > 15 {{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }}
+29
appview/pages/templates/user/fragments/followCard.html
···
··· 1 + {{ define "user/fragments/followCard" }} 2 + {{ $userIdent := resolve .UserDid }} 3 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 4 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 + <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" /> 7 + </div> 8 + 9 + <div class="flex-1 min-h-0 justify-around flex flex-col"> 10 + <a href="/{{ $userIdent }}"> 11 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 12 + </a> 13 + <p class="text-sm pb-2 md:pb-2">{{.Profile.Description}}</p> 14 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 15 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 16 + <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 17 + <span class="select-none after:content-['ยท']"></span> 18 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 19 + </div> 20 + </div> 21 + 22 + {{ if ne .FollowStatus.String "IsSelf" }} 23 + <div class="max-w-24"> 24 + {{ template "user/fragments/follow" . }} 25 + </div> 26 + {{ end }} 27 + </div> 28 + </div> 29 + {{ end }}
+17 -14
appview/pages/templates/user/fragments/profileCard.html
··· 1 {{ define "user/fragments/profileCard" }} 2 <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit"> 3 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 <div id="avatar" class="col-span-1 flex justify-center items-center"> ··· 8 </div> 9 <div class="col-span-2"> 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> 17 18 <div class="md:hidden"> 19 - {{ block "followerFollowing" (list .Followers .Following) }} {{ end }} 20 </div> 21 </div> 22 <div class="col-span-3 md:col-span-full"> ··· 29 {{ end }} 30 31 <div class="hidden md:block"> 32 - {{ block "followerFollowing" (list $.Followers $.Following) }} {{ end }} 33 </div> 34 35 <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> ··· 42 {{ if .IncludeBluesky }} 43 <div class="flex items-center gap-2"> 44 <span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" }}</span> 45 - <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ didOrHandle $.UserDid $.UserHandle }}</a> 46 </div> 47 {{ end }} 48 {{ range $link := .Links }} ··· 88 {{ end }} 89 90 {{ define "followerFollowing" }} 91 - {{ $followers := index . 0 }} 92 - {{ $following := index . 1 }} 93 - <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 94 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 95 - <span id="followers">{{ $followers }} followers</span> 96 - <span class="select-none after:content-['ยท']"></span> 97 - <span id="following">{{ $following }} following</span> 98 - </div> 99 {{ end }} 100
··· 1 {{ define "user/fragments/profileCard" }} 2 + {{ $userIdent := didOrHandle .UserDid .UserHandle }} 3 <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit"> 4 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 5 <div id="avatar" class="col-span-1 flex justify-center items-center"> ··· 9 </div> 10 <div class="col-span-2"> 11 <div class="flex items-center flex-row flex-nowrap gap-2"> 12 + <p title="{{ $userIdent }}" 13 class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 14 + {{ $userIdent }} 15 </p> 16 + <a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a> 17 </div> 18 19 <div class="md:hidden"> 20 + {{ block "followerFollowing" (list . $userIdent) }} {{ end }} 21 </div> 22 </div> 23 <div class="col-span-3 md:col-span-full"> ··· 30 {{ end }} 31 32 <div class="hidden md:block"> 33 + {{ block "followerFollowing" (list $ $userIdent) }} {{ end }} 34 </div> 35 36 <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> ··· 43 {{ if .IncludeBluesky }} 44 <div class="flex items-center gap-2"> 45 <span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" }}</span> 46 + <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ $userIdent }}</a> 47 </div> 48 {{ end }} 49 {{ range $link := .Links }} ··· 89 {{ end }} 90 91 {{ define "followerFollowing" }} 92 + {{ $root := index . 0 }} 93 + {{ $userIdent := index . 1 }} 94 + {{ with $root }} 95 + <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 96 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 97 + <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 98 + <span class="select-none after:content-['ยท']"></span> 99 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 100 + </div> 101 + {{ end }} 102 {{ end }} 103
+5 -5
appview/pages/templates/user/fragments/repoCard.html
··· 4 {{ $fullName := index . 2 }} 5 6 {{ with $repo }} 7 - <div class="py-4 px-6 gap-2 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800"> 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" }} ··· 14 15 {{ $repoOwner := resolve .Did }} 16 {{- if $fullName -}} 17 - <a href="/{{ $repoOwner }}/{{ .Name }}">{{ $repoOwner }}/{{ .Name }}</a> 18 {{- else -}} 19 - <a href="/{{ $repoOwner }}/{{ .Name }}">{{ .Name }}</a> 20 {{- end -}} 21 </div> 22 {{ with .Description }} 23 - <div class="text-gray-600 dark:text-gray-300 text-sm"> 24 - {{ . }} 25 </div> 26 {{ end }} 27
··· 4 {{ $fullName := index . 2 }} 5 6 {{ with $repo }} 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" }} ··· 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 }} 25 </div> 26 {{ end }} 27
+1 -1
appview/pages/templates/user/repos.html
··· 3 {{ define "extrameta" }} 4 <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s repos" /> 5 <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}/repos" /> 7 <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 {{ end }} 9
··· 3 {{ define "extrameta" }} 4 <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s repos" /> 5 <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=repos" /> 7 <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 {{ end }} 9
+94
appview/pages/templates/user/settings/emails.html
···
··· 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "emailSettings" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "emailSettings" }} 20 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 + <div class="col-span-1 md:col-span-2"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">Email Addresses</h2> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + Commits authored using emails listed here will be associated with your Tangled profile. 25 + </p> 26 + </div> 27 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 28 + {{ template "addEmailButton" . }} 29 + </div> 30 + </div> 31 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 32 + {{ range .Emails }} 33 + {{ template "user/settings/fragments/emailListing" (list $ .) }} 34 + {{ else }} 35 + <div class="flex items-center justify-center p-2 text-gray-500"> 36 + no emails added yet 37 + </div> 38 + {{ end }} 39 + </div> 40 + {{ end }} 41 + 42 + {{ define "addEmailButton" }} 43 + <button 44 + class="btn flex items-center gap-2" 45 + popovertarget="add-email-modal" 46 + popovertargetaction="toggle"> 47 + {{ i "plus" "size-4" }} 48 + add email 49 + </button> 50 + <div 51 + id="add-email-modal" 52 + popover 53 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 54 + {{ template "addEmailModal" . }} 55 + </div> 56 + {{ end}} 57 + 58 + {{ define "addEmailModal" }} 59 + <form 60 + hx-put="/settings/emails" 61 + hx-indicator="#spinner" 62 + hx-swap="none" 63 + class="flex flex-col gap-2" 64 + > 65 + <p class="uppercase p-0">ADD EMAIL</p> 66 + <p class="text-sm text-gray-500 dark:text-gray-400">Commits using this email will be associated with your profile.</p> 67 + <input 68 + type="email" 69 + id="email-address" 70 + name="email" 71 + required 72 + placeholder="your@email.com" 73 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 74 + /> 75 + <div class="flex gap-2 pt-2"> 76 + <button 77 + type="button" 78 + popovertarget="add-email-modal" 79 + popovertargetaction="hide" 80 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 81 + > 82 + {{ i "x" "size-4" }} cancel 83 + </button> 84 + <button type="submit" class="btn w-1/2 flex items-center"> 85 + <span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span> 86 + <span id="spinner" class="group"> 87 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 88 + </span> 89 + </button> 90 + </div> 91 + <div id="settings-emails-error" class="text-red-500 dark:text-red-400"></div> 92 + <div id="settings-emails-success" class="text-green-500 dark:text-green-400"></div> 93 + </form> 94 + {{ end }}
+62
appview/pages/templates/user/settings/fragments/emailListing.html
···
··· 1 + {{ define "user/settings/fragments/emailListing" }} 2 + {{ $root := index . 0 }} 3 + {{ $email := index . 1 }} 4 + <div id="email-{{$email.Address}}" class="flex items-center justify-between p-2"> 5 + <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 6 + <div class="flex items-center gap-2"> 7 + {{ i "mail" "w-4 h-4 text-gray-500 dark:text-gray-400" }} 8 + <span class="font-bold"> 9 + {{ $email.Address }} 10 + </span> 11 + <div class="inline-flex items-center gap-1"> 12 + {{ if $email.Verified }} 13 + <span class="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">verified</span> 14 + {{ else }} 15 + <span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">unverified</span> 16 + {{ end }} 17 + {{ if $email.Primary }} 18 + <span class="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded">primary</span> 19 + {{ end }} 20 + </div> 21 + </div> 22 + <div class="flex text-sm flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 23 + <span>added {{ template "repo/fragments/time" $email.CreatedAt }}</span> 24 + </div> 25 + </div> 26 + <div class="flex gap-2 items-center"> 27 + {{ if not $email.Verified }} 28 + <button 29 + class="btn flex gap-2 text-sm px-2 py-1" 30 + hx-post="/settings/emails/verify/resend" 31 + hx-swap="none" 32 + hx-vals='{"email": "{{ $email.Address }}"}'> 33 + {{ i "rotate-cw" "w-4 h-4" }} 34 + <span class="hidden md:inline">resend</span> 35 + </button> 36 + {{ end }} 37 + {{ if and (not $email.Primary) $email.Verified }} 38 + <button 39 + class="btn text-sm px-2 py-1 text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" 40 + hx-post="/settings/emails/primary" 41 + hx-swap="none" 42 + hx-vals='{"email": "{{ $email.Address }}"}'> 43 + set as primary 44 + </button> 45 + {{ end }} 46 + {{ if not $email.Primary }} 47 + <button 48 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 49 + title="Delete email" 50 + hx-delete="/settings/emails" 51 + hx-swap="none" 52 + hx-vals='{"email": "{{ $email.Address }}"}' 53 + hx-confirm="Are you sure you want to delete the email {{ $email.Address }}?" 54 + > 55 + {{ i "trash-2" "w-5 h-5" }} 56 + <span class="hidden md:inline">delete</span> 57 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 58 + </button> 59 + {{ end }} 60 + </div> 61 + </div> 62 + {{ end }}
+31
appview/pages/templates/user/settings/fragments/keyListing.html
···
··· 1 + {{ define "user/settings/fragments/keyListing" }} 2 + {{ $root := index . 0 }} 3 + {{ $key := index . 1 }} 4 + <div id="key-{{$key.Name}}" class="flex items-center justify-between p-2"> 5 + <div class="hover:no-underline flex flex-col gap-1 text min-w-0 max-w-[80%]"> 6 + <div class="flex items-center gap-2"> 7 + <span>{{ i "key" "w-4" "h-4" }}</span> 8 + <span class="font-bold"> 9 + {{ $key.Name }} 10 + </span> 11 + </div> 12 + <span class="font-mono text-sm text-gray-500 dark:text-gray-400"> 13 + {{ sshFingerprint $key.Key }} 14 + </span> 15 + <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 16 + <span>added {{ template "repo/fragments/time" $key.Created }}</span> 17 + </div> 18 + </div> 19 + <button 20 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 21 + title="Delete key" 22 + hx-delete="/settings/keys?name={{urlquery $key.Name}}&rkey={{urlquery $key.Rkey}}&key={{urlquery $key.Key}}" 23 + hx-swap="none" 24 + hx-confirm="Are you sure you want to delete the key {{ $key.Name }}?" 25 + > 26 + {{ i "trash-2" "w-5 h-5" }} 27 + <span class="hidden md:inline">delete</span> 28 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 29 + </button> 30 + </div> 31 + {{ end }}
+16
appview/pages/templates/user/settings/fragments/sidebar.html
···
··· 1 + {{ define "user/settings/fragments/sidebar" }} 2 + {{ $active := .Tab }} 3 + {{ $tabs := .Tabs }} 4 + <div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 shadow-inner"> 5 + {{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }} 6 + {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }} 7 + {{ range $tabs }} 8 + <a href="/settings/{{.Name}}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 9 + <div class="flex gap-3 items-center p-2 {{ if eq .Name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 10 + {{ i .Icon "size-4" }} 11 + {{ .Name }} 12 + </div> 13 + </a> 14 + {{ end }} 15 + </div> 16 + {{ end }}
+101
appview/pages/templates/user/settings/keys.html
···
··· 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "sshKeysSettings" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "sshKeysSettings" }} 20 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 + <div class="col-span-1 md:col-span-2"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">SSH Keys</h2> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + SSH public keys added here will be broadcasted to knots that you are a member of, 25 + allowing you to push to repositories there. 26 + </p> 27 + </div> 28 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 29 + {{ template "addKeyButton" . }} 30 + </div> 31 + </div> 32 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 33 + {{ range .PubKeys }} 34 + {{ template "user/settings/fragments/keyListing" (list $ .) }} 35 + {{ else }} 36 + <div class="flex items-center justify-center p-2 text-gray-500"> 37 + no keys added yet 38 + </div> 39 + {{ end }} 40 + </div> 41 + {{ end }} 42 + 43 + {{ define "addKeyButton" }} 44 + <button 45 + class="btn flex items-center gap-2" 46 + popovertarget="add-key-modal" 47 + popovertargetaction="toggle"> 48 + {{ i "plus" "size-4" }} 49 + add key 50 + </button> 51 + <div 52 + id="add-key-modal" 53 + popover 54 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 55 + {{ template "addKeyModal" . }} 56 + </div> 57 + {{ end}} 58 + 59 + {{ define "addKeyModal" }} 60 + <form 61 + hx-put="/settings/keys" 62 + hx-indicator="#spinner" 63 + hx-swap="none" 64 + class="flex flex-col gap-2" 65 + > 66 + <p class="uppercase p-0">ADD SSH KEY</p> 67 + <p class="text-sm text-gray-500 dark:text-gray-400">SSH keys allow you to push to repositories in knots you're a member of.</p> 68 + <input 69 + type="text" 70 + id="key-name" 71 + name="name" 72 + required 73 + placeholder="key name" 74 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 75 + /> 76 + <textarea 77 + type="text" 78 + id="key-value" 79 + name="key" 80 + required 81 + placeholder="ssh-rsa AAAAB3NzaC1yc2E..." 82 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"></textarea> 83 + <div class="flex gap-2 pt-2"> 84 + <button 85 + type="button" 86 + popovertarget="add-key-modal" 87 + popovertargetaction="hide" 88 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 89 + > 90 + {{ i "x" "size-4" }} cancel 91 + </button> 92 + <button type="submit" class="btn w-1/2 flex items-center"> 93 + <span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span> 94 + <span id="spinner" class="group"> 95 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 96 + </span> 97 + </button> 98 + </div> 99 + <div id="settings-keys" class="text-red-500 dark:text-red-400"></div> 100 + </form> 101 + {{ end }}
+64
appview/pages/templates/user/settings/profile.html
···
··· 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "profileInfo" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "profileInfo" }} 20 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 + <div class="col-span-1 md:col-span-2"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">Profile</h2> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + Your account information from your AT Protocol identity. 25 + </p> 26 + </div> 27 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 28 + </div> 29 + </div> 30 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 31 + <div class="flex items-center justify-between p-4"> 32 + <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 33 + <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 34 + <span>Handle</span> 35 + </div> 36 + {{ if .LoggedInUser.Handle }} 37 + <span class="font-bold"> 38 + @{{ .LoggedInUser.Handle }} 39 + </span> 40 + {{ end }} 41 + </div> 42 + </div> 43 + <div class="flex items-center justify-between p-4"> 44 + <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 45 + <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 46 + <span>Decentralized Identifier (DID)</span> 47 + </div> 48 + <span class="font-mono font-bold"> 49 + {{ .LoggedInUser.Did }} 50 + </span> 51 + </div> 52 + </div> 53 + <div class="flex items-center justify-between p-4"> 54 + <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 55 + <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 56 + <span>Personal Data Server (PDS)</span> 57 + </div> 58 + <span class="font-bold"> 59 + {{ .LoggedInUser.Pds }} 60 + </span> 61 + </div> 62 + </div> 63 + </div> 64 + {{ end }}
+141 -119
appview/pulls/pulls.go
··· 2 3 import ( 4 "database/sql" 5 - "encoding/json" 6 "errors" 7 "fmt" 8 - "io" 9 "log" 10 "net/http" 11 "sort" ··· 19 "tangled.sh/tangled.sh/core/appview/notify" 20 "tangled.sh/tangled.sh/core/appview/oauth" 21 "tangled.sh/tangled.sh/core/appview/pages" 22 "tangled.sh/tangled.sh/core/appview/reporesolver" 23 "tangled.sh/tangled.sh/core/idresolver" 24 "tangled.sh/tangled.sh/core/knotclient" 25 "tangled.sh/tangled.sh/core/patchutil" ··· 28 29 "github.com/bluekeyes/go-gitdiff/gitdiff" 30 comatproto "github.com/bluesky-social/indigo/api/atproto" 31 - "github.com/bluesky-social/indigo/atproto/syntax" 32 lexutil "github.com/bluesky-social/indigo/lex/util" 33 "github.com/go-chi/chi/v5" 34 "github.com/google/uuid" 35 ) ··· 96 return 97 } 98 99 - mergeCheckResponse := s.mergeCheck(f, pull, stack) 100 resubmitResult := pages.Unknown 101 if user.Did == pull.OwnerDid { 102 resubmitResult = s.resubmitCheck(f, pull, stack) ··· 151 } 152 } 153 154 - mergeCheckResponse := s.mergeCheck(f, pull, stack) 155 resubmitResult := pages.Unknown 156 if user != nil && user.Did == pull.OwnerDid { 157 resubmitResult = s.resubmitCheck(f, pull, stack) ··· 215 }) 216 } 217 218 - func (s *Pulls) mergeCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse { 219 if pull.State == db.PullMerged { 220 return types.MergeCheckResponse{} 221 } 222 223 - secret, err := db.GetRegistrationKey(s.db, f.Knot) 224 - if err != nil { 225 - log.Printf("failed to get registration key: %v", err) 226 - return types.MergeCheckResponse{ 227 - Error: "failed to check merge status: this knot is unregistered", 228 - } 229 } 230 231 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 232 - if err != nil { 233 - log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err) 234 - return types.MergeCheckResponse{ 235 - Error: "failed to check merge status", 236 - } 237 } 238 239 patch := pull.LatestPatch() ··· 246 patch = mergeable.CombinedPatch() 247 } 248 249 - resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch) 250 - if err != nil { 251 - log.Println("failed to check for mergeability:", err) 252 return types.MergeCheckResponse{ 253 - Error: "failed to check merge status", 254 } 255 } 256 - switch resp.StatusCode { 257 - case 404: 258 - return types.MergeCheckResponse{ 259 - Error: "failed to check merge status: this knot does not support PRs", 260 - } 261 - case 400: 262 - return types.MergeCheckResponse{ 263 - Error: "failed to check merge status: does this knot support PRs?", 264 } 265 } 266 267 - respBody, err := io.ReadAll(resp.Body) 268 - if err != nil { 269 - log.Println("failed to read merge check response body") 270 - return types.MergeCheckResponse{ 271 - Error: "failed to check merge status: knot is not speaking the right language", 272 - } 273 } 274 - defer resp.Body.Close() 275 276 - var mergeCheckResponse types.MergeCheckResponse 277 - err = json.Unmarshal(respBody, &mergeCheckResponse) 278 - if err != nil { 279 - log.Println("failed to unmarshal merge check response", err) 280 - return types.MergeCheckResponse{ 281 - Error: "failed to check merge status: knot is not speaking the right language", 282 - } 283 } 284 285 - return mergeCheckResponse 286 } 287 288 func (s *Pulls) resubmitCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult { ··· 307 // pulls within the same repo 308 knot = f.Knot 309 ownerDid = f.OwnerDid() 310 - repoName = f.RepoName 311 } 312 313 us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) ··· 483 484 pulls, err := db.GetPulls( 485 s.db, 486 - db.FilterEq("repo_at", f.RepoAt), 487 db.FilterEq("state", state), 488 ) 489 if err != nil { ··· 610 createdAt := time.Now().Format(time.RFC3339) 611 ownerDid := user.Did 612 613 - pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId) 614 if err != nil { 615 log.Println("failed to get pull at", err) 616 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 617 return 618 } 619 620 - atUri := f.RepoAt.String() 621 client, err := s.oauth.AuthorizedClient(r) 622 if err != nil { 623 log.Println("failed to get authorized client", err) ··· 646 647 comment := &db.PullComment{ 648 OwnerDid: user.Did, 649 - RepoAt: f.RepoAt.String(), 650 PullId: pull.PullId, 651 Body: body, 652 CommentAt: atResp.Uri, ··· 692 return 693 } 694 695 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 696 if err != nil { 697 log.Println("failed to fetch branches", err) 698 return ··· 738 if isPatchBased && !patchutil.IsFormatPatch(patch) { 739 if title == "" { 740 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 741 return 742 } 743 } ··· 816 return 817 } 818 819 - comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch) 820 if err != nil { 821 log.Println("failed to compare", err) 822 s.pages.Notice(w, "pull", err.Error()) ··· 862 return 863 } 864 865 - secret, err := db.GetRegistrationKey(s.db, fork.Knot) 866 - if err != nil { 867 - log.Println("failed to fetch registration key:", err) 868 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 869 - return 870 - } 871 - 872 - sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev) 873 if err != nil { 874 - log.Println("failed to create signed client:", err) 875 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 876 return 877 } ··· 883 return 884 } 885 886 - resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch) 887 - if err != nil { 888 - log.Println("failed to create hidden ref:", err, resp.StatusCode) 889 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 890 return 891 } 892 893 - switch resp.StatusCode { 894 - case 404: 895 - case 400: 896 - s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 897 return 898 } 899 ··· 918 return 919 } 920 921 - forkAtUri, err := syntax.ParseATURI(fork.AtUri) 922 - if err != nil { 923 - log.Println("failed to parse fork AT URI", err) 924 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 925 - return 926 - } 927 928 pullSource := &db.PullSource{ 929 Branch: sourceBranch, ··· 931 } 932 recordPullSource := &tangled.RepoPull_Source{ 933 Branch: sourceBranch, 934 - Repo: &fork.AtUri, 935 Sha: sourceRev, 936 } 937 ··· 1007 Body: body, 1008 TargetBranch: targetBranch, 1009 OwnerDid: user.Did, 1010 - RepoAt: f.RepoAt, 1011 Rkey: rkey, 1012 Submissions: []*db.PullSubmission{ 1013 &initialSubmission, ··· 1020 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1021 return 1022 } 1023 - pullId, err := db.NextPullId(tx, f.RepoAt) 1024 if err != nil { 1025 log.Println("failed to get pull id", err) 1026 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 1035 Val: &tangled.RepoPull{ 1036 Title: title, 1037 PullId: int64(pullId), 1038 - TargetRepo: string(f.RepoAt), 1039 TargetBranch: targetBranch, 1040 Patch: patch, 1041 Source: recordPullSource, ··· 1213 return 1214 } 1215 1216 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1217 if err != nil { 1218 log.Println("failed to reach knotserver", err) 1219 return ··· 1297 return 1298 } 1299 1300 - targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName) 1301 if err != nil { 1302 log.Println("failed to reach knotserver for target branches", err) 1303 return ··· 1413 return 1414 } 1415 1416 - comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch) 1417 if err != nil { 1418 log.Printf("compare request failed: %s", err) 1419 s.pages.Notice(w, "resubmit-error", err.Error()) ··· 1463 return 1464 } 1465 1466 - secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot) 1467 if err != nil { 1468 - log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err) 1469 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1470 return 1471 } 1472 1473 - // update the hidden tracking branch to latest 1474 - signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev) 1475 - if err != nil { 1476 - log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err) 1477 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1478 return 1479 } 1480 - 1481 - resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch) 1482 - if err != nil || resp.StatusCode != http.StatusNoContent { 1483 - log.Printf("failed to update tracking branch: %s", err) 1484 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1485 return 1486 } 1487 ··· 1597 Val: &tangled.RepoPull{ 1598 Title: pull.Title, 1599 PullId: int64(pull.PullId), 1600 - TargetRepo: string(f.RepoAt), 1601 TargetBranch: pull.TargetBranch, 1602 Patch: patch, // new patch 1603 Source: recordPullSource, ··· 1907 1908 patch := pullsToMerge.CombinedPatch() 1909 1910 - secret, err := db.GetRegistrationKey(s.db, f.Knot) 1911 - if err != nil { 1912 - log.Printf("no registration key found for domain %s: %s\n", f.Knot, err) 1913 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1914 - return 1915 - } 1916 - 1917 ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 1918 if err != nil { 1919 log.Printf("resolving identity: %s", err) ··· 1926 log.Printf("failed to get primary email: %s", err) 1927 } 1928 1929 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 1930 - if err != nil { 1931 - log.Printf("failed to create signed client for %s: %s", f.Knot, err) 1932 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1933 - return 1934 } 1935 1936 - // Merge the pull request 1937 - resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address) 1938 if err != nil { 1939 - log.Printf("failed to merge pull request: %s", err) 1940 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1941 return 1942 } 1943 1944 - if resp.StatusCode != http.StatusOK { 1945 - log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode) 1946 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1947 return 1948 } 1949 ··· 1956 defer tx.Rollback() 1957 1958 for _, p := range pullsToMerge { 1959 - err := db.MergePull(tx, f.RepoAt, p.PullId) 1960 if err != nil { 1961 log.Printf("failed to update pull request status in database: %s", err) 1962 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 1972 return 1973 } 1974 1975 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId)) 1976 } 1977 1978 func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { ··· 2024 2025 for _, p := range pullsToClose { 2026 // Close the pull in the database 2027 - err = db.ClosePull(tx, f.RepoAt, p.PullId) 2028 if err != nil { 2029 log.Println("failed to close pull", err) 2030 s.pages.Notice(w, "pull-close", "Failed to close pull.") ··· 2092 2093 for _, p := range pullsToReopen { 2094 // Close the pull in the database 2095 - err = db.ReopenPull(tx, f.RepoAt, p.PullId) 2096 if err != nil { 2097 log.Println("failed to close pull", err) 2098 s.pages.Notice(w, "pull-close", "Failed to close pull.") ··· 2144 Body: body, 2145 TargetBranch: targetBranch, 2146 OwnerDid: user.Did, 2147 - RepoAt: f.RepoAt, 2148 Rkey: rkey, 2149 Submissions: []*db.PullSubmission{ 2150 &initialSubmission,
··· 2 3 import ( 4 "database/sql" 5 "errors" 6 "fmt" 7 "log" 8 "net/http" 9 "sort" ··· 17 "tangled.sh/tangled.sh/core/appview/notify" 18 "tangled.sh/tangled.sh/core/appview/oauth" 19 "tangled.sh/tangled.sh/core/appview/pages" 20 + "tangled.sh/tangled.sh/core/appview/pages/markup" 21 "tangled.sh/tangled.sh/core/appview/reporesolver" 22 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 23 "tangled.sh/tangled.sh/core/idresolver" 24 "tangled.sh/tangled.sh/core/knotclient" 25 "tangled.sh/tangled.sh/core/patchutil" ··· 28 29 "github.com/bluekeyes/go-gitdiff/gitdiff" 30 comatproto "github.com/bluesky-social/indigo/api/atproto" 31 lexutil "github.com/bluesky-social/indigo/lex/util" 32 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 33 "github.com/go-chi/chi/v5" 34 "github.com/google/uuid" 35 ) ··· 96 return 97 } 98 99 + mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 100 resubmitResult := pages.Unknown 101 if user.Did == pull.OwnerDid { 102 resubmitResult = s.resubmitCheck(f, pull, stack) ··· 151 } 152 } 153 154 + mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 155 resubmitResult := pages.Unknown 156 if user != nil && user.Did == pull.OwnerDid { 157 resubmitResult = s.resubmitCheck(f, pull, stack) ··· 215 }) 216 } 217 218 + func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse { 219 if pull.State == db.PullMerged { 220 return types.MergeCheckResponse{} 221 } 222 223 + scheme := "https" 224 + if s.config.Core.Dev { 225 + scheme = "http" 226 } 227 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 228 229 + xrpcc := indigoxrpc.Client{ 230 + Host: host, 231 } 232 233 patch := pull.LatestPatch() ··· 240 patch = mergeable.CombinedPatch() 241 } 242 243 + resp, xe := tangled.RepoMergeCheck( 244 + r.Context(), 245 + &xrpcc, 246 + &tangled.RepoMergeCheck_Input{ 247 + Did: f.OwnerDid(), 248 + Name: f.Name, 249 + Branch: pull.TargetBranch, 250 + Patch: patch, 251 + }, 252 + ) 253 + if err := xrpcclient.HandleXrpcErr(xe); err != nil { 254 + log.Println("failed to check for mergeability", "err", err) 255 return types.MergeCheckResponse{ 256 + Error: fmt.Sprintf("failed to check merge status: %s", err.Error()), 257 } 258 } 259 + 260 + // convert xrpc response to internal types 261 + conflicts := make([]types.ConflictInfo, len(resp.Conflicts)) 262 + for i, conflict := range resp.Conflicts { 263 + conflicts[i] = types.ConflictInfo{ 264 + Filename: conflict.Filename, 265 + Reason: conflict.Reason, 266 } 267 } 268 269 + result := types.MergeCheckResponse{ 270 + IsConflicted: resp.Is_conflicted, 271 + Conflicts: conflicts, 272 + } 273 + 274 + if resp.Message != nil { 275 + result.Message = *resp.Message 276 } 277 278 + if resp.Error != nil { 279 + result.Error = *resp.Error 280 } 281 282 + return result 283 } 284 285 func (s *Pulls) resubmitCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult { ··· 304 // pulls within the same repo 305 knot = f.Knot 306 ownerDid = f.OwnerDid() 307 + repoName = f.Name 308 } 309 310 us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) ··· 480 481 pulls, err := db.GetPulls( 482 s.db, 483 + db.FilterEq("repo_at", f.RepoAt()), 484 db.FilterEq("state", state), 485 ) 486 if err != nil { ··· 607 createdAt := time.Now().Format(time.RFC3339) 608 ownerDid := user.Did 609 610 + pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId) 611 if err != nil { 612 log.Println("failed to get pull at", err) 613 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 614 return 615 } 616 617 + atUri := f.RepoAt().String() 618 client, err := s.oauth.AuthorizedClient(r) 619 if err != nil { 620 log.Println("failed to get authorized client", err) ··· 643 644 comment := &db.PullComment{ 645 OwnerDid: user.Did, 646 + RepoAt: f.RepoAt().String(), 647 PullId: pull.PullId, 648 Body: body, 649 CommentAt: atResp.Uri, ··· 689 return 690 } 691 692 + result, err := us.Branches(f.OwnerDid(), f.Name) 693 if err != nil { 694 log.Println("failed to fetch branches", err) 695 return ··· 735 if isPatchBased && !patchutil.IsFormatPatch(patch) { 736 if title == "" { 737 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 738 + return 739 + } 740 + sanitizer := markup.NewSanitizer() 741 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 742 + s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 743 return 744 } 745 } ··· 818 return 819 } 820 821 + comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, targetBranch, sourceBranch) 822 if err != nil { 823 log.Println("failed to compare", err) 824 s.pages.Notice(w, "pull", err.Error()) ··· 864 return 865 } 866 867 + client, err := s.oauth.ServiceClient( 868 + r, 869 + oauth.WithService(fork.Knot), 870 + oauth.WithLxm(tangled.RepoHiddenRefNSID), 871 + oauth.WithDev(s.config.Core.Dev), 872 + ) 873 if err != nil { 874 + log.Printf("failed to connect to knot server: %v", err) 875 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 876 return 877 } ··· 883 return 884 } 885 886 + resp, err := tangled.RepoHiddenRef( 887 + r.Context(), 888 + client, 889 + &tangled.RepoHiddenRef_Input{ 890 + ForkRef: sourceBranch, 891 + RemoteRef: targetBranch, 892 + Repo: fork.RepoAt().String(), 893 + }, 894 + ) 895 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 896 + s.pages.Notice(w, "pull", err.Error()) 897 return 898 } 899 900 + if !resp.Success { 901 + errorMsg := "Failed to create pull request" 902 + if resp.Error != nil { 903 + errorMsg = fmt.Sprintf("Failed to create pull request: %s", *resp.Error) 904 + } 905 + s.pages.Notice(w, "pull", errorMsg) 906 return 907 } 908 ··· 927 return 928 } 929 930 + forkAtUri := fork.RepoAt() 931 + forkAtUriStr := forkAtUri.String() 932 933 pullSource := &db.PullSource{ 934 Branch: sourceBranch, ··· 936 } 937 recordPullSource := &tangled.RepoPull_Source{ 938 Branch: sourceBranch, 939 + Repo: &forkAtUriStr, 940 Sha: sourceRev, 941 } 942 ··· 1012 Body: body, 1013 TargetBranch: targetBranch, 1014 OwnerDid: user.Did, 1015 + RepoAt: f.RepoAt(), 1016 Rkey: rkey, 1017 Submissions: []*db.PullSubmission{ 1018 &initialSubmission, ··· 1025 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1026 return 1027 } 1028 + pullId, err := db.NextPullId(tx, f.RepoAt()) 1029 if err != nil { 1030 log.Println("failed to get pull id", err) 1031 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 1040 Val: &tangled.RepoPull{ 1041 Title: title, 1042 PullId: int64(pullId), 1043 + TargetRepo: string(f.RepoAt()), 1044 TargetBranch: targetBranch, 1045 Patch: patch, 1046 Source: recordPullSource, ··· 1218 return 1219 } 1220 1221 + result, err := us.Branches(f.OwnerDid(), f.Name) 1222 if err != nil { 1223 log.Println("failed to reach knotserver", err) 1224 return ··· 1302 return 1303 } 1304 1305 + targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.Name) 1306 if err != nil { 1307 log.Println("failed to reach knotserver for target branches", err) 1308 return ··· 1418 return 1419 } 1420 1421 + comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, pull.TargetBranch, pull.PullSource.Branch) 1422 if err != nil { 1423 log.Printf("compare request failed: %s", err) 1424 s.pages.Notice(w, "resubmit-error", err.Error()) ··· 1468 return 1469 } 1470 1471 + // update the hidden tracking branch to latest 1472 + client, err := s.oauth.ServiceClient( 1473 + r, 1474 + oauth.WithService(forkRepo.Knot), 1475 + oauth.WithLxm(tangled.RepoHiddenRefNSID), 1476 + oauth.WithDev(s.config.Core.Dev), 1477 + ) 1478 if err != nil { 1479 + log.Printf("failed to connect to knot server: %v", err) 1480 return 1481 } 1482 1483 + resp, err := tangled.RepoHiddenRef( 1484 + r.Context(), 1485 + client, 1486 + &tangled.RepoHiddenRef_Input{ 1487 + ForkRef: pull.PullSource.Branch, 1488 + RemoteRef: pull.TargetBranch, 1489 + Repo: forkRepo.RepoAt().String(), 1490 + }, 1491 + ) 1492 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1493 + s.pages.Notice(w, "resubmit-error", err.Error()) 1494 return 1495 } 1496 + if !resp.Success { 1497 + log.Println("Failed to update tracking ref.", "err", resp.Error) 1498 + s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.") 1499 return 1500 } 1501 ··· 1611 Val: &tangled.RepoPull{ 1612 Title: pull.Title, 1613 PullId: int64(pull.PullId), 1614 + TargetRepo: string(f.RepoAt()), 1615 TargetBranch: pull.TargetBranch, 1616 Patch: patch, // new patch 1617 Source: recordPullSource, ··· 1921 1922 patch := pullsToMerge.CombinedPatch() 1923 1924 ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 1925 if err != nil { 1926 log.Printf("resolving identity: %s", err) ··· 1933 log.Printf("failed to get primary email: %s", err) 1934 } 1935 1936 + authorName := ident.Handle.String() 1937 + mergeInput := &tangled.RepoMerge_Input{ 1938 + Did: f.OwnerDid(), 1939 + Name: f.Name, 1940 + Branch: pull.TargetBranch, 1941 + Patch: patch, 1942 + CommitMessage: &pull.Title, 1943 + AuthorName: &authorName, 1944 + } 1945 + 1946 + if pull.Body != "" { 1947 + mergeInput.CommitBody = &pull.Body 1948 + } 1949 + 1950 + if email.Address != "" { 1951 + mergeInput.AuthorEmail = &email.Address 1952 } 1953 1954 + client, err := s.oauth.ServiceClient( 1955 + r, 1956 + oauth.WithService(f.Knot), 1957 + oauth.WithLxm(tangled.RepoMergeNSID), 1958 + oauth.WithDev(s.config.Core.Dev), 1959 + ) 1960 if err != nil { 1961 + log.Printf("failed to connect to knot server: %v", err) 1962 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1963 return 1964 } 1965 1966 + err = tangled.RepoMerge(r.Context(), client, mergeInput) 1967 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1968 + s.pages.Notice(w, "pull-merge-error", err.Error()) 1969 return 1970 } 1971 ··· 1978 defer tx.Rollback() 1979 1980 for _, p := range pullsToMerge { 1981 + err := db.MergePull(tx, f.RepoAt(), p.PullId) 1982 if err != nil { 1983 log.Printf("failed to update pull request status in database: %s", err) 1984 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 1994 return 1995 } 1996 1997 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 1998 } 1999 2000 func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { ··· 2046 2047 for _, p := range pullsToClose { 2048 // Close the pull in the database 2049 + err = db.ClosePull(tx, f.RepoAt(), p.PullId) 2050 if err != nil { 2051 log.Println("failed to close pull", err) 2052 s.pages.Notice(w, "pull-close", "Failed to close pull.") ··· 2114 2115 for _, p := range pullsToReopen { 2116 // Close the pull in the database 2117 + err = db.ReopenPull(tx, f.RepoAt(), p.PullId) 2118 if err != nil { 2119 log.Println("failed to close pull", err) 2120 s.pages.Notice(w, "pull-close", "Failed to close pull.") ··· 2166 Body: body, 2167 TargetBranch: targetBranch, 2168 OwnerDid: user.Did, 2169 + RepoAt: f.RepoAt(), 2170 Rkey: rkey, 2171 Submissions: []*db.PullSubmission{ 2172 &initialSubmission,
+6 -6
appview/repo/artifact.go
··· 76 Artifact: uploadBlobResp.Blob, 77 CreatedAt: createdAt.Format(time.RFC3339), 78 Name: handler.Filename, 79 - Repo: f.RepoAt.String(), 80 Tag: tag.Tag.Hash[:], 81 }, 82 }, ··· 100 artifact := db.Artifact{ 101 Did: user.Did, 102 Rkey: rkey, 103 - RepoAt: f.RepoAt, 104 Tag: tag.Tag.Hash, 105 CreatedAt: createdAt, 106 BlobCid: cid.Cid(uploadBlobResp.Blob.Ref), ··· 155 156 artifacts, err := db.GetArtifact( 157 rp.db, 158 - db.FilterEq("repo_at", f.RepoAt), 159 db.FilterEq("tag", tag.Tag.Hash[:]), 160 db.FilterEq("name", filename), 161 ) ··· 197 198 artifacts, err := db.GetArtifact( 199 rp.db, 200 - db.FilterEq("repo_at", f.RepoAt), 201 db.FilterEq("tag", tag[:]), 202 db.FilterEq("name", filename), 203 ) ··· 239 defer tx.Rollback() 240 241 err = db.DeleteArtifact(tx, 242 - db.FilterEq("repo_at", f.RepoAt), 243 db.FilterEq("tag", artifact.Tag[:]), 244 db.FilterEq("name", filename), 245 ) ··· 270 return nil, err 271 } 272 273 - result, err := us.Tags(f.OwnerDid(), f.RepoName) 274 if err != nil { 275 log.Println("failed to reach knotserver", err) 276 return nil, err
··· 76 Artifact: uploadBlobResp.Blob, 77 CreatedAt: createdAt.Format(time.RFC3339), 78 Name: handler.Filename, 79 + Repo: f.RepoAt().String(), 80 Tag: tag.Tag.Hash[:], 81 }, 82 }, ··· 100 artifact := db.Artifact{ 101 Did: user.Did, 102 Rkey: rkey, 103 + RepoAt: f.RepoAt(), 104 Tag: tag.Tag.Hash, 105 CreatedAt: createdAt, 106 BlobCid: cid.Cid(uploadBlobResp.Blob.Ref), ··· 155 156 artifacts, err := db.GetArtifact( 157 rp.db, 158 + db.FilterEq("repo_at", f.RepoAt()), 159 db.FilterEq("tag", tag.Tag.Hash[:]), 160 db.FilterEq("name", filename), 161 ) ··· 197 198 artifacts, err := db.GetArtifact( 199 rp.db, 200 + db.FilterEq("repo_at", f.RepoAt()), 201 db.FilterEq("tag", tag[:]), 202 db.FilterEq("name", filename), 203 ) ··· 239 defer tx.Rollback() 240 241 err = db.DeleteArtifact(tx, 242 + db.FilterEq("repo_at", f.RepoAt()), 243 db.FilterEq("tag", artifact.Tag[:]), 244 db.FilterEq("name", filename), 245 ) ··· 270 return nil, err 271 } 272 273 + result, err := us.Tags(f.OwnerDid(), f.Name) 274 if err != nil { 275 log.Println("failed to reach knotserver", err) 276 return nil, err
+165
appview/repo/feed.go
···
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log" 7 + "net/http" 8 + "slices" 9 + "time" 10 + 11 + "tangled.sh/tangled.sh/core/appview/db" 12 + "tangled.sh/tangled.sh/core/appview/reporesolver" 13 + 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + "github.com/gorilla/feeds" 16 + ) 17 + 18 + func (rp *Repo) getRepoFeed(ctx context.Context, f *reporesolver.ResolvedRepo) (*feeds.Feed, error) { 19 + const feedLimitPerType = 100 20 + 21 + pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt())) 22 + if err != nil { 23 + return nil, err 24 + } 25 + 26 + issues, err := db.GetIssuesWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt())) 27 + if err != nil { 28 + return nil, err 29 + } 30 + 31 + feed := &feeds.Feed{ 32 + Title: fmt.Sprintf("activity feed for %s", f.OwnerSlashRepo()), 33 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.AppviewHost, f.OwnerSlashRepo()), Type: "text/html", Rel: "alternate"}, 34 + Items: make([]*feeds.Item, 0), 35 + Updated: time.UnixMilli(0), 36 + } 37 + 38 + for _, pull := range pulls { 39 + items, err := rp.createPullItems(ctx, pull, f) 40 + if err != nil { 41 + return nil, err 42 + } 43 + feed.Items = append(feed.Items, items...) 44 + } 45 + 46 + for _, issue := range issues { 47 + item, err := rp.createIssueItem(ctx, issue, f) 48 + if err != nil { 49 + return nil, err 50 + } 51 + feed.Items = append(feed.Items, item) 52 + } 53 + 54 + slices.SortFunc(feed.Items, func(a, b *feeds.Item) int { 55 + if a.Created.After(b.Created) { 56 + return -1 57 + } 58 + return 1 59 + }) 60 + 61 + if len(feed.Items) > 0 { 62 + feed.Updated = feed.Items[0].Created 63 + } 64 + 65 + return feed, nil 66 + } 67 + 68 + func (rp *Repo) createPullItems(ctx context.Context, pull *db.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) { 69 + owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid) 70 + if err != nil { 71 + return nil, err 72 + } 73 + 74 + var items []*feeds.Item 75 + 76 + state := rp.getPullState(pull) 77 + description := rp.buildPullDescription(owner.Handle, state, pull, f.OwnerSlashRepo()) 78 + 79 + mainItem := &feeds.Item{ 80 + Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title), 81 + Description: description, 82 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId)}, 83 + Created: pull.Created, 84 + Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 85 + } 86 + items = append(items, mainItem) 87 + 88 + for _, round := range pull.Submissions { 89 + if round == nil || round.RoundNumber == 0 { 90 + continue 91 + } 92 + 93 + roundItem := &feeds.Item{ 94 + Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.RoundNumber), 95 + Description: fmt.Sprintf("@%s submitted changes (at round #%d) on PR #%d in %s", owner.Handle, round.RoundNumber, pull.PullId, f.OwnerSlashRepo()), 96 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId, round.RoundNumber)}, 97 + Created: round.Created, 98 + Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 99 + } 100 + items = append(items, roundItem) 101 + } 102 + 103 + return items, nil 104 + } 105 + 106 + func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { 107 + owner, err := rp.idResolver.ResolveIdent(ctx, issue.OwnerDid) 108 + if err != nil { 109 + return nil, err 110 + } 111 + 112 + state := "closed" 113 + if issue.Open { 114 + state = "opened" 115 + } 116 + 117 + return &feeds.Item{ 118 + Title: fmt.Sprintf("[Issue #%d] %s", issue.IssueId, issue.Title), 119 + Description: fmt.Sprintf("@%s %s issue #%d in %s", owner.Handle, state, issue.IssueId, f.OwnerSlashRepo()), 120 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), issue.IssueId)}, 121 + Created: issue.Created, 122 + Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 123 + }, nil 124 + } 125 + 126 + func (rp *Repo) getPullState(pull *db.Pull) string { 127 + if pull.State == db.PullOpen { 128 + return "opened" 129 + } 130 + return pull.State.String() 131 + } 132 + 133 + func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *db.Pull, repoName string) string { 134 + base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId) 135 + 136 + if pull.State == db.PullMerged { 137 + return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName) 138 + } 139 + 140 + return fmt.Sprintf("%s in %s", base, repoName) 141 + } 142 + 143 + func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) { 144 + f, err := rp.repoResolver.Resolve(r) 145 + if err != nil { 146 + log.Println("failed to fully resolve repo:", err) 147 + return 148 + } 149 + 150 + feed, err := rp.getRepoFeed(r.Context(), f) 151 + if err != nil { 152 + log.Println("failed to get repo feed:", err) 153 + rp.pages.Error500(w) 154 + return 155 + } 156 + 157 + atom, err := feed.ToAtom() 158 + if err != nil { 159 + rp.pages.Error500(w) 160 + return 161 + } 162 + 163 + w.Header().Set("content-type", "application/atom+xml") 164 + w.Write([]byte(atom)) 165 + }
+17 -104
appview/repo/index.go
··· 1 package repo 2 3 import ( 4 - "encoding/json" 5 - "fmt" 6 "log" 7 "net/http" 8 "slices" ··· 11 12 "tangled.sh/tangled.sh/core/appview/commitverify" 13 "tangled.sh/tangled.sh/core/appview/db" 14 - "tangled.sh/tangled.sh/core/appview/oauth" 15 "tangled.sh/tangled.sh/core/appview/pages" 16 - "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 17 "tangled.sh/tangled.sh/core/appview/reporesolver" 18 "tangled.sh/tangled.sh/core/knotclient" 19 "tangled.sh/tangled.sh/core/types" ··· 24 25 func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 26 ref := chi.URLParam(r, "ref") 27 f, err := rp.repoResolver.Resolve(r) 28 if err != nil { 29 log.Println("failed to fully resolve repo", err) ··· 37 return 38 } 39 40 - result, err := us.Index(f.OwnerDid(), f.RepoName, ref) 41 if err != nil { 42 rp.pages.Error503(w) 43 log.Println("failed to reach knotserver", err) ··· 104 user := rp.oauth.GetUser(r) 105 repoInfo := f.RepoInfo(user) 106 107 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 108 - if err != nil { 109 - log.Printf("failed to get registration key for %s: %s", f.Knot, err) 110 - rp.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 111 - } 112 - 113 - signedClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 114 - if err != nil { 115 - log.Printf("failed to create signed client for %s: %s", f.Knot, err) 116 - return 117 - } 118 - 119 - var forkInfo *types.ForkInfo 120 - if user != nil && (repoInfo.Roles.IsOwner() || repoInfo.Roles.IsCollaborator()) { 121 - forkInfo, err = getForkInfo(repoInfo, rp, f, user, signedClient) 122 - if err != nil { 123 - log.Printf("Failed to fetch fork information: %v", err) 124 - return 125 - } 126 - } 127 - 128 // TODO: a bit dirty 129 - languageInfo, err := rp.getLanguageInfo(f, signedClient, chi.URLParam(r, "ref") == "") 130 if err != nil { 131 log.Printf("failed to compute language percentages: %s", err) 132 // non-fatal ··· 143 } 144 145 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 146 - LoggedInUser: user, 147 - RepoInfo: repoInfo, 148 - TagMap: tagMap, 149 - RepoIndexResponse: *result, 150 - CommitsTrunc: commitsTrunc, 151 - TagsTrunc: tagsTrunc, 152 - ForkInfo: forkInfo, 153 BranchesTrunc: branchesTrunc, 154 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 155 VerifiedCommits: vc, ··· 160 161 func (rp *Repo) getLanguageInfo( 162 f *reporesolver.ResolvedRepo, 163 - signedClient *knotclient.SignedClient, 164 isDefaultRef bool, 165 ) ([]types.RepoLanguageDetails, error) { 166 // first attempt to fetch from db 167 langs, err := db.GetRepoLanguages( 168 rp.db, 169 - db.FilterEq("repo_at", f.RepoAt), 170 - db.FilterEq("ref", f.Ref), 171 ) 172 173 if err != nil || langs == nil { 174 // non-fatal, fetch langs from ks 175 - ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, f.Ref) 176 if err != nil { 177 return nil, err 178 } ··· 182 183 for l, s := range ls.Languages { 184 langs = append(langs, db.RepoLanguage{ 185 - RepoAt: f.RepoAt, 186 - Ref: f.Ref, 187 IsDefaultRef: isDefaultRef, 188 Language: l, 189 Bytes: s, ··· 229 230 return languageStats, nil 231 } 232 - 233 - func getForkInfo( 234 - repoInfo repoinfo.RepoInfo, 235 - rp *Repo, 236 - f *reporesolver.ResolvedRepo, 237 - user *oauth.User, 238 - signedClient *knotclient.SignedClient, 239 - ) (*types.ForkInfo, error) { 240 - if user == nil { 241 - return nil, nil 242 - } 243 - 244 - forkInfo := types.ForkInfo{ 245 - IsFork: repoInfo.Source != nil, 246 - Status: types.UpToDate, 247 - } 248 - 249 - if !forkInfo.IsFork { 250 - forkInfo.IsFork = false 251 - return &forkInfo, nil 252 - } 253 - 254 - us, err := knotclient.NewUnsignedClient(repoInfo.Source.Knot, rp.config.Core.Dev) 255 - if err != nil { 256 - log.Printf("failed to create unsigned client for %s", repoInfo.Source.Knot) 257 - return nil, err 258 - } 259 - 260 - result, err := us.Branches(repoInfo.Source.Did, repoInfo.Source.Name) 261 - if err != nil { 262 - log.Println("failed to reach knotserver", err) 263 - return nil, err 264 - } 265 - 266 - if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool { 267 - return branch.Name == f.Ref 268 - }) { 269 - forkInfo.Status = types.MissingBranch 270 - return &forkInfo, nil 271 - } 272 - 273 - newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, f.Ref, f.Ref) 274 - if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent { 275 - log.Printf("failed to update tracking branch: %s", err) 276 - return nil, err 277 - } 278 - 279 - hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref) 280 - 281 - var status types.AncestorCheckResponse 282 - forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt), repoInfo.Name, f.Ref, hiddenRef) 283 - if err != nil { 284 - log.Printf("failed to check if fork is ahead/behind: %s", err) 285 - return nil, err 286 - } 287 - 288 - if err := json.NewDecoder(forkSyncableResp.Body).Decode(&status); err != nil { 289 - log.Printf("failed to decode fork status: %s", err) 290 - return nil, err 291 - } 292 - 293 - forkInfo.Status = status.Status 294 - return &forkInfo, nil 295 - }
··· 1 package repo 2 3 import ( 4 "log" 5 "net/http" 6 "slices" ··· 9 10 "tangled.sh/tangled.sh/core/appview/commitverify" 11 "tangled.sh/tangled.sh/core/appview/db" 12 "tangled.sh/tangled.sh/core/appview/pages" 13 "tangled.sh/tangled.sh/core/appview/reporesolver" 14 "tangled.sh/tangled.sh/core/knotclient" 15 "tangled.sh/tangled.sh/core/types" ··· 20 21 func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 22 ref := chi.URLParam(r, "ref") 23 + 24 f, err := rp.repoResolver.Resolve(r) 25 if err != nil { 26 log.Println("failed to fully resolve repo", err) ··· 34 return 35 } 36 37 + result, err := us.Index(f.OwnerDid(), f.Name, ref) 38 if err != nil { 39 rp.pages.Error503(w) 40 log.Println("failed to reach knotserver", err) ··· 101 user := rp.oauth.GetUser(r) 102 repoInfo := f.RepoInfo(user) 103 104 // TODO: a bit dirty 105 + languageInfo, err := rp.getLanguageInfo(f, us, result.Ref, ref == "") 106 if err != nil { 107 log.Printf("failed to compute language percentages: %s", err) 108 // non-fatal ··· 119 } 120 121 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 122 + LoggedInUser: user, 123 + RepoInfo: repoInfo, 124 + TagMap: tagMap, 125 + RepoIndexResponse: *result, 126 + CommitsTrunc: commitsTrunc, 127 + TagsTrunc: tagsTrunc, 128 + // ForkInfo: forkInfo, // TODO: reinstate this after xrpc properly lands 129 BranchesTrunc: branchesTrunc, 130 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 131 VerifiedCommits: vc, ··· 136 137 func (rp *Repo) getLanguageInfo( 138 f *reporesolver.ResolvedRepo, 139 + us *knotclient.UnsignedClient, 140 + currentRef string, 141 isDefaultRef bool, 142 ) ([]types.RepoLanguageDetails, error) { 143 // first attempt to fetch from db 144 langs, err := db.GetRepoLanguages( 145 rp.db, 146 + db.FilterEq("repo_at", f.RepoAt()), 147 + db.FilterEq("ref", currentRef), 148 ) 149 150 if err != nil || langs == nil { 151 // non-fatal, fetch langs from ks 152 + ls, err := us.RepoLanguages(f.OwnerDid(), f.Name, currentRef) 153 if err != nil { 154 return nil, err 155 } ··· 159 160 for l, s := range ls.Languages { 161 langs = append(langs, db.RepoLanguage{ 162 + RepoAt: f.RepoAt(), 163 + Ref: currentRef, 164 IsDefaultRef: isDefaultRef, 165 Language: l, 166 Bytes: s, ··· 206 207 return languageStats, nil 208 }
+282 -244
appview/repo/repo.go
··· 17 "strings" 18 "time" 19 20 "tangled.sh/tangled.sh/core/api/tangled" 21 "tangled.sh/tangled.sh/core/appview/commitverify" 22 "tangled.sh/tangled.sh/core/appview/config" ··· 26 "tangled.sh/tangled.sh/core/appview/pages" 27 "tangled.sh/tangled.sh/core/appview/pages/markup" 28 "tangled.sh/tangled.sh/core/appview/reporesolver" 29 "tangled.sh/tangled.sh/core/eventconsumer" 30 "tangled.sh/tangled.sh/core/idresolver" 31 "tangled.sh/tangled.sh/core/knotclient" ··· 33 "tangled.sh/tangled.sh/core/rbac" 34 "tangled.sh/tangled.sh/core/tid" 35 "tangled.sh/tangled.sh/core/types" 36 37 securejoin "github.com/cyphar/filepath-securejoin" 38 "github.com/go-chi/chi/v5" 39 "github.com/go-git/go-git/v5/plumbing" 40 41 - comatproto "github.com/bluesky-social/indigo/api/atproto" 42 "github.com/bluesky-social/indigo/atproto/syntax" 43 - lexutil "github.com/bluesky-social/indigo/lex/util" 44 ) 45 46 type Repo struct { ··· 54 enforcer *rbac.Enforcer 55 notifier notify.Notifier 56 logger *slog.Logger 57 } 58 59 func New( ··· 95 } else { 96 uri = "https" 97 } 98 - url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.RepoName, url.PathEscape(refParam)) 99 100 http.Redirect(w, r, url, http.StatusFound) 101 } ··· 123 return 124 } 125 126 - repolog, err := us.Log(f.OwnerDid(), f.RepoName, ref, page) 127 if err != nil { 128 log.Println("failed to reach knotserver", err) 129 return 130 } 131 132 - tagResult, err := us.Tags(f.OwnerDid(), f.RepoName) 133 if err != nil { 134 log.Println("failed to reach knotserver", err) 135 return 136 } ··· 144 tagMap[hash] = append(tagMap[hash], tag.Name) 145 } 146 147 - branchResult, err := us.Branches(f.OwnerDid(), f.RepoName) 148 if err != nil { 149 log.Println("failed to reach knotserver", err) 150 return 151 } ··· 212 return 213 } 214 215 - repoAt := f.RepoAt 216 rkey := repoAt.RecordKey().String() 217 if rkey == "" { 218 log.Println("invalid aturi for repo", err) ··· 262 Record: &lexutil.LexiconTypeDecoder{ 263 Val: &tangled.Repo{ 264 Knot: f.Knot, 265 - Name: f.RepoName, 266 Owner: user.Did, 267 - CreatedAt: f.CreatedAt, 268 Description: &newDescription, 269 Spindle: &f.Spindle, 270 }, ··· 310 return 311 } 312 313 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)) 314 if err != nil { 315 log.Println("failed to reach knotserver", err) 316 return 317 } ··· 375 if !rp.config.Core.Dev { 376 protocol = "https" 377 } 378 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath)) 379 if err != nil { 380 log.Println("failed to reach knotserver", err) 381 return 382 } 383 384 body, err := io.ReadAll(resp.Body) 385 if err != nil { 386 log.Printf("Error reading response body: %v", err) ··· 405 user := rp.oauth.GetUser(r) 406 407 var breadcrumbs [][]string 408 - breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 409 if treePath != "" { 410 for idx, elem := range strings.Split(treePath, "/") { 411 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) ··· 436 return 437 } 438 439 - result, err := us.Tags(f.OwnerDid(), f.RepoName) 440 if err != nil { 441 log.Println("failed to reach knotserver", err) 442 return 443 } 444 445 - artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt)) 446 if err != nil { 447 log.Println("failed grab artifacts", err) 448 return ··· 493 return 494 } 495 496 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 497 if err != nil { 498 log.Println("failed to reach knotserver", err) 499 return 500 } ··· 522 if !rp.config.Core.Dev { 523 protocol = "https" 524 } 525 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 526 if err != nil { 527 log.Println("failed to reach knotserver", err) 528 return 529 } 530 531 body, err := io.ReadAll(resp.Body) 532 if err != nil { 533 log.Printf("Error reading response body: %v", err) ··· 542 } 543 544 var breadcrumbs [][]string 545 - breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 546 if filePath != "" { 547 for idx, elem := range strings.Split(filePath, "/") { 548 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) ··· 575 576 // fetch the actual binary content like in RepoBlobRaw 577 578 - blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath) 579 contentSrc = blobURL 580 if !rp.config.Core.Dev { 581 contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) ··· 612 if !rp.config.Core.Dev { 613 protocol = "https" 614 } 615 - blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath) 616 - resp, err := http.Get(blobURL) 617 if err != nil { 618 - log.Println("failed to reach knotserver:", err) 619 rp.pages.Error503(w) 620 return 621 } 622 defer resp.Body.Close() 623 624 if resp.StatusCode != http.StatusOK { 625 log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) ··· 668 return 669 } 670 671 - repoAt := f.RepoAt 672 rkey := repoAt.RecordKey().String() 673 if rkey == "" { 674 fail("Failed to resolve repo. Try again later", err) ··· 722 Record: &lexutil.LexiconTypeDecoder{ 723 Val: &tangled.Repo{ 724 Knot: f.Knot, 725 - Name: f.RepoName, 726 Owner: user.Did, 727 - CreatedAt: f.CreatedAt, 728 Description: &f.Description, 729 Spindle: spindlePtr, 730 }, ··· 805 Record: &lexutil.LexiconTypeDecoder{ 806 Val: &tangled.RepoCollaborator{ 807 Subject: collaboratorIdent.DID.String(), 808 - Repo: string(f.RepoAt), 809 CreatedAt: createdAt.Format(time.RFC3339), 810 }}, 811 }) ··· 814 fail("Failed to write record to PDS.", err) 815 return 816 } 817 - l = l.With("at-uri", resp.Uri) 818 l.Info("wrote record to PDS") 819 820 - l.Info("adding to knot") 821 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 822 if err != nil { 823 - fail("Failed to add to knot.", err) 824 return 825 } 826 827 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 828 - if err != nil { 829 - fail("Failed to add to knot.", err) 830 - return 831 - } 832 833 - ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 834 - if err != nil { 835 - fail("Knot was unreachable.", err) 836 - return 837 - } 838 839 - if ksResp.StatusCode != http.StatusNoContent { 840 - fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil) 841 - return 842 } 843 - 844 - tx, err := rp.db.BeginTx(r.Context(), nil) 845 - if err != nil { 846 - fail("Failed to add collaborator.", err) 847 - return 848 - } 849 - defer func() { 850 - tx.Rollback() 851 - err = rp.enforcer.E.LoadPolicy() 852 - if err != nil { 853 - fail("Failed to add collaborator.", err) 854 - } 855 - }() 856 857 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 858 if err != nil { ··· 864 Did: syntax.DID(currentUser.Did), 865 Rkey: rkey, 866 SubjectDid: collaboratorIdent.DID, 867 - RepoAt: f.RepoAt, 868 Created: createdAt, 869 }) 870 if err != nil { ··· 884 return 885 } 886 887 rp.pages.HxRefresh(w) 888 } 889 890 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 891 user := rp.oauth.GetUser(r) 892 893 f, err := rp.repoResolver.Resolve(r) 894 if err != nil { 895 log.Println("failed to get repo and knot", err) ··· 902 log.Println("failed to get authorized client", err) 903 return 904 } 905 - repoRkey := f.RepoAt.RecordKey().String() 906 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 907 Collection: tangled.RepoNSID, 908 Repo: user.Did, 909 - Rkey: repoRkey, 910 }) 911 if err != nil { 912 log.Printf("failed to delete record: %s", err) 913 - rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 914 - return 915 - } 916 - log.Println("removed repo record ", f.RepoAt.String()) 917 - 918 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 919 - if err != nil { 920 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 921 return 922 } 923 924 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 925 if err != nil { 926 - log.Println("failed to create client to ", f.Knot) 927 return 928 } 929 930 - ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName) 931 - if err != nil { 932 - log.Printf("failed to make request to %s: %s", f.Knot, err) 933 return 934 } 935 - 936 - if ksResp.StatusCode != http.StatusNoContent { 937 - log.Println("failed to remove repo from knot, continuing anyway ", f.Knot) 938 - } else { 939 - log.Println("removed repo from knot ", f.Knot) 940 - } 941 942 tx, err := rp.db.BeginTx(r.Context(), nil) 943 if err != nil { ··· 956 // remove collaborator RBAC 957 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 958 if err != nil { 959 - rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators") 960 return 961 } 962 for _, c := range repoCollaborators { ··· 968 // remove repo RBAC 969 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 970 if err != nil { 971 - rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") 972 return 973 } 974 975 // remove repo from db 976 - err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName) 977 if err != nil { 978 - rp.pages.Notice(w, "settings-delete", "Failed to update appview") 979 return 980 } 981 log.Println("removed repo from db") ··· 1004 return 1005 } 1006 1007 branch := r.FormValue("branch") 1008 if branch == "" { 1009 http.Error(w, "malformed form", http.StatusBadRequest) 1010 return 1011 } 1012 1013 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 1014 if err != nil { 1015 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 1016 return 1017 } 1018 1019 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 1020 - if err != nil { 1021 - log.Println("failed to create client to ", f.Knot) 1022 return 1023 } 1024 1025 - ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch) 1026 - if err != nil { 1027 - log.Printf("failed to make request to %s: %s", f.Knot, err) 1028 - return 1029 - } 1030 - 1031 - if ksResp.StatusCode != http.StatusNoContent { 1032 - rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.") 1033 - return 1034 - } 1035 - 1036 - w.Write(fmt.Append(nil, "default branch set to: ", branch)) 1037 } 1038 1039 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { ··· 1090 r.Context(), 1091 spindleClient, 1092 &tangled.RepoAddSecret_Input{ 1093 - Repo: f.RepoAt.String(), 1094 Key: key, 1095 Value: value, 1096 }, ··· 1108 r.Context(), 1109 spindleClient, 1110 &tangled.RepoRemoveSecret_Input{ 1111 - Repo: f.RepoAt.String(), 1112 Key: key, 1113 }, 1114 ) ··· 1149 case "pipelines": 1150 rp.pipelineSettings(w, r) 1151 } 1152 - 1153 - // user := rp.oauth.GetUser(r) 1154 - // repoCollaborators, err := f.Collaborators(r.Context()) 1155 - // if err != nil { 1156 - // log.Println("failed to get collaborators", err) 1157 - // } 1158 - 1159 - // isCollaboratorInviteAllowed := false 1160 - // if user != nil { 1161 - // ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 1162 - // if err == nil && ok { 1163 - // isCollaboratorInviteAllowed = true 1164 - // } 1165 - // } 1166 - 1167 - // us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1168 - // if err != nil { 1169 - // log.Println("failed to create unsigned client", err) 1170 - // return 1171 - // } 1172 - 1173 - // result, err := us.Branches(f.OwnerDid(), f.RepoName) 1174 - // if err != nil { 1175 - // log.Println("failed to reach knotserver", err) 1176 - // return 1177 - // } 1178 - 1179 - // // all spindles that this user is a member of 1180 - // spindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 1181 - // if err != nil { 1182 - // log.Println("failed to fetch spindles", err) 1183 - // return 1184 - // } 1185 - 1186 - // var secrets []*tangled.RepoListSecrets_Secret 1187 - // if f.Spindle != "" { 1188 - // if spindleClient, err := rp.oauth.ServiceClient( 1189 - // r, 1190 - // oauth.WithService(f.Spindle), 1191 - // oauth.WithLxm(tangled.RepoListSecretsNSID), 1192 - // oauth.WithDev(rp.config.Core.Dev), 1193 - // ); err != nil { 1194 - // log.Println("failed to create spindle client", err) 1195 - // } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil { 1196 - // log.Println("failed to fetch secrets", err) 1197 - // } else { 1198 - // secrets = resp.Secrets 1199 - // } 1200 - // } 1201 - 1202 - // rp.pages.RepoSettings(w, pages.RepoSettingsParams{ 1203 - // LoggedInUser: user, 1204 - // RepoInfo: f.RepoInfo(user), 1205 - // Collaborators: repoCollaborators, 1206 - // IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 1207 - // Branches: result.Branches, 1208 - // Spindles: spindles, 1209 - // CurrentSpindle: f.Spindle, 1210 - // Secrets: secrets, 1211 - // }) 1212 } 1213 1214 func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { ··· 1221 return 1222 } 1223 1224 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1225 if err != nil { 1226 log.Println("failed to reach knotserver", err) 1227 return 1228 } ··· 1275 oauth.WithDev(rp.config.Core.Dev), 1276 ); err != nil { 1277 log.Println("failed to create spindle client", err) 1278 - } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil { 1279 log.Println("failed to fetch secrets", err) 1280 } else { 1281 secrets = resp.Secrets ··· 1316 } 1317 1318 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1319 user := rp.oauth.GetUser(r) 1320 f, err := rp.repoResolver.Resolve(r) 1321 if err != nil { ··· 1325 1326 switch r.Method { 1327 case http.MethodPost: 1328 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 1329 if err != nil { 1330 - rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot)) 1331 return 1332 } 1333 1334 - client, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 1335 - if err != nil { 1336 - rp.pages.Notice(w, "repo", "Failed to reach knot server.") 1337 return 1338 } 1339 1340 - var uri string 1341 - if rp.config.Core.Dev { 1342 - uri = "http" 1343 - } else { 1344 - uri = "https" 1345 - } 1346 - forkName := fmt.Sprintf("%s", f.RepoName) 1347 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1348 - 1349 - _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref) 1350 - if err != nil { 1351 - rp.pages.Notice(w, "repo", "Failed to sync repository fork.") 1352 return 1353 } 1354 ··· 1381 }) 1382 1383 case http.MethodPost: 1384 1385 - knot := r.FormValue("knot") 1386 - if knot == "" { 1387 rp.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 1388 return 1389 } 1390 1391 - ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") 1392 if err != nil || !ok { 1393 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1394 return 1395 } 1396 1397 - forkName := fmt.Sprintf("%s", f.RepoName) 1398 - 1399 // this check is *only* to see if the forked repo name already exists 1400 // in the user's account. 1401 - existingRepo, err := db.GetRepo(rp.db, user.Did, f.RepoName) 1402 if err != nil { 1403 if errors.Is(err, sql.ErrNoRows) { 1404 // no existing repo with this name found, we can use the name as is ··· 1411 // repo with this name already exists, append random string 1412 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1413 } 1414 - secret, err := db.GetRegistrationKey(rp.db, knot) 1415 - if err != nil { 1416 - rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot)) 1417 - return 1418 - } 1419 1420 - client, err := knotclient.NewSignedClient(knot, secret, rp.config.Core.Dev) 1421 - if err != nil { 1422 - rp.pages.Notice(w, "repo", "Failed to reach knot server.") 1423 - return 1424 - } 1425 - 1426 - var uri string 1427 if rp.config.Core.Dev { 1428 uri = "http" 1429 - } else { 1430 - uri = "https" 1431 } 1432 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1433 - sourceAt := f.RepoAt.String() 1434 1435 rkey := tid.TID() 1436 repo := &db.Repo{ 1437 Did: user.Did, 1438 Name: forkName, 1439 - Knot: knot, 1440 Rkey: rkey, 1441 Source: sourceAt, 1442 } 1443 1444 - tx, err := rp.db.BeginTx(r.Context(), nil) 1445 - if err != nil { 1446 - log.Println(err) 1447 - rp.pages.Notice(w, "repo", "Failed to save repository information.") 1448 - return 1449 - } 1450 - defer func() { 1451 - tx.Rollback() 1452 - err = rp.enforcer.E.LoadPolicy() 1453 - if err != nil { 1454 - log.Println("failed to rollback policies") 1455 - } 1456 - }() 1457 - 1458 - resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName) 1459 - if err != nil { 1460 - rp.pages.Notice(w, "repo", "Failed to create repository on knot server.") 1461 - return 1462 - } 1463 - 1464 - switch resp.StatusCode { 1465 - case http.StatusConflict: 1466 - rp.pages.Notice(w, "repo", "A repository with that name already exists.") 1467 - return 1468 - case http.StatusInternalServerError: 1469 - rp.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 1470 - case http.StatusNoContent: 1471 - // continue 1472 - } 1473 - 1474 xrpcClient, err := rp.oauth.AuthorizedClient(r) 1475 if err != nil { 1476 - log.Println("failed to get authorized client", err) 1477 - rp.pages.Notice(w, "repo", "Failed to create repository.") 1478 return 1479 } 1480 ··· 1493 }}, 1494 }) 1495 if err != nil { 1496 - log.Printf("failed to create record: %s", err) 1497 rp.pages.Notice(w, "repo", "Failed to announce repository creation.") 1498 return 1499 } 1500 - log.Println("created repo record: ", atresp.Uri) 1501 1502 - repo.AtUri = atresp.Uri 1503 err = db.AddRepo(tx, repo) 1504 if err != nil { 1505 log.Println(err) ··· 1509 1510 // acls 1511 p, _ := securejoin.SecureJoin(user.Did, forkName) 1512 - err = rp.enforcer.AddRepo(user.Did, knot, p) 1513 if err != nil { 1514 log.Println(err) 1515 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") ··· 1530 return 1531 } 1532 1533 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1534 - return 1535 } 1536 } 1537 1538 func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { ··· 1550 return 1551 } 1552 1553 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1554 if err != nil { 1555 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1556 log.Println("failed to reach knotserver", err) ··· 1580 head = queryHead 1581 } 1582 1583 - tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1584 if err != nil { 1585 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1586 log.Println("failed to reach knotserver", err) ··· 1642 return 1643 } 1644 1645 - branches, err := us.Branches(f.OwnerDid(), f.RepoName) 1646 if err != nil { 1647 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1648 log.Println("failed to reach knotserver", err) 1649 return 1650 } 1651 1652 - tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1653 if err != nil { 1654 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1655 log.Println("failed to reach knotserver", err) 1656 return 1657 } 1658 1659 - formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head) 1660 if err != nil { 1661 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1662 log.Println("failed to compare", err)
··· 17 "strings" 18 "time" 19 20 + comatproto "github.com/bluesky-social/indigo/api/atproto" 21 + lexutil "github.com/bluesky-social/indigo/lex/util" 22 "tangled.sh/tangled.sh/core/api/tangled" 23 "tangled.sh/tangled.sh/core/appview/commitverify" 24 "tangled.sh/tangled.sh/core/appview/config" ··· 28 "tangled.sh/tangled.sh/core/appview/pages" 29 "tangled.sh/tangled.sh/core/appview/pages/markup" 30 "tangled.sh/tangled.sh/core/appview/reporesolver" 31 + xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 32 "tangled.sh/tangled.sh/core/eventconsumer" 33 "tangled.sh/tangled.sh/core/idresolver" 34 "tangled.sh/tangled.sh/core/knotclient" ··· 36 "tangled.sh/tangled.sh/core/rbac" 37 "tangled.sh/tangled.sh/core/tid" 38 "tangled.sh/tangled.sh/core/types" 39 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 40 41 securejoin "github.com/cyphar/filepath-securejoin" 42 "github.com/go-chi/chi/v5" 43 "github.com/go-git/go-git/v5/plumbing" 44 45 "github.com/bluesky-social/indigo/atproto/syntax" 46 ) 47 48 type Repo struct { ··· 56 enforcer *rbac.Enforcer 57 notifier notify.Notifier 58 logger *slog.Logger 59 + serviceAuth *serviceauth.ServiceAuth 60 } 61 62 func New( ··· 98 } else { 99 uri = "https" 100 } 101 + url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.Name, url.PathEscape(refParam)) 102 103 http.Redirect(w, r, url, http.StatusFound) 104 } ··· 126 return 127 } 128 129 + repolog, err := us.Log(f.OwnerDid(), f.Name, ref, page) 130 if err != nil { 131 + rp.pages.Error503(w) 132 log.Println("failed to reach knotserver", err) 133 return 134 } 135 136 + tagResult, err := us.Tags(f.OwnerDid(), f.Name) 137 if err != nil { 138 + rp.pages.Error503(w) 139 log.Println("failed to reach knotserver", err) 140 return 141 } ··· 149 tagMap[hash] = append(tagMap[hash], tag.Name) 150 } 151 152 + branchResult, err := us.Branches(f.OwnerDid(), f.Name) 153 if err != nil { 154 + rp.pages.Error503(w) 155 log.Println("failed to reach knotserver", err) 156 return 157 } ··· 218 return 219 } 220 221 + repoAt := f.RepoAt() 222 rkey := repoAt.RecordKey().String() 223 if rkey == "" { 224 log.Println("invalid aturi for repo", err) ··· 268 Record: &lexutil.LexiconTypeDecoder{ 269 Val: &tangled.Repo{ 270 Knot: f.Knot, 271 + Name: f.Name, 272 Owner: user.Did, 273 + CreatedAt: f.Created.Format(time.RFC3339), 274 Description: &newDescription, 275 Spindle: &f.Spindle, 276 }, ··· 316 return 317 } 318 319 + resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref)) 320 if err != nil { 321 + rp.pages.Error503(w) 322 log.Println("failed to reach knotserver", err) 323 return 324 } ··· 382 if !rp.config.Core.Dev { 383 protocol = "https" 384 } 385 + 386 + // if the tree path has a trailing slash, let's strip it 387 + // so we don't 404 388 + treePath = strings.TrimSuffix(treePath, "/") 389 + 390 + resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, treePath)) 391 if err != nil { 392 + rp.pages.Error503(w) 393 log.Println("failed to reach knotserver", err) 394 return 395 } 396 397 + // uhhh so knotserver returns a 500 if the entry isn't found in 398 + // the requested tree path, so let's stick to not-OK here. 399 + // we can fix this once we build out the xrpc apis for these operations. 400 + if resp.StatusCode != http.StatusOK { 401 + rp.pages.Error404(w) 402 + return 403 + } 404 + 405 body, err := io.ReadAll(resp.Body) 406 if err != nil { 407 log.Printf("Error reading response body: %v", err) ··· 426 user := rp.oauth.GetUser(r) 427 428 var breadcrumbs [][]string 429 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 430 if treePath != "" { 431 for idx, elem := range strings.Split(treePath, "/") { 432 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) ··· 457 return 458 } 459 460 + result, err := us.Tags(f.OwnerDid(), f.Name) 461 if err != nil { 462 + rp.pages.Error503(w) 463 log.Println("failed to reach knotserver", err) 464 return 465 } 466 467 + artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 468 if err != nil { 469 log.Println("failed grab artifacts", err) 470 return ··· 515 return 516 } 517 518 + result, err := us.Branches(f.OwnerDid(), f.Name) 519 if err != nil { 520 + rp.pages.Error503(w) 521 log.Println("failed to reach knotserver", err) 522 return 523 } ··· 545 if !rp.config.Core.Dev { 546 protocol = "https" 547 } 548 + resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath)) 549 if err != nil { 550 + rp.pages.Error503(w) 551 log.Println("failed to reach knotserver", err) 552 return 553 } 554 555 + if resp.StatusCode == http.StatusNotFound { 556 + rp.pages.Error404(w) 557 + return 558 + } 559 + 560 body, err := io.ReadAll(resp.Body) 561 if err != nil { 562 log.Printf("Error reading response body: %v", err) ··· 571 } 572 573 var breadcrumbs [][]string 574 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 575 if filePath != "" { 576 for idx, elem := range strings.Split(filePath, "/") { 577 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) ··· 604 605 // fetch the actual binary content like in RepoBlobRaw 606 607 + blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Name, ref, filePath) 608 contentSrc = blobURL 609 if !rp.config.Core.Dev { 610 contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) ··· 641 if !rp.config.Core.Dev { 642 protocol = "https" 643 } 644 + 645 + blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath) 646 + 647 + req, err := http.NewRequest("GET", blobURL, nil) 648 + if err != nil { 649 + log.Println("failed to create request", err) 650 + return 651 + } 652 + 653 + // forward the If-None-Match header 654 + if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 655 + req.Header.Set("If-None-Match", clientETag) 656 + } 657 + 658 + client := &http.Client{} 659 + resp, err := client.Do(req) 660 if err != nil { 661 + log.Println("failed to reach knotserver", err) 662 rp.pages.Error503(w) 663 return 664 } 665 defer resp.Body.Close() 666 + 667 + // forward 304 not modified 668 + if resp.StatusCode == http.StatusNotModified { 669 + w.WriteHeader(http.StatusNotModified) 670 + return 671 + } 672 673 if resp.StatusCode != http.StatusOK { 674 log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) ··· 717 return 718 } 719 720 + repoAt := f.RepoAt() 721 rkey := repoAt.RecordKey().String() 722 if rkey == "" { 723 fail("Failed to resolve repo. Try again later", err) ··· 771 Record: &lexutil.LexiconTypeDecoder{ 772 Val: &tangled.Repo{ 773 Knot: f.Knot, 774 + Name: f.Name, 775 Owner: user.Did, 776 + CreatedAt: f.Created.Format(time.RFC3339), 777 Description: &f.Description, 778 Spindle: spindlePtr, 779 }, ··· 854 Record: &lexutil.LexiconTypeDecoder{ 855 Val: &tangled.RepoCollaborator{ 856 Subject: collaboratorIdent.DID.String(), 857 + Repo: string(f.RepoAt()), 858 CreatedAt: createdAt.Format(time.RFC3339), 859 }}, 860 }) ··· 863 fail("Failed to write record to PDS.", err) 864 return 865 } 866 + 867 + aturi := resp.Uri 868 + l = l.With("at-uri", aturi) 869 l.Info("wrote record to PDS") 870 871 + tx, err := rp.db.BeginTx(r.Context(), nil) 872 if err != nil { 873 + fail("Failed to add collaborator.", err) 874 return 875 } 876 877 + rollback := func() { 878 + err1 := tx.Rollback() 879 + err2 := rp.enforcer.E.LoadPolicy() 880 + err3 := rollbackRecord(context.Background(), aturi, client) 881 882 + // ignore txn complete errors, this is okay 883 + if errors.Is(err1, sql.ErrTxDone) { 884 + err1 = nil 885 + } 886 887 + if errs := errors.Join(err1, err2, err3); errs != nil { 888 + l.Error("failed to rollback changes", "errs", errs) 889 + return 890 + } 891 } 892 + defer rollback() 893 894 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 895 if err != nil { ··· 901 Did: syntax.DID(currentUser.Did), 902 Rkey: rkey, 903 SubjectDid: collaboratorIdent.DID, 904 + RepoAt: f.RepoAt(), 905 Created: createdAt, 906 }) 907 if err != nil { ··· 921 return 922 } 923 924 + // clear aturi to when everything is successful 925 + aturi = "" 926 + 927 rp.pages.HxRefresh(w) 928 } 929 930 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 931 user := rp.oauth.GetUser(r) 932 933 + noticeId := "operation-error" 934 f, err := rp.repoResolver.Resolve(r) 935 if err != nil { 936 log.Println("failed to get repo and knot", err) ··· 943 log.Println("failed to get authorized client", err) 944 return 945 } 946 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 947 Collection: tangled.RepoNSID, 948 Repo: user.Did, 949 + Rkey: f.Rkey, 950 }) 951 if err != nil { 952 log.Printf("failed to delete record: %s", err) 953 + rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.") 954 return 955 } 956 + log.Println("removed repo record ", f.RepoAt().String()) 957 958 + client, err := rp.oauth.ServiceClient( 959 + r, 960 + oauth.WithService(f.Knot), 961 + oauth.WithLxm(tangled.RepoDeleteNSID), 962 + oauth.WithDev(rp.config.Core.Dev), 963 + ) 964 if err != nil { 965 + log.Println("failed to connect to knot server:", err) 966 return 967 } 968 969 + err = tangled.RepoDelete( 970 + r.Context(), 971 + client, 972 + &tangled.RepoDelete_Input{ 973 + Did: f.OwnerDid(), 974 + Name: f.Name, 975 + Rkey: f.Rkey, 976 + }, 977 + ) 978 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 979 + rp.pages.Notice(w, noticeId, err.Error()) 980 return 981 } 982 + log.Println("deleted repo from knot") 983 984 tx, err := rp.db.BeginTx(r.Context(), nil) 985 if err != nil { ··· 998 // remove collaborator RBAC 999 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 1000 if err != nil { 1001 + rp.pages.Notice(w, noticeId, "Failed to remove collaborators") 1002 return 1003 } 1004 for _, c := range repoCollaborators { ··· 1010 // remove repo RBAC 1011 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 1012 if err != nil { 1013 + rp.pages.Notice(w, noticeId, "Failed to update RBAC rules") 1014 return 1015 } 1016 1017 // remove repo from db 1018 + err = db.RemoveRepo(tx, f.OwnerDid(), f.Name) 1019 if err != nil { 1020 + rp.pages.Notice(w, noticeId, "Failed to update appview") 1021 return 1022 } 1023 log.Println("removed repo from db") ··· 1046 return 1047 } 1048 1049 + noticeId := "operation-error" 1050 branch := r.FormValue("branch") 1051 if branch == "" { 1052 http.Error(w, "malformed form", http.StatusBadRequest) 1053 return 1054 } 1055 1056 + client, err := rp.oauth.ServiceClient( 1057 + r, 1058 + oauth.WithService(f.Knot), 1059 + oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), 1060 + oauth.WithDev(rp.config.Core.Dev), 1061 + ) 1062 if err != nil { 1063 + log.Println("failed to connect to knot server:", err) 1064 + rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 1065 return 1066 } 1067 1068 + xe := tangled.RepoSetDefaultBranch( 1069 + r.Context(), 1070 + client, 1071 + &tangled.RepoSetDefaultBranch_Input{ 1072 + Repo: f.RepoAt().String(), 1073 + DefaultBranch: branch, 1074 + }, 1075 + ) 1076 + if err := xrpcclient.HandleXrpcErr(xe); err != nil { 1077 + log.Println("xrpc failed", "err", xe) 1078 + rp.pages.Notice(w, noticeId, err.Error()) 1079 return 1080 } 1081 1082 + rp.pages.HxRefresh(w) 1083 } 1084 1085 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { ··· 1136 r.Context(), 1137 spindleClient, 1138 &tangled.RepoAddSecret_Input{ 1139 + Repo: f.RepoAt().String(), 1140 Key: key, 1141 Value: value, 1142 }, ··· 1154 r.Context(), 1155 spindleClient, 1156 &tangled.RepoRemoveSecret_Input{ 1157 + Repo: f.RepoAt().String(), 1158 Key: key, 1159 }, 1160 ) ··· 1195 case "pipelines": 1196 rp.pipelineSettings(w, r) 1197 } 1198 } 1199 1200 func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { ··· 1207 return 1208 } 1209 1210 + result, err := us.Branches(f.OwnerDid(), f.Name) 1211 if err != nil { 1212 + rp.pages.Error503(w) 1213 log.Println("failed to reach knotserver", err) 1214 return 1215 } ··· 1262 oauth.WithDev(rp.config.Core.Dev), 1263 ); err != nil { 1264 log.Println("failed to create spindle client", err) 1265 + } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 1266 log.Println("failed to fetch secrets", err) 1267 } else { 1268 secrets = resp.Secrets ··· 1303 } 1304 1305 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1306 + ref := chi.URLParam(r, "ref") 1307 + 1308 user := rp.oauth.GetUser(r) 1309 f, err := rp.repoResolver.Resolve(r) 1310 if err != nil { ··· 1314 1315 switch r.Method { 1316 case http.MethodPost: 1317 + client, err := rp.oauth.ServiceClient( 1318 + r, 1319 + oauth.WithService(f.Knot), 1320 + oauth.WithLxm(tangled.RepoForkSyncNSID), 1321 + oauth.WithDev(rp.config.Core.Dev), 1322 + ) 1323 if err != nil { 1324 + rp.pages.Notice(w, "repo", "Failed to connect to knot server.") 1325 return 1326 } 1327 1328 + repoInfo := f.RepoInfo(user) 1329 + if repoInfo.Source == nil { 1330 + rp.pages.Notice(w, "repo", "This repository is not a fork.") 1331 return 1332 } 1333 1334 + err = tangled.RepoForkSync( 1335 + r.Context(), 1336 + client, 1337 + &tangled.RepoForkSync_Input{ 1338 + Did: user.Did, 1339 + Name: f.Name, 1340 + Source: repoInfo.Source.RepoAt().String(), 1341 + Branch: ref, 1342 + }, 1343 + ) 1344 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1345 + rp.pages.Notice(w, "repo", err.Error()) 1346 return 1347 } 1348 ··· 1375 }) 1376 1377 case http.MethodPost: 1378 + l := rp.logger.With("handler", "ForkRepo") 1379 1380 + targetKnot := r.FormValue("knot") 1381 + if targetKnot == "" { 1382 rp.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 1383 return 1384 } 1385 + l = l.With("targetKnot", targetKnot) 1386 1387 + ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create") 1388 if err != nil || !ok { 1389 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1390 return 1391 } 1392 1393 + // choose a name for a fork 1394 + forkName := f.Name 1395 // this check is *only* to see if the forked repo name already exists 1396 // in the user's account. 1397 + existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name) 1398 if err != nil { 1399 if errors.Is(err, sql.ErrNoRows) { 1400 // no existing repo with this name found, we can use the name as is ··· 1407 // repo with this name already exists, append random string 1408 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1409 } 1410 + l = l.With("forkName", forkName) 1411 1412 + uri := "https" 1413 if rp.config.Core.Dev { 1414 uri = "http" 1415 } 1416 + 1417 + forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1418 + l = l.With("cloneUrl", forkSourceUrl) 1419 1420 + sourceAt := f.RepoAt().String() 1421 + 1422 + // create an atproto record for this fork 1423 rkey := tid.TID() 1424 repo := &db.Repo{ 1425 Did: user.Did, 1426 Name: forkName, 1427 + Knot: targetKnot, 1428 Rkey: rkey, 1429 Source: sourceAt, 1430 } 1431 1432 xrpcClient, err := rp.oauth.AuthorizedClient(r) 1433 if err != nil { 1434 + l.Error("failed to create xrpcclient", "err", err) 1435 + rp.pages.Notice(w, "repo", "Failed to fork repository.") 1436 return 1437 } 1438 ··· 1451 }}, 1452 }) 1453 if err != nil { 1454 + l.Error("failed to write to PDS", "err", err) 1455 rp.pages.Notice(w, "repo", "Failed to announce repository creation.") 1456 return 1457 } 1458 + 1459 + aturi := atresp.Uri 1460 + l = l.With("aturi", aturi) 1461 + l.Info("wrote to PDS") 1462 + 1463 + tx, err := rp.db.BeginTx(r.Context(), nil) 1464 + if err != nil { 1465 + l.Info("txn failed", "err", err) 1466 + rp.pages.Notice(w, "repo", "Failed to save repository information.") 1467 + return 1468 + } 1469 + 1470 + // The rollback function reverts a few things on failure: 1471 + // - the pending txn 1472 + // - the ACLs 1473 + // - the atproto record created 1474 + rollback := func() { 1475 + err1 := tx.Rollback() 1476 + err2 := rp.enforcer.E.LoadPolicy() 1477 + err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 1478 + 1479 + // ignore txn complete errors, this is okay 1480 + if errors.Is(err1, sql.ErrTxDone) { 1481 + err1 = nil 1482 + } 1483 + 1484 + if errs := errors.Join(err1, err2, err3); errs != nil { 1485 + l.Error("failed to rollback changes", "errs", errs) 1486 + return 1487 + } 1488 + } 1489 + defer rollback() 1490 + 1491 + client, err := rp.oauth.ServiceClient( 1492 + r, 1493 + oauth.WithService(targetKnot), 1494 + oauth.WithLxm(tangled.RepoCreateNSID), 1495 + oauth.WithDev(rp.config.Core.Dev), 1496 + ) 1497 + if err != nil { 1498 + l.Error("could not create service client", "err", err) 1499 + rp.pages.Notice(w, "repo", "Failed to connect to knot server.") 1500 + return 1501 + } 1502 + 1503 + err = tangled.RepoCreate( 1504 + r.Context(), 1505 + client, 1506 + &tangled.RepoCreate_Input{ 1507 + Rkey: rkey, 1508 + Source: &forkSourceUrl, 1509 + }, 1510 + ) 1511 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1512 + rp.pages.Notice(w, "repo", err.Error()) 1513 + return 1514 + } 1515 1516 err = db.AddRepo(tx, repo) 1517 if err != nil { 1518 log.Println(err) ··· 1522 1523 // acls 1524 p, _ := securejoin.SecureJoin(user.Did, forkName) 1525 + err = rp.enforcer.AddRepo(user.Did, targetKnot, p) 1526 if err != nil { 1527 log.Println(err) 1528 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") ··· 1543 return 1544 } 1545 1546 + // reset the ATURI because the transaction completed successfully 1547 + aturi = "" 1548 + 1549 + rp.notifier.NewRepo(r.Context(), repo) 1550 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1551 + } 1552 + } 1553 + 1554 + // this is used to rollback changes made to the PDS 1555 + // 1556 + // it is a no-op if the provided ATURI is empty 1557 + func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 1558 + if aturi == "" { 1559 + return nil 1560 } 1561 + 1562 + parsed := syntax.ATURI(aturi) 1563 + 1564 + collection := parsed.Collection().String() 1565 + repo := parsed.Authority().String() 1566 + rkey := parsed.RecordKey().String() 1567 + 1568 + _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 1569 + Collection: collection, 1570 + Repo: repo, 1571 + Rkey: rkey, 1572 + }) 1573 + return err 1574 } 1575 1576 func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { ··· 1588 return 1589 } 1590 1591 + result, err := us.Branches(f.OwnerDid(), f.Name) 1592 if err != nil { 1593 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1594 log.Println("failed to reach knotserver", err) ··· 1618 head = queryHead 1619 } 1620 1621 + tags, err := us.Tags(f.OwnerDid(), f.Name) 1622 if err != nil { 1623 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1624 log.Println("failed to reach knotserver", err) ··· 1680 return 1681 } 1682 1683 + branches, err := us.Branches(f.OwnerDid(), f.Name) 1684 if err != nil { 1685 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1686 log.Println("failed to reach knotserver", err) 1687 return 1688 } 1689 1690 + tags, err := us.Tags(f.OwnerDid(), f.Name) 1691 if err != nil { 1692 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1693 log.Println("failed to reach knotserver", err) 1694 return 1695 } 1696 1697 + formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head) 1698 if err != nil { 1699 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1700 log.Println("failed to compare", err)
+1
appview/repo/router.go
··· 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 r := chi.NewRouter() 12 r.Get("/", rp.RepoIndex) 13 r.Get("/commits/{ref}", rp.RepoLog) 14 r.Route("/tree/{ref}", func(r chi.Router) { 15 r.Get("/", rp.RepoIndex)
··· 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 r := chi.NewRouter() 12 r.Get("/", rp.RepoIndex) 13 + r.Get("/feed.atom", rp.RepoAtomFeed) 14 r.Get("/commits/{ref}", rp.RepoLog) 15 r.Route("/tree/{ref}", func(r chi.Router) { 16 r.Get("/", rp.RepoIndex)
+37 -104
appview/reporesolver/resolver.go
··· 7 "fmt" 8 "log" 9 "net/http" 10 - "net/url" 11 "path" 12 "strings" 13 14 "github.com/bluesky-social/indigo/atproto/identity" 15 - "github.com/bluesky-social/indigo/atproto/syntax" 16 securejoin "github.com/cyphar/filepath-securejoin" 17 "github.com/go-chi/chi/v5" 18 "tangled.sh/tangled.sh/core/appview/config" ··· 21 "tangled.sh/tangled.sh/core/appview/pages" 22 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 23 "tangled.sh/tangled.sh/core/idresolver" 24 - "tangled.sh/tangled.sh/core/knotclient" 25 "tangled.sh/tangled.sh/core/rbac" 26 ) 27 28 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 38 39 rr *RepoResolver 40 } ··· 51 } 52 53 func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) { 54 - repoName := chi.URLParam(r, "repo") 55 - knot, ok := r.Context().Value("knot").(string) 56 if !ok { 57 - log.Println("malformed middleware") 58 return nil, fmt.Errorf("malformed middleware") 59 } 60 id, ok := r.Context().Value("resolvedId").(identity.Identity) ··· 63 return nil, fmt.Errorf("malformed middleware") 64 } 65 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 - 78 ref := chi.URLParam(r, "ref") 79 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 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, 111 112 rr: rr, 113 }, nil ··· 126 127 var p string 128 if handle != "" && !handle.IsInvalidHandle() { 129 - p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName) 130 } else { 131 - p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 132 } 133 134 - return p 135 - } 136 - 137 - func (f *ResolvedRepo) DidSlashRepo() string { 138 - p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 139 return p 140 } 141 ··· 187 // this function is a bit weird since it now returns RepoInfo from an entirely different 188 // package. we should refactor this or get rid of RepoInfo entirely. 189 func (f *ResolvedRepo) RepoInfo(user *oauth.User) repoinfo.RepoInfo { 190 isStarred := false 191 if user != nil { 192 - isStarred = db.GetStarStatus(f.rr.execer, user.Did, syntax.ATURI(f.RepoAt)) 193 } 194 195 - starCount, err := db.GetStarCount(f.rr.execer, f.RepoAt) 196 if err != nil { 197 - log.Println("failed to get star count for ", f.RepoAt) 198 } 199 - issueCount, err := db.GetIssueCount(f.rr.execer, f.RepoAt) 200 if err != nil { 201 - log.Println("failed to get issue count for ", f.RepoAt) 202 } 203 - pullCount, err := db.GetPullCount(f.rr.execer, f.RepoAt) 204 if err != nil { 205 - log.Println("failed to get issue count for ", f.RepoAt) 206 } 207 - source, err := db.GetRepoSource(f.rr.execer, f.RepoAt) 208 if errors.Is(err, sql.ErrNoRows) { 209 source = "" 210 } else if err != nil { 211 - log.Println("failed to get repo source for ", f.RepoAt, err) 212 } 213 214 var sourceRepo *db.Repo ··· 228 } 229 230 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 246 repoInfo := repoinfo.RepoInfo{ 247 OwnerDid: f.OwnerDid(), 248 OwnerHandle: f.OwnerHandle(), 249 - Name: f.RepoName, 250 - RepoAt: f.RepoAt, 251 Description: f.Description, 252 - Ref: f.Ref, 253 IsStarred: isStarred, 254 Knot: knot, 255 Spindle: f.Spindle, ··· 259 IssueCount: issueCount, 260 PullCount: pullCount, 261 }, 262 - DisableFork: disableFork, 263 - CurrentDir: f.CurrentDir, 264 } 265 266 if sourceRepo != nil { ··· 284 // after the ref. for example: 285 // 286 // /@icyphox.sh/foorepo/blob/main/abc/xyz/ => abc/xyz/ 287 - func extractPathAfterRef(fullPath, ref string) string { 288 fullPath = strings.TrimPrefix(fullPath, "/") 289 290 - ref = url.PathEscape(ref) 291 292 - prefixes := []string{ 293 - fmt.Sprintf("blob/%s/", ref), 294 - fmt.Sprintf("tree/%s/", ref), 295 - fmt.Sprintf("raw/%s/", ref), 296 - } 297 298 - for _, prefix := range prefixes { 299 - idx := strings.Index(fullPath, prefix) 300 - if idx != -1 { 301 - return fullPath[idx+len(prefix):] 302 - } 303 } 304 305 return ""
··· 7 "fmt" 8 "log" 9 "net/http" 10 "path" 11 + "regexp" 12 "strings" 13 14 "github.com/bluesky-social/indigo/atproto/identity" 15 securejoin "github.com/cyphar/filepath-securejoin" 16 "github.com/go-chi/chi/v5" 17 "tangled.sh/tangled.sh/core/appview/config" ··· 20 "tangled.sh/tangled.sh/core/appview/pages" 21 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 22 "tangled.sh/tangled.sh/core/idresolver" 23 "tangled.sh/tangled.sh/core/rbac" 24 ) 25 26 type ResolvedRepo struct { 27 + db.Repo 28 + OwnerId identity.Identity 29 + CurrentDir string 30 + Ref string 31 32 rr *RepoResolver 33 } ··· 44 } 45 46 func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) { 47 + repo, ok := r.Context().Value("repo").(*db.Repo) 48 if !ok { 49 + log.Println("malformed middleware: `repo` not exist in context") 50 return nil, fmt.Errorf("malformed middleware") 51 } 52 id, ok := r.Context().Value("resolvedId").(identity.Identity) ··· 55 return nil, fmt.Errorf("malformed middleware") 56 } 57 58 + currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath())) 59 ref := chi.URLParam(r, "ref") 60 61 return &ResolvedRepo{ 62 + Repo: *repo, 63 + OwnerId: id, 64 + CurrentDir: currentDir, 65 + Ref: ref, 66 67 rr: rr, 68 }, nil ··· 81 82 var p string 83 if handle != "" && !handle.IsInvalidHandle() { 84 + p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.Name) 85 } else { 86 + p, _ = securejoin.SecureJoin(f.OwnerDid(), f.Name) 87 } 88 89 return p 90 } 91 ··· 137 // this function is a bit weird since it now returns RepoInfo from an entirely different 138 // package. we should refactor this or get rid of RepoInfo entirely. 139 func (f *ResolvedRepo) RepoInfo(user *oauth.User) repoinfo.RepoInfo { 140 + repoAt := f.RepoAt() 141 isStarred := false 142 if user != nil { 143 + isStarred = db.GetStarStatus(f.rr.execer, user.Did, repoAt) 144 } 145 146 + starCount, err := db.GetStarCount(f.rr.execer, repoAt) 147 if err != nil { 148 + log.Println("failed to get star count for ", repoAt) 149 } 150 + issueCount, err := db.GetIssueCount(f.rr.execer, repoAt) 151 if err != nil { 152 + log.Println("failed to get issue count for ", repoAt) 153 } 154 + pullCount, err := db.GetPullCount(f.rr.execer, repoAt) 155 if err != nil { 156 + log.Println("failed to get issue count for ", repoAt) 157 } 158 + source, err := db.GetRepoSource(f.rr.execer, repoAt) 159 if errors.Is(err, sql.ErrNoRows) { 160 source = "" 161 } else if err != nil { 162 + log.Println("failed to get repo source for ", repoAt, err) 163 } 164 165 var sourceRepo *db.Repo ··· 179 } 180 181 knot := f.Knot 182 183 repoInfo := repoinfo.RepoInfo{ 184 OwnerDid: f.OwnerDid(), 185 OwnerHandle: f.OwnerHandle(), 186 + Name: f.Name, 187 + RepoAt: repoAt, 188 Description: f.Description, 189 IsStarred: isStarred, 190 Knot: knot, 191 Spindle: f.Spindle, ··· 195 IssueCount: issueCount, 196 PullCount: pullCount, 197 }, 198 + CurrentDir: f.CurrentDir, 199 + Ref: f.Ref, 200 } 201 202 if sourceRepo != nil { ··· 220 // after the ref. for example: 221 // 222 // /@icyphox.sh/foorepo/blob/main/abc/xyz/ => abc/xyz/ 223 + func extractPathAfterRef(fullPath string) string { 224 fullPath = strings.TrimPrefix(fullPath, "/") 225 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)/[^/]+/(.*)$` 230 231 + re := regexp.MustCompile(pattern) 232 + matches := re.FindStringSubmatch(fullPath) 233 234 + if len(matches) > 1 { 235 + return matches[1] 236 } 237 238 return ""
+164
appview/serververify/verify.go
···
··· 1 + package serververify 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "strings" 10 + "time" 11 + 12 + "tangled.sh/tangled.sh/core/appview/db" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + ) 15 + 16 + var ( 17 + FetchError = errors.New("failed to fetch owner") 18 + ) 19 + 20 + // fetchOwner fetches the owner DID from a server's /owner endpoint 21 + func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) { 22 + scheme := "https" 23 + if dev { 24 + scheme = "http" 25 + } 26 + 27 + url := fmt.Sprintf("%s://%s/owner", scheme, domain) 28 + req, err := http.NewRequest("GET", url, nil) 29 + if err != nil { 30 + return "", err 31 + } 32 + 33 + client := &http.Client{ 34 + Timeout: 1 * time.Second, 35 + } 36 + 37 + resp, err := client.Do(req.WithContext(ctx)) 38 + if err != nil || resp.StatusCode != 200 { 39 + return "", fmt.Errorf("failed to fetch /owner") 40 + } 41 + 42 + body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data 43 + if err != nil { 44 + return "", fmt.Errorf("failed to read /owner response: %w", err) 45 + } 46 + 47 + did := strings.TrimSpace(string(body)) 48 + if did == "" { 49 + return "", fmt.Errorf("empty DID in /owner response") 50 + } 51 + 52 + return did, nil 53 + } 54 + 55 + type OwnerMismatch struct { 56 + expected string 57 + observed string 58 + } 59 + 60 + func (e *OwnerMismatch) Error() string { 61 + return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed) 62 + } 63 + 64 + // RunVerification verifies that the server at the given domain has the expected owner 65 + func RunVerification(ctx context.Context, domain, expectedOwner string, dev bool) error { 66 + observedOwner, err := fetchOwner(ctx, domain, dev) 67 + if err != nil { 68 + return fmt.Errorf("%w: %w", FetchError, err) 69 + } 70 + 71 + if observedOwner != expectedOwner { 72 + return &OwnerMismatch{ 73 + expected: expectedOwner, 74 + observed: observedOwner, 75 + } 76 + } 77 + 78 + return nil 79 + } 80 + 81 + // MarkSpindleVerified marks a spindle as verified in the DB and adds the user as its owner 82 + func MarkSpindleVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) { 83 + tx, err := d.Begin() 84 + if err != nil { 85 + return 0, fmt.Errorf("failed to create txn: %w", err) 86 + } 87 + defer func() { 88 + tx.Rollback() 89 + e.E.LoadPolicy() 90 + }() 91 + 92 + // mark this spindle as verified in the db 93 + rowId, err := db.VerifySpindle( 94 + tx, 95 + db.FilterEq("owner", owner), 96 + db.FilterEq("instance", instance), 97 + ) 98 + if err != nil { 99 + return 0, fmt.Errorf("failed to write to DB: %w", err) 100 + } 101 + 102 + err = e.AddSpindleOwner(instance, owner) 103 + if err != nil { 104 + return 0, fmt.Errorf("failed to update ACL: %w", err) 105 + } 106 + 107 + err = tx.Commit() 108 + if err != nil { 109 + return 0, fmt.Errorf("failed to commit txn: %w", err) 110 + } 111 + 112 + err = e.E.SavePolicy() 113 + if err != nil { 114 + return 0, fmt.Errorf("failed to update ACL: %w", err) 115 + } 116 + 117 + return rowId, nil 118 + } 119 + 120 + // MarkKnotVerified marks a knot as verified and sets up ownership/permissions 121 + func MarkKnotVerified(d *db.DB, e *rbac.Enforcer, domain, owner string) error { 122 + tx, err := d.BeginTx(context.Background(), nil) 123 + if err != nil { 124 + return fmt.Errorf("failed to start tx: %w", err) 125 + } 126 + defer func() { 127 + tx.Rollback() 128 + e.E.LoadPolicy() 129 + }() 130 + 131 + // mark as registered 132 + err = db.MarkRegistered( 133 + tx, 134 + db.FilterEq("did", owner), 135 + db.FilterEq("domain", domain), 136 + ) 137 + if err != nil { 138 + return fmt.Errorf("failed to register domain: %w", err) 139 + } 140 + 141 + // add basic acls for this domain 142 + err = e.AddKnot(domain) 143 + if err != nil { 144 + return fmt.Errorf("failed to add knot to enforcer: %w", err) 145 + } 146 + 147 + // add this did as owner of this domain 148 + err = e.AddKnotOwner(domain, owner) 149 + if err != nil { 150 + return fmt.Errorf("failed to add knot owner to enforcer: %w", err) 151 + } 152 + 153 + err = tx.Commit() 154 + if err != nil { 155 + return fmt.Errorf("failed to commit changes: %w", err) 156 + } 157 + 158 + err = e.E.SavePolicy() 159 + if err != nil { 160 + return fmt.Errorf("failed to update ACLs: %w", err) 161 + } 162 + 163 + return nil 164 + }
+44 -9
appview/settings/settings.go
··· 33 Config *config.Config 34 } 35 36 func (s *Settings) Router() http.Handler { 37 r := chi.NewRouter() 38 39 r.Use(middleware.AuthMiddleware(s.OAuth)) 40 41 - r.Get("/", s.settings) 42 43 r.Route("/keys", func(r chi.Router) { 44 r.Put("/", s.keys) 45 r.Delete("/", s.keys) 46 }) 47 48 r.Route("/emails", func(r chi.Router) { 49 r.Put("/", s.emails) 50 r.Delete("/", s.emails) 51 r.Get("/verify", s.emailsVerify) ··· 56 return r 57 } 58 59 - func (s *Settings) settings(w http.ResponseWriter, r *http.Request) { 60 user := s.OAuth.GetUser(r) 61 pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did) 62 if err != nil { 63 log.Println(err) 64 } 65 66 emails, err := db.GetAllEmails(s.Db, user.Did) 67 if err != nil { 68 log.Println(err) 69 } 70 71 - s.Pages.Settings(w, pages.SettingsParams{ 72 LoggedInUser: user, 73 - PubKeys: pubKeys, 74 Emails: emails, 75 }) 76 } 77 ··· 201 return 202 } 203 204 - s.Pages.HxLocation(w, "/settings") 205 return 206 } 207 } ··· 244 return 245 } 246 247 - http.Redirect(w, r, "/settings", http.StatusSeeOther) 248 } 249 250 func (s *Settings) emailsVerifyResend(w http.ResponseWriter, r *http.Request) { ··· 339 return 340 } 341 342 - s.Pages.HxLocation(w, "/settings") 343 } 344 345 func (s *Settings) keys(w http.ResponseWriter, r *http.Request) { ··· 410 return 411 } 412 413 - s.Pages.HxLocation(w, "/settings") 414 return 415 416 case http.MethodDelete: ··· 455 } 456 log.Println("deleted successfully") 457 458 - s.Pages.HxLocation(w, "/settings") 459 return 460 } 461 }
··· 33 Config *config.Config 34 } 35 36 + type tab = map[string]any 37 + 38 + var ( 39 + settingsTabs []tab = []tab{ 40 + {"Name": "profile", "Icon": "user"}, 41 + {"Name": "keys", "Icon": "key"}, 42 + {"Name": "emails", "Icon": "mail"}, 43 + } 44 + ) 45 + 46 func (s *Settings) Router() http.Handler { 47 r := chi.NewRouter() 48 49 r.Use(middleware.AuthMiddleware(s.OAuth)) 50 51 + // settings pages 52 + r.Get("/", s.profileSettings) 53 + r.Get("/profile", s.profileSettings) 54 55 r.Route("/keys", func(r chi.Router) { 56 + r.Get("/", s.keysSettings) 57 r.Put("/", s.keys) 58 r.Delete("/", s.keys) 59 }) 60 61 r.Route("/emails", func(r chi.Router) { 62 + r.Get("/", s.emailsSettings) 63 r.Put("/", s.emails) 64 r.Delete("/", s.emails) 65 r.Get("/verify", s.emailsVerify) ··· 70 return r 71 } 72 73 + func (s *Settings) profileSettings(w http.ResponseWriter, r *http.Request) { 74 + user := s.OAuth.GetUser(r) 75 + 76 + s.Pages.UserProfileSettings(w, pages.UserProfileSettingsParams{ 77 + LoggedInUser: user, 78 + Tabs: settingsTabs, 79 + Tab: "profile", 80 + }) 81 + } 82 + 83 + func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) { 84 user := s.OAuth.GetUser(r) 85 pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did) 86 if err != nil { 87 log.Println(err) 88 } 89 90 + s.Pages.UserKeysSettings(w, pages.UserKeysSettingsParams{ 91 + LoggedInUser: user, 92 + PubKeys: pubKeys, 93 + Tabs: settingsTabs, 94 + Tab: "keys", 95 + }) 96 + } 97 + 98 + func (s *Settings) emailsSettings(w http.ResponseWriter, r *http.Request) { 99 + user := s.OAuth.GetUser(r) 100 emails, err := db.GetAllEmails(s.Db, user.Did) 101 if err != nil { 102 log.Println(err) 103 } 104 105 + s.Pages.UserEmailsSettings(w, pages.UserEmailsSettingsParams{ 106 LoggedInUser: user, 107 Emails: emails, 108 + Tabs: settingsTabs, 109 + Tab: "emails", 110 }) 111 } 112 ··· 236 return 237 } 238 239 + s.Pages.HxLocation(w, "/settings/emails") 240 return 241 } 242 } ··· 279 return 280 } 281 282 + http.Redirect(w, r, "/settings/emails", http.StatusSeeOther) 283 } 284 285 func (s *Settings) emailsVerifyResend(w http.ResponseWriter, r *http.Request) { ··· 374 return 375 } 376 377 + s.Pages.HxLocation(w, "/settings/emails") 378 } 379 380 func (s *Settings) keys(w http.ResponseWriter, r *http.Request) { ··· 445 return 446 } 447 448 + s.Pages.HxLocation(w, "/settings/keys") 449 return 450 451 case http.MethodDelete: ··· 490 } 491 log.Println("deleted successfully") 492 493 + s.Pages.HxLocation(w, "/settings/keys") 494 return 495 } 496 }
+8 -8
appview/spindles/spindles.go
··· 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 - verify "tangled.sh/tangled.sh/core/appview/spindleverify" 19 "tangled.sh/tangled.sh/core/idresolver" 20 "tangled.sh/tangled.sh/core/rbac" 21 "tangled.sh/tangled.sh/core/tid" ··· 227 } 228 229 // begin verification 230 - err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 231 if err != nil { 232 l.Error("verification failed", "err", err) 233 s.Pages.HxRefresh(w) 234 return 235 } 236 237 - _, err = verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did) 238 if err != nil { 239 l.Error("failed to mark verified", "err", err) 240 s.Pages.HxRefresh(w) ··· 400 } 401 402 // begin verification 403 - err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 404 if err != nil { 405 l.Error("verification failed", "err", err) 406 407 - if errors.Is(err, verify.FetchError) { 408 - s.Pages.Notice(w, noticeId, err.Error()) 409 return 410 } 411 412 - if e, ok := err.(*verify.OwnerMismatch); ok { 413 s.Pages.Notice(w, noticeId, e.Error()) 414 return 415 } ··· 418 return 419 } 420 421 - rowId, err := verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did) 422 if err != nil { 423 l.Error("failed to mark verified", "err", err) 424 s.Pages.Notice(w, noticeId, err.Error())
··· 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/serververify" 19 "tangled.sh/tangled.sh/core/idresolver" 20 "tangled.sh/tangled.sh/core/rbac" 21 "tangled.sh/tangled.sh/core/tid" ··· 227 } 228 229 // begin verification 230 + err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 231 if err != nil { 232 l.Error("verification failed", "err", err) 233 s.Pages.HxRefresh(w) 234 return 235 } 236 237 + _, err = serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 238 if err != nil { 239 l.Error("failed to mark verified", "err", err) 240 s.Pages.HxRefresh(w) ··· 400 } 401 402 // begin verification 403 + err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 404 if err != nil { 405 l.Error("verification failed", "err", err) 406 407 + if errors.Is(err, serververify.FetchError) { 408 + s.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.") 409 return 410 } 411 412 + if e, ok := err.(*serververify.OwnerMismatch); ok { 413 s.Pages.Notice(w, noticeId, e.Error()) 414 return 415 } ··· 418 return 419 } 420 421 + rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 422 if err != nil { 423 l.Error("failed to mark verified", "err", err) 424 s.Pages.Notice(w, noticeId, err.Error())
-118
appview/spindleverify/verify.go
··· 1 - package spindleverify 2 - 3 - import ( 4 - "context" 5 - "errors" 6 - "fmt" 7 - "io" 8 - "net/http" 9 - "strings" 10 - "time" 11 - 12 - "tangled.sh/tangled.sh/core/appview/db" 13 - "tangled.sh/tangled.sh/core/rbac" 14 - ) 15 - 16 - var ( 17 - FetchError = errors.New("failed to fetch owner") 18 - ) 19 - 20 - // TODO: move this to "spindleclient" or similar 21 - func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) { 22 - scheme := "https" 23 - if dev { 24 - scheme = "http" 25 - } 26 - 27 - url := fmt.Sprintf("%s://%s/owner", scheme, domain) 28 - req, err := http.NewRequest("GET", url, nil) 29 - if err != nil { 30 - return "", err 31 - } 32 - 33 - client := &http.Client{ 34 - Timeout: 1 * time.Second, 35 - } 36 - 37 - resp, err := client.Do(req.WithContext(ctx)) 38 - if err != nil || resp.StatusCode != 200 { 39 - return "", fmt.Errorf("failed to fetch /owner") 40 - } 41 - 42 - body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data 43 - if err != nil { 44 - return "", fmt.Errorf("failed to read /owner response: %w", err) 45 - } 46 - 47 - did := strings.TrimSpace(string(body)) 48 - if did == "" { 49 - return "", fmt.Errorf("empty DID in /owner response") 50 - } 51 - 52 - return did, nil 53 - } 54 - 55 - type OwnerMismatch struct { 56 - expected string 57 - observed string 58 - } 59 - 60 - func (e *OwnerMismatch) Error() string { 61 - return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed) 62 - } 63 - 64 - func RunVerification(ctx context.Context, instance, expectedOwner string, dev bool) error { 65 - // begin verification 66 - observedOwner, err := fetchOwner(ctx, instance, dev) 67 - if err != nil { 68 - return fmt.Errorf("%w: %w", FetchError, err) 69 - } 70 - 71 - if observedOwner != expectedOwner { 72 - return &OwnerMismatch{ 73 - expected: expectedOwner, 74 - observed: observedOwner, 75 - } 76 - } 77 - 78 - return nil 79 - } 80 - 81 - // mark this spindle as verified in the DB and add this user as its owner 82 - func MarkVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) { 83 - tx, err := d.Begin() 84 - if err != nil { 85 - return 0, fmt.Errorf("failed to create txn: %w", err) 86 - } 87 - defer func() { 88 - tx.Rollback() 89 - e.E.LoadPolicy() 90 - }() 91 - 92 - // mark this spindle as verified in the db 93 - rowId, err := db.VerifySpindle( 94 - tx, 95 - db.FilterEq("owner", owner), 96 - db.FilterEq("instance", instance), 97 - ) 98 - if err != nil { 99 - return 0, fmt.Errorf("failed to write to DB: %w", err) 100 - } 101 - 102 - err = e.AddSpindleOwner(instance, owner) 103 - if err != nil { 104 - return 0, fmt.Errorf("failed to update ACL: %w", err) 105 - } 106 - 107 - err = tx.Commit() 108 - if err != nil { 109 - return 0, fmt.Errorf("failed to commit txn: %w", err) 110 - } 111 - 112 - err = e.E.SavePolicy() 113 - if err != nil { 114 - return 0, fmt.Errorf("failed to update ACL: %w", err) 115 - } 116 - 117 - return rowId, nil 118 - }
···
+9 -12
appview/state/git_http.go
··· 3 import ( 4 "fmt" 5 "io" 6 "net/http" 7 8 "github.com/bluesky-social/indigo/atproto/identity" 9 "github.com/go-chi/chi/v5" 10 ) 11 12 func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) { 13 user := r.Context().Value("resolvedId").(identity.Identity) 14 - knot := r.Context().Value("knot").(string) 15 - repo := chi.URLParam(r, "repo") 16 17 scheme := "https" 18 if s.config.Core.Dev { 19 scheme = "http" 20 } 21 22 - targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, knot, user.DID, repo, r.URL.RawQuery) 23 s.proxyRequest(w, r, targetURL) 24 25 } ··· 30 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 31 return 32 } 33 - knot := r.Context().Value("knot").(string) 34 - repo := chi.URLParam(r, "repo") 35 36 scheme := "https" 37 if s.config.Core.Dev { 38 scheme = "http" 39 } 40 41 - targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery) 42 s.proxyRequest(w, r, targetURL) 43 } 44 ··· 48 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 49 return 50 } 51 - knot := r.Context().Value("knot").(string) 52 - repo := chi.URLParam(r, "repo") 53 54 scheme := "https" 55 if s.config.Core.Dev { 56 scheme = "http" 57 } 58 59 - targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery) 60 s.proxyRequest(w, r, targetURL) 61 } 62 ··· 85 defer resp.Body.Close() 86 87 // Copy response headers 88 - for k, v := range resp.Header { 89 - w.Header()[k] = v 90 - } 91 92 // Set response status code 93 w.WriteHeader(resp.StatusCode)
··· 3 import ( 4 "fmt" 5 "io" 6 + "maps" 7 "net/http" 8 9 "github.com/bluesky-social/indigo/atproto/identity" 10 "github.com/go-chi/chi/v5" 11 + "tangled.sh/tangled.sh/core/appview/db" 12 ) 13 14 func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) { 15 user := r.Context().Value("resolvedId").(identity.Identity) 16 + repo := r.Context().Value("repo").(*db.Repo) 17 18 scheme := "https" 19 if s.config.Core.Dev { 20 scheme = "http" 21 } 22 23 + targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 24 s.proxyRequest(w, r, targetURL) 25 26 } ··· 31 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 32 return 33 } 34 + repo := r.Context().Value("repo").(*db.Repo) 35 36 scheme := "https" 37 if s.config.Core.Dev { 38 scheme = "http" 39 } 40 41 + targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 42 s.proxyRequest(w, r, targetURL) 43 } 44 ··· 48 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 49 return 50 } 51 + repo := r.Context().Value("repo").(*db.Repo) 52 53 scheme := "https" 54 if s.config.Core.Dev { 55 scheme = "http" 56 } 57 58 + targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 59 s.proxyRequest(w, r, targetURL) 60 } 61 ··· 84 defer resp.Body.Close() 85 86 // Copy response headers 87 + maps.Copy(w.Header(), resp.Header) 88 89 // Set response status code 90 w.WriteHeader(resp.StatusCode)
+5 -2
appview/state/knotstream.go
··· 24 ) 25 26 func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) { 27 - knots, err := db.GetCompletedRegistrations(d) 28 if err != nil { 29 return nil, err 30 } 31 32 srcs := make(map[ec.Source]struct{}) 33 for _, k := range knots { 34 - s := ec.NewKnotSource(k) 35 srcs[s] = struct{}{} 36 } 37
··· 24 ) 25 26 func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) { 27 + knots, err := db.GetRegistrations( 28 + d, 29 + db.FilterIsNot("registered", "null"), 30 + ) 31 if err != nil { 32 return nil, err 33 } 34 35 srcs := make(map[ec.Source]struct{}) 36 for _, k := range knots { 37 + s := ec.NewKnotSource(k.Domain) 38 srcs[s] = struct{}{} 39 } 40
+291 -128
appview/state/profile.go
··· 17 "github.com/gorilla/feeds" 18 "tangled.sh/tangled.sh/core/api/tangled" 19 "tangled.sh/tangled.sh/core/appview/db" 20 "tangled.sh/tangled.sh/core/appview/pages" 21 ) 22 ··· 24 tabVal := r.URL.Query().Get("tab") 25 switch tabVal { 26 case "": 27 - s.profilePage(w, r) 28 case "repos": 29 s.reposPage(w, r) 30 } 31 } 32 33 - func (s *State) profilePage(w http.ResponseWriter, r *http.Request) { 34 didOrHandle := chi.URLParam(r, "user") 35 if didOrHandle == "" { 36 - http.Error(w, "Bad request", http.StatusBadRequest) 37 - return 38 } 39 40 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 41 if !ok { 42 - s.pages.Error404(w) 43 - return 44 } 45 46 - profile, err := db.GetProfile(s.db, ident.DID.String()) 47 if err != nil { 48 - log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 49 } 50 51 repos, err := db.GetRepos( 52 s.db, 53 0, 54 - db.FilterEq("did", ident.DID.String()), 55 ) 56 if err != nil { 57 - log.Printf("getting repos for %s: %s", ident.DID.String(), err) 58 } 59 60 // filter out ones that are pinned 61 pinnedRepos := []db.Repo{} 62 for i, r := range repos { ··· 71 } 72 } 73 74 - collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String()) 75 if err != nil { 76 - log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err) 77 } 78 79 pinnedCollaboratingRepos := []db.Repo{} ··· 84 } 85 } 86 87 - timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String()) 88 if err != nil { 89 - log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err) 90 } 91 92 var didsToResolve []string ··· 108 } 109 } 110 111 - followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String()) 112 - if err != nil { 113 - log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 114 - } 115 - 116 - loggedInUser := s.oauth.GetUser(r) 117 - followStatus := db.IsNotFollowing 118 - if loggedInUser != nil { 119 - followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 120 - } 121 - 122 now := time.Now() 123 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 124 punchcard, err := db.MakePunchcard( 125 s.db, 126 - db.FilterEq("did", ident.DID.String()), 127 db.FilterGte("date", startOfYear.Format(time.DateOnly)), 128 db.FilterLte("date", now.Format(time.DateOnly)), 129 ) 130 if err != nil { 131 - log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err) 132 } 133 134 - s.pages.ProfilePage(w, pages.ProfilePageParams{ 135 - LoggedInUser: loggedInUser, 136 Repos: pinnedRepos, 137 CollaboratingRepos: pinnedCollaboratingRepos, 138 - Card: pages.ProfileCard{ 139 - UserDid: ident.DID.String(), 140 - UserHandle: ident.Handle.String(), 141 - Profile: profile, 142 - FollowStatus: followStatus, 143 - Followers: followers, 144 - Following: following, 145 - }, 146 - Punchcard: punchcard, 147 - ProfileTimeline: timeline, 148 }) 149 } 150 151 func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 152 - ident, ok := r.Context().Value("resolvedId").(identity.Identity) 153 - if !ok { 154 - s.pages.Error404(w) 155 return 156 } 157 158 - profile, err := db.GetProfile(s.db, ident.DID.String()) 159 - if err != nil { 160 - log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 161 - } 162 - 163 repos, err := db.GetRepos( 164 s.db, 165 0, 166 - db.FilterEq("did", ident.DID.String()), 167 ) 168 if err != nil { 169 - log.Printf("getting repos for %s: %s", ident.DID.String(), err) 170 } 171 172 - loggedInUser := s.oauth.GetUser(r) 173 - followStatus := db.IsNotFollowing 174 if loggedInUser != nil { 175 - followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 176 } 177 178 - followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String()) 179 if err != nil { 180 - log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 181 } 182 183 - s.pages.ReposPage(w, pages.ReposPageParams{ 184 - LoggedInUser: loggedInUser, 185 - Repos: repos, 186 - Card: pages.ProfileCard{ 187 - UserDid: ident.DID.String(), 188 - UserHandle: ident.Handle.String(), 189 - Profile: profile, 190 - FollowStatus: followStatus, 191 - Followers: followers, 192 - Following: following, 193 - }, 194 }) 195 } 196 197 - func (s *State) feedFromRequest(w http.ResponseWriter, r *http.Request) *feeds.Feed { 198 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 199 if !ok { 200 s.pages.Error404(w) 201 - return nil 202 } 203 204 - feed, err := s.GetProfileFeed(r.Context(), ident.Handle.String(), ident.DID.String()) 205 if err != nil { 206 s.pages.Error500(w) 207 - return nil 208 } 209 210 - return feed 211 - } 212 - 213 - func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) { 214 - feed := s.feedFromRequest(w, r) 215 if feed == nil { 216 return 217 } ··· 226 w.Write([]byte(atom)) 227 } 228 229 - func (s *State) GetProfileFeed(ctx context.Context, handle string, did string) (*feeds.Feed, error) { 230 - timeline, err := db.MakeProfileTimeline(s.db, did) 231 if err != nil { 232 return nil, err 233 } 234 235 author := &feeds.Author{ 236 - Name: fmt.Sprintf("@%s", handle), 237 } 238 - feed := &feeds.Feed{ 239 - Title: fmt.Sprintf("timeline feed for %s", author.Name), 240 - Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, handle), Type: "text/html", Rel: "alternate"}, 241 Items: make([]*feeds.Item, 0), 242 Updated: time.UnixMilli(0), 243 Author: author, 244 } 245 for _, byMonth := range timeline.ByMonth { 246 - for _, pull := range byMonth.PullEvents.Items { 247 - owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 248 - if err != nil { 249 - return nil, err 250 - } 251 - feed.Items = append(feed.Items, &feeds.Item{ 252 - Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), 253 - 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"}, 254 - Created: pull.Created, 255 - Author: author, 256 - }) 257 - for _, submission := range pull.Submissions { 258 - feed.Items = append(feed.Items, &feeds.Item{ 259 - Title: fmt.Sprintf("%s submitted pull request '%s' (round #%d) in @%s/%s", author.Name, pull.Title, submission.RoundNumber, owner.Handle, pull.Repo.Name), 260 - 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"}, 261 - Created: submission.Created, 262 - Author: author, 263 - }) 264 - } 265 } 266 - for _, issue := range byMonth.IssueEvents.Items { 267 - owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did) 268 - if err != nil { 269 - return nil, err 270 - } 271 - feed.Items = append(feed.Items, &feeds.Item{ 272 - Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name), 273 - 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"}, 274 - Created: issue.Created, 275 - Author: author, 276 - }) 277 } 278 - for _, repo := range byMonth.RepoEvents { 279 - var title string 280 - if repo.Source != nil { 281 - id, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did) 282 - if err != nil { 283 - return nil, err 284 - } 285 - title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, id.Handle, repo.Source.Name, repo.Repo.Name) 286 - } else { 287 - title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name) 288 - } 289 - feed.Items = append(feed.Items, &feeds.Item{ 290 - Title: title, 291 - Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.AppviewHost, handle, repo.Repo.Name), Type: "text/html", Rel: "alternate"}, 292 - Created: repo.Repo.Created, 293 - Author: author, 294 - }) 295 } 296 } 297 slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int { 298 return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli()) 299 }) 300 if len(feed.Items) > 0 { 301 feed.Updated = feed.Items[0].Created 302 } 303 304 - return feed, nil 305 } 306 307 func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
··· 17 "github.com/gorilla/feeds" 18 "tangled.sh/tangled.sh/core/api/tangled" 19 "tangled.sh/tangled.sh/core/appview/db" 20 + "tangled.sh/tangled.sh/core/appview/oauth" 21 "tangled.sh/tangled.sh/core/appview/pages" 22 ) 23 ··· 25 tabVal := r.URL.Query().Get("tab") 26 switch tabVal { 27 case "": 28 + s.profileHomePage(w, r) 29 case "repos": 30 s.reposPage(w, r) 31 + case "followers": 32 + s.followersPage(w, r) 33 + case "following": 34 + s.followingPage(w, r) 35 } 36 } 37 38 + type ProfilePageParams struct { 39 + Id identity.Identity 40 + LoggedInUser *oauth.User 41 + Card pages.ProfileCard 42 + } 43 + 44 + func (s *State) profilePage(w http.ResponseWriter, r *http.Request) *ProfilePageParams { 45 didOrHandle := chi.URLParam(r, "user") 46 if didOrHandle == "" { 47 + http.Error(w, "bad request", http.StatusBadRequest) 48 + return nil 49 } 50 51 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 52 if !ok { 53 + log.Printf("malformed middleware") 54 + w.WriteHeader(http.StatusInternalServerError) 55 + return nil 56 + } 57 + did := ident.DID.String() 58 + 59 + profile, err := db.GetProfile(s.db, did) 60 + if err != nil { 61 + log.Printf("getting profile data for %s: %s", did, err) 62 + s.pages.Error500(w) 63 + return nil 64 } 65 66 + followStats, err := db.GetFollowerFollowingCount(s.db, did) 67 if err != nil { 68 + log.Printf("getting follow stats for %s: %s", did, err) 69 + } 70 + 71 + loggedInUser := s.oauth.GetUser(r) 72 + followStatus := db.IsNotFollowing 73 + if loggedInUser != nil { 74 + followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 75 + } 76 + 77 + return &ProfilePageParams{ 78 + Id: ident, 79 + LoggedInUser: loggedInUser, 80 + Card: pages.ProfileCard{ 81 + UserDid: did, 82 + UserHandle: ident.Handle.String(), 83 + Profile: profile, 84 + FollowStatus: followStatus, 85 + FollowersCount: followStats.Followers, 86 + FollowingCount: followStats.Following, 87 + }, 88 + } 89 + } 90 + 91 + func (s *State) profileHomePage(w http.ResponseWriter, r *http.Request) { 92 + pageWithProfile := s.profilePage(w, r) 93 + if pageWithProfile == nil { 94 + return 95 } 96 97 + id := pageWithProfile.Id 98 repos, err := db.GetRepos( 99 s.db, 100 0, 101 + db.FilterEq("did", id.DID), 102 ) 103 if err != nil { 104 + log.Printf("getting repos for %s: %s", id.DID, err) 105 } 106 107 + profile := pageWithProfile.Card.Profile 108 // filter out ones that are pinned 109 pinnedRepos := []db.Repo{} 110 for i, r := range repos { ··· 119 } 120 } 121 122 + collaboratingRepos, err := db.CollaboratingIn(s.db, id.DID.String()) 123 if err != nil { 124 + log.Printf("getting collaborating repos for %s: %s", id.DID, err) 125 } 126 127 pinnedCollaboratingRepos := []db.Repo{} ··· 132 } 133 } 134 135 + timeline, err := db.MakeProfileTimeline(s.db, id.DID.String()) 136 if err != nil { 137 + log.Printf("failed to create profile timeline for %s: %s", id.DID, err) 138 } 139 140 var didsToResolve []string ··· 156 } 157 } 158 159 now := time.Now() 160 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 161 punchcard, err := db.MakePunchcard( 162 s.db, 163 + db.FilterEq("did", id.DID), 164 db.FilterGte("date", startOfYear.Format(time.DateOnly)), 165 db.FilterLte("date", now.Format(time.DateOnly)), 166 ) 167 if err != nil { 168 + log.Println("failed to get punchcard for did", "did", id.DID, "err", err) 169 } 170 171 + s.pages.ProfileHomePage(w, pages.ProfileHomePageParams{ 172 + LoggedInUser: pageWithProfile.LoggedInUser, 173 Repos: pinnedRepos, 174 CollaboratingRepos: pinnedCollaboratingRepos, 175 + Card: pageWithProfile.Card, 176 + Punchcard: punchcard, 177 + ProfileTimeline: timeline, 178 }) 179 } 180 181 func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 182 + pageWithProfile := s.profilePage(w, r) 183 + if pageWithProfile == nil { 184 return 185 } 186 187 + id := pageWithProfile.Id 188 repos, err := db.GetRepos( 189 s.db, 190 0, 191 + db.FilterEq("did", id.DID), 192 ) 193 if err != nil { 194 + log.Printf("getting repos for %s: %s", id.DID, err) 195 + } 196 + 197 + s.pages.ReposPage(w, pages.ReposPageParams{ 198 + LoggedInUser: pageWithProfile.LoggedInUser, 199 + Repos: repos, 200 + Card: pageWithProfile.Card, 201 + }) 202 + } 203 + 204 + type FollowsPageParams struct { 205 + LoggedInUser *oauth.User 206 + Follows []pages.FollowCard 207 + Card pages.ProfileCard 208 + } 209 + 210 + func (s *State) followPage(w http.ResponseWriter, r *http.Request, fetchFollows func(db.Execer, string) ([]db.Follow, error), extractDid func(db.Follow) string) (FollowsPageParams, error) { 211 + pageWithProfile := s.profilePage(w, r) 212 + if pageWithProfile == nil { 213 + return FollowsPageParams{}, nil 214 + } 215 + 216 + id := pageWithProfile.Id 217 + loggedInUser := pageWithProfile.LoggedInUser 218 + 219 + follows, err := fetchFollows(s.db, id.DID.String()) 220 + if err != nil { 221 + log.Printf("getting followers for %s: %s", id.DID, err) 222 + return FollowsPageParams{}, err 223 + } 224 + 225 + if len(follows) == 0 { 226 + return FollowsPageParams{ 227 + LoggedInUser: loggedInUser, 228 + Follows: []pages.FollowCard{}, 229 + Card: pageWithProfile.Card, 230 + }, nil 231 + } 232 + 233 + followDids := make([]string, 0, len(follows)) 234 + for _, follow := range follows { 235 + followDids = append(followDids, extractDid(follow)) 236 } 237 238 + profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids)) 239 + if err != nil { 240 + log.Printf("getting profile for %s: %s", followDids, err) 241 + return FollowsPageParams{}, err 242 + } 243 + 244 + followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids) 245 + if err != nil { 246 + log.Printf("getting follow counts for %s: %s", followDids, err) 247 + } 248 + 249 + var loggedInUserFollowing map[string]struct{} 250 if loggedInUser != nil { 251 + following, err := db.GetFollowing(s.db, loggedInUser.Did) 252 + if err != nil { 253 + return FollowsPageParams{}, err 254 + } 255 + if len(following) > 0 { 256 + loggedInUserFollowing = make(map[string]struct{}, len(following)) 257 + for _, follow := range following { 258 + loggedInUserFollowing[follow.SubjectDid] = struct{}{} 259 + } 260 + } 261 + } 262 + 263 + followCards := make([]pages.FollowCard, 0, len(follows)) 264 + for _, did := range followDids { 265 + followStats, exists := followStatsMap[did] 266 + if !exists { 267 + followStats = db.FollowStats{} 268 + } 269 + followStatus := db.IsNotFollowing 270 + if loggedInUserFollowing != nil { 271 + if _, exists := loggedInUserFollowing[did]; exists { 272 + followStatus = db.IsFollowing 273 + } else if loggedInUser.Did == did { 274 + followStatus = db.IsSelf 275 + } 276 + } 277 + var profile *db.Profile 278 + if p, exists := profiles[did]; exists { 279 + profile = p 280 + } else { 281 + profile = &db.Profile{} 282 + profile.Did = did 283 + } 284 + followCards = append(followCards, pages.FollowCard{ 285 + UserDid: did, 286 + FollowStatus: followStatus, 287 + FollowersCount: followStats.Followers, 288 + FollowingCount: followStats.Following, 289 + Profile: profile, 290 + }) 291 + } 292 + 293 + return FollowsPageParams{ 294 + LoggedInUser: loggedInUser, 295 + Follows: followCards, 296 + Card: pageWithProfile.Card, 297 + }, nil 298 + } 299 + 300 + func (s *State) followersPage(w http.ResponseWriter, r *http.Request) { 301 + followPage, err := s.followPage(w, r, db.GetFollowers, func(f db.Follow) string { return f.UserDid }) 302 + if err != nil { 303 + s.pages.Notice(w, "all-followers", "Failed to load followers") 304 + return 305 } 306 307 + s.pages.FollowersPage(w, pages.FollowersPageParams{ 308 + LoggedInUser: followPage.LoggedInUser, 309 + Followers: followPage.Follows, 310 + Card: followPage.Card, 311 + }) 312 + } 313 + 314 + func (s *State) followingPage(w http.ResponseWriter, r *http.Request) { 315 + followPage, err := s.followPage(w, r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid }) 316 if err != nil { 317 + s.pages.Notice(w, "all-following", "Failed to load following") 318 + return 319 } 320 321 + s.pages.FollowingPage(w, pages.FollowingPageParams{ 322 + LoggedInUser: followPage.LoggedInUser, 323 + Following: followPage.Follows, 324 + Card: followPage.Card, 325 }) 326 } 327 328 + func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) { 329 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 330 if !ok { 331 s.pages.Error404(w) 332 + return 333 } 334 335 + feed, err := s.getProfileFeed(r.Context(), &ident) 336 if err != nil { 337 s.pages.Error500(w) 338 + return 339 } 340 341 if feed == nil { 342 return 343 } ··· 352 w.Write([]byte(atom)) 353 } 354 355 + func (s *State) getProfileFeed(ctx context.Context, id *identity.Identity) (*feeds.Feed, error) { 356 + timeline, err := db.MakeProfileTimeline(s.db, id.DID.String()) 357 if err != nil { 358 return nil, err 359 } 360 361 author := &feeds.Author{ 362 + Name: fmt.Sprintf("@%s", id.Handle), 363 } 364 + 365 + feed := feeds.Feed{ 366 + Title: fmt.Sprintf("%s's timeline", author.Name), 367 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, id.Handle), Type: "text/html", Rel: "alternate"}, 368 Items: make([]*feeds.Item, 0), 369 Updated: time.UnixMilli(0), 370 Author: author, 371 } 372 + 373 for _, byMonth := range timeline.ByMonth { 374 + if err := s.addPullRequestItems(ctx, &feed, byMonth.PullEvents.Items, author); err != nil { 375 + return nil, err 376 } 377 + if err := s.addIssueItems(ctx, &feed, byMonth.IssueEvents.Items, author); err != nil { 378 + return nil, err 379 } 380 + if err := s.addRepoItems(ctx, &feed, byMonth.RepoEvents, author); err != nil { 381 + return nil, err 382 } 383 } 384 + 385 slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int { 386 return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli()) 387 }) 388 + 389 if len(feed.Items) > 0 { 390 feed.Updated = feed.Items[0].Created 391 } 392 393 + return &feed, nil 394 + } 395 + 396 + func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error { 397 + for _, pull := range pulls { 398 + owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 399 + if err != nil { 400 + return err 401 + } 402 + 403 + // Add pull request creation item 404 + feed.Items = append(feed.Items, s.createPullRequestItem(pull, owner, author)) 405 + } 406 + return nil 407 + } 408 + 409 + func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error { 410 + for _, issue := range issues { 411 + owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did) 412 + if err != nil { 413 + return err 414 + } 415 + 416 + feed.Items = append(feed.Items, s.createIssueItem(issue, owner, author)) 417 + } 418 + return nil 419 + } 420 + 421 + func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error { 422 + for _, repo := range repos { 423 + item, err := s.createRepoItem(ctx, repo, author) 424 + if err != nil { 425 + return err 426 + } 427 + feed.Items = append(feed.Items, item) 428 + } 429 + return nil 430 + } 431 + 432 + func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item { 433 + return &feeds.Item{ 434 + Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), 435 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"}, 436 + Created: pull.Created, 437 + Author: author, 438 + } 439 + } 440 + 441 + func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 442 + return &feeds.Item{ 443 + Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name), 444 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Metadata.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"}, 445 + Created: issue.Created, 446 + Author: author, 447 + } 448 + } 449 + 450 + func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) { 451 + var title string 452 + if repo.Source != nil { 453 + sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did) 454 + if err != nil { 455 + return nil, err 456 + } 457 + title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, sourceOwner.Handle, repo.Source.Name, repo.Repo.Name) 458 + } else { 459 + title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name) 460 + } 461 + 462 + return &feeds.Item{ 463 + Title: title, 464 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.AppviewHost, author.Name[1:], repo.Repo.Name), Type: "text/html", Rel: "alternate"}, // Remove @ prefix 465 + Created: repo.Repo.Created, 466 + Author: author, 467 + }, nil 468 } 469 470 func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
+14 -6
appview/state/router.go
··· 35 router.Get("/favicon.svg", s.Favicon) 36 router.Get("/favicon.ico", s.Favicon) 37 38 router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { 39 pat := chi.URLParam(r, "*") 40 if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") { 41 - s.UserRouter(&middleware).ServeHTTP(w, r) 42 } else { 43 // Check if the first path element is a valid handle without '@' or a flattened DID 44 pathParts := strings.SplitN(pat, "/", 2) ··· 61 return 62 } 63 } 64 - s.StandardRouter(&middleware).ServeHTTP(w, r) 65 } 66 }) 67 ··· 75 r.Get("/", s.Profile) 76 r.Get("/feed.atom", s.AtomFeedPage) 77 78 r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { 79 r.Use(mw.GoImport()) 80 - 81 r.Mount("/", s.RepoRouter(mw)) 82 r.Mount("/issues", s.IssuesRouter(mw)) 83 r.Mount("/pulls", s.PullsRouter(mw)) ··· 139 140 r.Mount("/settings", s.SettingsRouter()) 141 r.Mount("/strings", s.StringsRouter(mw)) 142 - r.Mount("/knots", s.KnotsRouter(mw)) 143 r.Mount("/spindles", s.SpindlesRouter()) 144 r.Mount("/signup", s.SignupRouter()) 145 r.Mount("/", s.OAuthRouter()) ··· 187 return spindles.Router() 188 } 189 190 - func (s *State) KnotsRouter(mw *middleware.Middleware) http.Handler { 191 logger := log.New("knots") 192 193 knots := &knots.Knots{ ··· 201 Logger: logger, 202 } 203 204 - return knots.Router(mw) 205 } 206 207 func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {
··· 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 + 41 router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { 42 pat := chi.URLParam(r, "*") 43 if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") { 44 + userRouter.ServeHTTP(w, r) 45 } else { 46 // Check if the first path element is a valid handle without '@' or a flattened DID 47 pathParts := strings.SplitN(pat, "/", 2) ··· 64 return 65 } 66 } 67 + standardRouter.ServeHTTP(w, r) 68 } 69 }) 70 ··· 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 + }) 86 + 87 r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { 88 r.Use(mw.GoImport()) 89 r.Mount("/", s.RepoRouter(mw)) 90 r.Mount("/issues", s.IssuesRouter(mw)) 91 r.Mount("/pulls", s.PullsRouter(mw)) ··· 147 148 r.Mount("/settings", s.SettingsRouter()) 149 r.Mount("/strings", s.StringsRouter(mw)) 150 + r.Mount("/knots", s.KnotsRouter()) 151 r.Mount("/spindles", s.SpindlesRouter()) 152 r.Mount("/signup", s.SignupRouter()) 153 r.Mount("/", s.OAuthRouter()) ··· 195 return spindles.Router() 196 } 197 198 + func (s *State) KnotsRouter() http.Handler { 199 logger := log.New("knots") 200 201 knots := &knots.Knots{ ··· 209 Logger: logger, 210 } 211 212 + return knots.Router() 213 } 214 215 func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {
+108 -39
appview/state/state.go
··· 2 3 import ( 4 "context" 5 "fmt" 6 "log" 7 "log/slog" ··· 10 "time" 11 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 lexutil "github.com/bluesky-social/indigo/lex/util" 14 securejoin "github.com/cyphar/filepath-securejoin" 15 "github.com/go-chi/chi/v5" ··· 25 "tangled.sh/tangled.sh/core/appview/pages" 26 posthogService "tangled.sh/tangled.sh/core/appview/posthog" 27 "tangled.sh/tangled.sh/core/appview/reporesolver" 28 "tangled.sh/tangled.sh/core/eventconsumer" 29 "tangled.sh/tangled.sh/core/idresolver" 30 "tangled.sh/tangled.sh/core/jetstream" 31 - "tangled.sh/tangled.sh/core/knotclient" 32 tlog "tangled.sh/tangled.sh/core/log" 33 "tangled.sh/tangled.sh/core/rbac" 34 "tangled.sh/tangled.sh/core/tid" 35 ) 36 37 type State struct { ··· 48 repoResolver *reporesolver.RepoResolver 49 knotstream *eventconsumer.Consumer 50 spindlestream *eventconsumer.Consumer 51 } 52 53 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 152 repoResolver, 153 knotstream, 154 spindlestream, 155 } 156 157 return state, nil ··· 193 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 194 } 195 196 s.pages.Timeline(w, pages.TimelineParams{ 197 LoggedInUser: user, 198 Timeline: timeline, 199 }) 200 } 201 ··· 263 return nil 264 } 265 266 func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { 267 switch r.Method { 268 case http.MethodGet: ··· 279 }) 280 281 case http.MethodPost: 282 user := s.oauth.GetUser(r) 283 284 domain := r.FormValue("domain") 285 if domain == "" { 286 s.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 287 return 288 } 289 290 repoName := r.FormValue("name") 291 if repoName == "" { ··· 297 s.pages.Notice(w, "repo", err.Error()) 298 return 299 } 300 301 defaultBranch := r.FormValue("branch") 302 if defaultBranch == "" { 303 defaultBranch = "main" 304 } 305 306 description := r.FormValue("description") 307 308 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create") 309 if err != nil || !ok { 310 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 311 return 312 } 313 314 existingRepo, err := db.GetRepo(s.db, user.Did, repoName) 315 if err == nil && existingRepo != nil { 316 - s.pages.Notice(w, "repo", fmt.Sprintf("A repo by this name already exists on %s", existingRepo.Knot)) 317 - return 318 - } 319 - 320 - secret, err := db.GetRegistrationKey(s.db, domain) 321 - if err != nil { 322 - s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain)) 323 return 324 } 325 326 - client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 327 - if err != nil { 328 - s.pages.Notice(w, "repo", "Failed to connect to knot server.") 329 - return 330 - } 331 - 332 rkey := tid.TID() 333 repo := &db.Repo{ 334 Did: user.Did, ··· 340 341 xrpcClient, err := s.oauth.AuthorizedClient(r) 342 if err != nil { 343 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 344 return 345 } ··· 358 }}, 359 }) 360 if err != nil { 361 - log.Printf("failed to create record: %s", err) 362 s.pages.Notice(w, "repo", "Failed to announce repository creation.") 363 return 364 } 365 - log.Println("created repo record: ", atresp.Uri) 366 367 tx, err := s.db.BeginTx(r.Context(), nil) 368 if err != nil { 369 - log.Println(err) 370 s.pages.Notice(w, "repo", "Failed to save repository information.") 371 return 372 } 373 - defer func() { 374 - tx.Rollback() 375 - err = s.enforcer.E.LoadPolicy() 376 - if err != nil { 377 - log.Println("failed to rollback policies") 378 } 379 - }() 380 381 - resp, err := client.NewRepo(user.Did, repoName, defaultBranch) 382 if err != nil { 383 - s.pages.Notice(w, "repo", "Failed to create repository on knot server.") 384 return 385 } 386 387 - switch resp.StatusCode { 388 - case http.StatusConflict: 389 - s.pages.Notice(w, "repo", "A repository with that name already exists.") 390 return 391 - case http.StatusInternalServerError: 392 - s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 393 - case http.StatusNoContent: 394 - // continue 395 } 396 397 - repo.AtUri = atresp.Uri 398 err = db.AddRepo(tx, repo) 399 if err != nil { 400 - log.Println(err) 401 s.pages.Notice(w, "repo", "Failed to save repository information.") 402 return 403 } ··· 406 p, _ := securejoin.SecureJoin(user.Did, repoName) 407 err = s.enforcer.AddRepo(user.Did, domain, p) 408 if err != nil { 409 - log.Println(err) 410 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 411 return 412 } 413 414 err = tx.Commit() 415 if err != nil { 416 - log.Println("failed to commit changes", err) 417 http.Error(w, err.Error(), http.StatusInternalServerError) 418 return 419 } 420 421 err = s.enforcer.E.SavePolicy() 422 if err != nil { 423 - log.Println("failed to update ACLs", err) 424 http.Error(w, err.Error(), http.StatusInternalServerError) 425 return 426 } 427 428 s.notifier.NewRepo(r.Context(), repo) 429 - 430 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 431 - return 432 } 433 }
··· 2 3 import ( 4 "context" 5 + "database/sql" 6 + "errors" 7 "fmt" 8 "log" 9 "log/slog" ··· 12 "time" 13 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 15 + "github.com/bluesky-social/indigo/atproto/syntax" 16 lexutil "github.com/bluesky-social/indigo/lex/util" 17 securejoin "github.com/cyphar/filepath-securejoin" 18 "github.com/go-chi/chi/v5" ··· 28 "tangled.sh/tangled.sh/core/appview/pages" 29 posthogService "tangled.sh/tangled.sh/core/appview/posthog" 30 "tangled.sh/tangled.sh/core/appview/reporesolver" 31 + xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 32 "tangled.sh/tangled.sh/core/eventconsumer" 33 "tangled.sh/tangled.sh/core/idresolver" 34 "tangled.sh/tangled.sh/core/jetstream" 35 tlog "tangled.sh/tangled.sh/core/log" 36 "tangled.sh/tangled.sh/core/rbac" 37 "tangled.sh/tangled.sh/core/tid" 38 + // xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 39 ) 40 41 type State struct { ··· 52 repoResolver *reporesolver.RepoResolver 53 knotstream *eventconsumer.Consumer 54 spindlestream *eventconsumer.Consumer 55 + logger *slog.Logger 56 } 57 58 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 157 repoResolver, 158 knotstream, 159 spindlestream, 160 + slog.Default(), 161 } 162 163 return state, nil ··· 199 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 200 } 201 202 + repos, err := db.GetTopStarredReposLastWeek(s.db) 203 + if err != nil { 204 + log.Println(err) 205 + s.pages.Notice(w, "topstarredrepos", "Unable to load.") 206 + return 207 + } 208 + 209 s.pages.Timeline(w, pages.TimelineParams{ 210 LoggedInUser: user, 211 Timeline: timeline, 212 + Repos: repos, 213 }) 214 } 215 ··· 277 return nil 278 } 279 280 + func stripGitExt(name string) string { 281 + return strings.TrimSuffix(name, ".git") 282 + } 283 + 284 func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { 285 switch r.Method { 286 case http.MethodGet: ··· 297 }) 298 299 case http.MethodPost: 300 + l := s.logger.With("handler", "NewRepo") 301 + 302 user := s.oauth.GetUser(r) 303 + l = l.With("did", user.Did) 304 + l = l.With("handle", user.Handle) 305 306 + // form validation 307 domain := r.FormValue("domain") 308 if domain == "" { 309 s.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 310 return 311 } 312 + l = l.With("knot", domain) 313 314 repoName := r.FormValue("name") 315 if repoName == "" { ··· 321 s.pages.Notice(w, "repo", err.Error()) 322 return 323 } 324 + repoName = stripGitExt(repoName) 325 + l = l.With("repoName", repoName) 326 327 defaultBranch := r.FormValue("branch") 328 if defaultBranch == "" { 329 defaultBranch = "main" 330 } 331 + l = l.With("defaultBranch", defaultBranch) 332 333 description := r.FormValue("description") 334 335 + // ACL validation 336 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create") 337 if err != nil || !ok { 338 + l.Info("unauthorized") 339 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 340 return 341 } 342 343 + // Check for existing repos 344 existingRepo, err := db.GetRepo(s.db, user.Did, repoName) 345 if err == nil && existingRepo != nil { 346 + l.Info("repo exists") 347 + s.pages.Notice(w, "repo", fmt.Sprintf("You already have a repository by this name on %s", existingRepo.Knot)) 348 return 349 } 350 351 + // create atproto record for this repo 352 rkey := tid.TID() 353 repo := &db.Repo{ 354 Did: user.Did, ··· 360 361 xrpcClient, err := s.oauth.AuthorizedClient(r) 362 if err != nil { 363 + l.Info("PDS write failed", "err", err) 364 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 365 return 366 } ··· 379 }}, 380 }) 381 if err != nil { 382 + l.Info("PDS write failed", "err", err) 383 s.pages.Notice(w, "repo", "Failed to announce repository creation.") 384 return 385 } 386 + 387 + aturi := atresp.Uri 388 + l = l.With("aturi", aturi) 389 + l.Info("wrote to PDS") 390 391 tx, err := s.db.BeginTx(r.Context(), nil) 392 if err != nil { 393 + l.Info("txn failed", "err", err) 394 s.pages.Notice(w, "repo", "Failed to save repository information.") 395 return 396 } 397 + 398 + // The rollback function reverts a few things on failure: 399 + // - the pending txn 400 + // - the ACLs 401 + // - the atproto record created 402 + rollback := func() { 403 + err1 := tx.Rollback() 404 + err2 := s.enforcer.E.LoadPolicy() 405 + err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 406 + 407 + // ignore txn complete errors, this is okay 408 + if errors.Is(err1, sql.ErrTxDone) { 409 + err1 = nil 410 + } 411 + 412 + if errs := errors.Join(err1, err2, err3); errs != nil { 413 + l.Error("failed to rollback changes", "errs", errs) 414 + return 415 } 416 + } 417 + defer rollback() 418 419 + client, err := s.oauth.ServiceClient( 420 + r, 421 + oauth.WithService(domain), 422 + oauth.WithLxm(tangled.RepoCreateNSID), 423 + oauth.WithDev(s.config.Core.Dev), 424 + ) 425 if err != nil { 426 + l.Error("service auth failed", "err", err) 427 + s.pages.Notice(w, "repo", "Failed to reach PDS.") 428 return 429 } 430 431 + xe := tangled.RepoCreate( 432 + r.Context(), 433 + client, 434 + &tangled.RepoCreate_Input{ 435 + Rkey: rkey, 436 + }, 437 + ) 438 + if err := xrpcclient.HandleXrpcErr(xe); err != nil { 439 + l.Error("xrpc error", "xe", xe) 440 + s.pages.Notice(w, "repo", err.Error()) 441 return 442 } 443 444 err = db.AddRepo(tx, repo) 445 if err != nil { 446 + l.Error("db write failed", "err", err) 447 s.pages.Notice(w, "repo", "Failed to save repository information.") 448 return 449 } ··· 452 p, _ := securejoin.SecureJoin(user.Did, repoName) 453 err = s.enforcer.AddRepo(user.Did, domain, p) 454 if err != nil { 455 + l.Error("acl setup failed", "err", err) 456 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 457 return 458 } 459 460 err = tx.Commit() 461 if err != nil { 462 + l.Error("txn commit failed", "err", err) 463 http.Error(w, err.Error(), http.StatusInternalServerError) 464 return 465 } 466 467 err = s.enforcer.E.SavePolicy() 468 if err != nil { 469 + l.Error("acl save failed", "err", err) 470 http.Error(w, err.Error(), http.StatusInternalServerError) 471 return 472 } 473 474 + // reset the ATURI because the transaction completed successfully 475 + aturi = "" 476 + 477 s.notifier.NewRepo(r.Context(), repo) 478 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 479 } 480 } 481 + 482 + // this is used to rollback changes made to the PDS 483 + // 484 + // it is a no-op if the provided ATURI is empty 485 + func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 486 + if aturi == "" { 487 + return nil 488 + } 489 + 490 + parsed := syntax.ATURI(aturi) 491 + 492 + collection := parsed.Collection().String() 493 + repo := parsed.Authority().String() 494 + rkey := parsed.RecordKey().String() 495 + 496 + _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 497 + Collection: collection, 498 + Repo: repo, 499 + Rkey: rkey, 500 + }) 501 + return err 502 + }
+30 -19
appview/strings/strings.go
··· 7 "path" 8 "slices" 9 "strconv" 10 - "strings" 11 "time" 12 13 "tangled.sh/tangled.sh/core/api/tangled" ··· 44 r := chi.NewRouter() 45 46 r. 47 With(mw.ResolveIdent()). 48 Route("/{user}", func(r chi.Router) { 49 r.Get("/", s.dashboard) ··· 70 return r 71 } 72 73 func (s *Strings) contents(w http.ResponseWriter, r *http.Request) { 74 l := s.Logger.With("handler", "contents") 75 ··· 91 92 strings, err := db.GetStrings( 93 s.Db, 94 db.FilterEq("did", id.DID), 95 db.FilterEq("rkey", rkey), 96 ) ··· 154 155 all, err := db.GetStrings( 156 s.Db, 157 db.FilterEq("did", id.DID), 158 ) 159 if err != nil { ··· 182 followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String()) 183 } 184 185 - followers, following, err := db.GetFollowerFollowingCount(s.Db, id.DID.String()) 186 if err != nil { 187 l.Error("failed to get follow stats", "err", err) 188 } ··· 190 s.Pages.StringsDashboard(w, pages.StringsDashboardParams{ 191 LoggedInUser: s.OAuth.GetUser(r), 192 Card: pages.ProfileCard{ 193 - UserDid: id.DID.String(), 194 - UserHandle: id.Handle.String(), 195 - Profile: profile, 196 - FollowStatus: followStatus, 197 - Followers: followers, 198 - Following: following, 199 }, 200 Strings: all, 201 }) ··· 225 // get the string currently being edited 226 all, err := db.GetStrings( 227 s.Db, 228 db.FilterEq("did", id.DID), 229 db.FilterEq("rkey", rkey), 230 ) ··· 266 fail("Empty filename.", nil) 267 return 268 } 269 - if !strings.Contains(filename, ".") { 270 - // TODO: make this a htmx form validation 271 - fail("No extension provided for filename.", nil) 272 - return 273 - } 274 275 content := r.FormValue("content") 276 if content == "" { ··· 353 fail("Empty filename.", nil) 354 return 355 } 356 - if !strings.Contains(filename, ".") { 357 - // TODO: make this a htmx form validation 358 - fail("No extension provided for filename.", nil) 359 - return 360 - } 361 362 content := r.FormValue("content") 363 if content == "" { ··· 434 } 435 436 if user.Did != id.DID.String() { 437 - fail("You cannot delete this gist", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) 438 return 439 } 440
··· 7 "path" 8 "slices" 9 "strconv" 10 "time" 11 12 "tangled.sh/tangled.sh/core/api/tangled" ··· 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) ··· 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 ··· 109 110 strings, err := db.GetStrings( 111 s.Db, 112 + 0, 113 db.FilterEq("did", id.DID), 114 db.FilterEq("rkey", rkey), 115 ) ··· 173 174 all, err := db.GetStrings( 175 s.Db, 176 + 0, 177 db.FilterEq("did", id.DID), 178 ) 179 if err != nil { ··· 202 followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String()) 203 } 204 205 + followStats, err := db.GetFollowerFollowingCount(s.Db, id.DID.String()) 206 if err != nil { 207 l.Error("failed to get follow stats", "err", err) 208 } ··· 210 s.Pages.StringsDashboard(w, pages.StringsDashboardParams{ 211 LoggedInUser: s.OAuth.GetUser(r), 212 Card: pages.ProfileCard{ 213 + UserDid: id.DID.String(), 214 + UserHandle: id.Handle.String(), 215 + Profile: profile, 216 + FollowStatus: followStatus, 217 + FollowersCount: followStats.Followers, 218 + FollowingCount: followStats.Following, 219 }, 220 Strings: all, 221 }) ··· 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 ) ··· 287 fail("Empty filename.", nil) 288 return 289 } 290 291 content := r.FormValue("content") 292 if content == "" { ··· 369 fail("Empty filename.", nil) 370 return 371 } 372 373 content := r.FormValue("content") 374 if content == "" { ··· 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
+25
appview/xrpcclient/xrpc.go
··· 3 import ( 4 "bytes" 5 "context" 6 "io" 7 8 "github.com/bluesky-social/indigo/api/atproto" 9 "github.com/bluesky-social/indigo/xrpc" 10 oauth "tangled.sh/icyphox.sh/atproto-oauth" 11 ) 12 ··· 102 103 return &out, nil 104 }
··· 3 import ( 4 "bytes" 5 "context" 6 + "errors" 7 + "fmt" 8 "io" 9 + "net/http" 10 11 "github.com/bluesky-social/indigo/api/atproto" 12 "github.com/bluesky-social/indigo/xrpc" 13 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 14 oauth "tangled.sh/icyphox.sh/atproto-oauth" 15 ) 16 ··· 106 107 return &out, nil 108 } 109 + 110 + // produces a more manageable error 111 + func HandleXrpcErr(err error) error { 112 + if err == nil { 113 + return nil 114 + } 115 + 116 + var xrpcerr *indigoxrpc.Error 117 + if ok := errors.As(err, &xrpcerr); !ok { 118 + return fmt.Errorf("Recieved invalid XRPC error response.") 119 + } 120 + 121 + switch xrpcerr.StatusCode { 122 + case http.StatusNotFound: 123 + return fmt.Errorf("XRPC is unsupported on this knot, consider upgrading your knot.") 124 + case http.StatusUnauthorized: 125 + return fmt.Errorf("Unauthorized XRPC request.") 126 + default: 127 + return fmt.Errorf("Failed to perform operation. Try again later.") 128 + } 129 + }
+1 -2
cmd/gen.go
··· 24 tangled.GitRefUpdate_Meta_LangBreakdown{}, 25 tangled.GitRefUpdate_Pair{}, 26 tangled.GraphFollow{}, 27 tangled.KnotMember{}, 28 tangled.Pipeline{}, 29 tangled.Pipeline_CloneOpts{}, 30 - tangled.Pipeline_Dependency{}, 31 tangled.Pipeline_ManualTriggerData{}, 32 tangled.Pipeline_Pair{}, 33 tangled.Pipeline_PullRequestTriggerData{}, 34 tangled.Pipeline_PushTriggerData{}, 35 tangled.PipelineStatus{}, 36 - tangled.Pipeline_Step{}, 37 tangled.Pipeline_TriggerMetadata{}, 38 tangled.Pipeline_TriggerRepo{}, 39 tangled.Pipeline_Workflow{},
··· 24 tangled.GitRefUpdate_Meta_LangBreakdown{}, 25 tangled.GitRefUpdate_Pair{}, 26 tangled.GraphFollow{}, 27 + tangled.Knot{}, 28 tangled.KnotMember{}, 29 tangled.Pipeline{}, 30 tangled.Pipeline_CloneOpts{}, 31 tangled.Pipeline_ManualTriggerData{}, 32 tangled.Pipeline_Pair{}, 33 tangled.Pipeline_PullRequestTriggerData{}, 34 tangled.Pipeline_PushTriggerData{}, 35 tangled.PipelineStatus{}, 36 tangled.Pipeline_TriggerMetadata{}, 37 tangled.Pipeline_TriggerRepo{}, 38 tangled.Pipeline_Workflow{},
+1 -1
cmd/punchcardPopulate/main.go
··· 11 ) 12 13 func main() { 14 - db, err := sql.Open("sqlite3", "./appview.db") 15 if err != nil { 16 log.Fatal("Failed to open database:", err) 17 }
··· 11 ) 12 13 func main() { 14 + db, err := sql.Open("sqlite3", "./appview.db?_foreign_keys=1") 15 if err != nil { 16 log.Fatal("Failed to open database:", err) 17 }
+6
docs/contributing.md
··· 55 - Avoid noisy commit messages like "wip" or "final fix"โ€”rewrite history 56 before submitting if necessary. 57 58 ## proposals for bigger changes 59 60 Small fixes like typos, minor bugs, or trivial refactors can be
··· 55 - Avoid noisy commit messages like "wip" or "final fix"โ€”rewrite history 56 before submitting if necessary. 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 + 64 ## proposals for bigger changes 65 66 Small fixes like typos, minor bugs, or trivial refactors can be
+19 -19
docs/hacking.md
··· 55 quite cumbersome. So the nix flake provides a 56 `nixosConfiguration` to do so. 57 58 - To begin, head to `http://localhost:3000/knots` in the browser 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. 63 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: 70 ··· 75 ``` 76 77 This starts a knot on port 6000, a spindle on port 6555 78 - with `ssh` exposed on port 2222. You can push repositories 79 - to this VM with this ssh config block on your main machine: 80 81 ```bash 82 Host nixos-shell ··· 95 96 ## running a spindle 97 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. 106 107 Of interest when debugging spindles: 108
··· 55 quite cumbersome. So the nix flake provides a 56 `nixosConfiguration` to do so. 57 58 + To begin, grab your DID from http://localhost:3000/settings. 59 + Then, set `TANGLED_VM_KNOT_OWNER` and 60 + `TANGLED_VM_SPINDLE_OWNER` to your DID. 61 62 + If you don't want to [set up a spindle](#running-a-spindle), 63 + you can use any placeholder value. 64 65 You can now start a lightweight NixOS VM like so: 66 ··· 71 ``` 72 73 This starts a knot on port 6000, a spindle on port 6555 74 + with `ssh` exposed on port 2222. 75 + 76 + Once the services are running, head to 77 + http://localhost:3000/knots and hit verify (and similarly, 78 + http://localhost:3000/spindles to verify your spindle). It 79 + should verify the ownership of the services instantly if 80 + everything went smoothly. 81 + 82 + You can push repositories to this VM with this ssh config 83 + block on your main machine: 84 85 ```bash 86 Host nixos-shell ··· 99 100 ## running a spindle 101 102 + The above VM should already be running a spindle on 103 + `localhost:6555`. Head to http://localhost:3000/spindles and 104 + hit verify. You can then configure each repository to use 105 + this spindle and run CI jobs. 106 107 Of interest when debugging spindles: 108
+7 -5
docs/knot-hosting.md
··· 73 ``` 74 75 Create `/home/git/.knot.env` with the following, updating the values as 76 - necessary. The `KNOT_SERVER_SECRET` can be obtained from the 77 - [/knots](https://tangled.sh/knots) page on Tangled. 78 79 ``` 80 KNOT_REPO_SCAN_PATH=/home/git 81 KNOT_SERVER_HOSTNAME=knot.example.com 82 APPVIEW_ENDPOINT=https://tangled.sh 83 - KNOT_SERVER_SECRET=secret 84 KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444 85 KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 86 ``` ··· 128 Remember to use Let's Encrypt or similar to procure a certificate for your 129 knot domain. 130 131 - You should now have a running knot server! You can finalize your registration by hitting the 132 - `initialize` button on the [/knots](https://tangled.sh/knots) page. 133 134 ### custom paths 135
··· 73 ``` 74 75 Create `/home/git/.knot.env` with the following, updating the values as 76 + necessary. The `KNOT_SERVER_OWNER` should be set to your 77 + DID, you can find your DID in the [Settings](https://tangled.sh/settings) page. 78 79 ``` 80 KNOT_REPO_SCAN_PATH=/home/git 81 KNOT_SERVER_HOSTNAME=knot.example.com 82 APPVIEW_ENDPOINT=https://tangled.sh 83 + KNOT_SERVER_OWNER=did:plc:foobar 84 KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444 85 KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 86 ``` ··· 128 Remember to use Let's Encrypt or similar to procure a certificate for your 129 knot domain. 130 131 + You should now have a running knot server! You can finalize 132 + your registration by hitting the `verify` button on the 133 + [/knots](https://tangled.sh/knots) page. This simply creates 134 + a record on your PDS to announce the existence of the knot. 135 136 ### custom paths 137
+35
docs/migrations/knot-1.7.0.md
···
··· 1 + # Upgrading from v1.7.0 2 + 3 + After v1.7.0, knot secrets have been deprecated. You no 4 + longer need a secret from the appview to run a knot. All 5 + authorized commands to knots are managed via [Inter-Service 6 + Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 7 + Knots will be read-only until upgraded. 8 + 9 + Upgrading is quite easy, in essence: 10 + 11 + - `KNOT_SERVER_SECRET` is no more, you can remove this 12 + environment variable entirely 13 + - `KNOT_SERVER_OWNER` is now required on boot, set this to 14 + your DID. You can find your DID in the 15 + [settings](https://tangled.sh/settings) page. 16 + - Restart your knot once you have replaced the environment 17 + variable 18 + - Head to the [knot dashboard](https://tangled.sh/knots) and 19 + hit the "retry" button to verify your knot. This simply 20 + writes a `sh.tangled.knot` record to your PDS. 21 + 22 + ## Nix 23 + 24 + If you use the nix module, simply bump the flake to the 25 + latest revision, and change your config block like so: 26 + 27 + ```diff 28 + services.tangled-knot = { 29 + enable = true; 30 + server = { 31 + - secretFile = /path/to/secret; 32 + + owner = "did:plc:foo"; 33 + }; 34 + }; 35 + ```
+26 -3
docs/spindle/pipeline.md
··· 4 repo. Generally: 5 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. 10 11 Here's an example that uses all fields: 12
··· 4 repo. Generally: 5 6 * Pipelines are defined in YAML. 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). 33 34 Here's an example that uses all fields: 35
+1 -1
eventconsumer/cursor/sqlite.go
··· 21 } 22 23 func NewSQLiteStore(dbPath string, opts ...SqliteStoreOpt) (*SqliteStore, error) { 24 - db, err := sql.Open("sqlite3", dbPath) 25 if err != nil { 26 return nil, fmt.Errorf("failed to open sqlite database: %w", err) 27 }
··· 21 } 22 23 func NewSQLiteStore(dbPath string, opts ...SqliteStoreOpt) (*SqliteStore, error) { 24 + db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1") 25 if err != nil { 26 return nil, fmt.Errorf("failed to open sqlite database: %w", err) 27 }
+33 -2
flake.nix
··· 106 pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot; 107 pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped; 108 pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle; 109 }); 110 defaultPackage = forAllSystems (system: self.packages.${system}.appview); 111 - formatter = forAllSystems (system: nixpkgsFor.${system}.alejandra); 112 devShells = forAllSystems (system: let 113 pkgs = nixpkgsFor.${system}; 114 packages' = self.packages.${system}; ··· 129 pkgs.redis 130 pkgs.coreutils # for those of us who are on systems that use busybox (alpine) 131 packages'.lexgen 132 ]; 133 shellHook = '' 134 mkdir -p appview/pages/static ··· 158 ${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css 159 ''; 160 in { 161 watch-appview = { 162 type = "app"; 163 program = toString (pkgs.writeShellScript "watch-appview" '' ··· 221 rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 222 cd "$rootDir" 223 224 - rm api/tangled/* 225 lexgen --build-file lexicon-build-config.json lexicons 226 sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/* 227 ${pkgs.gotools}/bin/goimports -w api/tangled/*
··· 106 pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot; 107 pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped; 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 + }; 136 }); 137 defaultPackage = forAllSystems (system: self.packages.${system}.appview); 138 devShells = forAllSystems (system: let 139 pkgs = nixpkgsFor.${system}; 140 packages' = self.packages.${system}; ··· 155 pkgs.redis 156 pkgs.coreutils # for those of us who are on systems that use busybox (alpine) 157 packages'.lexgen 158 + packages'.treefmt-wrapper 159 ]; 160 shellHook = '' 161 mkdir -p appview/pages/static ··· 185 ${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css 186 ''; 187 in { 188 + fmt = { 189 + type = "app"; 190 + program = pkgs.lib.getExe packages'.treefmt-wrapper; 191 + }; 192 watch-appview = { 193 type = "app"; 194 program = toString (pkgs.writeShellScript "watch-appview" '' ··· 252 rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 253 cd "$rootDir" 254 255 + rm -f api/tangled/* 256 lexgen --build-file lexicon-build-config.json lexicons 257 sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/* 258 ${pkgs.gotools}/bin/goimports -w api/tangled/*
+1 -2
input.css
··· 78 @supports (font-variation-settings: normal) { 79 html { 80 font-feature-settings: 81 - "ss01" 1, 82 "kern" 1, 83 "liga" 1, 84 "cv05" 1, ··· 104 } 105 106 code { 107 - @apply font-mono rounded bg-gray-100 dark:bg-gray-700; 108 } 109 } 110
··· 78 @supports (font-variation-settings: normal) { 79 html { 80 font-feature-settings: 81 "kern" 1, 82 "liga" 1, 83 "cv05" 1, ··· 103 } 104 105 code { 106 + @apply font-mono rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white; 107 } 108 } 109
+6 -4
jetstream/jetstream.go
··· 68 type processor func(context.Context, *models.Event) error 69 70 func (j *JetstreamClient) withDidFilter(processFunc processor) processor { 71 - // empty filter => all dids allowed 72 - if len(j.wantedDids) == 0 { 73 - return processFunc 74 - } 75 // since this closure references j.WantedDids; it should auto-update 76 // existing instances of the closure when j.WantedDids is mutated 77 return func(ctx context.Context, evt *models.Event) error { 78 if _, ok := j.wantedDids[evt.Did]; ok { 79 return processFunc(ctx, evt) 80 } else {
··· 68 type processor func(context.Context, *models.Event) error 69 70 func (j *JetstreamClient) withDidFilter(processFunc processor) processor { 71 // since this closure references j.WantedDids; it should auto-update 72 // existing instances of the closure when j.WantedDids is mutated 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 + 80 if _, ok := j.wantedDids[evt.Did]; ok { 81 return processFunc(ctx, evt) 82 } else {
-336
knotclient/signer.go
··· 1 - package knotclient 2 - 3 - import ( 4 - "bytes" 5 - "crypto/hmac" 6 - "crypto/sha256" 7 - "encoding/hex" 8 - "encoding/json" 9 - "fmt" 10 - "io" 11 - "log" 12 - "net/http" 13 - "net/url" 14 - "time" 15 - 16 - "tangled.sh/tangled.sh/core/types" 17 - ) 18 - 19 - type SignerTransport struct { 20 - Secret string 21 - } 22 - 23 - func (s SignerTransport) RoundTrip(req *http.Request) (*http.Response, error) { 24 - timestamp := time.Now().Format(time.RFC3339) 25 - mac := hmac.New(sha256.New, []byte(s.Secret)) 26 - message := req.Method + req.URL.Path + timestamp 27 - mac.Write([]byte(message)) 28 - signature := hex.EncodeToString(mac.Sum(nil)) 29 - req.Header.Set("X-Signature", signature) 30 - req.Header.Set("X-Timestamp", timestamp) 31 - return http.DefaultTransport.RoundTrip(req) 32 - } 33 - 34 - type SignedClient struct { 35 - Secret string 36 - Url *url.URL 37 - client *http.Client 38 - } 39 - 40 - func NewSignedClient(domain, secret string, dev bool) (*SignedClient, error) { 41 - client := &http.Client{ 42 - Timeout: 5 * time.Second, 43 - Transport: SignerTransport{ 44 - Secret: secret, 45 - }, 46 - } 47 - 48 - scheme := "https" 49 - if dev { 50 - scheme = "http" 51 - } 52 - url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain)) 53 - if err != nil { 54 - return nil, err 55 - } 56 - 57 - signedClient := &SignedClient{ 58 - Secret: secret, 59 - client: client, 60 - Url: url, 61 - } 62 - 63 - return signedClient, nil 64 - } 65 - 66 - func (s *SignedClient) newRequest(method, endpoint string, body []byte) (*http.Request, error) { 67 - return http.NewRequest(method, s.Url.JoinPath(endpoint).String(), bytes.NewReader(body)) 68 - } 69 - 70 - func (s *SignedClient) Init(did string) (*http.Response, error) { 71 - const ( 72 - Method = "POST" 73 - Endpoint = "/init" 74 - ) 75 - 76 - body, _ := json.Marshal(map[string]any{ 77 - "did": did, 78 - }) 79 - 80 - req, err := s.newRequest(Method, Endpoint, body) 81 - if err != nil { 82 - return nil, err 83 - } 84 - 85 - return s.client.Do(req) 86 - } 87 - 88 - func (s *SignedClient) NewRepo(did, repoName, defaultBranch string) (*http.Response, error) { 89 - const ( 90 - Method = "PUT" 91 - Endpoint = "/repo/new" 92 - ) 93 - 94 - body, _ := json.Marshal(map[string]any{ 95 - "did": did, 96 - "name": repoName, 97 - "default_branch": defaultBranch, 98 - }) 99 - 100 - req, err := s.newRequest(Method, Endpoint, body) 101 - if err != nil { 102 - return nil, err 103 - } 104 - 105 - return s.client.Do(req) 106 - } 107 - 108 - func (s *SignedClient) RepoLanguages(ownerDid, repoName, ref string) (*types.RepoLanguageResponse, error) { 109 - const ( 110 - Method = "GET" 111 - ) 112 - endpoint := fmt.Sprintf("/%s/%s/languages/%s", ownerDid, repoName, url.PathEscape(ref)) 113 - 114 - req, err := s.newRequest(Method, endpoint, nil) 115 - if err != nil { 116 - return nil, err 117 - } 118 - 119 - resp, err := s.client.Do(req) 120 - if err != nil { 121 - return nil, err 122 - } 123 - 124 - var result types.RepoLanguageResponse 125 - if resp.StatusCode != http.StatusOK { 126 - log.Println("failed to calculate languages", resp.Status) 127 - return &types.RepoLanguageResponse{}, nil 128 - } 129 - 130 - body, err := io.ReadAll(resp.Body) 131 - if err != nil { 132 - return nil, err 133 - } 134 - 135 - err = json.Unmarshal(body, &result) 136 - if err != nil { 137 - return nil, err 138 - } 139 - 140 - return &result, nil 141 - } 142 - 143 - func (s *SignedClient) RepoForkAheadBehind(ownerDid, source, name, branch, hiddenRef string) (*http.Response, error) { 144 - const ( 145 - Method = "GET" 146 - ) 147 - endpoint := fmt.Sprintf("/repo/fork/sync/%s", url.PathEscape(branch)) 148 - 149 - body, _ := json.Marshal(map[string]any{ 150 - "did": ownerDid, 151 - "source": source, 152 - "name": name, 153 - "hiddenref": hiddenRef, 154 - }) 155 - 156 - req, err := s.newRequest(Method, endpoint, body) 157 - if err != nil { 158 - return nil, err 159 - } 160 - 161 - return s.client.Do(req) 162 - } 163 - 164 - func (s *SignedClient) SyncRepoFork(ownerDid, source, name, branch string) (*http.Response, error) { 165 - const ( 166 - Method = "POST" 167 - ) 168 - endpoint := fmt.Sprintf("/repo/fork/sync/%s", url.PathEscape(branch)) 169 - 170 - body, _ := json.Marshal(map[string]any{ 171 - "did": ownerDid, 172 - "source": source, 173 - "name": name, 174 - }) 175 - 176 - req, err := s.newRequest(Method, endpoint, body) 177 - if err != nil { 178 - return nil, err 179 - } 180 - 181 - return s.client.Do(req) 182 - } 183 - 184 - func (s *SignedClient) ForkRepo(ownerDid, source, name string) (*http.Response, error) { 185 - const ( 186 - Method = "POST" 187 - Endpoint = "/repo/fork" 188 - ) 189 - 190 - body, _ := json.Marshal(map[string]any{ 191 - "did": ownerDid, 192 - "source": source, 193 - "name": name, 194 - }) 195 - 196 - req, err := s.newRequest(Method, Endpoint, body) 197 - if err != nil { 198 - return nil, err 199 - } 200 - 201 - return s.client.Do(req) 202 - } 203 - 204 - func (s *SignedClient) RemoveRepo(did, repoName string) (*http.Response, error) { 205 - const ( 206 - Method = "DELETE" 207 - Endpoint = "/repo" 208 - ) 209 - 210 - body, _ := json.Marshal(map[string]any{ 211 - "did": did, 212 - "name": repoName, 213 - }) 214 - 215 - req, err := s.newRequest(Method, Endpoint, body) 216 - if err != nil { 217 - return nil, err 218 - } 219 - 220 - return s.client.Do(req) 221 - } 222 - 223 - func (s *SignedClient) AddMember(did string) (*http.Response, error) { 224 - const ( 225 - Method = "PUT" 226 - Endpoint = "/member/add" 227 - ) 228 - 229 - body, _ := json.Marshal(map[string]any{ 230 - "did": did, 231 - }) 232 - 233 - req, err := s.newRequest(Method, Endpoint, body) 234 - if err != nil { 235 - return nil, err 236 - } 237 - 238 - return s.client.Do(req) 239 - } 240 - 241 - func (s *SignedClient) SetDefaultBranch(ownerDid, repoName, branch string) (*http.Response, error) { 242 - const ( 243 - Method = "PUT" 244 - ) 245 - endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName) 246 - 247 - body, _ := json.Marshal(map[string]any{ 248 - "branch": branch, 249 - }) 250 - 251 - req, err := s.newRequest(Method, endpoint, body) 252 - if err != nil { 253 - return nil, err 254 - } 255 - 256 - return s.client.Do(req) 257 - } 258 - 259 - func (s *SignedClient) AddCollaborator(ownerDid, repoName, memberDid string) (*http.Response, error) { 260 - const ( 261 - Method = "POST" 262 - ) 263 - endpoint := fmt.Sprintf("/%s/%s/collaborator/add", ownerDid, repoName) 264 - 265 - body, _ := json.Marshal(map[string]any{ 266 - "did": memberDid, 267 - }) 268 - 269 - req, err := s.newRequest(Method, endpoint, body) 270 - if err != nil { 271 - return nil, err 272 - } 273 - 274 - return s.client.Do(req) 275 - } 276 - 277 - func (s *SignedClient) Merge( 278 - patch []byte, 279 - ownerDid, targetRepo, branch, commitMessage, commitBody, authorName, authorEmail string, 280 - ) (*http.Response, error) { 281 - const ( 282 - Method = "POST" 283 - ) 284 - endpoint := fmt.Sprintf("/%s/%s/merge", ownerDid, targetRepo) 285 - 286 - mr := types.MergeRequest{ 287 - Branch: branch, 288 - CommitMessage: commitMessage, 289 - CommitBody: commitBody, 290 - AuthorName: authorName, 291 - AuthorEmail: authorEmail, 292 - Patch: string(patch), 293 - } 294 - 295 - body, _ := json.Marshal(mr) 296 - 297 - req, err := s.newRequest(Method, endpoint, body) 298 - if err != nil { 299 - return nil, err 300 - } 301 - 302 - return s.client.Do(req) 303 - } 304 - 305 - func (s *SignedClient) MergeCheck(patch []byte, ownerDid, targetRepo, branch string) (*http.Response, error) { 306 - const ( 307 - Method = "POST" 308 - ) 309 - endpoint := fmt.Sprintf("/%s/%s/merge/check", ownerDid, targetRepo) 310 - 311 - body, _ := json.Marshal(map[string]any{ 312 - "patch": string(patch), 313 - "branch": branch, 314 - }) 315 - 316 - req, err := s.newRequest(Method, endpoint, body) 317 - if err != nil { 318 - return nil, err 319 - } 320 - 321 - return s.client.Do(req) 322 - } 323 - 324 - func (s *SignedClient) NewHiddenRef(ownerDid, targetRepo, forkBranch, remoteBranch string) (*http.Response, error) { 325 - const ( 326 - Method = "POST" 327 - ) 328 - endpoint := fmt.Sprintf("/%s/%s/hidden-ref/%s/%s", ownerDid, targetRepo, url.PathEscape(forkBranch), url.PathEscape(remoteBranch)) 329 - 330 - req, err := s.newRequest(Method, endpoint, nil) 331 - if err != nil { 332 - return nil, err 333 - } 334 - 335 - return s.client.Do(req) 336 - }
···
+35
knotclient/unsigned.go
··· 248 249 return &formatPatchResponse, nil 250 }
··· 248 249 return &formatPatchResponse, nil 250 } 251 + 252 + func (s *UnsignedClient) RepoLanguages(ownerDid, repoName, ref string) (*types.RepoLanguageResponse, error) { 253 + const ( 254 + Method = "GET" 255 + ) 256 + endpoint := fmt.Sprintf("/%s/%s/languages/%s", ownerDid, repoName, url.PathEscape(ref)) 257 + 258 + req, err := s.newRequest(Method, endpoint, nil, nil) 259 + if err != nil { 260 + return nil, err 261 + } 262 + 263 + resp, err := s.client.Do(req) 264 + if err != nil { 265 + return nil, err 266 + } 267 + 268 + var result types.RepoLanguageResponse 269 + if resp.StatusCode != http.StatusOK { 270 + log.Println("failed to calculate languages", resp.Status) 271 + return &types.RepoLanguageResponse{}, nil 272 + } 273 + 274 + body, err := io.ReadAll(resp.Body) 275 + if err != nil { 276 + return nil, err 277 + } 278 + 279 + err = json.Unmarshal(body, &result) 280 + if err != nil { 281 + return nil, err 282 + } 283 + 284 + return &result, nil 285 + }
+1 -1
knotserver/config/config.go
··· 17 type Server struct { 18 ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:5555"` 19 InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"` 20 - Secret string `env:"SECRET, required"` 21 DBPath string `env:"DB_PATH, default=knotserver.db"` 22 Hostname string `env:"HOSTNAME, required"` 23 JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 24 LogDids bool `env:"LOG_DIDS, default=true"` 25 26 // This disables signature verification so use with caution.
··· 17 type Server struct { 18 ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:5555"` 19 InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"` 20 DBPath string `env:"DB_PATH, default=knotserver.db"` 21 Hostname string `env:"HOSTNAME, required"` 22 JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 23 + Owner string `env:"OWNER, required"` 24 LogDids bool `env:"LOG_DIDS, default=true"` 25 26 // This disables signature verification so use with caution.
+14 -10
knotserver/db/init.go
··· 2 3 import ( 4 "database/sql" 5 6 _ "github.com/mattn/go-sqlite3" 7 ) ··· 11 } 12 13 func Setup(dbPath string) (*DB, error) { 14 - db, err := sql.Open("sqlite3", dbPath) 15 if err != nil { 16 return nil, err 17 } 18 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 29 create table if not exists known_dids ( 30 did text primary key 31 );
··· 2 3 import ( 4 "database/sql" 5 + "strings" 6 7 _ "github.com/mattn/go-sqlite3" 8 ) ··· 12 } 13 14 func Setup(dbPath string) (*DB, error) { 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, "&")) 24 if err != nil { 25 return nil, err 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. 31 32 + _, err = db.Exec(` 33 create table if not exists known_dids ( 34 did text primary key 35 );
+8 -10
knotserver/git/fork.go
··· 10 ) 11 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 { 19 return fmt.Errorf("failed to bare clone repository: %w", err) 20 } 21 22 - err = exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden").Run() 23 - if err != nil { 24 return fmt.Errorf("failed to configure hidden refs: %w", err) 25 } 26 27 return nil 28 } 29 30 - func (g *GitRepo) Sync(branch string) error { 31 fetchOpts := &git.FetchOptions{ 32 RefSpecs: []config.RefSpec{ 33 - config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/heads/%s", branch, branch)), 34 }, 35 } 36
··· 10 ) 11 12 func Fork(repoPath, source string) error { 13 + cloneCmd := exec.Command("git", "clone", "--bare", source, repoPath) 14 + if err := cloneCmd.Run(); err != nil { 15 return fmt.Errorf("failed to bare clone repository: %w", err) 16 } 17 18 + configureCmd := exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden") 19 + if err := configureCmd.Run(); err != nil { 20 return fmt.Errorf("failed to configure hidden refs: %w", err) 21 } 22 23 return nil 24 } 25 26 + func (g *GitRepo) Sync() error { 27 + branch := g.h.String() 28 + 29 fetchOpts := &git.FetchOptions{ 30 RefSpecs: []config.RefSpec{ 31 + config.RefSpec("+" + branch + ":" + branch), // +refs/heads/master:refs/heads/master 32 }, 33 } 34
+5
knotserver/git.go
··· 129 // If the appview gave us the repository owner's handle we can attempt to 130 // construct the correct ssh url. 131 ownerHandle := r.Header.Get("x-tangled-repo-owner-handle") 132 if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") { 133 hostname := d.c.Server.Hostname 134 if strings.Contains(hostname, ":") { 135 hostname = strings.Split(hostname, ":")[0] 136 } 137 138 fmt.Fprintf(w, " Try:\ngit remote set-url --push origin git@%s:%s/%s\n\n... and push again.", hostname, ownerHandle, unqualifiedRepoName)
··· 129 // If the appview gave us the repository owner's handle we can attempt to 130 // construct the correct ssh url. 131 ownerHandle := r.Header.Get("x-tangled-repo-owner-handle") 132 + ownerHandle = strings.TrimPrefix(ownerHandle, "@") 133 if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") { 134 hostname := d.c.Server.Hostname 135 if strings.Contains(hostname, ":") { 136 hostname = strings.Split(hostname, ":")[0] 137 + } 138 + 139 + if hostname == "knot1.tangled.sh" { 140 + hostname = "tangled.sh" 141 } 142 143 fmt.Fprintf(w, " Try:\ngit remote set-url --push origin git@%s:%s/%s\n\n... and push again.", hostname, ownerHandle, unqualifiedRepoName)
+1008 -150
knotserver/handler.go
··· 1 package knotserver 2 3 import ( 4 "context" 5 "fmt" 6 - "log/slog" 7 "net/http" 8 - "runtime/debug" 9 10 "github.com/go-chi/chi/v5" 11 - "tangled.sh/tangled.sh/core/idresolver" 12 - "tangled.sh/tangled.sh/core/jetstream" 13 - "tangled.sh/tangled.sh/core/knotserver/config" 14 "tangled.sh/tangled.sh/core/knotserver/db" 15 - "tangled.sh/tangled.sh/core/knotserver/xrpc" 16 - tlog "tangled.sh/tangled.sh/core/log" 17 - "tangled.sh/tangled.sh/core/notifier" 18 - "tangled.sh/tangled.sh/core/rbac" 19 ) 20 21 - type Handle struct { 22 - c *config.Config 23 - db *db.DB 24 - jc *jetstream.JetstreamClient 25 - e *rbac.Enforcer 26 - l *slog.Logger 27 - n *notifier.Notifier 28 - resolver *idresolver.Resolver 29 30 - // init is a channel that is closed when the knot has been initailized 31 - // i.e. when the first user (knot owner) has been added. 32 - init chan struct{} 33 - knotInitialized bool 34 } 35 36 - func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) { 37 - r := chi.NewRouter() 38 39 - h := Handle{ 40 - c: c, 41 - db: db, 42 - e: e, 43 - l: l, 44 - jc: jc, 45 - n: n, 46 - resolver: idresolver.DefaultResolver(), 47 - init: make(chan struct{}), 48 } 49 50 - err := e.AddKnot(rbac.ThisServer) 51 if err != nil { 52 - return nil, fmt.Errorf("failed to setup enforcer: %w", err) 53 } 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 - // Check if the knot knows about any Dids; 61 - // if it does, it is already initialized and we can repopulate the 62 - // Jetstream subscriptions. 63 - dids, err := db.GetAllDids() 64 if err != nil { 65 - return nil, fmt.Errorf("failed to get all Dids: %w", err) 66 } 67 68 - if len(dids) > 0 { 69 - h.knotInitialized = true 70 - close(h.init) 71 - for _, d := range dids { 72 - h.jc.AddDid(d) 73 } 74 } 75 76 - r.Get("/", h.Index) 77 - r.Get("/capabilities", h.Capabilities) 78 - r.Get("/version", h.Version) 79 - r.Route("/{did}", func(r chi.Router) { 80 - // Repo routes 81 - r.Route("/{name}", func(r chi.Router) { 82 - r.Route("/collaborator", func(r chi.Router) { 83 - r.Use(h.VerifySignature) 84 - r.Post("/add", h.AddRepoCollaborator) 85 - }) 86 87 - r.Route("/languages", func(r chi.Router) { 88 - r.With(h.VerifySignature) 89 - r.Get("/", h.RepoLanguages) 90 - r.Get("/{ref}", h.RepoLanguages) 91 - }) 92 93 - r.Get("/", h.RepoIndex) 94 - r.Get("/info/refs", h.InfoRefs) 95 - r.Post("/git-upload-pack", h.UploadPack) 96 - r.Post("/git-receive-pack", h.ReceivePack) 97 - r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects 98 99 - r.With(h.VerifySignature).Post("/hidden-ref/{forkRef}/{remoteRef}", h.NewHiddenRef) 100 101 - r.Route("/merge", func(r chi.Router) { 102 - r.With(h.VerifySignature) 103 - r.Post("/", h.Merge) 104 - r.Post("/check", h.MergeCheck) 105 - }) 106 107 - r.Route("/tree/{ref}", func(r chi.Router) { 108 - r.Get("/", h.RepoIndex) 109 - r.Get("/*", h.RepoTree) 110 - }) 111 112 - r.Route("/blob/{ref}", func(r chi.Router) { 113 - r.Get("/*", h.Blob) 114 - }) 115 116 - r.Route("/raw/{ref}", func(r chi.Router) { 117 - r.Get("/*", h.BlobRaw) 118 - }) 119 120 - r.Get("/log/{ref}", h.Log) 121 - r.Get("/archive/{file}", h.Archive) 122 - r.Get("/commit/{ref}", h.Diff) 123 - r.Get("/tags", h.Tags) 124 - r.Route("/branches", func(r chi.Router) { 125 - r.Get("/", h.Branches) 126 - r.Get("/{branch}", h.Branch) 127 - r.Route("/default", func(r chi.Router) { 128 - r.Get("/", h.DefaultBranch) 129 - r.With(h.VerifySignature).Put("/", h.SetDefaultBranch) 130 - }) 131 - }) 132 - }) 133 - }) 134 135 - // xrpc apis 136 - r.Mount("/xrpc", h.XrpcRouter()) 137 138 - // Create a new repository. 139 - r.Route("/repo", func(r chi.Router) { 140 - r.Use(h.VerifySignature) 141 - r.Put("/new", h.NewRepo) 142 - r.Delete("/", h.RemoveRepo) 143 - r.Route("/fork", func(r chi.Router) { 144 - r.Post("/", h.RepoFork) 145 - r.Post("/sync/{branch}", h.RepoForkSync) 146 - r.Get("/sync/{branch}", h.RepoForkAheadBehind) 147 - }) 148 - }) 149 150 - r.Route("/member", func(r chi.Router) { 151 - r.Use(h.VerifySignature) 152 - r.Put("/add", h.AddMember) 153 - }) 154 155 - // Socket that streams git oplogs 156 - r.Get("/events", h.Events) 157 158 - // Initialize the knot with an owner and public key. 159 - r.With(h.VerifySignature).Post("/init", h.Init) 160 161 - // Health check. Used for two-way verification with appview. 162 - r.With(h.VerifySignature).Get("/health", h.Health) 163 164 - // All public keys on the knot. 165 - r.Get("/keys", h.Keys) 166 167 - return r, nil 168 } 169 170 - func (h *Handle) XrpcRouter() http.Handler { 171 - logger := tlog.New("knots") 172 173 - xrpc := &xrpc.Xrpc{ 174 - Config: h.c, 175 - Db: h.db, 176 - Ingester: h.jc, 177 - Enforcer: h.e, 178 - Logger: logger, 179 - Notifier: h.n, 180 - Resolver: h.resolver, 181 } 182 - return xrpc.Router() 183 } 184 185 - // version is set during build time. 186 - var version string 187 188 - func (h *Handle) Version(w http.ResponseWriter, r *http.Request) { 189 - if version == "" { 190 - info, ok := debug.ReadBuildInfo() 191 - if !ok { 192 - http.Error(w, "failed to read build info", http.StatusInternalServerError) 193 return 194 } 195 196 - var modVer string 197 - for _, mod := range info.Deps { 198 - if mod.Path == "tangled.sh/tangled.sh/knotserver" { 199 - version = mod.Version 200 - break 201 - } 202 } 203 204 - if modVer == "" { 205 - version = "unknown" 206 } 207 } 208 209 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 210 - fmt.Fprintf(w, "knotserver/%s", version) 211 }
··· 1 package knotserver 2 3 import ( 4 + "compress/gzip" 5 "context" 6 + "crypto/sha256" 7 + "encoding/json" 8 + "errors" 9 "fmt" 10 + "log" 11 "net/http" 12 + "net/url" 13 + "path/filepath" 14 + "strconv" 15 + "strings" 16 + "sync" 17 + "time" 18 19 + securejoin "github.com/cyphar/filepath-securejoin" 20 + "github.com/gliderlabs/ssh" 21 "github.com/go-chi/chi/v5" 22 + "github.com/go-git/go-git/v5/plumbing" 23 + "github.com/go-git/go-git/v5/plumbing/object" 24 "tangled.sh/tangled.sh/core/knotserver/db" 25 + "tangled.sh/tangled.sh/core/knotserver/git" 26 + "tangled.sh/tangled.sh/core/types" 27 ) 28 29 + func (h *Handle) Index(w http.ResponseWriter, r *http.Request) { 30 + w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 31 + } 32 + 33 + func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) { 34 + w.Header().Set("Content-Type", "application/json") 35 + 36 + capabilities := map[string]any{ 37 + "pull_requests": map[string]any{ 38 + "format_patch": true, 39 + "patch_submissions": true, 40 + "branch_submissions": true, 41 + "fork_submissions": true, 42 + }, 43 + "xrpc": true, 44 + } 45 46 + jsonData, err := json.Marshal(capabilities) 47 + if err != nil { 48 + http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError) 49 + return 50 + } 51 + 52 + w.Write(jsonData) 53 } 54 55 + func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) { 56 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 57 + l := h.l.With("path", path, "handler", "RepoIndex") 58 + ref := chi.URLParam(r, "ref") 59 + ref, _ = url.PathUnescape(ref) 60 + 61 + gr, err := git.Open(path, ref) 62 + if err != nil { 63 + plain, err2 := git.PlainOpen(path) 64 + if err2 != nil { 65 + l.Error("opening repo", "error", err2.Error()) 66 + notFound(w) 67 + return 68 + } 69 + branches, _ := plain.Branches() 70 71 + log.Println(err) 72 + 73 + if errors.Is(err, plumbing.ErrReferenceNotFound) { 74 + resp := types.RepoIndexResponse{ 75 + IsEmpty: true, 76 + Branches: branches, 77 + } 78 + writeJSON(w, resp) 79 + return 80 + } else { 81 + l.Error("opening repo", "error", err.Error()) 82 + notFound(w) 83 + return 84 + } 85 } 86 87 + var ( 88 + commits []*object.Commit 89 + total int 90 + branches []types.Branch 91 + files []types.NiceTree 92 + tags []object.Tag 93 + ) 94 + 95 + var wg sync.WaitGroup 96 + errorsCh := make(chan error, 5) 97 + 98 + wg.Add(1) 99 + go func() { 100 + defer wg.Done() 101 + cs, err := gr.Commits(0, 60) 102 + if err != nil { 103 + errorsCh <- fmt.Errorf("commits: %w", err) 104 + return 105 + } 106 + commits = cs 107 + }() 108 + 109 + wg.Add(1) 110 + go func() { 111 + defer wg.Done() 112 + t, err := gr.TotalCommits() 113 + if err != nil { 114 + errorsCh <- fmt.Errorf("calculating total: %w", err) 115 + return 116 + } 117 + total = t 118 + }() 119 + 120 + wg.Add(1) 121 + go func() { 122 + defer wg.Done() 123 + bs, err := gr.Branches() 124 + if err != nil { 125 + errorsCh <- fmt.Errorf("fetching branches: %w", err) 126 + return 127 + } 128 + branches = bs 129 + }() 130 + 131 + wg.Add(1) 132 + go func() { 133 + defer wg.Done() 134 + ts, err := gr.Tags() 135 + if err != nil { 136 + errorsCh <- fmt.Errorf("fetching tags: %w", err) 137 + return 138 + } 139 + tags = ts 140 + }() 141 + 142 + wg.Add(1) 143 + go func() { 144 + defer wg.Done() 145 + fs, err := gr.FileTree(r.Context(), "") 146 + if err != nil { 147 + errorsCh <- fmt.Errorf("fetching filetree: %w", err) 148 + return 149 + } 150 + files = fs 151 + }() 152 + 153 + wg.Wait() 154 + close(errorsCh) 155 + 156 + // show any errors 157 + for err := range errorsCh { 158 + l.Error("loading repo", "error", err.Error()) 159 + writeError(w, err.Error(), http.StatusInternalServerError) 160 + return 161 + } 162 + 163 + rtags := []*types.TagReference{} 164 + for _, tag := range tags { 165 + var target *object.Tag 166 + if tag.Target != plumbing.ZeroHash { 167 + target = &tag 168 + } 169 + tr := types.TagReference{ 170 + Tag: target, 171 + } 172 + 173 + tr.Reference = types.Reference{ 174 + Name: tag.Name, 175 + Hash: tag.Hash.String(), 176 + } 177 + 178 + if tag.Message != "" { 179 + tr.Message = tag.Message 180 + } 181 + 182 + rtags = append(rtags, &tr) 183 + } 184 + 185 + var readmeContent string 186 + var readmeFile string 187 + for _, readme := range h.c.Repo.Readme { 188 + content, _ := gr.FileContent(readme) 189 + if len(content) > 0 { 190 + readmeContent = string(content) 191 + readmeFile = readme 192 + } 193 + } 194 + 195 + if ref == "" { 196 + mainBranch, err := gr.FindMainBranch() 197 + if err != nil { 198 + writeError(w, err.Error(), http.StatusInternalServerError) 199 + l.Error("finding main branch", "error", err.Error()) 200 + return 201 + } 202 + ref = mainBranch 203 + } 204 + 205 + resp := types.RepoIndexResponse{ 206 + IsEmpty: false, 207 + Ref: ref, 208 + Commits: commits, 209 + Description: getDescription(path), 210 + Readme: readmeContent, 211 + ReadmeFileName: readmeFile, 212 + Files: files, 213 + Branches: branches, 214 + Tags: rtags, 215 + TotalCommits: total, 216 + } 217 + 218 + writeJSON(w, resp) 219 + } 220 + 221 + func (h *Handle) RepoTree(w http.ResponseWriter, r *http.Request) { 222 + treePath := chi.URLParam(r, "*") 223 + ref := chi.URLParam(r, "ref") 224 + ref, _ = url.PathUnescape(ref) 225 + 226 + l := h.l.With("handler", "RepoTree", "ref", ref, "treePath", treePath) 227 + 228 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 229 + gr, err := git.Open(path, ref) 230 if err != nil { 231 + notFound(w) 232 + return 233 } 234 235 + files, err := gr.FileTree(r.Context(), treePath) 236 if err != nil { 237 + writeError(w, err.Error(), http.StatusInternalServerError) 238 + l.Error("file tree", "error", err.Error()) 239 + return 240 } 241 242 + resp := types.RepoTreeResponse{ 243 + Ref: ref, 244 + Parent: treePath, 245 + Description: getDescription(path), 246 + DotDot: filepath.Dir(treePath), 247 + Files: files, 248 + } 249 + 250 + writeJSON(w, resp) 251 + } 252 + 253 + func (h *Handle) BlobRaw(w http.ResponseWriter, r *http.Request) { 254 + treePath := chi.URLParam(r, "*") 255 + ref := chi.URLParam(r, "ref") 256 + ref, _ = url.PathUnescape(ref) 257 + 258 + l := h.l.With("handler", "BlobRaw", "ref", ref, "treePath", treePath) 259 + 260 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 261 + gr, err := git.Open(path, ref) 262 if err != nil { 263 + notFound(w) 264 + return 265 + } 266 + 267 + contents, err := gr.RawContent(treePath) 268 + if err != nil { 269 + writeError(w, err.Error(), http.StatusBadRequest) 270 + l.Error("file content", "error", err.Error()) 271 + return 272 + } 273 + 274 + mimeType := http.DetectContentType(contents) 275 + 276 + // exception for svg 277 + if filepath.Ext(treePath) == ".svg" { 278 + mimeType = "image/svg+xml" 279 } 280 281 + contentHash := sha256.Sum256(contents) 282 + eTag := fmt.Sprintf("\"%x\"", contentHash) 283 + 284 + // allow image, video, and text/plain files to be served directly 285 + switch { 286 + case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"): 287 + if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag { 288 + w.WriteHeader(http.StatusNotModified) 289 + return 290 } 291 + w.Header().Set("ETag", eTag) 292 + 293 + case strings.HasPrefix(mimeType, "text/plain"): 294 + w.Header().Set("Cache-Control", "public, no-cache") 295 + 296 + default: 297 + l.Error("attempted to serve disallowed file type", "mimetype", mimeType) 298 + writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden) 299 + return 300 } 301 302 + w.Header().Set("Content-Type", mimeType) 303 + w.Write(contents) 304 + } 305 + 306 + func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) { 307 + treePath := chi.URLParam(r, "*") 308 + ref := chi.URLParam(r, "ref") 309 + ref, _ = url.PathUnescape(ref) 310 + 311 + l := h.l.With("handler", "Blob", "ref", ref, "treePath", treePath) 312 + 313 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 314 + gr, err := git.Open(path, ref) 315 + if err != nil { 316 + notFound(w) 317 + return 318 + } 319 + 320 + var isBinaryFile bool = false 321 + contents, err := gr.FileContent(treePath) 322 + if errors.Is(err, git.ErrBinaryFile) { 323 + isBinaryFile = true 324 + } else if errors.Is(err, object.ErrFileNotFound) { 325 + notFound(w) 326 + return 327 + } else if err != nil { 328 + writeError(w, err.Error(), http.StatusInternalServerError) 329 + return 330 + } 331 + 332 + bytes := []byte(contents) 333 + // safe := string(sanitize(bytes)) 334 + sizeHint := len(bytes) 335 + 336 + resp := types.RepoBlobResponse{ 337 + Ref: ref, 338 + Contents: string(bytes), 339 + Path: treePath, 340 + IsBinary: isBinaryFile, 341 + SizeHint: uint64(sizeHint), 342 + } 343 + 344 + h.showFile(resp, w, l) 345 + } 346 + 347 + func (h *Handle) Archive(w http.ResponseWriter, r *http.Request) { 348 + name := chi.URLParam(r, "name") 349 + file := chi.URLParam(r, "file") 350 351 + l := h.l.With("handler", "Archive", "name", name, "file", file) 352 353 + // TODO: extend this to add more files compression (e.g.: xz) 354 + if !strings.HasSuffix(file, ".tar.gz") { 355 + notFound(w) 356 + return 357 + } 358 359 + ref := strings.TrimSuffix(file, ".tar.gz") 360 361 + unescapedRef, err := url.PathUnescape(ref) 362 + if err != nil { 363 + notFound(w) 364 + return 365 + } 366 + 367 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-") 368 + 369 + // This allows the browser to use a proper name for the file when 370 + // downloading 371 + filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename) 372 + setContentDisposition(w, filename) 373 + setGZipMIME(w) 374 + 375 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 376 + gr, err := git.Open(path, unescapedRef) 377 + if err != nil { 378 + notFound(w) 379 + return 380 + } 381 + 382 + gw := gzip.NewWriter(w) 383 + defer gw.Close() 384 + 385 + prefix := fmt.Sprintf("%s-%s", name, safeRefFilename) 386 + err = gr.WriteTar(gw, prefix) 387 + if err != nil { 388 + // once we start writing to the body we can't report error anymore 389 + // so we are only left with printing the error. 390 + l.Error("writing tar file", "error", err.Error()) 391 + return 392 + } 393 + 394 + err = gw.Flush() 395 + if err != nil { 396 + // once we start writing to the body we can't report error anymore 397 + // so we are only left with printing the error. 398 + l.Error("flushing?", "error", err.Error()) 399 + return 400 + } 401 + } 402 403 + func (h *Handle) Log(w http.ResponseWriter, r *http.Request) { 404 + ref := chi.URLParam(r, "ref") 405 + ref, _ = url.PathUnescape(ref) 406 407 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 408 409 + l := h.l.With("handler", "Log", "ref", ref, "path", path) 410 411 + gr, err := git.Open(path, ref) 412 + if err != nil { 413 + notFound(w) 414 + return 415 + } 416 417 + // Get page parameters 418 + page := 1 419 + pageSize := 30 420 421 + if pageParam := r.URL.Query().Get("page"); pageParam != "" { 422 + if p, err := strconv.Atoi(pageParam); err == nil && p > 0 { 423 + page = p 424 + } 425 + } 426 427 + if pageSizeParam := r.URL.Query().Get("per_page"); pageSizeParam != "" { 428 + if ps, err := strconv.Atoi(pageSizeParam); err == nil && ps > 0 { 429 + pageSize = ps 430 + } 431 + } 432 433 + // convert to offset/limit 434 + offset := (page - 1) * pageSize 435 + limit := pageSize 436 437 + commits, err := gr.Commits(offset, limit) 438 + if err != nil { 439 + writeError(w, err.Error(), http.StatusInternalServerError) 440 + l.Error("fetching commits", "error", err.Error()) 441 + return 442 + } 443 444 + total := len(commits) 445 446 + resp := types.RepoLogResponse{ 447 + Commits: commits, 448 + Ref: ref, 449 + Description: getDescription(path), 450 + Log: true, 451 + Total: total, 452 + Page: page, 453 + PerPage: pageSize, 454 + } 455 456 + writeJSON(w, resp) 457 } 458 459 + func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) { 460 + ref := chi.URLParam(r, "ref") 461 + ref, _ = url.PathUnescape(ref) 462 463 + l := h.l.With("handler", "Diff", "ref", ref) 464 + 465 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 466 + gr, err := git.Open(path, ref) 467 + if err != nil { 468 + notFound(w) 469 + return 470 } 471 + 472 + diff, err := gr.Diff() 473 + if err != nil { 474 + writeError(w, err.Error(), http.StatusInternalServerError) 475 + l.Error("getting diff", "error", err.Error()) 476 + return 477 + } 478 + 479 + resp := types.RepoCommitResponse{ 480 + Ref: ref, 481 + Diff: diff, 482 + } 483 + 484 + writeJSON(w, resp) 485 } 486 487 + func (h *Handle) Tags(w http.ResponseWriter, r *http.Request) { 488 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 489 + l := h.l.With("handler", "Refs") 490 491 + gr, err := git.Open(path, "") 492 + if err != nil { 493 + notFound(w) 494 + return 495 + } 496 + 497 + tags, err := gr.Tags() 498 + if err != nil { 499 + // Non-fatal, we *should* have at least one branch to show. 500 + l.Warn("getting tags", "error", err.Error()) 501 + } 502 + 503 + rtags := []*types.TagReference{} 504 + for _, tag := range tags { 505 + var target *object.Tag 506 + if tag.Target != plumbing.ZeroHash { 507 + target = &tag 508 + } 509 + tr := types.TagReference{ 510 + Tag: target, 511 + } 512 + 513 + tr.Reference = types.Reference{ 514 + Name: tag.Name, 515 + Hash: tag.Hash.String(), 516 + } 517 + 518 + if tag.Message != "" { 519 + tr.Message = tag.Message 520 + } 521 + 522 + rtags = append(rtags, &tr) 523 + } 524 + 525 + resp := types.RepoTagsResponse{ 526 + Tags: rtags, 527 + } 528 + 529 + writeJSON(w, resp) 530 + } 531 + 532 + func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) { 533 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 534 + 535 + gr, err := git.PlainOpen(path) 536 + if err != nil { 537 + notFound(w) 538 + return 539 + } 540 + 541 + branches, _ := gr.Branches() 542 + 543 + resp := types.RepoBranchesResponse{ 544 + Branches: branches, 545 + } 546 + 547 + writeJSON(w, resp) 548 + } 549 + 550 + func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) { 551 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 552 + branchName := chi.URLParam(r, "branch") 553 + branchName, _ = url.PathUnescape(branchName) 554 + 555 + l := h.l.With("handler", "Branch") 556 + 557 + gr, err := git.PlainOpen(path) 558 + if err != nil { 559 + notFound(w) 560 + return 561 + } 562 + 563 + ref, err := gr.Branch(branchName) 564 + if err != nil { 565 + l.Error("getting branch", "error", err.Error()) 566 + writeError(w, err.Error(), http.StatusInternalServerError) 567 + return 568 + } 569 + 570 + commit, err := gr.Commit(ref.Hash()) 571 + if err != nil { 572 + l.Error("getting commit object", "error", err.Error()) 573 + writeError(w, err.Error(), http.StatusInternalServerError) 574 + return 575 + } 576 + 577 + defaultBranch, err := gr.FindMainBranch() 578 + isDefault := false 579 + if err != nil { 580 + l.Error("getting default branch", "error", err.Error()) 581 + // do not quit though 582 + } else if defaultBranch == branchName { 583 + isDefault = true 584 + } 585 + 586 + resp := types.RepoBranchResponse{ 587 + Branch: types.Branch{ 588 + Reference: types.Reference{ 589 + Name: ref.Name().Short(), 590 + Hash: ref.Hash().String(), 591 + }, 592 + Commit: commit, 593 + IsDefault: isDefault, 594 + }, 595 + } 596 + 597 + writeJSON(w, resp) 598 + } 599 + 600 + func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) { 601 + l := h.l.With("handler", "Keys") 602 + 603 + switch r.Method { 604 + case http.MethodGet: 605 + keys, err := h.db.GetAllPublicKeys() 606 + if err != nil { 607 + writeError(w, err.Error(), http.StatusInternalServerError) 608 + l.Error("getting public keys", "error", err.Error()) 609 return 610 } 611 612 + data := make([]map[string]any, 0) 613 + for _, key := range keys { 614 + j := key.JSON() 615 + data = append(data, j) 616 } 617 + writeJSON(w, data) 618 + return 619 620 + case http.MethodPut: 621 + pk := db.PublicKey{} 622 + if err := json.NewDecoder(r.Body).Decode(&pk); err != nil { 623 + writeError(w, "invalid request body", http.StatusBadRequest) 624 + return 625 } 626 + 627 + _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key)) 628 + if err != nil { 629 + writeError(w, "invalid pubkey", http.StatusBadRequest) 630 + } 631 + 632 + if err := h.db.AddPublicKey(pk); err != nil { 633 + writeError(w, err.Error(), http.StatusInternalServerError) 634 + l.Error("adding public key", "error", err.Error()) 635 + return 636 + } 637 + 638 + w.WriteHeader(http.StatusNoContent) 639 + return 640 + } 641 + } 642 + 643 + // func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) { 644 + // l := h.l.With("handler", "RepoForkSync") 645 + // 646 + // data := struct { 647 + // Did string `json:"did"` 648 + // Source string `json:"source"` 649 + // Name string `json:"name,omitempty"` 650 + // HiddenRef string `json:"hiddenref"` 651 + // }{} 652 + // 653 + // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 654 + // writeError(w, "invalid request body", http.StatusBadRequest) 655 + // return 656 + // } 657 + // 658 + // did := data.Did 659 + // source := data.Source 660 + // 661 + // if did == "" || source == "" { 662 + // l.Error("invalid request body, empty did or name") 663 + // w.WriteHeader(http.StatusBadRequest) 664 + // return 665 + // } 666 + // 667 + // var name string 668 + // if data.Name != "" { 669 + // name = data.Name 670 + // } else { 671 + // name = filepath.Base(source) 672 + // } 673 + // 674 + // branch := chi.URLParam(r, "branch") 675 + // branch, _ = url.PathUnescape(branch) 676 + // 677 + // relativeRepoPath := filepath.Join(did, name) 678 + // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 679 + // 680 + // gr, err := git.PlainOpen(repoPath) 681 + // if err != nil { 682 + // log.Println(err) 683 + // notFound(w) 684 + // return 685 + // } 686 + // 687 + // forkCommit, err := gr.ResolveRevision(branch) 688 + // if err != nil { 689 + // l.Error("error resolving ref revision", "msg", err.Error()) 690 + // writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest) 691 + // return 692 + // } 693 + // 694 + // sourceCommit, err := gr.ResolveRevision(data.HiddenRef) 695 + // if err != nil { 696 + // l.Error("error resolving hidden ref revision", "msg", err.Error()) 697 + // writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest) 698 + // return 699 + // } 700 + // 701 + // status := types.UpToDate 702 + // if forkCommit.Hash.String() != sourceCommit.Hash.String() { 703 + // isAncestor, err := forkCommit.IsAncestor(sourceCommit) 704 + // if err != nil { 705 + // log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err) 706 + // return 707 + // } 708 + // 709 + // if isAncestor { 710 + // status = types.FastForwardable 711 + // } else { 712 + // status = types.Conflict 713 + // } 714 + // } 715 + // 716 + // w.Header().Set("Content-Type", "application/json") 717 + // json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status}) 718 + // } 719 + 720 + func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) { 721 + repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 722 + ref := chi.URLParam(r, "ref") 723 + ref, _ = url.PathUnescape(ref) 724 + 725 + l := h.l.With("handler", "RepoLanguages") 726 + 727 + gr, err := git.Open(repoPath, ref) 728 + if err != nil { 729 + l.Error("opening repo", "error", err.Error()) 730 + notFound(w) 731 + return 732 } 733 734 + ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 735 + defer cancel() 736 + 737 + sizes, err := gr.AnalyzeLanguages(ctx) 738 + if err != nil { 739 + l.Error("failed to analyze languages", "error", err.Error()) 740 + writeError(w, err.Error(), http.StatusNoContent) 741 + return 742 + } 743 + 744 + resp := types.RepoLanguageResponse{Languages: sizes} 745 + 746 + writeJSON(w, resp) 747 + } 748 + 749 + // func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) { 750 + // l := h.l.With("handler", "RepoForkSync") 751 + // 752 + // data := struct { 753 + // Did string `json:"did"` 754 + // Source string `json:"source"` 755 + // Name string `json:"name,omitempty"` 756 + // }{} 757 + // 758 + // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 759 + // writeError(w, "invalid request body", http.StatusBadRequest) 760 + // return 761 + // } 762 + // 763 + // did := data.Did 764 + // source := data.Source 765 + // 766 + // if did == "" || source == "" { 767 + // l.Error("invalid request body, empty did or name") 768 + // w.WriteHeader(http.StatusBadRequest) 769 + // return 770 + // } 771 + // 772 + // var name string 773 + // if data.Name != "" { 774 + // name = data.Name 775 + // } else { 776 + // name = filepath.Base(source) 777 + // } 778 + // 779 + // branch := chi.URLParam(r, "branch") 780 + // branch, _ = url.PathUnescape(branch) 781 + // 782 + // relativeRepoPath := filepath.Join(did, name) 783 + // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 784 + // 785 + // gr, err := git.Open(repoPath, branch) 786 + // if err != nil { 787 + // log.Println(err) 788 + // notFound(w) 789 + // return 790 + // } 791 + // 792 + // err = gr.Sync() 793 + // if err != nil { 794 + // l.Error("error syncing repo fork", "error", err.Error()) 795 + // writeError(w, err.Error(), http.StatusInternalServerError) 796 + // return 797 + // } 798 + // 799 + // w.WriteHeader(http.StatusNoContent) 800 + // } 801 + 802 + // func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) { 803 + // l := h.l.With("handler", "RepoFork") 804 + // 805 + // data := struct { 806 + // Did string `json:"did"` 807 + // Source string `json:"source"` 808 + // Name string `json:"name,omitempty"` 809 + // }{} 810 + // 811 + // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 812 + // writeError(w, "invalid request body", http.StatusBadRequest) 813 + // return 814 + // } 815 + // 816 + // did := data.Did 817 + // source := data.Source 818 + // 819 + // if did == "" || source == "" { 820 + // l.Error("invalid request body, empty did or name") 821 + // w.WriteHeader(http.StatusBadRequest) 822 + // return 823 + // } 824 + // 825 + // var name string 826 + // if data.Name != "" { 827 + // name = data.Name 828 + // } else { 829 + // name = filepath.Base(source) 830 + // } 831 + // 832 + // relativeRepoPath := filepath.Join(did, name) 833 + // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 834 + // 835 + // err := git.Fork(repoPath, source) 836 + // if err != nil { 837 + // l.Error("forking repo", "error", err.Error()) 838 + // writeError(w, err.Error(), http.StatusInternalServerError) 839 + // return 840 + // } 841 + // 842 + // // add perms for this user to access the repo 843 + // err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 844 + // if err != nil { 845 + // l.Error("adding repo permissions", "error", err.Error()) 846 + // writeError(w, err.Error(), http.StatusInternalServerError) 847 + // return 848 + // } 849 + // 850 + // hook.SetupRepo( 851 + // hook.Config( 852 + // hook.WithScanPath(h.c.Repo.ScanPath), 853 + // hook.WithInternalApi(h.c.Server.InternalListenAddr), 854 + // ), 855 + // repoPath, 856 + // ) 857 + // 858 + // w.WriteHeader(http.StatusNoContent) 859 + // } 860 + 861 + // func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) { 862 + // l := h.l.With("handler", "RemoveRepo") 863 + // 864 + // data := struct { 865 + // Did string `json:"did"` 866 + // Name string `json:"name"` 867 + // }{} 868 + // 869 + // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 870 + // writeError(w, "invalid request body", http.StatusBadRequest) 871 + // return 872 + // } 873 + // 874 + // did := data.Did 875 + // name := data.Name 876 + // 877 + // if did == "" || name == "" { 878 + // l.Error("invalid request body, empty did or name") 879 + // w.WriteHeader(http.StatusBadRequest) 880 + // return 881 + // } 882 + // 883 + // relativeRepoPath := filepath.Join(did, name) 884 + // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 885 + // err := os.RemoveAll(repoPath) 886 + // if err != nil { 887 + // l.Error("removing repo", "error", err.Error()) 888 + // writeError(w, err.Error(), http.StatusInternalServerError) 889 + // return 890 + // } 891 + // 892 + // w.WriteHeader(http.StatusNoContent) 893 + // 894 + // } 895 + 896 + // func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) { 897 + // path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 898 + // 899 + // data := types.MergeRequest{} 900 + // 901 + // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 902 + // writeError(w, err.Error(), http.StatusBadRequest) 903 + // h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err) 904 + // return 905 + // } 906 + // 907 + // mo := &git.MergeOptions{ 908 + // AuthorName: data.AuthorName, 909 + // AuthorEmail: data.AuthorEmail, 910 + // CommitBody: data.CommitBody, 911 + // CommitMessage: data.CommitMessage, 912 + // } 913 + // 914 + // patch := data.Patch 915 + // branch := data.Branch 916 + // gr, err := git.Open(path, branch) 917 + // if err != nil { 918 + // notFound(w) 919 + // return 920 + // } 921 + // 922 + // mo.FormatPatch = patchutil.IsFormatPatch(patch) 923 + // 924 + // if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil { 925 + // var mergeErr *git.ErrMerge 926 + // if errors.As(err, &mergeErr) { 927 + // conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 928 + // for i, conflict := range mergeErr.Conflicts { 929 + // conflicts[i] = types.ConflictInfo{ 930 + // Filename: conflict.Filename, 931 + // Reason: conflict.Reason, 932 + // } 933 + // } 934 + // response := types.MergeCheckResponse{ 935 + // IsConflicted: true, 936 + // Conflicts: conflicts, 937 + // Message: mergeErr.Message, 938 + // } 939 + // writeConflict(w, response) 940 + // h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr) 941 + // } else { 942 + // writeError(w, err.Error(), http.StatusBadRequest) 943 + // h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error()) 944 + // } 945 + // return 946 + // } 947 + // 948 + // w.WriteHeader(http.StatusOK) 949 + // } 950 + 951 + // func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) { 952 + // path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 953 + // 954 + // var data struct { 955 + // Patch string `json:"patch"` 956 + // Branch string `json:"branch"` 957 + // } 958 + // 959 + // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 960 + // writeError(w, err.Error(), http.StatusBadRequest) 961 + // h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err) 962 + // return 963 + // } 964 + // 965 + // patch := data.Patch 966 + // branch := data.Branch 967 + // gr, err := git.Open(path, branch) 968 + // if err != nil { 969 + // notFound(w) 970 + // return 971 + // } 972 + // 973 + // err = gr.MergeCheck([]byte(patch), branch) 974 + // if err == nil { 975 + // response := types.MergeCheckResponse{ 976 + // IsConflicted: false, 977 + // } 978 + // writeJSON(w, response) 979 + // return 980 + // } 981 + // 982 + // var mergeErr *git.ErrMerge 983 + // if errors.As(err, &mergeErr) { 984 + // conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 985 + // for i, conflict := range mergeErr.Conflicts { 986 + // conflicts[i] = types.ConflictInfo{ 987 + // Filename: conflict.Filename, 988 + // Reason: conflict.Reason, 989 + // } 990 + // } 991 + // response := types.MergeCheckResponse{ 992 + // IsConflicted: true, 993 + // Conflicts: conflicts, 994 + // Message: mergeErr.Message, 995 + // } 996 + // writeConflict(w, response) 997 + // h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error()) 998 + // return 999 + // } 1000 + // writeError(w, err.Error(), http.StatusInternalServerError) 1001 + // h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error()) 1002 + // } 1003 + 1004 + func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) { 1005 + rev1 := chi.URLParam(r, "rev1") 1006 + rev1, _ = url.PathUnescape(rev1) 1007 + 1008 + rev2 := chi.URLParam(r, "rev2") 1009 + rev2, _ = url.PathUnescape(rev2) 1010 + 1011 + l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2) 1012 + 1013 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1014 + gr, err := git.PlainOpen(path) 1015 + if err != nil { 1016 + notFound(w) 1017 + return 1018 + } 1019 + 1020 + commit1, err := gr.ResolveRevision(rev1) 1021 + if err != nil { 1022 + l.Error("error resolving revision 1", "msg", err.Error()) 1023 + writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest) 1024 + return 1025 + } 1026 + 1027 + commit2, err := gr.ResolveRevision(rev2) 1028 + if err != nil { 1029 + l.Error("error resolving revision 2", "msg", err.Error()) 1030 + writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest) 1031 + return 1032 + } 1033 + 1034 + rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 1035 + if err != nil { 1036 + l.Error("error comparing revisions", "msg", err.Error()) 1037 + writeError(w, "error comparing revisions", http.StatusBadRequest) 1038 + return 1039 + } 1040 + 1041 + writeJSON(w, types.RepoFormatPatchResponse{ 1042 + Rev1: commit1.Hash.String(), 1043 + Rev2: commit2.Hash.String(), 1044 + FormatPatch: formatPatch, 1045 + Patch: rawPatch, 1046 + }) 1047 + } 1048 + 1049 + func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) { 1050 + l := h.l.With("handler", "DefaultBranch") 1051 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1052 + 1053 + gr, err := git.Open(path, "") 1054 + if err != nil { 1055 + notFound(w) 1056 + return 1057 + } 1058 + 1059 + branch, err := gr.FindMainBranch() 1060 + if err != nil { 1061 + writeError(w, err.Error(), http.StatusInternalServerError) 1062 + l.Error("getting default branch", "error", err.Error()) 1063 + return 1064 + } 1065 + 1066 + writeJSON(w, types.RepoDefaultBranchResponse{ 1067 + Branch: branch, 1068 + }) 1069 }
-10
knotserver/http_util.go
··· 20 func notFound(w http.ResponseWriter) { 21 writeError(w, "not found", http.StatusNotFound) 22 } 23 - 24 - func writeMsg(w http.ResponseWriter, msg string) { 25 - writeJSON(w, map[string]string{"msg": msg}) 26 - } 27 - 28 - func writeConflict(w http.ResponseWriter, data interface{}) { 29 - w.Header().Set("Content-Type", "application/json") 30 - w.WriteHeader(http.StatusConflict) 31 - json.NewEncoder(w).Encode(data) 32 - }
··· 20 func notFound(w http.ResponseWriter) { 21 writeError(w, "not found", http.StatusNotFound) 22 }
+65 -76
knotserver/ingester.go
··· 8 "net/http" 9 "net/url" 10 "path/filepath" 11 - "slices" 12 "strings" 13 14 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 25 "tangled.sh/tangled.sh/core/workflow" 26 ) 27 28 - func (h *Handle) processPublicKey(ctx context.Context, did string, record tangled.PublicKey) error { 29 l := log.FromContext(ctx) 30 pk := db.PublicKey{ 31 Did: did, 32 PublicKey: record, ··· 39 return nil 40 } 41 42 - func (h *Handle) processKnotMember(ctx context.Context, did string, record tangled.KnotMember) error { 43 l := log.FromContext(ctx) 44 45 if record.Domain != h.c.Server.Hostname { 46 l.Error("domain mismatch", "domain", record.Domain, "expected", h.c.Server.Hostname) ··· 59 } 60 l.Info("added member from firehose", "member", record.Subject) 61 62 - if err := h.db.AddDid(did); err != nil { 63 l.Error("failed to add did", "error", err) 64 return fmt.Errorf("failed to add did: %w", err) 65 } 66 - h.jc.AddDid(did) 67 68 - if err := h.fetchAndAddKeys(ctx, did); err != nil { 69 return fmt.Errorf("failed to fetch and add keys: %w", err) 70 } 71 72 return nil 73 } 74 75 - func (h *Handle) processPull(ctx context.Context, did string, record tangled.RepoPull) error { 76 l := log.FromContext(ctx) 77 l = l.With("handler", "processPull") 78 l = l.With("did", did) ··· 80 l = l.With("target_branch", record.TargetBranch) 81 82 if record.Source == nil { 83 - reason := "not a branch-based pull request" 84 - l.Info("ignoring pull record", "reason", reason) 85 - return fmt.Errorf("ignoring pull record: %s", reason) 86 } 87 88 if record.Source.Repo != nil { 89 - reason := "fork based pull" 90 - l.Info("ignoring pull record", "reason", reason) 91 - return fmt.Errorf("ignoring pull record: %s", reason) 92 - } 93 - 94 - allDids, err := h.db.GetAllDids() 95 - if err != nil { 96 - return err 97 - } 98 - 99 - // presently: we only process PRs from collaborators for pipelines 100 - if !slices.Contains(allDids, did) { 101 - reason := "not a known did" 102 - l.Info("rejecting pull record", "reason", reason) 103 - return fmt.Errorf("rejected pull record: %s, %s", reason, did) 104 } 105 106 repoAt, err := syntax.ParseATURI(record.TargetRepo) 107 if err != nil { 108 - return err 109 } 110 111 // resolve this aturi to extract the repo record ··· 121 122 resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 123 if err != nil { 124 - return err 125 } 126 127 repo := resp.Value.Val.(*tangled.Repo) 128 129 if repo.Knot != h.c.Server.Hostname { 130 - reason := "not this knot" 131 - l.Info("rejecting pull record", "reason", reason) 132 - return fmt.Errorf("rejected pull record: %s", reason) 133 } 134 135 didSlashRepo, err := securejoin.SecureJoin(repo.Owner, repo.Name) 136 if err != nil { 137 - return err 138 } 139 140 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 141 if err != nil { 142 - return err 143 } 144 145 gr, err := git.Open(repoPath, record.Source.Branch) 146 if err != nil { 147 - return err 148 } 149 150 workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir) 151 if err != nil { 152 - return err 153 } 154 155 var pipeline workflow.RawPipeline ··· 192 cp := compiler.Compile(compiler.Parse(pipeline)) 193 eventJson, err := json.Marshal(cp) 194 if err != nil { 195 - return err 196 } 197 198 // do not run empty pipelines ··· 200 return nil 201 } 202 203 - event := db.Event{ 204 Rkey: TID(), 205 Nsid: tangled.PipelineNSID, 206 EventJson: string(eventJson), 207 } 208 209 - return h.db.InsertEvent(event, h.n) 210 } 211 212 // duplicated from add collaborator 213 - func (h *Handle) processCollaborator(ctx context.Context, did string, record tangled.RepoCollaborator) error { 214 repoAt, err := syntax.ParseATURI(record.Repo) 215 if err != nil { 216 return err ··· 243 didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 244 245 // check perms for this user 246 - if ok, err := h.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil { 247 - return fmt.Errorf("insufficient permissions: %w", err) 248 } 249 250 if err := h.db.AddDid(subjectId.DID.String()); err != nil { ··· 286 return fmt.Errorf("error reading response body: %w", err) 287 } 288 289 - for _, key := range strings.Split(string(plaintext), "\n") { 290 if key == "" { 291 continue 292 } ··· 303 } 304 305 func (h *Handle) processMessages(ctx context.Context, event *models.Event) error { 306 - did := event.Did 307 if event.Kind != models.EventKindCommit { 308 return nil 309 } ··· 317 } 318 }() 319 320 - raw := json.RawMessage(event.Commit.Record) 321 - 322 switch event.Commit.Collection { 323 case tangled.PublicKeyNSID: 324 - var record tangled.PublicKey 325 - if err := json.Unmarshal(raw, &record); err != nil { 326 - return fmt.Errorf("failed to unmarshal record: %w", err) 327 - } 328 - if err := h.processPublicKey(ctx, did, record); err != nil { 329 - return fmt.Errorf("failed to process public key: %w", err) 330 - } 331 - 332 case tangled.KnotMemberNSID: 333 - var record tangled.KnotMember 334 - if err := json.Unmarshal(raw, &record); err != nil { 335 - return fmt.Errorf("failed to unmarshal record: %w", err) 336 - } 337 - if err := h.processKnotMember(ctx, did, record); err != nil { 338 - return fmt.Errorf("failed to process knot member: %w", err) 339 - } 340 - 341 case tangled.RepoPullNSID: 342 - var record tangled.RepoPull 343 - if err := json.Unmarshal(raw, &record); err != nil { 344 - return fmt.Errorf("failed to unmarshal record: %w", err) 345 - } 346 - if err := h.processPull(ctx, did, record); err != nil { 347 - return fmt.Errorf("failed to process knot member: %w", err) 348 - } 349 - 350 case tangled.RepoCollaboratorNSID: 351 - var record tangled.RepoCollaborator 352 - if err := json.Unmarshal(raw, &record); err != nil { 353 - return fmt.Errorf("failed to unmarshal record: %w", err) 354 - } 355 - if err := h.processCollaborator(ctx, did, record); err != nil { 356 - return fmt.Errorf("failed to process knot member: %w", err) 357 - } 358 359 } 360 361 - return err 362 }
··· 8 "net/http" 9 "net/url" 10 "path/filepath" 11 "strings" 12 13 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 24 "tangled.sh/tangled.sh/core/workflow" 25 ) 26 27 + func (h *Handle) processPublicKey(ctx context.Context, event *models.Event) error { 28 l := log.FromContext(ctx) 29 + raw := json.RawMessage(event.Commit.Record) 30 + did := event.Did 31 + 32 + var record tangled.PublicKey 33 + if err := json.Unmarshal(raw, &record); err != nil { 34 + return fmt.Errorf("failed to unmarshal record: %w", err) 35 + } 36 + 37 pk := db.PublicKey{ 38 Did: did, 39 PublicKey: record, ··· 46 return nil 47 } 48 49 + func (h *Handle) processKnotMember(ctx context.Context, event *models.Event) error { 50 l := log.FromContext(ctx) 51 + raw := json.RawMessage(event.Commit.Record) 52 + did := event.Did 53 + 54 + var record tangled.KnotMember 55 + if err := json.Unmarshal(raw, &record); err != nil { 56 + return fmt.Errorf("failed to unmarshal record: %w", err) 57 + } 58 59 if record.Domain != h.c.Server.Hostname { 60 l.Error("domain mismatch", "domain", record.Domain, "expected", h.c.Server.Hostname) ··· 73 } 74 l.Info("added member from firehose", "member", record.Subject) 75 76 + if err := h.db.AddDid(record.Subject); err != nil { 77 l.Error("failed to add did", "error", err) 78 return fmt.Errorf("failed to add did: %w", err) 79 } 80 + h.jc.AddDid(record.Subject) 81 82 + if err := h.fetchAndAddKeys(ctx, record.Subject); err != nil { 83 return fmt.Errorf("failed to fetch and add keys: %w", err) 84 } 85 86 return nil 87 } 88 89 + func (h *Handle) processPull(ctx context.Context, event *models.Event) error { 90 + raw := json.RawMessage(event.Commit.Record) 91 + did := event.Did 92 + 93 + var record tangled.RepoPull 94 + if err := json.Unmarshal(raw, &record); err != nil { 95 + return fmt.Errorf("failed to unmarshal record: %w", err) 96 + } 97 + 98 l := log.FromContext(ctx) 99 l = l.With("handler", "processPull") 100 l = l.With("did", did) ··· 102 l = l.With("target_branch", record.TargetBranch) 103 104 if record.Source == nil { 105 + return fmt.Errorf("ignoring pull record: not a branch-based pull request") 106 } 107 108 if record.Source.Repo != nil { 109 + return fmt.Errorf("ignoring pull record: fork based pull") 110 } 111 112 repoAt, err := syntax.ParseATURI(record.TargetRepo) 113 if err != nil { 114 + return fmt.Errorf("failed to parse ATURI: %w", err) 115 } 116 117 // resolve this aturi to extract the repo record ··· 127 128 resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 129 if err != nil { 130 + return fmt.Errorf("failed to resolver repo: %w", err) 131 } 132 133 repo := resp.Value.Val.(*tangled.Repo) 134 135 if repo.Knot != h.c.Server.Hostname { 136 + return fmt.Errorf("rejected pull record: not this knot, %s != %s", repo.Knot, h.c.Server.Hostname) 137 } 138 139 didSlashRepo, err := securejoin.SecureJoin(repo.Owner, repo.Name) 140 if err != nil { 141 + return fmt.Errorf("failed to construct relative repo path: %w", err) 142 } 143 144 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 145 if err != nil { 146 + return fmt.Errorf("failed to construct absolute repo path: %w", err) 147 } 148 149 gr, err := git.Open(repoPath, record.Source.Branch) 150 if err != nil { 151 + return fmt.Errorf("failed to open git repository: %w", err) 152 } 153 154 workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir) 155 if err != nil { 156 + return fmt.Errorf("failed to open workflow directory: %w", err) 157 } 158 159 var pipeline workflow.RawPipeline ··· 196 cp := compiler.Compile(compiler.Parse(pipeline)) 197 eventJson, err := json.Marshal(cp) 198 if err != nil { 199 + return fmt.Errorf("failed to marshal pipeline event: %w", err) 200 } 201 202 // do not run empty pipelines ··· 204 return nil 205 } 206 207 + ev := db.Event{ 208 Rkey: TID(), 209 Nsid: tangled.PipelineNSID, 210 EventJson: string(eventJson), 211 } 212 213 + return h.db.InsertEvent(ev, h.n) 214 } 215 216 // duplicated from add collaborator 217 + func (h *Handle) processCollaborator(ctx context.Context, event *models.Event) error { 218 + raw := json.RawMessage(event.Commit.Record) 219 + did := event.Did 220 + 221 + var record tangled.RepoCollaborator 222 + if err := json.Unmarshal(raw, &record); err != nil { 223 + return fmt.Errorf("failed to unmarshal record: %w", err) 224 + } 225 + 226 repoAt, err := syntax.ParseATURI(record.Repo) 227 if err != nil { 228 return err ··· 255 didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 256 257 // check perms for this user 258 + ok, err := h.e.IsCollaboratorInviteAllowed(did, rbac.ThisServer, didSlashRepo) 259 + if err != nil { 260 + return fmt.Errorf("failed to check permissions: %w", err) 261 + } 262 + if !ok { 263 + return fmt.Errorf("insufficient permissions: %s, %s, %s", did, "IsCollaboratorInviteAllowed", didSlashRepo) 264 } 265 266 if err := h.db.AddDid(subjectId.DID.String()); err != nil { ··· 302 return fmt.Errorf("error reading response body: %w", err) 303 } 304 305 + for key := range strings.SplitSeq(string(plaintext), "\n") { 306 if key == "" { 307 continue 308 } ··· 319 } 320 321 func (h *Handle) processMessages(ctx context.Context, event *models.Event) error { 322 if event.Kind != models.EventKindCommit { 323 return nil 324 } ··· 332 } 333 }() 334 335 switch event.Commit.Collection { 336 case tangled.PublicKeyNSID: 337 + err = h.processPublicKey(ctx, event) 338 case tangled.KnotMemberNSID: 339 + err = h.processKnotMember(ctx, event) 340 case tangled.RepoPullNSID: 341 + err = h.processPull(ctx, event) 342 case tangled.RepoCollaboratorNSID: 343 + err = h.processCollaborator(ctx, event) 344 + } 345 346 + if err != nil { 347 + h.l.Debug("failed to process event", "nsid", event.Commit.Collection, "err", err) 348 } 349 350 + return nil 351 }
+4 -6
knotserver/internal.go
··· 47 } 48 49 w.WriteHeader(http.StatusNoContent) 50 - return 51 } 52 53 func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) { ··· 63 data = append(data, j) 64 } 65 writeJSON(w, data) 66 - return 67 } 68 69 type PushOptions struct { ··· 242 return err 243 } 244 245 if pushOptions.verboseCi { 246 if compiler.Diagnostics.IsEmpty() { 247 *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 248 - } 249 - 250 - for _, e := range compiler.Diagnostics.Errors { 251 - *clientMsgs = append(*clientMsgs, e.String()) 252 } 253 254 for _, w := range compiler.Diagnostics.Warnings {
··· 47 } 48 49 w.WriteHeader(http.StatusNoContent) 50 } 51 52 func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) { ··· 62 data = append(data, j) 63 } 64 writeJSON(w, data) 65 } 66 67 type PushOptions struct { ··· 240 return err 241 } 242 243 + for _, e := range compiler.Diagnostics.Errors { 244 + *clientMsgs = append(*clientMsgs, e.String()) 245 + } 246 + 247 if pushOptions.verboseCi { 248 if compiler.Diagnostics.IsEmpty() { 249 *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 250 } 251 252 for _, w := range compiler.Diagnostics.Warnings {
-53
knotserver/middleware.go
··· 1 - package knotserver 2 - 3 - import ( 4 - "crypto/hmac" 5 - "crypto/sha256" 6 - "encoding/hex" 7 - "net/http" 8 - "time" 9 - ) 10 - 11 - func (h *Handle) VerifySignature(next http.Handler) http.Handler { 12 - if h.c.Server.Dev { 13 - return next 14 - } 15 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 - signature := r.Header.Get("X-Signature") 17 - if signature == "" || !h.verifyHMAC(signature, r) { 18 - writeError(w, "signature verification failed", http.StatusForbidden) 19 - return 20 - } 21 - next.ServeHTTP(w, r) 22 - }) 23 - } 24 - 25 - func (h *Handle) verifyHMAC(signature string, r *http.Request) bool { 26 - secret := h.c.Server.Secret 27 - timestamp := r.Header.Get("X-Timestamp") 28 - if timestamp == "" { 29 - return false 30 - } 31 - 32 - // Verify that the timestamp is not older than a minute 33 - reqTime, err := time.Parse(time.RFC3339, timestamp) 34 - if err != nil { 35 - return false 36 - } 37 - if time.Since(reqTime) > time.Minute { 38 - return false 39 - } 40 - 41 - message := r.Method + r.URL.Path + timestamp 42 - 43 - mac := hmac.New(sha256.New, []byte(secret)) 44 - mac.Write([]byte(message)) 45 - expectedMAC := mac.Sum(nil) 46 - 47 - signatureBytes, err := hex.DecodeString(signature) 48 - if err != nil { 49 - return false 50 - } 51 - 52 - return hmac.Equal(signatureBytes, expectedMAC) 53 - }
···
+138 -1287
knotserver/routes.go
··· 1 package knotserver 2 3 import ( 4 - "compress/gzip" 5 "context" 6 - "crypto/hmac" 7 - "crypto/sha256" 8 - "encoding/hex" 9 - "encoding/json" 10 - "errors" 11 "fmt" 12 - "log" 13 "net/http" 14 - "net/url" 15 - "os" 16 - "path/filepath" 17 - "strconv" 18 - "strings" 19 - "sync" 20 - "time" 21 22 - securejoin "github.com/cyphar/filepath-securejoin" 23 - "github.com/gliderlabs/ssh" 24 "github.com/go-chi/chi/v5" 25 - gogit "github.com/go-git/go-git/v5" 26 - "github.com/go-git/go-git/v5/plumbing" 27 - "github.com/go-git/go-git/v5/plumbing/object" 28 - "tangled.sh/tangled.sh/core/hook" 29 "tangled.sh/tangled.sh/core/knotserver/db" 30 - "tangled.sh/tangled.sh/core/knotserver/git" 31 - "tangled.sh/tangled.sh/core/patchutil" 32 "tangled.sh/tangled.sh/core/rbac" 33 - "tangled.sh/tangled.sh/core/types" 34 ) 35 36 - func (h *Handle) Index(w http.ResponseWriter, r *http.Request) { 37 - w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 38 } 39 40 - func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) { 41 - w.Header().Set("Content-Type", "application/json") 42 43 - capabilities := map[string]any{ 44 - "pull_requests": map[string]any{ 45 - "format_patch": true, 46 - "patch_submissions": true, 47 - "branch_submissions": true, 48 - "fork_submissions": true, 49 - }, 50 } 51 52 - jsonData, err := json.Marshal(capabilities) 53 if err != nil { 54 - http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError) 55 - return 56 } 57 58 - w.Write(jsonData) 59 - } 60 - 61 - func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) { 62 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 63 - l := h.l.With("path", path, "handler", "RepoIndex") 64 - ref := chi.URLParam(r, "ref") 65 - ref, _ = url.PathUnescape(ref) 66 - 67 - gr, err := git.Open(path, ref) 68 - if err != nil { 69 - plain, err2 := git.PlainOpen(path) 70 - if err2 != nil { 71 - l.Error("opening repo", "error", err2.Error()) 72 - notFound(w) 73 - return 74 - } 75 - branches, _ := plain.Branches() 76 - 77 - log.Println(err) 78 - 79 - if errors.Is(err, plumbing.ErrReferenceNotFound) { 80 - resp := types.RepoIndexResponse{ 81 - IsEmpty: true, 82 - Branches: branches, 83 - } 84 - writeJSON(w, resp) 85 - return 86 - } else { 87 - l.Error("opening repo", "error", err.Error()) 88 - notFound(w) 89 - return 90 - } 91 } 92 93 - var ( 94 - commits []*object.Commit 95 - total int 96 - branches []types.Branch 97 - files []types.NiceTree 98 - tags []object.Tag 99 - ) 100 - 101 - var wg sync.WaitGroup 102 - errorsCh := make(chan error, 5) 103 - 104 - wg.Add(1) 105 - go func() { 106 - defer wg.Done() 107 - cs, err := gr.Commits(0, 60) 108 - if err != nil { 109 - errorsCh <- fmt.Errorf("commits: %w", err) 110 - return 111 - } 112 - commits = cs 113 - }() 114 - 115 - wg.Add(1) 116 - go func() { 117 - defer wg.Done() 118 - t, err := gr.TotalCommits() 119 - if err != nil { 120 - errorsCh <- fmt.Errorf("calculating total: %w", err) 121 - return 122 - } 123 - total = t 124 - }() 125 - 126 - wg.Add(1) 127 - go func() { 128 - defer wg.Done() 129 - bs, err := gr.Branches() 130 - if err != nil { 131 - errorsCh <- fmt.Errorf("fetching branches: %w", err) 132 - return 133 - } 134 - branches = bs 135 - }() 136 - 137 - wg.Add(1) 138 - go func() { 139 - defer wg.Done() 140 - ts, err := gr.Tags() 141 - if err != nil { 142 - errorsCh <- fmt.Errorf("fetching tags: %w", err) 143 - return 144 - } 145 - tags = ts 146 - }() 147 - 148 - wg.Add(1) 149 - go func() { 150 - defer wg.Done() 151 - fs, err := gr.FileTree(r.Context(), "") 152 - if err != nil { 153 - errorsCh <- fmt.Errorf("fetching filetree: %w", err) 154 - return 155 - } 156 - files = fs 157 - }() 158 - 159 - wg.Wait() 160 - close(errorsCh) 161 - 162 - // show any errors 163 - for err := range errorsCh { 164 - l.Error("loading repo", "error", err.Error()) 165 - writeError(w, err.Error(), http.StatusInternalServerError) 166 - return 167 - } 168 - 169 - rtags := []*types.TagReference{} 170 - for _, tag := range tags { 171 - var target *object.Tag 172 - if tag.Target != plumbing.ZeroHash { 173 - target = &tag 174 - } 175 - tr := types.TagReference{ 176 - Tag: target, 177 - } 178 - 179 - tr.Reference = types.Reference{ 180 - Name: tag.Name, 181 - Hash: tag.Hash.String(), 182 - } 183 - 184 - if tag.Message != "" { 185 - tr.Message = tag.Message 186 - } 187 - 188 - rtags = append(rtags, &tr) 189 - } 190 - 191 - var readmeContent string 192 - var readmeFile string 193 - for _, readme := range h.c.Repo.Readme { 194 - content, _ := gr.FileContent(readme) 195 - if len(content) > 0 { 196 - readmeContent = string(content) 197 - readmeFile = readme 198 - } 199 - } 200 - 201 - if ref == "" { 202 - mainBranch, err := gr.FindMainBranch() 203 - if err != nil { 204 - writeError(w, err.Error(), http.StatusInternalServerError) 205 - l.Error("finding main branch", "error", err.Error()) 206 - return 207 - } 208 - ref = mainBranch 209 - } 210 - 211 - resp := types.RepoIndexResponse{ 212 - IsEmpty: false, 213 - Ref: ref, 214 - Commits: commits, 215 - Description: getDescription(path), 216 - Readme: readmeContent, 217 - ReadmeFileName: readmeFile, 218 - Files: files, 219 - Branches: branches, 220 - Tags: rtags, 221 - TotalCommits: total, 222 - } 223 - 224 - writeJSON(w, resp) 225 - return 226 - } 227 - 228 - func (h *Handle) RepoTree(w http.ResponseWriter, r *http.Request) { 229 - treePath := chi.URLParam(r, "*") 230 - ref := chi.URLParam(r, "ref") 231 - ref, _ = url.PathUnescape(ref) 232 - 233 - l := h.l.With("handler", "RepoTree", "ref", ref, "treePath", treePath) 234 - 235 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 236 - gr, err := git.Open(path, ref) 237 if err != nil { 238 - notFound(w) 239 - return 240 - } 241 - 242 - files, err := gr.FileTree(r.Context(), treePath) 243 - if err != nil { 244 - writeError(w, err.Error(), http.StatusInternalServerError) 245 - l.Error("file tree", "error", err.Error()) 246 - return 247 } 248 - 249 - resp := types.RepoTreeResponse{ 250 - Ref: ref, 251 - Parent: treePath, 252 - Description: getDescription(path), 253 - DotDot: filepath.Dir(treePath), 254 - Files: files, 255 } 256 257 - writeJSON(w, resp) 258 - return 259 - } 260 - 261 - func (h *Handle) BlobRaw(w http.ResponseWriter, r *http.Request) { 262 - treePath := chi.URLParam(r, "*") 263 - ref := chi.URLParam(r, "ref") 264 - ref, _ = url.PathUnescape(ref) 265 - 266 - l := h.l.With("handler", "BlobRaw", "ref", ref, "treePath", treePath) 267 - 268 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 269 - gr, err := git.Open(path, ref) 270 if err != nil { 271 - notFound(w) 272 - return 273 } 274 275 - contents, err := gr.RawContent(treePath) 276 - if err != nil { 277 - writeError(w, err.Error(), http.StatusBadRequest) 278 - l.Error("file content", "error", err.Error()) 279 - return 280 - } 281 282 - mimeType := http.DetectContentType(contents) 283 284 - // exception for svg 285 - if filepath.Ext(treePath) == ".svg" { 286 - mimeType = "image/svg+xml" 287 - } 288 289 - // allow image, video, and text/plain files to be served directly 290 - switch { 291 - case strings.HasPrefix(mimeType, "image/"): 292 - // allowed 293 - case strings.HasPrefix(mimeType, "video/"): 294 - // allowed 295 - case strings.HasPrefix(mimeType, "text/plain"): 296 - // allowed 297 - default: 298 - l.Error("attempted to serve disallowed file type", "mimetype", mimeType) 299 - writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden) 300 - return 301 - } 302 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 - w.Header().Set("Content-Type", mimeType) 306 - w.Write(contents) 307 - } 308 309 - func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) { 310 - treePath := chi.URLParam(r, "*") 311 - ref := chi.URLParam(r, "ref") 312 - ref, _ = url.PathUnescape(ref) 313 314 - l := h.l.With("handler", "Blob", "ref", ref, "treePath", treePath) 315 - 316 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 317 - gr, err := git.Open(path, ref) 318 - if err != nil { 319 - notFound(w) 320 - return 321 - } 322 - 323 - var isBinaryFile bool = false 324 - contents, err := gr.FileContent(treePath) 325 - if errors.Is(err, git.ErrBinaryFile) { 326 - isBinaryFile = true 327 - } else if errors.Is(err, object.ErrFileNotFound) { 328 - notFound(w) 329 - return 330 - } else if err != nil { 331 - writeError(w, err.Error(), http.StatusInternalServerError) 332 - return 333 - } 334 - 335 - bytes := []byte(contents) 336 - // safe := string(sanitize(bytes)) 337 - sizeHint := len(bytes) 338 - 339 - resp := types.RepoBlobResponse{ 340 - Ref: ref, 341 - Contents: string(bytes), 342 - Path: treePath, 343 - IsBinary: isBinaryFile, 344 - SizeHint: uint64(sizeHint), 345 - } 346 - 347 - h.showFile(resp, w, l) 348 - } 349 - 350 - func (h *Handle) Archive(w http.ResponseWriter, r *http.Request) { 351 - name := chi.URLParam(r, "name") 352 - file := chi.URLParam(r, "file") 353 - 354 - l := h.l.With("handler", "Archive", "name", name, "file", file) 355 - 356 - // TODO: extend this to add more files compression (e.g.: xz) 357 - if !strings.HasSuffix(file, ".tar.gz") { 358 - notFound(w) 359 - return 360 - } 361 - 362 - ref := strings.TrimSuffix(file, ".tar.gz") 363 - 364 - unescapedRef, err := url.PathUnescape(ref) 365 - if err != nil { 366 - notFound(w) 367 - return 368 - } 369 - 370 - safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-") 371 - 372 - // This allows the browser to use a proper name for the file when 373 - // downloading 374 - filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename) 375 - setContentDisposition(w, filename) 376 - setGZipMIME(w) 377 - 378 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 379 - gr, err := git.Open(path, unescapedRef) 380 - if err != nil { 381 - notFound(w) 382 - return 383 - } 384 - 385 - gw := gzip.NewWriter(w) 386 - defer gw.Close() 387 - 388 - prefix := fmt.Sprintf("%s-%s", name, safeRefFilename) 389 - err = gr.WriteTar(gw, prefix) 390 - if err != nil { 391 - // once we start writing to the body we can't report error anymore 392 - // so we are only left with printing the error. 393 - l.Error("writing tar file", "error", err.Error()) 394 - return 395 - } 396 - 397 - err = gw.Flush() 398 - if err != nil { 399 - // once we start writing to the body we can't report error anymore 400 - // so we are only left with printing the error. 401 - l.Error("flushing?", "error", err.Error()) 402 - return 403 - } 404 - } 405 406 - func (h *Handle) Log(w http.ResponseWriter, r *http.Request) { 407 - ref := chi.URLParam(r, "ref") 408 - ref, _ = url.PathUnescape(ref) 409 410 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 411 412 - l := h.l.With("handler", "Log", "ref", ref, "path", path) 413 414 - gr, err := git.Open(path, ref) 415 - if err != nil { 416 - notFound(w) 417 - return 418 - } 419 - 420 - // Get page parameters 421 - page := 1 422 - pageSize := 30 423 - 424 - if pageParam := r.URL.Query().Get("page"); pageParam != "" { 425 - if p, err := strconv.Atoi(pageParam); err == nil && p > 0 { 426 - page = p 427 - } 428 - } 429 - 430 - if pageSizeParam := r.URL.Query().Get("per_page"); pageSizeParam != "" { 431 - if ps, err := strconv.Atoi(pageSizeParam); err == nil && ps > 0 { 432 - pageSize = ps 433 - } 434 - } 435 - 436 - // convert to offset/limit 437 - offset := (page - 1) * pageSize 438 - limit := pageSize 439 - 440 - commits, err := gr.Commits(offset, limit) 441 - if err != nil { 442 - writeError(w, err.Error(), http.StatusInternalServerError) 443 - l.Error("fetching commits", "error", err.Error()) 444 - return 445 - } 446 - 447 - total := len(commits) 448 - 449 - resp := types.RepoLogResponse{ 450 - Commits: commits, 451 - Ref: ref, 452 - Description: getDescription(path), 453 - Log: true, 454 - Total: total, 455 - Page: page, 456 - PerPage: pageSize, 457 - } 458 - 459 - writeJSON(w, resp) 460 - return 461 } 462 463 - func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) { 464 - ref := chi.URLParam(r, "ref") 465 - ref, _ = url.PathUnescape(ref) 466 - 467 - l := h.l.With("handler", "Diff", "ref", ref) 468 469 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 470 - gr, err := git.Open(path, ref) 471 - if err != nil { 472 - notFound(w) 473 - return 474 - } 475 - 476 - diff, err := gr.Diff() 477 - if err != nil { 478 - writeError(w, err.Error(), http.StatusInternalServerError) 479 - l.Error("getting diff", "error", err.Error()) 480 - return 481 - } 482 483 - resp := types.RepoCommitResponse{ 484 - Ref: ref, 485 - Diff: diff, 486 } 487 - 488 - writeJSON(w, resp) 489 - return 490 } 491 492 - func (h *Handle) Tags(w http.ResponseWriter, r *http.Request) { 493 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 494 - l := h.l.With("handler", "Refs") 495 496 - gr, err := git.Open(path, "") 497 - if err != nil { 498 - notFound(w) 499 - return 500 - } 501 - 502 - tags, err := gr.Tags() 503 - if err != nil { 504 - // Non-fatal, we *should* have at least one branch to show. 505 - l.Warn("getting tags", "error", err.Error()) 506 - } 507 - 508 - rtags := []*types.TagReference{} 509 - for _, tag := range tags { 510 - var target *object.Tag 511 - if tag.Target != plumbing.ZeroHash { 512 - target = &tag 513 - } 514 - tr := types.TagReference{ 515 - Tag: target, 516 } 517 518 - tr.Reference = types.Reference{ 519 - Name: tag.Name, 520 - Hash: tag.Hash.String(), 521 } 522 523 - if tag.Message != "" { 524 - tr.Message = tag.Message 525 } 526 - 527 - rtags = append(rtags, &tr) 528 } 529 530 - resp := types.RepoTagsResponse{ 531 - Tags: rtags, 532 - } 533 - 534 - writeJSON(w, resp) 535 - return 536 - } 537 - 538 - func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) { 539 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 540 - 541 - gr, err := git.PlainOpen(path) 542 - if err != nil { 543 - notFound(w) 544 - return 545 - } 546 - 547 - branches, _ := gr.Branches() 548 - 549 - resp := types.RepoBranchesResponse{ 550 - Branches: branches, 551 - } 552 - 553 - writeJSON(w, resp) 554 - return 555 } 556 557 - func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) { 558 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 559 - branchName := chi.URLParam(r, "branch") 560 - branchName, _ = url.PathUnescape(branchName) 561 - 562 - l := h.l.With("handler", "Branch") 563 564 - gr, err := git.PlainOpen(path) 565 - if err != nil { 566 - notFound(w) 567 - return 568 - } 569 570 - ref, err := gr.Branch(branchName) 571 if err != nil { 572 - l.Error("getting branch", "error", err.Error()) 573 - writeError(w, err.Error(), http.StatusInternalServerError) 574 - return 575 } 576 577 - commit, err := gr.Commit(ref.Hash()) 578 - if err != nil { 579 - l.Error("getting commit object", "error", err.Error()) 580 - writeError(w, err.Error(), http.StatusInternalServerError) 581 - return 582 - } 583 584 - defaultBranch, err := gr.FindMainBranch() 585 - isDefault := false 586 - if err != nil { 587 - l.Error("getting default branch", "error", err.Error()) 588 - // do not quit though 589 - } else if defaultBranch == branchName { 590 - isDefault = true 591 - } 592 - 593 - resp := types.RepoBranchResponse{ 594 - Branch: types.Branch{ 595 - Reference: types.Reference{ 596 - Name: ref.Name().Short(), 597 - Hash: ref.Hash().String(), 598 - }, 599 - Commit: commit, 600 - IsDefault: isDefault, 601 - }, 602 - } 603 - 604 - writeJSON(w, resp) 605 - return 606 - } 607 - 608 - func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) { 609 - l := h.l.With("handler", "Keys") 610 - 611 - switch r.Method { 612 - case http.MethodGet: 613 - keys, err := h.db.GetAllPublicKeys() 614 - if err != nil { 615 - writeError(w, err.Error(), http.StatusInternalServerError) 616 - l.Error("getting public keys", "error", err.Error()) 617 - return 618 } 619 620 - data := make([]map[string]any, 0) 621 - for _, key := range keys { 622 - j := key.JSON() 623 - data = append(data, j) 624 - } 625 - writeJSON(w, data) 626 - return 627 - 628 - case http.MethodPut: 629 - pk := db.PublicKey{} 630 - if err := json.NewDecoder(r.Body).Decode(&pk); err != nil { 631 - writeError(w, "invalid request body", http.StatusBadRequest) 632 - return 633 - } 634 - 635 - _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key)) 636 if err != nil { 637 - writeError(w, "invalid pubkey", http.StatusBadRequest) 638 } 639 - 640 - if err := h.db.AddPublicKey(pk); err != nil { 641 - writeError(w, err.Error(), http.StatusInternalServerError) 642 - l.Error("adding public key", "error", err.Error()) 643 - return 644 - } 645 - 646 - w.WriteHeader(http.StatusNoContent) 647 - return 648 - } 649 - } 650 - 651 - func (h *Handle) NewRepo(w http.ResponseWriter, r *http.Request) { 652 - l := h.l.With("handler", "NewRepo") 653 - 654 - data := struct { 655 - Did string `json:"did"` 656 - Name string `json:"name"` 657 - DefaultBranch string `json:"default_branch,omitempty"` 658 - }{} 659 - 660 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 661 - writeError(w, "invalid request body", http.StatusBadRequest) 662 - return 663 } 664 665 - if data.DefaultBranch == "" { 666 - data.DefaultBranch = h.c.Repo.MainBranch 667 - } 668 - 669 - did := data.Did 670 - name := data.Name 671 - defaultBranch := data.DefaultBranch 672 - 673 - if err := validateRepoName(name); err != nil { 674 - l.Error("creating repo", "error", err.Error()) 675 - writeError(w, err.Error(), http.StatusBadRequest) 676 - return 677 - } 678 - 679 - relativeRepoPath := filepath.Join(did, name) 680 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 681 - err := git.InitBare(repoPath, defaultBranch) 682 - if err != nil { 683 - l.Error("initializing bare repo", "error", err.Error()) 684 - if errors.Is(err, gogit.ErrRepositoryAlreadyExists) { 685 - writeError(w, "That repo already exists!", http.StatusConflict) 686 - return 687 - } else { 688 - writeError(w, err.Error(), http.StatusInternalServerError) 689 - return 690 - } 691 - } 692 - 693 - // add perms for this user to access the repo 694 - err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 695 - if err != nil { 696 - l.Error("adding repo permissions", "error", err.Error()) 697 - writeError(w, err.Error(), http.StatusInternalServerError) 698 - return 699 - } 700 - 701 - hook.SetupRepo( 702 - hook.Config( 703 - hook.WithScanPath(h.c.Repo.ScanPath), 704 - hook.WithInternalApi(h.c.Server.InternalListenAddr), 705 - ), 706 - repoPath, 707 - ) 708 - 709 - w.WriteHeader(http.StatusNoContent) 710 - } 711 - 712 - func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) { 713 - l := h.l.With("handler", "RepoForkSync") 714 - 715 - data := struct { 716 - Did string `json:"did"` 717 - Source string `json:"source"` 718 - Name string `json:"name,omitempty"` 719 - HiddenRef string `json:"hiddenref"` 720 - }{} 721 - 722 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 723 - writeError(w, "invalid request body", http.StatusBadRequest) 724 - return 725 - } 726 - 727 - did := data.Did 728 - source := data.Source 729 - 730 - if did == "" || source == "" { 731 - l.Error("invalid request body, empty did or name") 732 - w.WriteHeader(http.StatusBadRequest) 733 - return 734 - } 735 - 736 - var name string 737 - if data.Name != "" { 738 - name = data.Name 739 - } else { 740 - name = filepath.Base(source) 741 - } 742 - 743 - branch := chi.URLParam(r, "branch") 744 - branch, _ = url.PathUnescape(branch) 745 - 746 - relativeRepoPath := filepath.Join(did, name) 747 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 748 - 749 - gr, err := git.PlainOpen(repoPath) 750 - if err != nil { 751 - log.Println(err) 752 - notFound(w) 753 - return 754 - } 755 - 756 - forkCommit, err := gr.ResolveRevision(branch) 757 - if err != nil { 758 - l.Error("error resolving ref revision", "msg", err.Error()) 759 - writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest) 760 - return 761 - } 762 - 763 - sourceCommit, err := gr.ResolveRevision(data.HiddenRef) 764 - if err != nil { 765 - l.Error("error resolving hidden ref revision", "msg", err.Error()) 766 - writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest) 767 - return 768 - } 769 - 770 - status := types.UpToDate 771 - if forkCommit.Hash.String() != sourceCommit.Hash.String() { 772 - isAncestor, err := forkCommit.IsAncestor(sourceCommit) 773 - if err != nil { 774 - log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err) 775 - return 776 - } 777 - 778 - if isAncestor { 779 - status = types.FastForwardable 780 - } else { 781 - status = types.Conflict 782 - } 783 - } 784 - 785 - w.Header().Set("Content-Type", "application/json") 786 - json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status}) 787 - } 788 - 789 - func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) { 790 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 791 - ref := chi.URLParam(r, "ref") 792 - ref, _ = url.PathUnescape(ref) 793 - 794 - l := h.l.With("handler", "RepoLanguages") 795 - 796 - gr, err := git.Open(repoPath, ref) 797 - if err != nil { 798 - l.Error("opening repo", "error", err.Error()) 799 - notFound(w) 800 - return 801 - } 802 - 803 - ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 804 - defer cancel() 805 - 806 - sizes, err := gr.AnalyzeLanguages(ctx) 807 - if err != nil { 808 - l.Error("failed to analyze languages", "error", err.Error()) 809 - writeError(w, err.Error(), http.StatusNoContent) 810 - return 811 - } 812 - 813 - resp := types.RepoLanguageResponse{Languages: sizes} 814 - 815 - writeJSON(w, resp) 816 - } 817 - 818 - func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) { 819 - l := h.l.With("handler", "RepoForkSync") 820 - 821 - data := struct { 822 - Did string `json:"did"` 823 - Source string `json:"source"` 824 - Name string `json:"name,omitempty"` 825 - }{} 826 - 827 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 828 - writeError(w, "invalid request body", http.StatusBadRequest) 829 - return 830 - } 831 - 832 - did := data.Did 833 - source := data.Source 834 - 835 - if did == "" || source == "" { 836 - l.Error("invalid request body, empty did or name") 837 - w.WriteHeader(http.StatusBadRequest) 838 - return 839 - } 840 - 841 - var name string 842 - if data.Name != "" { 843 - name = data.Name 844 - } else { 845 - name = filepath.Base(source) 846 - } 847 - 848 - branch := chi.URLParam(r, "branch") 849 - branch, _ = url.PathUnescape(branch) 850 - 851 - relativeRepoPath := filepath.Join(did, name) 852 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 853 - 854 - gr, err := git.PlainOpen(repoPath) 855 - if err != nil { 856 - log.Println(err) 857 - notFound(w) 858 - return 859 - } 860 - 861 - err = gr.Sync(branch) 862 - if err != nil { 863 - l.Error("error syncing repo fork", "error", err.Error()) 864 - writeError(w, err.Error(), http.StatusInternalServerError) 865 - return 866 - } 867 - 868 - w.WriteHeader(http.StatusNoContent) 869 - } 870 - 871 - func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) { 872 - l := h.l.With("handler", "RepoFork") 873 - 874 - data := struct { 875 - Did string `json:"did"` 876 - Source string `json:"source"` 877 - Name string `json:"name,omitempty"` 878 - }{} 879 - 880 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 881 - writeError(w, "invalid request body", http.StatusBadRequest) 882 - return 883 - } 884 - 885 - did := data.Did 886 - source := data.Source 887 - 888 - if did == "" || source == "" { 889 - l.Error("invalid request body, empty did or name") 890 - w.WriteHeader(http.StatusBadRequest) 891 - return 892 - } 893 - 894 - var name string 895 - if data.Name != "" { 896 - name = data.Name 897 - } else { 898 - name = filepath.Base(source) 899 - } 900 - 901 - relativeRepoPath := filepath.Join(did, name) 902 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 903 - 904 - err := git.Fork(repoPath, source) 905 - if err != nil { 906 - l.Error("forking repo", "error", err.Error()) 907 - writeError(w, err.Error(), http.StatusInternalServerError) 908 - return 909 - } 910 - 911 - // add perms for this user to access the repo 912 - err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 913 - if err != nil { 914 - l.Error("adding repo permissions", "error", err.Error()) 915 - writeError(w, err.Error(), http.StatusInternalServerError) 916 - return 917 - } 918 - 919 - hook.SetupRepo( 920 - hook.Config( 921 - hook.WithScanPath(h.c.Repo.ScanPath), 922 - hook.WithInternalApi(h.c.Server.InternalListenAddr), 923 - ), 924 - repoPath, 925 - ) 926 - 927 - w.WriteHeader(http.StatusNoContent) 928 - } 929 - 930 - func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) { 931 - l := h.l.With("handler", "RemoveRepo") 932 - 933 - data := struct { 934 - Did string `json:"did"` 935 - Name string `json:"name"` 936 - }{} 937 - 938 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 939 - writeError(w, "invalid request body", http.StatusBadRequest) 940 - return 941 - } 942 - 943 - did := data.Did 944 - name := data.Name 945 - 946 - if did == "" || name == "" { 947 - l.Error("invalid request body, empty did or name") 948 - w.WriteHeader(http.StatusBadRequest) 949 - return 950 - } 951 - 952 - relativeRepoPath := filepath.Join(did, name) 953 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 954 - err := os.RemoveAll(repoPath) 955 - if err != nil { 956 - l.Error("removing repo", "error", err.Error()) 957 - writeError(w, err.Error(), http.StatusInternalServerError) 958 - return 959 - } 960 - 961 - w.WriteHeader(http.StatusNoContent) 962 - 963 - } 964 - func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) { 965 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 966 - 967 - data := types.MergeRequest{} 968 - 969 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 970 - writeError(w, err.Error(), http.StatusBadRequest) 971 - h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err) 972 - return 973 - } 974 - 975 - mo := &git.MergeOptions{ 976 - AuthorName: data.AuthorName, 977 - AuthorEmail: data.AuthorEmail, 978 - CommitBody: data.CommitBody, 979 - CommitMessage: data.CommitMessage, 980 - } 981 - 982 - patch := data.Patch 983 - branch := data.Branch 984 - gr, err := git.Open(path, branch) 985 - if err != nil { 986 - notFound(w) 987 - return 988 - } 989 - 990 - mo.FormatPatch = patchutil.IsFormatPatch(patch) 991 - 992 - if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil { 993 - var mergeErr *git.ErrMerge 994 - if errors.As(err, &mergeErr) { 995 - conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 996 - for i, conflict := range mergeErr.Conflicts { 997 - conflicts[i] = types.ConflictInfo{ 998 - Filename: conflict.Filename, 999 - Reason: conflict.Reason, 1000 - } 1001 - } 1002 - response := types.MergeCheckResponse{ 1003 - IsConflicted: true, 1004 - Conflicts: conflicts, 1005 - Message: mergeErr.Message, 1006 - } 1007 - writeConflict(w, response) 1008 - h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr) 1009 - } else { 1010 - writeError(w, err.Error(), http.StatusBadRequest) 1011 - h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error()) 1012 - } 1013 - return 1014 - } 1015 - 1016 - w.WriteHeader(http.StatusOK) 1017 - } 1018 - 1019 - func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) { 1020 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1021 - 1022 - var data struct { 1023 - Patch string `json:"patch"` 1024 - Branch string `json:"branch"` 1025 - } 1026 - 1027 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1028 - writeError(w, err.Error(), http.StatusBadRequest) 1029 - h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err) 1030 - return 1031 - } 1032 - 1033 - patch := data.Patch 1034 - branch := data.Branch 1035 - gr, err := git.Open(path, branch) 1036 - if err != nil { 1037 - notFound(w) 1038 - return 1039 - } 1040 - 1041 - err = gr.MergeCheck([]byte(patch), branch) 1042 - if err == nil { 1043 - response := types.MergeCheckResponse{ 1044 - IsConflicted: false, 1045 - } 1046 - writeJSON(w, response) 1047 - return 1048 - } 1049 - 1050 - var mergeErr *git.ErrMerge 1051 - if errors.As(err, &mergeErr) { 1052 - conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 1053 - for i, conflict := range mergeErr.Conflicts { 1054 - conflicts[i] = types.ConflictInfo{ 1055 - Filename: conflict.Filename, 1056 - Reason: conflict.Reason, 1057 - } 1058 - } 1059 - response := types.MergeCheckResponse{ 1060 - IsConflicted: true, 1061 - Conflicts: conflicts, 1062 - Message: mergeErr.Message, 1063 - } 1064 - writeConflict(w, response) 1065 - h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error()) 1066 - return 1067 - } 1068 - writeError(w, err.Error(), http.StatusInternalServerError) 1069 - h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error()) 1070 - } 1071 - 1072 - func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) { 1073 - rev1 := chi.URLParam(r, "rev1") 1074 - rev1, _ = url.PathUnescape(rev1) 1075 - 1076 - rev2 := chi.URLParam(r, "rev2") 1077 - rev2, _ = url.PathUnescape(rev2) 1078 - 1079 - l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2) 1080 - 1081 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1082 - gr, err := git.PlainOpen(path) 1083 - if err != nil { 1084 - notFound(w) 1085 - return 1086 - } 1087 - 1088 - commit1, err := gr.ResolveRevision(rev1) 1089 - if err != nil { 1090 - l.Error("error resolving revision 1", "msg", err.Error()) 1091 - writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest) 1092 - return 1093 - } 1094 - 1095 - commit2, err := gr.ResolveRevision(rev2) 1096 - if err != nil { 1097 - l.Error("error resolving revision 2", "msg", err.Error()) 1098 - writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest) 1099 - return 1100 - } 1101 - 1102 - rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 1103 - if err != nil { 1104 - l.Error("error comparing revisions", "msg", err.Error()) 1105 - writeError(w, "error comparing revisions", http.StatusBadRequest) 1106 - return 1107 - } 1108 - 1109 - writeJSON(w, types.RepoFormatPatchResponse{ 1110 - Rev1: commit1.Hash.String(), 1111 - Rev2: commit2.Hash.String(), 1112 - FormatPatch: formatPatch, 1113 - Patch: rawPatch, 1114 - }) 1115 - return 1116 - } 1117 - 1118 - func (h *Handle) NewHiddenRef(w http.ResponseWriter, r *http.Request) { 1119 - l := h.l.With("handler", "NewHiddenRef") 1120 - 1121 - forkRef := chi.URLParam(r, "forkRef") 1122 - forkRef, _ = url.PathUnescape(forkRef) 1123 - 1124 - remoteRef := chi.URLParam(r, "remoteRef") 1125 - remoteRef, _ = url.PathUnescape(remoteRef) 1126 - 1127 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1128 - gr, err := git.PlainOpen(path) 1129 - if err != nil { 1130 - notFound(w) 1131 - return 1132 - } 1133 - 1134 - err = gr.TrackHiddenRemoteRef(forkRef, remoteRef) 1135 - if err != nil { 1136 - l.Error("error tracking hidden remote ref", "msg", err.Error()) 1137 - writeError(w, "error tracking hidden remote ref", http.StatusBadRequest) 1138 - return 1139 - } 1140 - 1141 - w.WriteHeader(http.StatusNoContent) 1142 - return 1143 - } 1144 - 1145 - func (h *Handle) AddMember(w http.ResponseWriter, r *http.Request) { 1146 - l := h.l.With("handler", "AddMember") 1147 - 1148 - data := struct { 1149 - Did string `json:"did"` 1150 - }{} 1151 - 1152 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1153 - writeError(w, "invalid request body", http.StatusBadRequest) 1154 - return 1155 - } 1156 - 1157 - did := data.Did 1158 - 1159 - if err := h.db.AddDid(did); err != nil { 1160 - l.Error("adding did", "error", err.Error()) 1161 - writeError(w, err.Error(), http.StatusInternalServerError) 1162 - return 1163 - } 1164 - h.jc.AddDid(did) 1165 - 1166 - if err := h.e.AddKnotMember(rbac.ThisServer, did); err != nil { 1167 - l.Error("adding member", "error", err.Error()) 1168 - writeError(w, err.Error(), http.StatusInternalServerError) 1169 - return 1170 - } 1171 - 1172 - if err := h.fetchAndAddKeys(r.Context(), did); err != nil { 1173 - l.Error("fetching and adding keys", "error", err.Error()) 1174 - writeError(w, err.Error(), http.StatusInternalServerError) 1175 - return 1176 - } 1177 - 1178 - w.WriteHeader(http.StatusNoContent) 1179 - } 1180 - 1181 - func (h *Handle) AddRepoCollaborator(w http.ResponseWriter, r *http.Request) { 1182 - l := h.l.With("handler", "AddRepoCollaborator") 1183 - 1184 - data := struct { 1185 - Did string `json:"did"` 1186 - }{} 1187 - 1188 - ownerDid := chi.URLParam(r, "did") 1189 - repo := chi.URLParam(r, "name") 1190 - 1191 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1192 - writeError(w, "invalid request body", http.StatusBadRequest) 1193 - return 1194 - } 1195 - 1196 - if err := h.db.AddDid(data.Did); err != nil { 1197 - l.Error("adding did", "error", err.Error()) 1198 - writeError(w, err.Error(), http.StatusInternalServerError) 1199 - return 1200 - } 1201 - h.jc.AddDid(data.Did) 1202 - 1203 - repoName, _ := securejoin.SecureJoin(ownerDid, repo) 1204 - if err := h.e.AddCollaborator(data.Did, rbac.ThisServer, repoName); err != nil { 1205 - l.Error("adding repo collaborator", "error", err.Error()) 1206 - writeError(w, err.Error(), http.StatusInternalServerError) 1207 - return 1208 - } 1209 - 1210 - if err := h.fetchAndAddKeys(r.Context(), data.Did); err != nil { 1211 - l.Error("fetching and adding keys", "error", err.Error()) 1212 - writeError(w, err.Error(), http.StatusInternalServerError) 1213 - return 1214 - } 1215 - 1216 - w.WriteHeader(http.StatusNoContent) 1217 - } 1218 - 1219 - func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) { 1220 - l := h.l.With("handler", "DefaultBranch") 1221 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1222 - 1223 - gr, err := git.Open(path, "") 1224 - if err != nil { 1225 - notFound(w) 1226 - return 1227 - } 1228 - 1229 - branch, err := gr.FindMainBranch() 1230 - if err != nil { 1231 - writeError(w, err.Error(), http.StatusInternalServerError) 1232 - l.Error("getting default branch", "error", err.Error()) 1233 - return 1234 - } 1235 - 1236 - writeJSON(w, types.RepoDefaultBranchResponse{ 1237 - Branch: branch, 1238 - }) 1239 - } 1240 - 1241 - func (h *Handle) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1242 - l := h.l.With("handler", "SetDefaultBranch") 1243 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1244 - 1245 - data := struct { 1246 - Branch string `json:"branch"` 1247 - }{} 1248 - 1249 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1250 - writeError(w, err.Error(), http.StatusBadRequest) 1251 - return 1252 - } 1253 - 1254 - gr, err := git.PlainOpen(path) 1255 - if err != nil { 1256 - notFound(w) 1257 - return 1258 - } 1259 - 1260 - err = gr.SetDefaultBranch(data.Branch) 1261 - if err != nil { 1262 - writeError(w, err.Error(), http.StatusInternalServerError) 1263 - l.Error("setting default branch", "error", err.Error()) 1264 - return 1265 - } 1266 - 1267 - w.WriteHeader(http.StatusNoContent) 1268 - } 1269 - 1270 - func (h *Handle) Init(w http.ResponseWriter, r *http.Request) { 1271 - l := h.l.With("handler", "Init") 1272 - 1273 - if h.knotInitialized { 1274 - writeError(w, "knot already initialized", http.StatusConflict) 1275 - return 1276 - } 1277 - 1278 - data := struct { 1279 - Did string `json:"did"` 1280 - }{} 1281 - 1282 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1283 - l.Error("failed to decode request body", "error", err.Error()) 1284 - writeError(w, "invalid request body", http.StatusBadRequest) 1285 - return 1286 - } 1287 - 1288 - if data.Did == "" { 1289 - l.Error("empty DID in request", "did", data.Did) 1290 - writeError(w, "did is empty", http.StatusBadRequest) 1291 - return 1292 - } 1293 - 1294 - if err := h.db.AddDid(data.Did); err != nil { 1295 - l.Error("failed to add DID", "error", err.Error()) 1296 - writeError(w, err.Error(), http.StatusInternalServerError) 1297 - return 1298 - } 1299 - h.jc.AddDid(data.Did) 1300 - 1301 - if err := h.e.AddKnotOwner(rbac.ThisServer, data.Did); err != nil { 1302 - l.Error("adding owner", "error", err.Error()) 1303 - writeError(w, err.Error(), http.StatusInternalServerError) 1304 - return 1305 - } 1306 - 1307 - if err := h.fetchAndAddKeys(r.Context(), data.Did); err != nil { 1308 - l.Error("fetching and adding keys", "error", err.Error()) 1309 - writeError(w, err.Error(), http.StatusInternalServerError) 1310 - return 1311 - } 1312 - 1313 - close(h.init) 1314 - 1315 - mac := hmac.New(sha256.New, []byte(h.c.Server.Secret)) 1316 - mac.Write([]byte("ok")) 1317 - w.Header().Add("X-Signature", hex.EncodeToString(mac.Sum(nil))) 1318 - 1319 - w.WriteHeader(http.StatusNoContent) 1320 - } 1321 - 1322 - func (h *Handle) Health(w http.ResponseWriter, r *http.Request) { 1323 - w.Write([]byte("ok")) 1324 - } 1325 - 1326 - func validateRepoName(name string) error { 1327 - // check for path traversal attempts 1328 - if name == "." || name == ".." || 1329 - strings.Contains(name, "/") || strings.Contains(name, "\\") { 1330 - return fmt.Errorf("Repository name contains invalid path characters") 1331 - } 1332 - 1333 - // check for sequences that could be used for traversal when normalized 1334 - if strings.Contains(name, "./") || strings.Contains(name, "../") || 1335 - strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { 1336 - return fmt.Errorf("Repository name contains invalid path sequence") 1337 - } 1338 - 1339 - // then continue with character validation 1340 - for _, char := range name { 1341 - if !((char >= 'a' && char <= 'z') || 1342 - (char >= 'A' && char <= 'Z') || 1343 - (char >= '0' && char <= '9') || 1344 - char == '-' || char == '_' || char == '.') { 1345 - return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") 1346 - } 1347 - } 1348 - 1349 - // additional check to prevent multiple sequential dots 1350 - if strings.Contains(name, "..") { 1351 - return fmt.Errorf("Repository name cannot contain sequential dots") 1352 - } 1353 - 1354 - // if all checks pass 1355 - return nil 1356 }
··· 1 package knotserver 2 3 import ( 4 "context" 5 "fmt" 6 + "log/slog" 7 "net/http" 8 + "runtime/debug" 9 10 "github.com/go-chi/chi/v5" 11 + "tangled.sh/tangled.sh/core/idresolver" 12 + "tangled.sh/tangled.sh/core/jetstream" 13 + "tangled.sh/tangled.sh/core/knotserver/config" 14 "tangled.sh/tangled.sh/core/knotserver/db" 15 + "tangled.sh/tangled.sh/core/knotserver/xrpc" 16 + tlog "tangled.sh/tangled.sh/core/log" 17 + "tangled.sh/tangled.sh/core/notifier" 18 "tangled.sh/tangled.sh/core/rbac" 19 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 20 ) 21 22 + type Handle struct { 23 + c *config.Config 24 + db *db.DB 25 + jc *jetstream.JetstreamClient 26 + e *rbac.Enforcer 27 + l *slog.Logger 28 + n *notifier.Notifier 29 + resolver *idresolver.Resolver 30 } 31 32 + func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) { 33 + r := chi.NewRouter() 34 35 + h := Handle{ 36 + c: c, 37 + db: db, 38 + e: e, 39 + l: l, 40 + jc: jc, 41 + n: n, 42 + resolver: idresolver.DefaultResolver(), 43 } 44 45 + err := e.AddKnot(rbac.ThisServer) 46 if err != nil { 47 + return nil, fmt.Errorf("failed to setup enforcer: %w", err) 48 } 49 50 + // configure owner 51 + if err = h.configureOwner(); err != nil { 52 + return nil, err 53 } 54 + h.l.Info("owner set", "did", h.c.Server.Owner) 55 + h.jc.AddDid(h.c.Server.Owner) 56 57 + // configure known-dids in jetstream consumer 58 + dids, err := h.db.GetAllDids() 59 if err != nil { 60 + return nil, fmt.Errorf("failed to get all dids: %w", err) 61 } 62 + for _, d := range dids { 63 + jc.AddDid(d) 64 } 65 66 + err = h.jc.StartJetstream(ctx, h.processMessages) 67 if err != nil { 68 + return nil, fmt.Errorf("failed to start jetstream: %w", err) 69 } 70 71 + r.Get("/", h.Index) 72 + r.Get("/capabilities", h.Capabilities) 73 + r.Get("/version", h.Version) 74 + r.Get("/owner", func(w http.ResponseWriter, r *http.Request) { 75 + w.Write([]byte(h.c.Server.Owner)) 76 + }) 77 + r.Route("/{did}", func(r chi.Router) { 78 + // Repo routes 79 + r.Route("/{name}", func(r chi.Router) { 80 81 + r.Route("/languages", func(r chi.Router) { 82 + r.Get("/", h.RepoLanguages) 83 + r.Get("/{ref}", h.RepoLanguages) 84 + }) 85 86 + r.Get("/", h.RepoIndex) 87 + r.Get("/info/refs", h.InfoRefs) 88 + r.Post("/git-upload-pack", h.UploadPack) 89 + r.Post("/git-receive-pack", h.ReceivePack) 90 + r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects 91 92 + r.Route("/tree/{ref}", func(r chi.Router) { 93 + r.Get("/", h.RepoIndex) 94 + r.Get("/*", h.RepoTree) 95 + }) 96 97 + r.Route("/blob/{ref}", func(r chi.Router) { 98 + r.Get("/*", h.Blob) 99 + }) 100 101 + r.Route("/raw/{ref}", func(r chi.Router) { 102 + r.Get("/*", h.BlobRaw) 103 + }) 104 105 + r.Get("/log/{ref}", h.Log) 106 + r.Get("/archive/{file}", h.Archive) 107 + r.Get("/commit/{ref}", h.Diff) 108 + r.Get("/tags", h.Tags) 109 + r.Route("/branches", func(r chi.Router) { 110 + r.Get("/", h.Branches) 111 + r.Get("/{branch}", h.Branch) 112 + r.Get("/default", h.DefaultBranch) 113 + }) 114 + }) 115 + }) 116 117 + // xrpc apis 118 + r.Mount("/xrpc", h.XrpcRouter()) 119 120 + // Socket that streams git oplogs 121 + r.Get("/events", h.Events) 122 123 + // All public keys on the knot. 124 + r.Get("/keys", h.Keys) 125 126 + return r, nil 127 } 128 129 + func (h *Handle) XrpcRouter() http.Handler { 130 + logger := tlog.New("knots") 131 132 + serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 133 134 + xrpc := &xrpc.Xrpc{ 135 + Config: h.c, 136 + Db: h.db, 137 + Ingester: h.jc, 138 + Enforcer: h.e, 139 + Logger: logger, 140 + Notifier: h.n, 141 + Resolver: h.resolver, 142 + ServiceAuth: serviceAuth, 143 } 144 + return xrpc.Router() 145 } 146 147 + // version is set during build time. 148 + var version string 149 150 + func (h *Handle) Version(w http.ResponseWriter, r *http.Request) { 151 + if version == "" { 152 + info, ok := debug.ReadBuildInfo() 153 + if !ok { 154 + http.Error(w, "failed to read build info", http.StatusInternalServerError) 155 + return 156 } 157 158 + var modVer string 159 + for _, mod := range info.Deps { 160 + if mod.Path == "tangled.sh/tangled.sh/knotserver" { 161 + version = mod.Version 162 + break 163 + } 164 } 165 166 + if modVer == "" { 167 + version = "unknown" 168 } 169 } 170 171 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 172 + fmt.Fprintf(w, "knotserver/%s", version) 173 } 174 175 + func (h *Handle) configureOwner() error { 176 + cfgOwner := h.c.Server.Owner 177 178 + rbacDomain := "thisserver" 179 180 + existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain) 181 if err != nil { 182 + return err 183 } 184 185 + switch len(existing) { 186 + case 0: 187 + // no owner configured, continue 188 + case 1: 189 + // find existing owner 190 + existingOwner := existing[0] 191 192 + // no ownership change, this is okay 193 + if existingOwner == h.c.Server.Owner { 194 + break 195 } 196 197 + // remove existing owner 198 + err = h.e.RemoveKnotOwner(rbacDomain, existingOwner) 199 if err != nil { 200 + return nil 201 } 202 + default: 203 + return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", h.c.Server.DBPath) 204 } 205 206 + return h.e.AddKnotOwner(rbacDomain, cfgOwner) 207 }
+156
knotserver/xrpc/create_repo.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "net/http" 8 + "path/filepath" 9 + "strings" 10 + 11 + comatproto "github.com/bluesky-social/indigo/api/atproto" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + "github.com/bluesky-social/indigo/xrpc" 14 + securejoin "github.com/cyphar/filepath-securejoin" 15 + gogit "github.com/go-git/go-git/v5" 16 + "tangled.sh/tangled.sh/core/api/tangled" 17 + "tangled.sh/tangled.sh/core/hook" 18 + "tangled.sh/tangled.sh/core/knotserver/git" 19 + "tangled.sh/tangled.sh/core/rbac" 20 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 21 + ) 22 + 23 + func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) { 24 + l := h.Logger.With("handler", "NewRepo") 25 + fail := func(e xrpcerr.XrpcError) { 26 + l.Error("failed", "kind", e.Tag, "error", e.Message) 27 + writeError(w, e, http.StatusBadRequest) 28 + } 29 + 30 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 31 + if !ok { 32 + fail(xrpcerr.MissingActorDidError) 33 + return 34 + } 35 + 36 + isMember, err := h.Enforcer.IsRepoCreateAllowed(actorDid.String(), rbac.ThisServer) 37 + if err != nil { 38 + fail(xrpcerr.GenericError(err)) 39 + return 40 + } 41 + if !isMember { 42 + fail(xrpcerr.AccessControlError(actorDid.String())) 43 + return 44 + } 45 + 46 + var data tangled.RepoCreate_Input 47 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 48 + fail(xrpcerr.GenericError(err)) 49 + return 50 + } 51 + 52 + rkey := data.Rkey 53 + 54 + ident, err := h.Resolver.ResolveIdent(r.Context(), actorDid.String()) 55 + if err != nil || ident.Handle.IsInvalidHandle() { 56 + fail(xrpcerr.GenericError(err)) 57 + return 58 + } 59 + 60 + xrpcc := xrpc.Client{ 61 + Host: ident.PDSEndpoint(), 62 + } 63 + 64 + resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, actorDid.String(), rkey) 65 + if err != nil { 66 + fail(xrpcerr.GenericError(err)) 67 + return 68 + } 69 + 70 + repo := resp.Value.Val.(*tangled.Repo) 71 + 72 + defaultBranch := h.Config.Repo.MainBranch 73 + if data.DefaultBranch != nil && *data.DefaultBranch != "" { 74 + defaultBranch = *data.DefaultBranch 75 + } 76 + 77 + if err := validateRepoName(repo.Name); err != nil { 78 + l.Error("creating repo", "error", err.Error()) 79 + fail(xrpcerr.GenericError(err)) 80 + return 81 + } 82 + 83 + relativeRepoPath := filepath.Join(actorDid.String(), repo.Name) 84 + repoPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, relativeRepoPath) 85 + 86 + if data.Source != nil && *data.Source != "" { 87 + err = git.Fork(repoPath, *data.Source) 88 + if err != nil { 89 + l.Error("forking repo", "error", err.Error()) 90 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 91 + return 92 + } 93 + } else { 94 + err = git.InitBare(repoPath, defaultBranch) 95 + if err != nil { 96 + l.Error("initializing bare repo", "error", err.Error()) 97 + if errors.Is(err, gogit.ErrRepositoryAlreadyExists) { 98 + fail(xrpcerr.RepoExistsError("repository already exists")) 99 + return 100 + } else { 101 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 102 + return 103 + } 104 + } 105 + } 106 + 107 + // add perms for this user to access the repo 108 + err = h.Enforcer.AddRepo(actorDid.String(), rbac.ThisServer, relativeRepoPath) 109 + if err != nil { 110 + l.Error("adding repo permissions", "error", err.Error()) 111 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 112 + return 113 + } 114 + 115 + hook.SetupRepo( 116 + hook.Config( 117 + hook.WithScanPath(h.Config.Repo.ScanPath), 118 + hook.WithInternalApi(h.Config.Server.InternalListenAddr), 119 + ), 120 + repoPath, 121 + ) 122 + 123 + w.WriteHeader(http.StatusOK) 124 + } 125 + 126 + func validateRepoName(name string) error { 127 + // check for path traversal attempts 128 + if name == "." || name == ".." || 129 + strings.Contains(name, "/") || strings.Contains(name, "\\") { 130 + return fmt.Errorf("Repository name contains invalid path characters") 131 + } 132 + 133 + // check for sequences that could be used for traversal when normalized 134 + if strings.Contains(name, "./") || strings.Contains(name, "../") || 135 + strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { 136 + return fmt.Errorf("Repository name contains invalid path sequence") 137 + } 138 + 139 + // then continue with character validation 140 + for _, char := range name { 141 + if !((char >= 'a' && char <= 'z') || 142 + (char >= 'A' && char <= 'Z') || 143 + (char >= '0' && char <= '9') || 144 + char == '-' || char == '_' || char == '.') { 145 + return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") 146 + } 147 + } 148 + 149 + // additional check to prevent multiple sequential dots 150 + if strings.Contains(name, "..") { 151 + return fmt.Errorf("Repository name cannot contain sequential dots") 152 + } 153 + 154 + // if all checks pass 155 + return nil 156 + }
+96
knotserver/xrpc/delete_repo.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "os" 8 + "path/filepath" 9 + 10 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + "github.com/bluesky-social/indigo/xrpc" 13 + securejoin "github.com/cyphar/filepath-securejoin" 14 + "tangled.sh/tangled.sh/core/api/tangled" 15 + "tangled.sh/tangled.sh/core/rbac" 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 + ) 18 + 19 + func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) { 20 + l := x.Logger.With("handler", "DeleteRepo") 21 + fail := func(e xrpcerr.XrpcError) { 22 + l.Error("failed", "kind", e.Tag, "error", e.Message) 23 + writeError(w, e, http.StatusBadRequest) 24 + } 25 + 26 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 + if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 + return 30 + } 31 + 32 + var data tangled.RepoDelete_Input 33 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 + fail(xrpcerr.GenericError(err)) 35 + return 36 + } 37 + 38 + did := data.Did 39 + name := data.Name 40 + rkey := data.Rkey 41 + 42 + if did == "" || name == "" { 43 + fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) 44 + return 45 + } 46 + 47 + ident, err := x.Resolver.ResolveIdent(r.Context(), actorDid.String()) 48 + if err != nil || ident.Handle.IsInvalidHandle() { 49 + fail(xrpcerr.GenericError(err)) 50 + return 51 + } 52 + 53 + xrpcc := xrpc.Client{ 54 + Host: ident.PDSEndpoint(), 55 + } 56 + 57 + // ensure that the record does not exists 58 + _, err = comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, actorDid.String(), rkey) 59 + if err == nil { 60 + fail(xrpcerr.RecordExistsError(rkey)) 61 + return 62 + } 63 + 64 + relativeRepoPath := filepath.Join(did, name) 65 + isDeleteAllowed, err := x.Enforcer.IsRepoDeleteAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath) 66 + if err != nil { 67 + fail(xrpcerr.GenericError(err)) 68 + return 69 + } 70 + if !isDeleteAllowed { 71 + fail(xrpcerr.AccessControlError(actorDid.String())) 72 + return 73 + } 74 + 75 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 76 + if err != nil { 77 + fail(xrpcerr.GenericError(err)) 78 + return 79 + } 80 + 81 + err = os.RemoveAll(repoPath) 82 + if err != nil { 83 + l.Error("deleting repo", "error", err.Error()) 84 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 85 + return 86 + } 87 + 88 + err = x.Enforcer.RemoveRepo(did, rbac.ThisServer, relativeRepoPath) 89 + if err != nil { 90 + l.Error("failed to delete repo from enforcer", "error", err.Error()) 91 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 92 + return 93 + } 94 + 95 + w.WriteHeader(http.StatusOK) 96 + }
+111
knotserver/xrpc/fork_status.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "path/filepath" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + securejoin "github.com/cyphar/filepath-securejoin" 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/knotserver/git" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + "tangled.sh/tangled.sh/core/types" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + ) 17 + 18 + func (x *Xrpc) ForkStatus(w http.ResponseWriter, r *http.Request) { 19 + l := x.Logger.With("handler", "ForkStatus") 20 + fail := func(e xrpcerr.XrpcError) { 21 + l.Error("failed", "kind", e.Tag, "error", e.Message) 22 + writeError(w, e, http.StatusBadRequest) 23 + } 24 + 25 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 + if !ok { 27 + fail(xrpcerr.MissingActorDidError) 28 + return 29 + } 30 + 31 + var data tangled.RepoForkStatus_Input 32 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 + fail(xrpcerr.GenericError(err)) 34 + return 35 + } 36 + 37 + did := data.Did 38 + source := data.Source 39 + branch := data.Branch 40 + hiddenRef := data.HiddenRef 41 + 42 + if did == "" || source == "" || branch == "" || hiddenRef == "" { 43 + fail(xrpcerr.GenericError(fmt.Errorf("did, source, branch, and hiddenRef are required"))) 44 + return 45 + } 46 + 47 + var name string 48 + if data.Name != "" { 49 + name = data.Name 50 + } else { 51 + name = filepath.Base(source) 52 + } 53 + 54 + relativeRepoPath := filepath.Join(did, name) 55 + 56 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { 57 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) 58 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 59 + return 60 + } 61 + 62 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 63 + if err != nil { 64 + fail(xrpcerr.GenericError(err)) 65 + return 66 + } 67 + 68 + gr, err := git.PlainOpen(repoPath) 69 + if err != nil { 70 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 71 + return 72 + } 73 + 74 + forkCommit, err := gr.ResolveRevision(branch) 75 + if err != nil { 76 + l.Error("error resolving ref revision", "msg", err.Error()) 77 + fail(xrpcerr.GenericError(fmt.Errorf("error resolving revision %s: %w", branch, err))) 78 + return 79 + } 80 + 81 + sourceCommit, err := gr.ResolveRevision(hiddenRef) 82 + if err != nil { 83 + l.Error("error resolving hidden ref revision", "msg", err.Error()) 84 + fail(xrpcerr.GenericError(fmt.Errorf("error resolving revision %s: %w", hiddenRef, err))) 85 + return 86 + } 87 + 88 + status := types.UpToDate 89 + if forkCommit.Hash.String() != sourceCommit.Hash.String() { 90 + isAncestor, err := forkCommit.IsAncestor(sourceCommit) 91 + if err != nil { 92 + l.Error("error checking ancestor relationship", "error", err.Error()) 93 + fail(xrpcerr.GenericError(fmt.Errorf("error resolving whether %s is ancestor of %s: %w", branch, hiddenRef, err))) 94 + return 95 + } 96 + 97 + if isAncestor { 98 + status = types.FastForwardable 99 + } else { 100 + status = types.Conflict 101 + } 102 + } 103 + 104 + response := tangled.RepoForkStatus_Output{ 105 + Status: int64(status), 106 + } 107 + 108 + w.Header().Set("Content-Type", "application/json") 109 + w.WriteHeader(http.StatusOK) 110 + json.NewEncoder(w).Encode(response) 111 + }
+73
knotserver/xrpc/fork_sync.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "path/filepath" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + securejoin "github.com/cyphar/filepath-securejoin" 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/knotserver/git" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 15 + ) 16 + 17 + func (x *Xrpc) ForkSync(w http.ResponseWriter, r *http.Request) { 18 + l := x.Logger.With("handler", "ForkSync") 19 + fail := func(e xrpcerr.XrpcError) { 20 + l.Error("failed", "kind", e.Tag, "error", e.Message) 21 + writeError(w, e, http.StatusBadRequest) 22 + } 23 + 24 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 25 + if !ok { 26 + fail(xrpcerr.MissingActorDidError) 27 + return 28 + } 29 + 30 + var data tangled.RepoForkSync_Input 31 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 32 + fail(xrpcerr.GenericError(err)) 33 + return 34 + } 35 + 36 + did := data.Did 37 + name := data.Name 38 + branch := data.Branch 39 + 40 + if did == "" || name == "" { 41 + fail(xrpcerr.GenericError(fmt.Errorf("did, name are required"))) 42 + return 43 + } 44 + 45 + relativeRepoPath := filepath.Join(did, name) 46 + 47 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { 48 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) 49 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 50 + return 51 + } 52 + 53 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 54 + if err != nil { 55 + fail(xrpcerr.GenericError(err)) 56 + return 57 + } 58 + 59 + gr, err := git.Open(repoPath, branch) 60 + if err != nil { 61 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 62 + return 63 + } 64 + 65 + err = gr.Sync() 66 + if err != nil { 67 + l.Error("error syncing repo fork", "error", err.Error()) 68 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 69 + return 70 + } 71 + 72 + w.WriteHeader(http.StatusOK) 73 + }
+104
knotserver/xrpc/hidden_ref.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/bluesky-social/indigo/xrpc" 11 + securejoin "github.com/cyphar/filepath-securejoin" 12 + "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.sh/tangled.sh/core/knotserver/git" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + ) 17 + 18 + func (x *Xrpc) HiddenRef(w http.ResponseWriter, r *http.Request) { 19 + l := x.Logger.With("handler", "HiddenRef") 20 + fail := func(e xrpcerr.XrpcError) { 21 + l.Error("failed", "kind", e.Tag, "error", e.Message) 22 + writeError(w, e, http.StatusBadRequest) 23 + } 24 + 25 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 + if !ok { 27 + fail(xrpcerr.MissingActorDidError) 28 + return 29 + } 30 + 31 + var data tangled.RepoHiddenRef_Input 32 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 + fail(xrpcerr.GenericError(err)) 34 + return 35 + } 36 + 37 + forkRef := data.ForkRef 38 + remoteRef := data.RemoteRef 39 + repoAtUri := data.Repo 40 + 41 + if forkRef == "" || remoteRef == "" || repoAtUri == "" { 42 + fail(xrpcerr.GenericError(fmt.Errorf("forkRef, remoteRef, and repo are required"))) 43 + return 44 + } 45 + 46 + repoAt, err := syntax.ParseATURI(repoAtUri) 47 + if err != nil { 48 + fail(xrpcerr.InvalidRepoError(repoAtUri)) 49 + return 50 + } 51 + 52 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 53 + if err != nil || ident.Handle.IsInvalidHandle() { 54 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 55 + return 56 + } 57 + 58 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 59 + resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 60 + if err != nil { 61 + fail(xrpcerr.GenericError(err)) 62 + return 63 + } 64 + 65 + repo := resp.Value.Val.(*tangled.Repo) 66 + didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name) 67 + if err != nil { 68 + fail(xrpcerr.GenericError(err)) 69 + return 70 + } 71 + 72 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 73 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", didPath) 74 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 75 + return 76 + } 77 + 78 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) 79 + if err != nil { 80 + fail(xrpcerr.GenericError(err)) 81 + return 82 + } 83 + 84 + gr, err := git.PlainOpen(repoPath) 85 + if err != nil { 86 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 87 + return 88 + } 89 + 90 + err = gr.TrackHiddenRemoteRef(forkRef, remoteRef) 91 + if err != nil { 92 + l.Error("error tracking hidden remote ref", "error", err.Error()) 93 + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 94 + return 95 + } 96 + 97 + response := tangled.RepoHiddenRef_Output{ 98 + Success: true, 99 + } 100 + 101 + w.Header().Set("Content-Type", "application/json") 102 + w.WriteHeader(http.StatusOK) 103 + json.NewEncoder(w).Encode(response) 104 + }
+112
knotserver/xrpc/merge.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "net/http" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + securejoin "github.com/cyphar/filepath-securejoin" 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/knotserver/git" 13 + "tangled.sh/tangled.sh/core/patchutil" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + "tangled.sh/tangled.sh/core/types" 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 + ) 18 + 19 + func (x *Xrpc) Merge(w http.ResponseWriter, r *http.Request) { 20 + l := x.Logger.With("handler", "Merge") 21 + fail := func(e xrpcerr.XrpcError) { 22 + l.Error("failed", "kind", e.Tag, "error", e.Message) 23 + writeError(w, e, http.StatusBadRequest) 24 + } 25 + 26 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 + if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 + return 30 + } 31 + 32 + var data tangled.RepoMerge_Input 33 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 + fail(xrpcerr.GenericError(err)) 35 + return 36 + } 37 + 38 + did := data.Did 39 + name := data.Name 40 + 41 + if did == "" || name == "" { 42 + fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) 43 + return 44 + } 45 + 46 + relativeRepoPath, err := securejoin.SecureJoin(did, name) 47 + if err != nil { 48 + fail(xrpcerr.GenericError(err)) 49 + return 50 + } 51 + 52 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { 53 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) 54 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 55 + return 56 + } 57 + 58 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 59 + if err != nil { 60 + fail(xrpcerr.GenericError(err)) 61 + return 62 + } 63 + 64 + gr, err := git.Open(repoPath, data.Branch) 65 + if err != nil { 66 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 67 + return 68 + } 69 + 70 + mo := &git.MergeOptions{} 71 + if data.AuthorName != nil { 72 + mo.AuthorName = *data.AuthorName 73 + } 74 + if data.AuthorEmail != nil { 75 + mo.AuthorEmail = *data.AuthorEmail 76 + } 77 + if data.CommitBody != nil { 78 + mo.CommitBody = *data.CommitBody 79 + } 80 + if data.CommitMessage != nil { 81 + mo.CommitMessage = *data.CommitMessage 82 + } 83 + 84 + mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) 85 + 86 + err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo) 87 + if err != nil { 88 + var mergeErr *git.ErrMerge 89 + if errors.As(err, &mergeErr) { 90 + conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 91 + for i, conflict := range mergeErr.Conflicts { 92 + conflicts[i] = types.ConflictInfo{ 93 + Filename: conflict.Filename, 94 + Reason: conflict.Reason, 95 + } 96 + } 97 + 98 + conflictErr := xrpcerr.NewXrpcError( 99 + xrpcerr.WithTag("MergeConflict"), 100 + xrpcerr.WithMessage(fmt.Sprintf("Merge failed due to conflicts: %s", mergeErr.Message)), 101 + ) 102 + writeError(w, conflictErr, http.StatusConflict) 103 + return 104 + } else { 105 + l.Error("failed to merge", "error", err.Error()) 106 + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 107 + return 108 + } 109 + } 110 + 111 + w.WriteHeader(http.StatusOK) 112 + }
+87
knotserver/xrpc/merge_check.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "net/http" 8 + 9 + securejoin "github.com/cyphar/filepath-securejoin" 10 + "tangled.sh/tangled.sh/core/api/tangled" 11 + "tangled.sh/tangled.sh/core/knotserver/git" 12 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 + ) 14 + 15 + func (x *Xrpc) MergeCheck(w http.ResponseWriter, r *http.Request) { 16 + l := x.Logger.With("handler", "MergeCheck") 17 + fail := func(e xrpcerr.XrpcError) { 18 + l.Error("failed", "kind", e.Tag, "error", e.Message) 19 + writeError(w, e, http.StatusBadRequest) 20 + } 21 + 22 + var data tangled.RepoMergeCheck_Input 23 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 24 + fail(xrpcerr.GenericError(err)) 25 + return 26 + } 27 + 28 + did := data.Did 29 + name := data.Name 30 + 31 + if did == "" || name == "" { 32 + fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) 33 + return 34 + } 35 + 36 + relativeRepoPath, err := securejoin.SecureJoin(did, name) 37 + if err != nil { 38 + fail(xrpcerr.GenericError(err)) 39 + return 40 + } 41 + 42 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 43 + if err != nil { 44 + fail(xrpcerr.GenericError(err)) 45 + return 46 + } 47 + 48 + gr, err := git.Open(repoPath, data.Branch) 49 + if err != nil { 50 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 51 + return 52 + } 53 + 54 + err = gr.MergeCheck([]byte(data.Patch), data.Branch) 55 + 56 + response := tangled.RepoMergeCheck_Output{ 57 + Is_conflicted: false, 58 + } 59 + 60 + if err != nil { 61 + var mergeErr *git.ErrMerge 62 + if errors.As(err, &mergeErr) { 63 + response.Is_conflicted = true 64 + 65 + conflicts := make([]*tangled.RepoMergeCheck_ConflictInfo, len(mergeErr.Conflicts)) 66 + for i, conflict := range mergeErr.Conflicts { 67 + conflicts[i] = &tangled.RepoMergeCheck_ConflictInfo{ 68 + Filename: conflict.Filename, 69 + Reason: conflict.Reason, 70 + } 71 + } 72 + response.Conflicts = conflicts 73 + 74 + if mergeErr.Message != "" { 75 + response.Message = &mergeErr.Message 76 + } 77 + } else { 78 + response.Is_conflicted = true 79 + errMsg := err.Error() 80 + response.Error = &errMsg 81 + } 82 + } 83 + 84 + w.Header().Set("Content-Type", "application/json") 85 + w.WriteHeader(http.StatusOK) 86 + json.NewEncoder(w).Encode(response) 87 + }
-149
knotserver/xrpc/router.go
··· 1 - package xrpc 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - "log/slog" 8 - "net/http" 9 - "strings" 10 - 11 - "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/idresolver" 13 - "tangled.sh/tangled.sh/core/jetstream" 14 - "tangled.sh/tangled.sh/core/knotserver/config" 15 - "tangled.sh/tangled.sh/core/knotserver/db" 16 - "tangled.sh/tangled.sh/core/notifier" 17 - "tangled.sh/tangled.sh/core/rbac" 18 - 19 - "github.com/bluesky-social/indigo/atproto/auth" 20 - "github.com/go-chi/chi/v5" 21 - ) 22 - 23 - type Xrpc struct { 24 - Config *config.Config 25 - Db *db.DB 26 - Ingester *jetstream.JetstreamClient 27 - Enforcer *rbac.Enforcer 28 - Logger *slog.Logger 29 - Notifier *notifier.Notifier 30 - Resolver *idresolver.Resolver 31 - } 32 - 33 - func (x *Xrpc) Router() http.Handler { 34 - r := chi.NewRouter() 35 - 36 - r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 37 - 38 - return r 39 - } 40 - 41 - func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler { 42 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 43 - l := x.Logger.With("url", r.URL) 44 - 45 - token := r.Header.Get("Authorization") 46 - token = strings.TrimPrefix(token, "Bearer ") 47 - 48 - s := auth.ServiceAuthValidator{ 49 - Audience: x.Config.Server.Did().String(), 50 - Dir: x.Resolver.Directory(), 51 - } 52 - 53 - did, err := s.Validate(r.Context(), token, nil) 54 - if err != nil { 55 - l.Error("signature verification failed", "err", err) 56 - writeError(w, AuthError(err), http.StatusForbidden) 57 - return 58 - } 59 - 60 - r = r.WithContext( 61 - context.WithValue(r.Context(), ActorDid, did), 62 - ) 63 - 64 - next.ServeHTTP(w, r) 65 - }) 66 - } 67 - 68 - type XrpcError struct { 69 - Tag string `json:"error"` 70 - Message string `json:"message"` 71 - } 72 - 73 - func NewXrpcError(opts ...ErrOpt) XrpcError { 74 - x := XrpcError{} 75 - for _, o := range opts { 76 - o(&x) 77 - } 78 - 79 - return x 80 - } 81 - 82 - type ErrOpt = func(xerr *XrpcError) 83 - 84 - func WithTag(tag string) ErrOpt { 85 - return func(xerr *XrpcError) { 86 - xerr.Tag = tag 87 - } 88 - } 89 - 90 - func WithMessage[S ~string](s S) ErrOpt { 91 - return func(xerr *XrpcError) { 92 - xerr.Message = string(s) 93 - } 94 - } 95 - 96 - func WithError(e error) ErrOpt { 97 - return func(xerr *XrpcError) { 98 - xerr.Message = e.Error() 99 - } 100 - } 101 - 102 - var MissingActorDidError = NewXrpcError( 103 - WithTag("MissingActorDid"), 104 - WithMessage("actor DID not supplied"), 105 - ) 106 - 107 - var AuthError = func(err error) XrpcError { 108 - return NewXrpcError( 109 - WithTag("Auth"), 110 - WithError(fmt.Errorf("signature verification failed: %w", err)), 111 - ) 112 - } 113 - 114 - var InvalidRepoError = func(r string) XrpcError { 115 - return NewXrpcError( 116 - WithTag("InvalidRepo"), 117 - WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)), 118 - ) 119 - } 120 - 121 - var AccessControlError = func(d string) XrpcError { 122 - return NewXrpcError( 123 - WithTag("AccessControl"), 124 - WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)), 125 - ) 126 - } 127 - 128 - var GitError = func(e error) XrpcError { 129 - return NewXrpcError( 130 - WithTag("Git"), 131 - WithError(fmt.Errorf("git error: %w", e)), 132 - ) 133 - } 134 - 135 - func GenericError(err error) XrpcError { 136 - return NewXrpcError( 137 - WithTag("Generic"), 138 - WithError(err), 139 - ) 140 - } 141 - 142 - // this is slightly different from http_util::write_error to follow the spec: 143 - // 144 - // the json object returned must include an "error" and a "message" 145 - func writeError(w http.ResponseWriter, e XrpcError, status int) { 146 - w.Header().Set("Content-Type", "application/json") 147 - w.WriteHeader(status) 148 - json.NewEncoder(w).Encode(e) 149 - }
···
+12 -10
knotserver/xrpc/set_default_branch.go
··· 12 "tangled.sh/tangled.sh/core/api/tangled" 13 "tangled.sh/tangled.sh/core/knotserver/git" 14 "tangled.sh/tangled.sh/core/rbac" 15 ) 16 17 const ActorDid string = "ActorDid" 18 19 func (x *Xrpc) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 20 l := x.Logger 21 - fail := func(e XrpcError) { 22 l.Error("failed", "kind", e.Tag, "error", e.Message) 23 writeError(w, e, http.StatusBadRequest) 24 } 25 26 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 if !ok { 28 - fail(MissingActorDidError) 29 return 30 } 31 32 var data tangled.RepoSetDefaultBranch_Input 33 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 - fail(GenericError(err)) 35 return 36 } 37 38 // unfortunately we have to resolve repo-at here 39 repoAt, err := syntax.ParseATURI(data.Repo) 40 if err != nil { 41 - fail(InvalidRepoError(data.Repo)) 42 return 43 } 44 45 // resolve this aturi to extract the repo record 46 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 47 if err != nil || ident.Handle.IsInvalidHandle() { 48 - fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 49 return 50 } 51 52 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 53 resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 54 if err != nil { 55 - fail(GenericError(err)) 56 return 57 } 58 59 repo := resp.Value.Val.(*tangled.Repo) 60 didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name) 61 if err != nil { 62 - fail(GenericError(err)) 63 return 64 } 65 66 if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 67 l.Error("insufficent permissions", "did", actorDid.String()) 68 - writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 69 return 70 } 71 72 path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) 73 gr, err := git.PlainOpen(path) 74 if err != nil { 75 - fail(InvalidRepoError(data.Repo)) 76 return 77 } 78 79 err = gr.SetDefaultBranch(data.DefaultBranch) 80 if err != nil { 81 l.Error("setting default branch", "error", err.Error()) 82 - writeError(w, GitError(err), http.StatusInternalServerError) 83 return 84 } 85
··· 12 "tangled.sh/tangled.sh/core/api/tangled" 13 "tangled.sh/tangled.sh/core/knotserver/git" 14 "tangled.sh/tangled.sh/core/rbac" 15 + 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 ) 18 19 const ActorDid string = "ActorDid" 20 21 func (x *Xrpc) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 22 l := x.Logger 23 + fail := func(e xrpcerr.XrpcError) { 24 l.Error("failed", "kind", e.Tag, "error", e.Message) 25 writeError(w, e, http.StatusBadRequest) 26 } 27 28 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 29 if !ok { 30 + fail(xrpcerr.MissingActorDidError) 31 return 32 } 33 34 var data tangled.RepoSetDefaultBranch_Input 35 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 36 + fail(xrpcerr.GenericError(err)) 37 return 38 } 39 40 // unfortunately we have to resolve repo-at here 41 repoAt, err := syntax.ParseATURI(data.Repo) 42 if err != nil { 43 + fail(xrpcerr.InvalidRepoError(data.Repo)) 44 return 45 } 46 47 // resolve this aturi to extract the repo record 48 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 49 if err != nil || ident.Handle.IsInvalidHandle() { 50 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 51 return 52 } 53 54 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 55 resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 56 if err != nil { 57 + fail(xrpcerr.GenericError(err)) 58 return 59 } 60 61 repo := resp.Value.Val.(*tangled.Repo) 62 didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name) 63 if err != nil { 64 + fail(xrpcerr.GenericError(err)) 65 return 66 } 67 68 if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 69 l.Error("insufficent permissions", "did", actorDid.String()) 70 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 71 return 72 } 73 74 path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) 75 gr, err := git.PlainOpen(path) 76 if err != nil { 77 + fail(xrpcerr.GenericError(err)) 78 return 79 } 80 81 err = gr.SetDefaultBranch(data.DefaultBranch) 82 if err != nil { 83 l.Error("setting default branch", "error", err.Error()) 84 + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 85 return 86 } 87
+60
knotserver/xrpc/xrpc.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "log/slog" 6 + "net/http" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + "tangled.sh/tangled.sh/core/idresolver" 10 + "tangled.sh/tangled.sh/core/jetstream" 11 + "tangled.sh/tangled.sh/core/knotserver/config" 12 + "tangled.sh/tangled.sh/core/knotserver/db" 13 + "tangled.sh/tangled.sh/core/notifier" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 17 + 18 + "github.com/go-chi/chi/v5" 19 + ) 20 + 21 + type Xrpc struct { 22 + Config *config.Config 23 + Db *db.DB 24 + Ingester *jetstream.JetstreamClient 25 + Enforcer *rbac.Enforcer 26 + Logger *slog.Logger 27 + Notifier *notifier.Notifier 28 + Resolver *idresolver.Resolver 29 + ServiceAuth *serviceauth.ServiceAuth 30 + } 31 + 32 + func (x *Xrpc) Router() http.Handler { 33 + r := chi.NewRouter() 34 + 35 + r.Group(func(r chi.Router) { 36 + r.Use(x.ServiceAuth.VerifyServiceAuth) 37 + 38 + r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 39 + r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) 40 + r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo) 41 + r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus) 42 + r.Post("/"+tangled.RepoForkSyncNSID, x.ForkSync) 43 + r.Post("/"+tangled.RepoHiddenRefNSID, x.HiddenRef) 44 + r.Post("/"+tangled.RepoMergeNSID, x.Merge) 45 + }) 46 + 47 + // merge check is an open endpoint 48 + // 49 + // TODO: should we constrain this more? 50 + // - we can calculate on PR submit/resubmit/gitRefUpdate etc. 51 + // - use ETags on clients to keep requests to a minimum 52 + r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck) 53 + return r 54 + } 55 + 56 + func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 57 + w.Header().Set("Content-Type", "application/json") 58 + w.WriteHeader(status) 59 + json.NewEncoder(w).Encode(e) 60 + }
+24
lexicons/knot/knot.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.knot", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "any", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "createdAt" 14 + ], 15 + "properties": { 16 + "createdAt": { 17 + "type": "string", 18 + "format": "datetime" 19 + } 20 + } 21 + } 22 + } 23 + } 24 + }
+7 -63
lexicons/pipeline/pipeline.json
··· 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 }, ··· 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 },
··· 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 }, ··· 185 }, 186 "submodules": { 187 "type": "boolean" 188 } 189 } 190 },
+33
lexicons/repo/create.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.create", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create a new repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "rkey" 14 + ], 15 + "properties": { 16 + "rkey": { 17 + "type": "string", 18 + "description": "Rkey of the repository record" 19 + }, 20 + "defaultBranch": { 21 + "type": "string", 22 + "description": "Default branch to push to" 23 + }, 24 + "source": { 25 + "type": "string", 26 + "description": "A source URL to clone from, populate this when forking or importing a repository." 27 + } 28 + } 29 + } 30 + } 31 + } 32 + } 33 + }
+32
lexicons/repo/delete.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.delete", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Delete a repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["did", "name", "rkey"], 13 + "properties": { 14 + "did": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the repository owner" 18 + }, 19 + "name": { 20 + "type": "string", 21 + "description": "Name of the repository to delete" 22 + }, 23 + "rkey": { 24 + "type": "string", 25 + "description": "Rkey of the repository record" 26 + } 27 + } 28 + } 29 + } 30 + } 31 + } 32 + }
+53
lexicons/repo/forkStatus.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.forkStatus", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Check fork status relative to upstream source", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["did", "name", "source", "branch", "hiddenRef"], 13 + "properties": { 14 + "did": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the fork owner" 18 + }, 19 + "name": { 20 + "type": "string", 21 + "description": "Name of the forked repository" 22 + }, 23 + "source": { 24 + "type": "string", 25 + "description": "Source repository URL" 26 + }, 27 + "branch": { 28 + "type": "string", 29 + "description": "Branch to check status for" 30 + }, 31 + "hiddenRef": { 32 + "type": "string", 33 + "description": "Hidden ref to use for comparison" 34 + } 35 + } 36 + } 37 + }, 38 + "output": { 39 + "encoding": "application/json", 40 + "schema": { 41 + "type": "object", 42 + "required": ["status"], 43 + "properties": { 44 + "status": { 45 + "type": "integer", 46 + "description": "Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch" 47 + } 48 + } 49 + } 50 + } 51 + } 52 + } 53 + }
+42
lexicons/repo/forkSync.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.forkSync", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Sync a forked repository with its upstream source", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "did", 14 + "source", 15 + "name", 16 + "branch" 17 + ], 18 + "properties": { 19 + "did": { 20 + "type": "string", 21 + "format": "did", 22 + "description": "DID of the fork owner" 23 + }, 24 + "source": { 25 + "type": "string", 26 + "format": "at-uri", 27 + "description": "AT-URI of the source repository" 28 + }, 29 + "name": { 30 + "type": "string", 31 + "description": "Name of the forked repository" 32 + }, 33 + "branch": { 34 + "type": "string", 35 + "description": "Branch to sync" 36 + } 37 + } 38 + } 39 + } 40 + } 41 + } 42 + }
+59
lexicons/repo/hiddenRef.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.hiddenRef", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create a hidden ref in a repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "forkRef", 15 + "remoteRef" 16 + ], 17 + "properties": { 18 + "repo": { 19 + "type": "string", 20 + "format": "at-uri", 21 + "description": "AT-URI of the repository" 22 + }, 23 + "forkRef": { 24 + "type": "string", 25 + "description": "Fork reference name" 26 + }, 27 + "remoteRef": { 28 + "type": "string", 29 + "description": "Remote reference name" 30 + } 31 + } 32 + } 33 + }, 34 + "output": { 35 + "encoding": "application/json", 36 + "schema": { 37 + "type": "object", 38 + "required": [ 39 + "success" 40 + ], 41 + "properties": { 42 + "success": { 43 + "type": "boolean", 44 + "description": "Whether the hidden ref was created successfully" 45 + }, 46 + "ref": { 47 + "type": "string", 48 + "description": "The created hidden ref name" 49 + }, 50 + "error": { 51 + "type": "string", 52 + "description": "Error message if creation failed" 53 + } 54 + } 55 + } 56 + } 57 + } 58 + } 59 + }
+52
lexicons/repo/merge.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.merge", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Merge a patch into a repository branch", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["did", "name", "patch", "branch"], 13 + "properties": { 14 + "did": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the repository owner" 18 + }, 19 + "name": { 20 + "type": "string", 21 + "description": "Name of the repository" 22 + }, 23 + "patch": { 24 + "type": "string", 25 + "description": "Patch content to merge" 26 + }, 27 + "branch": { 28 + "type": "string", 29 + "description": "Target branch to merge into" 30 + }, 31 + "authorName": { 32 + "type": "string", 33 + "description": "Author name for the merge commit" 34 + }, 35 + "authorEmail": { 36 + "type": "string", 37 + "description": "Author email for the merge commit" 38 + }, 39 + "commitBody": { 40 + "type": "string", 41 + "description": "Additional commit message body" 42 + }, 43 + "commitMessage": { 44 + "type": "string", 45 + "description": "Merge commit message" 46 + } 47 + } 48 + } 49 + } 50 + } 51 + } 52 + }
+79
lexicons/repo/mergeCheck.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.mergeCheck", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Check if a merge is possible between two branches", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["did", "name", "patch", "branch"], 13 + "properties": { 14 + "did": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the repository owner" 18 + }, 19 + "name": { 20 + "type": "string", 21 + "description": "Name of the repository" 22 + }, 23 + "patch": { 24 + "type": "string", 25 + "description": "Patch or pull request to check for merge conflicts" 26 + }, 27 + "branch": { 28 + "type": "string", 29 + "description": "Target branch to merge into" 30 + } 31 + } 32 + } 33 + }, 34 + "output": { 35 + "encoding": "application/json", 36 + "schema": { 37 + "type": "object", 38 + "required": ["is_conflicted"], 39 + "properties": { 40 + "is_conflicted": { 41 + "type": "boolean", 42 + "description": "Whether the merge has conflicts" 43 + }, 44 + "conflicts": { 45 + "type": "array", 46 + "description": "List of files with merge conflicts", 47 + "items": { 48 + "type": "ref", 49 + "ref": "#conflictInfo" 50 + } 51 + }, 52 + "message": { 53 + "type": "string", 54 + "description": "Additional message about the merge check" 55 + }, 56 + "error": { 57 + "type": "string", 58 + "description": "Error message if check failed" 59 + } 60 + } 61 + } 62 + } 63 + }, 64 + "conflictInfo": { 65 + "type": "object", 66 + "required": ["filename", "reason"], 67 + "properties": { 68 + "filename": { 69 + "type": "string", 70 + "description": "Name of the conflicted file" 71 + }, 72 + "reason": { 73 + "type": "string", 74 + "description": "Reason for the conflict" 75 + } 76 + } 77 + } 78 + } 79 + }
+3 -1
log/log.go
··· 9 // NewHandler sets up a new slog.Handler with the service name 10 // as an attribute 11 func NewHandler(name string) slog.Handler { 12 - handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{}) 13 14 var attrs []slog.Attr 15 attrs = append(attrs, slog.Attr{Key: "service", Value: slog.StringValue(name)})
··· 9 // NewHandler sets up a new slog.Handler with the service name 10 // as an attribute 11 func NewHandler(name string) slog.Handler { 12 + handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ 13 + Level: slog.LevelDebug, 14 + }) 15 16 var attrs []slog.Attr 17 attrs = append(attrs, slog.Attr{Key: "service", Value: slog.StringValue(name)})
+5 -5
nix/modules/knot.nix
··· 93 description = "Internal address for inter-service communication"; 94 }; 95 96 - secretFile = mkOption { 97 - type = lib.types.path; 98 - example = "KNOT_SERVER_SECRET=<hash>"; 99 - description = "File containing secret key provided by appview (required)"; 100 }; 101 102 dbPath = mkOption { ··· 199 "KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 200 "KNOT_SERVER_DB_PATH=${cfg.server.dbPath}" 201 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 202 ]; 203 - EnvironmentFile = cfg.server.secretFile; 204 ExecStart = "${cfg.package}/bin/knot server"; 205 Restart = "always"; 206 };
··· 93 description = "Internal address for inter-service communication"; 94 }; 95 96 + owner = mkOption { 97 + type = types.str; 98 + example = "did:plc:qfpnj4og54vl56wngdriaxug"; 99 + description = "DID of owner (required)"; 100 }; 101 102 dbPath = mkOption { ··· 199 "KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 200 "KNOT_SERVER_DB_PATH=${cfg.server.dbPath}" 201 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 202 + "KNOT_SERVER_OWNER=${cfg.server.owner}" 203 ]; 204 ExecStart = "${cfg.package}/bin/knot server"; 205 Restart = "always"; 206 };
+2 -2
nix/modules/spindle.nix
··· 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_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 115 - "SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 116 ]; 117 ExecStart = "${cfg.package}/bin/spindle"; 118 Restart = "always";
··· 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}" 116 ]; 117 ExecStart = "${cfg.package}/bin/spindle"; 118 Restart = "always";
+2 -1
nix/vm.nix
··· 70 }; 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 };
··· 70 }; 71 # This is fine because any and all ports that are forwarded to host are explicitly marked above, we don't need a separate guest firewall 72 networking.firewall.enable = false; 73 + time.timeZone = "Europe/London"; 74 services.getty.autologinUser = "root"; 75 environment.systemPackages = with pkgs; [curl vim git sqlite litecli]; 76 services.tangled-knot = { 77 enable = true; 78 motd = "Welcome to the development knot!\n"; 79 server = { 80 + owner = envVar "TANGLED_VM_KNOT_OWNER"; 81 hostname = "localhost:6000"; 82 listenAddr = "0.0.0.0:6000"; 83 };
+14 -1
rbac/rbac.go
··· 43 return nil, err 44 } 45 46 - db, err := sql.Open("sqlite3", path) 47 if err != nil { 48 return nil, err 49 } ··· 97 func (e *Enforcer) RemoveSpindle(spindle string) error { 98 spindle = intoSpindle(spindle) 99 _, err := e.E.DeleteDomains(spindle) 100 return err 101 } 102 ··· 270 271 func (e *Enforcer) IsSpindleInviteAllowed(user, domain string) (bool, error) { 272 return e.isInviteAllowed(user, intoSpindle(domain)) 273 } 274 275 func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) {
··· 43 return nil, err 44 } 45 46 + db, err := sql.Open("sqlite3", path+"?_foreign_keys=1") 47 if err != nil { 48 return nil, err 49 } ··· 97 func (e *Enforcer) RemoveSpindle(spindle string) error { 98 spindle = intoSpindle(spindle) 99 _, err := e.E.DeleteDomains(spindle) 100 + return err 101 + } 102 + 103 + func (e *Enforcer) RemoveKnot(knot string) error { 104 + _, err := e.E.DeleteDomains(knot) 105 return err 106 } 107 ··· 275 276 func (e *Enforcer) IsSpindleInviteAllowed(user, domain string) (bool, error) { 277 return e.isInviteAllowed(user, intoSpindle(domain)) 278 + } 279 + 280 + func (e *Enforcer) IsRepoCreateAllowed(user, domain string) (bool, error) { 281 + return e.E.Enforce(user, domain, domain, "repo:create") 282 + } 283 + 284 + func (e *Enforcer) IsRepoDeleteAllowed(user, domain, repo string) (bool, error) { 285 + return e.E.Enforce(user, domain, repo, "repo:delete") 286 } 287 288 func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) {
+1 -1
rbac/rbac_test.go
··· 14 ) 15 16 func setup(t *testing.T) *rbac.Enforcer { 17 - db, err := sql.Open("sqlite3", ":memory:") 18 assert.NoError(t, err) 19 20 a, err := adapter.NewAdapter(db, "sqlite3", "acl")
··· 14 ) 15 16 func setup(t *testing.T) *rbac.Enforcer { 17 + db, err := sql.Open("sqlite3", ":memory:?_foreign_keys=1") 18 assert.NoError(t, err) 19 20 a, err := adapter.NewAdapter(db, "sqlite3", "acl")
+4 -4
spindle/config/config.go
··· 16 Dev bool `env:"DEV, default=false"` 17 Owner string `env:"OWNER, required"` 18 Secrets Secrets `env:",prefix=SECRETS_"` 19 } 20 21 func (s Server) Did() syntax.DID { ··· 32 Mount string `env:"MOUNT, default=spindle"` 33 } 34 35 - type Pipelines struct { 36 Nixery string `env:"NIXERY, default=nixery.tangled.sh"` 37 WorkflowTimeout string `env:"WORKFLOW_TIMEOUT, default=5m"` 38 - LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 39 } 40 41 type Config struct { 42 - Server Server `env:",prefix=SPINDLE_SERVER_"` 43 - Pipelines Pipelines `env:",prefix=SPINDLE_PIPELINES_"` 44 } 45 46 func Load(ctx context.Context) (*Config, error) {
··· 16 Dev bool `env:"DEV, default=false"` 17 Owner string `env:"OWNER, required"` 18 Secrets Secrets `env:",prefix=SECRETS_"` 19 + LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 20 } 21 22 func (s Server) Did() syntax.DID { ··· 33 Mount string `env:"MOUNT, default=spindle"` 34 } 35 36 + type NixeryPipelines struct { 37 Nixery string `env:"NIXERY, default=nixery.tangled.sh"` 38 WorkflowTimeout string `env:"WORKFLOW_TIMEOUT, default=5m"` 39 } 40 41 type Config struct { 42 + Server Server `env:",prefix=SPINDLE_SERVER_"` 43 + NixeryPipelines NixeryPipelines `env:",prefix=SPINDLE_NIXERY_PIPELINES_"` 44 } 45 46 func Load(ctx context.Context) (*Config, error) {
+14 -10
spindle/db/db.go
··· 2 3 import ( 4 "database/sql" 5 6 _ "github.com/mattn/go-sqlite3" 7 ) ··· 11 } 12 13 func Make(dbPath string) (*DB, error) { 14 - db, err := sql.Open("sqlite3", dbPath) 15 if err != nil { 16 return nil, err 17 } 18 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 29 create table if not exists _jetstream ( 30 id integer primary key autoincrement, 31 last_time_us integer not null
··· 2 3 import ( 4 "database/sql" 5 + "strings" 6 7 _ "github.com/mattn/go-sqlite3" 8 ) ··· 12 } 13 14 func Make(dbPath string) (*DB, error) { 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, "&")) 24 if err != nil { 25 return nil, err 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. 31 32 + _, err = db.Exec(` 33 create table if not exists _jetstream ( 34 id integer primary key autoincrement, 35 last_time_us integer not null
-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 "context" 5 "errors" 6 "fmt" 7 - "io" 8 "log/slog" 9 - "os" 10 - "strings" 11 - "sync" 12 - "time" 13 14 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 "golang.org/x/sync/errgroup" 23 - "tangled.sh/tangled.sh/core/log" 24 "tangled.sh/tangled.sh/core/notifier" 25 "tangled.sh/tangled.sh/core/spindle/config" 26 "tangled.sh/tangled.sh/core/spindle/db" ··· 28 "tangled.sh/tangled.sh/core/spindle/secrets" 29 ) 30 31 - const ( 32 - workspaceDir = "/tangled/workspace" 33 ) 34 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) 73 74 // extract secrets 75 var allSecrets []secrets.UnlockedSecret 76 if didSlashRepo, err := securejoin.SecureJoin(pipeline.RepoOwner, pipeline.RepoName); err == nil { 77 - if res, err := e.vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)); err == nil { 78 allSecrets = res 79 } 80 } 81 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 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 - } 102 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()) 113 114 - err := e.db.StatusFailed(wid, err.Error(), -1, e.n) 115 if err != nil { 116 return err 117 } 118 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() 126 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 133 } 134 - } else { 135 - dbErr := e.db.StatusFailed(wid, err.Error(), -1, e.n) 136 if dbErr != nil { 137 return dbErr 138 } 139 } 140 141 - return fmt.Errorf("starting steps image: %w", err) 142 - } 143 144 - err = e.db.StatusSuccess(wid, e.n) 145 - if err != nil { 146 - return err 147 - } 148 149 - return nil 150 - }) 151 - } 152 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 - } 159 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) 165 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 - }) 197 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 307 } 308 } 309 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 338 } 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 }
··· 4 "context" 5 "errors" 6 "fmt" 7 "log/slog" 8 9 securejoin "github.com/cyphar/filepath-securejoin" 10 "golang.org/x/sync/errgroup" 11 "tangled.sh/tangled.sh/core/notifier" 12 "tangled.sh/tangled.sh/core/spindle/config" 13 "tangled.sh/tangled.sh/core/spindle/db" ··· 15 "tangled.sh/tangled.sh/core/spindle/secrets" 16 ) 17 18 + var ( 19 + ErrTimedOut = errors.New("timed out") 20 + ErrWorkflowFailed = errors.New("workflow failed") 21 ) 22 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) 25 26 // extract secrets 27 var allSecrets []secrets.UnlockedSecret 28 if didSlashRepo, err := securejoin.SecureJoin(pipeline.RepoOwner, pipeline.RepoName); err == nil { 29 + if res, err := vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)); err == nil { 30 allSecrets = res 31 } 32 } 33 34 eg, ctx := errgroup.WithContext(ctx) 35 + for eng, wfs := range pipeline.Workflows { 36 + workflowTimeout := eng.WorkflowTimeout() 37 + l.Info("using workflow timeout", "timeout", workflowTimeout) 38 39 + for _, w := range wfs { 40 + eg.Go(func() error { 41 + wid := models.WorkflowId{ 42 + PipelineId: pipelineId, 43 + Name: w.Name, 44 + } 45 46 + err := db.StatusRunning(wid, n) 47 if err != nil { 48 return err 49 } 50 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) 56 57 + destroyErr := eng.DestroyWorkflow(ctx, wid) 58 + if destroyErr != nil { 59 + l.Error("failed to destroy workflow after setup failure", "error", destroyErr) 60 } 61 + 62 + dbErr := db.StatusFailed(wid, err.Error(), -1, n) 63 if dbErr != nil { 64 return dbErr 65 } 66 + return err 67 } 68 + defer eng.DestroyWorkflow(ctx, wid) 69 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 + } 77 78 + ctx, cancel := context.WithTimeout(ctx, workflowTimeout) 79 + defer cancel() 80 81 + for stepIdx, step := range w.Steps { 82 + if wfLogger != nil { 83 + ctl := wfLogger.ControlWriter(stepIdx, step) 84 + ctl.Write([]byte(step.Name())) 85 + } 86 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 + } 100 101 + return fmt.Errorf("starting steps image: %w", err) 102 + } 103 + } 104 105 + err = db.StatusSuccess(wid, n) 106 + if err != nil { 107 + return err 108 + } 109 110 + return nil 111 + }) 112 } 113 } 114 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") 119 } 120 }
-28
spindle/engine/envs.go
··· 1 - package engine 2 - 3 - import ( 4 - "fmt" 5 - ) 6 - 7 - type EnvVars []string 8 - 9 - // ConstructEnvs converts a tangled.Pipeline_Step_Environment_Elem.{Key,Value} 10 - // representation into a docker-friendly []string{"KEY=value", ...} slice. 11 - func ConstructEnvs(envs map[string]string) EnvVars { 12 - var dockerEnvs EnvVars 13 - for k, v := range envs { 14 - ev := fmt.Sprintf("%s=%s", k, v) 15 - dockerEnvs = append(dockerEnvs, ev) 16 - } 17 - return dockerEnvs 18 - } 19 - 20 - // Slice returns the EnvVar as a []string slice. 21 - func (ev EnvVars) Slice() []string { 22 - return ev 23 - } 24 - 25 - // AddEnv adds a key=value string to the EnvVar. 26 - func (ev *EnvVars) AddEnv(key, value string) { 27 - *ev = append(*ev, fmt.Sprintf("%s=%s", key, value)) 28 - }
···
-48
spindle/engine/envs_test.go
··· 1 - package engine 2 - 3 - import ( 4 - "testing" 5 - 6 - "github.com/stretchr/testify/assert" 7 - ) 8 - 9 - func TestConstructEnvs(t *testing.T) { 10 - tests := []struct { 11 - name string 12 - in map[string]string 13 - want EnvVars 14 - }{ 15 - { 16 - name: "empty input", 17 - in: make(map[string]string), 18 - want: EnvVars{}, 19 - }, 20 - { 21 - name: "single env var", 22 - in: map[string]string{"FOO": "bar"}, 23 - want: EnvVars{"FOO=bar"}, 24 - }, 25 - { 26 - name: "multiple env vars", 27 - in: map[string]string{"FOO": "bar", "BAZ": "qux"}, 28 - want: EnvVars{"FOO=bar", "BAZ=qux"}, 29 - }, 30 - } 31 - for _, tt := range tests { 32 - t.Run(tt.name, func(t *testing.T) { 33 - got := ConstructEnvs(tt.in) 34 - if got == nil { 35 - got = EnvVars{} 36 - } 37 - assert.ElementsMatch(t, tt.want, got) 38 - }) 39 - } 40 - } 41 - 42 - func TestAddEnv(t *testing.T) { 43 - ev := EnvVars{} 44 - ev.AddEnv("FOO", "bar") 45 - ev.AddEnv("BAZ", "qux") 46 - want := EnvVars{"FOO=bar", "BAZ=qux"} 47 - assert.ElementsMatch(t, want, ev) 48 - }
···
-9
spindle/engine/errors.go
··· 1 - package engine 2 - 3 - import "errors" 4 - 5 - var ( 6 - ErrOOMKilled = errors.New("oom killed") 7 - ErrTimedOut = errors.New("timed out") 8 - ErrWorkflowFailed = errors.New("workflow failed") 9 - )
···
-84
spindle/engine/logger.go
··· 1 - package engine 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - "io" 7 - "os" 8 - "path/filepath" 9 - "strings" 10 - 11 - "tangled.sh/tangled.sh/core/spindle/models" 12 - ) 13 - 14 - type WorkflowLogger struct { 15 - file *os.File 16 - encoder *json.Encoder 17 - } 18 - 19 - func NewWorkflowLogger(baseDir string, wid models.WorkflowId) (*WorkflowLogger, error) { 20 - path := LogFilePath(baseDir, wid) 21 - 22 - file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) 23 - if err != nil { 24 - return nil, fmt.Errorf("creating log file: %w", err) 25 - } 26 - 27 - return &WorkflowLogger{ 28 - file: file, 29 - encoder: json.NewEncoder(file), 30 - }, nil 31 - } 32 - 33 - func LogFilePath(baseDir string, workflowID models.WorkflowId) string { 34 - logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String())) 35 - return logFilePath 36 - } 37 - 38 - func (l *WorkflowLogger) Close() error { 39 - return l.file.Close() 40 - } 41 - 42 - func (l *WorkflowLogger) DataWriter(stream string) io.Writer { 43 - // TODO: emit stream 44 - return &dataWriter{ 45 - logger: l, 46 - stream: stream, 47 - } 48 - } 49 - 50 - func (l *WorkflowLogger) ControlWriter(idx int, step models.Step) io.Writer { 51 - return &controlWriter{ 52 - logger: l, 53 - idx: idx, 54 - step: step, 55 - } 56 - } 57 - 58 - type dataWriter struct { 59 - logger *WorkflowLogger 60 - stream string 61 - } 62 - 63 - func (w *dataWriter) Write(p []byte) (int, error) { 64 - line := strings.TrimRight(string(p), "\r\n") 65 - entry := models.NewDataLogLine(line, w.stream) 66 - if err := w.logger.encoder.Encode(entry); err != nil { 67 - return 0, err 68 - } 69 - return len(p), nil 70 - } 71 - 72 - type controlWriter struct { 73 - logger *WorkflowLogger 74 - idx int 75 - step models.Step 76 - } 77 - 78 - func (w *controlWriter) Write(_ []byte) (int, error) { 79 - entry := models.NewControlLogLine(w.idx, w.step) 80 - if err := w.logger.encoder.Encode(entry); err != nil { 81 - return 0, err 82 - } 83 - return len(w.step.Name), nil 84 - }
···
+21
spindle/engines/nixery/ansi_stripper.go
···
··· 1 + package nixery 2 + 3 + import ( 4 + "io" 5 + 6 + "regexp" 7 + ) 8 + 9 + // regex to match ANSI escape codes (e.g., color codes, cursor moves) 10 + const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" 11 + 12 + var re = regexp.MustCompile(ansi) 13 + 14 + type ansiStrippingWriter struct { 15 + underlying io.Writer 16 + } 17 + 18 + func (w *ansiStrippingWriter) Write(p []byte) (int, error) { 19 + clean := re.ReplaceAll(p, []byte{}) 20 + return w.underlying.Write(clean) 21 + }
+421
spindle/engines/nixery/engine.go
···
··· 1 + package nixery 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "log/slog" 9 + "os" 10 + "path" 11 + "runtime" 12 + "sync" 13 + "time" 14 + 15 + "github.com/docker/docker/api/types/container" 16 + "github.com/docker/docker/api/types/image" 17 + "github.com/docker/docker/api/types/mount" 18 + "github.com/docker/docker/api/types/network" 19 + "github.com/docker/docker/client" 20 + "github.com/docker/docker/pkg/stdcopy" 21 + "gopkg.in/yaml.v3" 22 + "tangled.sh/tangled.sh/core/api/tangled" 23 + "tangled.sh/tangled.sh/core/log" 24 + "tangled.sh/tangled.sh/core/spindle/config" 25 + "tangled.sh/tangled.sh/core/spindle/engine" 26 + "tangled.sh/tangled.sh/core/spindle/models" 27 + "tangled.sh/tangled.sh/core/spindle/secrets" 28 + ) 29 + 30 + const ( 31 + workspaceDir = "/tangled/workspace" 32 + homeDir = "/tangled/home" 33 + ) 34 + 35 + type cleanupFunc func(context.Context) error 36 + 37 + type Engine struct { 38 + docker client.APIClient 39 + l *slog.Logger 40 + cfg *config.Config 41 + 42 + cleanupMu sync.Mutex 43 + cleanup map[string][]cleanupFunc 44 + } 45 + 46 + type Step struct { 47 + name string 48 + kind models.StepKind 49 + command string 50 + environment map[string]string 51 + } 52 + 53 + func (s Step) Name() string { 54 + return s.name 55 + } 56 + 57 + func (s Step) Command() string { 58 + return s.command 59 + } 60 + 61 + func (s Step) Kind() models.StepKind { 62 + return s.kind 63 + } 64 + 65 + // setupSteps get added to start of Steps 66 + type setupSteps []models.Step 67 + 68 + // addStep adds a step to the beginning of the workflow's steps. 69 + func (ss *setupSteps) addStep(step models.Step) { 70 + *ss = append(*ss, step) 71 + } 72 + 73 + type addlFields struct { 74 + image string 75 + container string 76 + env map[string]string 77 + } 78 + 79 + func (e *Engine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) { 80 + swf := &models.Workflow{} 81 + addl := addlFields{} 82 + 83 + dwf := &struct { 84 + Steps []struct { 85 + Command string `yaml:"command"` 86 + Name string `yaml:"name"` 87 + Environment map[string]string `yaml:"environment"` 88 + } `yaml:"steps"` 89 + Dependencies map[string][]string `yaml:"dependencies"` 90 + Environment map[string]string `yaml:"environment"` 91 + }{} 92 + err := yaml.Unmarshal([]byte(twf.Raw), &dwf) 93 + if err != nil { 94 + return nil, err 95 + } 96 + 97 + for _, dstep := range dwf.Steps { 98 + sstep := Step{} 99 + sstep.environment = dstep.Environment 100 + sstep.command = dstep.Command 101 + sstep.name = dstep.Name 102 + sstep.kind = models.StepKindUser 103 + swf.Steps = append(swf.Steps, sstep) 104 + } 105 + swf.Name = twf.Name 106 + addl.env = dwf.Environment 107 + addl.image = workflowImage(dwf.Dependencies, e.cfg.NixeryPipelines.Nixery) 108 + 109 + setup := &setupSteps{} 110 + 111 + setup.addStep(nixConfStep()) 112 + setup.addStep(cloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev)) 113 + // this step could be empty 114 + if s := dependencyStep(dwf.Dependencies); s != nil { 115 + setup.addStep(*s) 116 + } 117 + 118 + // append setup steps in order to the start of workflow steps 119 + swf.Steps = append(*setup, swf.Steps...) 120 + swf.Data = addl 121 + 122 + return swf, nil 123 + } 124 + 125 + func (e *Engine) WorkflowTimeout() time.Duration { 126 + workflowTimeoutStr := e.cfg.NixeryPipelines.WorkflowTimeout 127 + workflowTimeout, err := time.ParseDuration(workflowTimeoutStr) 128 + if err != nil { 129 + e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr) 130 + workflowTimeout = 5 * time.Minute 131 + } 132 + 133 + return workflowTimeout 134 + } 135 + 136 + func workflowImage(deps map[string][]string, nixery string) string { 137 + var dependencies string 138 + for reg, ds := range deps { 139 + if reg == "nixpkgs" { 140 + dependencies = path.Join(ds...) 141 + } 142 + } 143 + 144 + // load defaults from somewhere else 145 + dependencies = path.Join(dependencies, "bash", "git", "coreutils", "nix") 146 + 147 + if runtime.GOARCH == "arm64" { 148 + dependencies = path.Join("arm64", dependencies) 149 + } 150 + 151 + return path.Join(nixery, dependencies) 152 + } 153 + 154 + func New(ctx context.Context, cfg *config.Config) (*Engine, error) { 155 + dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 156 + if err != nil { 157 + return nil, err 158 + } 159 + 160 + l := log.FromContext(ctx).With("component", "spindle") 161 + 162 + e := &Engine{ 163 + docker: dcli, 164 + l: l, 165 + cfg: cfg, 166 + } 167 + 168 + e.cleanup = make(map[string][]cleanupFunc) 169 + 170 + return e, nil 171 + } 172 + 173 + func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId, wf *models.Workflow) error { 174 + e.l.Info("setting up workflow", "workflow", wid) 175 + 176 + _, err := e.docker.NetworkCreate(ctx, networkName(wid), network.CreateOptions{ 177 + Driver: "bridge", 178 + }) 179 + if err != nil { 180 + return err 181 + } 182 + e.registerCleanup(wid, func(ctx context.Context) error { 183 + return e.docker.NetworkRemove(ctx, networkName(wid)) 184 + }) 185 + 186 + addl := wf.Data.(addlFields) 187 + 188 + reader, err := e.docker.ImagePull(ctx, addl.image, image.PullOptions{}) 189 + if err != nil { 190 + e.l.Error("pipeline image pull failed!", "image", addl.image, "workflowId", wid, "error", err.Error()) 191 + 192 + return fmt.Errorf("pulling image: %w", err) 193 + } 194 + defer reader.Close() 195 + io.Copy(os.Stdout, reader) 196 + 197 + resp, err := e.docker.ContainerCreate(ctx, &container.Config{ 198 + Image: addl.image, 199 + Cmd: []string{"cat"}, 200 + OpenStdin: true, // so cat stays alive :3 201 + Tty: false, 202 + Hostname: "spindle", 203 + WorkingDir: workspaceDir, 204 + Labels: map[string]string{ 205 + "sh.tangled.pipeline/workflow_id": wid.String(), 206 + }, 207 + // TODO(winter): investigate whether environment variables passed here 208 + // get propagated to ContainerExec processes 209 + }, &container.HostConfig{ 210 + Mounts: []mount.Mount{ 211 + { 212 + Type: mount.TypeTmpfs, 213 + Target: "/tmp", 214 + ReadOnly: false, 215 + TmpfsOptions: &mount.TmpfsOptions{ 216 + Mode: 0o1777, // world-writeable sticky bit 217 + Options: [][]string{ 218 + {"exec"}, 219 + }, 220 + }, 221 + }, 222 + }, 223 + ReadonlyRootfs: false, 224 + CapDrop: []string{"ALL"}, 225 + CapAdd: []string{"CAP_DAC_OVERRIDE"}, 226 + SecurityOpt: []string{"no-new-privileges"}, 227 + ExtraHosts: []string{"host.docker.internal:host-gateway"}, 228 + }, nil, nil, "") 229 + if err != nil { 230 + return fmt.Errorf("creating container: %w", err) 231 + } 232 + e.registerCleanup(wid, func(ctx context.Context) error { 233 + err = e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{}) 234 + if err != nil { 235 + return err 236 + } 237 + 238 + return e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{ 239 + RemoveVolumes: true, 240 + RemoveLinks: false, 241 + Force: false, 242 + }) 243 + }) 244 + 245 + err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}) 246 + if err != nil { 247 + return fmt.Errorf("starting container: %w", err) 248 + } 249 + 250 + mkExecResp, err := e.docker.ContainerExecCreate(ctx, resp.ID, container.ExecOptions{ 251 + Cmd: []string{"mkdir", "-p", workspaceDir, homeDir}, 252 + AttachStdout: true, // NOTE(winter): pretty sure this will make it so that when stdout read is done below, mkdir is done. maybe?? 253 + AttachStderr: true, // for good measure, backed up by docker/cli ("If -d is not set, attach to everything by default") 254 + }) 255 + if err != nil { 256 + return err 257 + } 258 + 259 + // This actually *starts* the command. Thanks, Docker! 260 + execResp, err := e.docker.ContainerExecAttach(ctx, mkExecResp.ID, container.ExecAttachOptions{}) 261 + if err != nil { 262 + return err 263 + } 264 + defer execResp.Close() 265 + 266 + // This is apparently best way to wait for the command to complete. 267 + _, err = io.ReadAll(execResp.Reader) 268 + if err != nil { 269 + return err 270 + } 271 + 272 + execInspectResp, err := e.docker.ContainerExecInspect(ctx, mkExecResp.ID) 273 + if err != nil { 274 + return err 275 + } 276 + 277 + if execInspectResp.ExitCode != 0 { 278 + return fmt.Errorf("mkdir exited with exit code %d", execInspectResp.ExitCode) 279 + } else if execInspectResp.Running { 280 + return errors.New("mkdir is somehow still running??") 281 + } 282 + 283 + addl.container = resp.ID 284 + wf.Data = addl 285 + 286 + return nil 287 + } 288 + 289 + func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *models.WorkflowLogger) error { 290 + addl := w.Data.(addlFields) 291 + workflowEnvs := ConstructEnvs(addl.env) 292 + // TODO(winter): should SetupWorkflow also have secret access? 293 + // IMO yes, but probably worth thinking on. 294 + for _, s := range secrets { 295 + workflowEnvs.AddEnv(s.Key, s.Value) 296 + } 297 + 298 + step := w.Steps[idx].(Step) 299 + 300 + select { 301 + case <-ctx.Done(): 302 + return ctx.Err() 303 + default: 304 + } 305 + 306 + envs := append(EnvVars(nil), workflowEnvs...) 307 + for k, v := range step.environment { 308 + envs.AddEnv(k, v) 309 + } 310 + envs.AddEnv("HOME", homeDir) 311 + 312 + mkExecResp, err := e.docker.ContainerExecCreate(ctx, addl.container, container.ExecOptions{ 313 + Cmd: []string{"bash", "-c", step.command}, 314 + AttachStdout: true, 315 + AttachStderr: true, 316 + Env: envs, 317 + }) 318 + if err != nil { 319 + return fmt.Errorf("creating exec: %w", err) 320 + } 321 + 322 + // start tailing logs in background 323 + tailDone := make(chan error, 1) 324 + go func() { 325 + tailDone <- e.tailStep(ctx, wfLogger, mkExecResp.ID, wid, idx, step) 326 + }() 327 + 328 + select { 329 + case <-tailDone: 330 + 331 + case <-ctx.Done(): 332 + // cleanup will be handled by DestroyWorkflow, since 333 + // Docker doesn't provide an API to kill an exec run 334 + // (sure, we could grab the PID and kill it ourselves, 335 + // but that's wasted effort) 336 + e.l.Warn("step timed out", "step", step.Name) 337 + 338 + <-tailDone 339 + 340 + return engine.ErrTimedOut 341 + } 342 + 343 + select { 344 + case <-ctx.Done(): 345 + return ctx.Err() 346 + default: 347 + } 348 + 349 + execInspectResp, err := e.docker.ContainerExecInspect(ctx, mkExecResp.ID) 350 + if err != nil { 351 + return err 352 + } 353 + 354 + if execInspectResp.ExitCode != 0 { 355 + inspectResp, err := e.docker.ContainerInspect(ctx, addl.container) 356 + if err != nil { 357 + return err 358 + } 359 + 360 + e.l.Error("workflow failed!", "workflow_id", wid.String(), "exit_code", execInspectResp.ExitCode, "oom_killed", inspectResp.State.OOMKilled) 361 + 362 + if inspectResp.State.OOMKilled { 363 + return ErrOOMKilled 364 + } 365 + return engine.ErrWorkflowFailed 366 + } 367 + 368 + return nil 369 + } 370 + 371 + func (e *Engine) tailStep(ctx context.Context, wfLogger *models.WorkflowLogger, execID string, wid models.WorkflowId, stepIdx int, step models.Step) error { 372 + if wfLogger == nil { 373 + return nil 374 + } 375 + 376 + // This actually *starts* the command. Thanks, Docker! 377 + logs, err := e.docker.ContainerExecAttach(ctx, execID, container.ExecAttachOptions{}) 378 + if err != nil { 379 + return err 380 + } 381 + defer logs.Close() 382 + 383 + _, err = stdcopy.StdCopy( 384 + wfLogger.DataWriter("stdout"), 385 + wfLogger.DataWriter("stderr"), 386 + logs.Reader, 387 + ) 388 + if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) { 389 + return fmt.Errorf("failed to copy logs: %w", err) 390 + } 391 + 392 + return nil 393 + } 394 + 395 + func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error { 396 + e.cleanupMu.Lock() 397 + key := wid.String() 398 + 399 + fns := e.cleanup[key] 400 + delete(e.cleanup, key) 401 + e.cleanupMu.Unlock() 402 + 403 + for _, fn := range fns { 404 + if err := fn(ctx); err != nil { 405 + e.l.Error("failed to cleanup workflow resource", "workflowId", wid, "error", err) 406 + } 407 + } 408 + return nil 409 + } 410 + 411 + func (e *Engine) registerCleanup(wid models.WorkflowId, fn cleanupFunc) { 412 + e.cleanupMu.Lock() 413 + defer e.cleanupMu.Unlock() 414 + 415 + key := wid.String() 416 + e.cleanup[key] = append(e.cleanup[key], fn) 417 + } 418 + 419 + func networkName(wid models.WorkflowId) string { 420 + return fmt.Sprintf("workflow-network-%s", wid) 421 + }
+28
spindle/engines/nixery/envs.go
···
··· 1 + package nixery 2 + 3 + import ( 4 + "fmt" 5 + ) 6 + 7 + type EnvVars []string 8 + 9 + // ConstructEnvs converts a tangled.Pipeline_Step_Environment_Elem.{Key,Value} 10 + // representation into a docker-friendly []string{"KEY=value", ...} slice. 11 + func ConstructEnvs(envs map[string]string) EnvVars { 12 + var dockerEnvs EnvVars 13 + for k, v := range envs { 14 + ev := fmt.Sprintf("%s=%s", k, v) 15 + dockerEnvs = append(dockerEnvs, ev) 16 + } 17 + return dockerEnvs 18 + } 19 + 20 + // Slice returns the EnvVar as a []string slice. 21 + func (ev EnvVars) Slice() []string { 22 + return ev 23 + } 24 + 25 + // AddEnv adds a key=value string to the EnvVar. 26 + func (ev *EnvVars) AddEnv(key, value string) { 27 + *ev = append(*ev, fmt.Sprintf("%s=%s", key, value)) 28 + }
+48
spindle/engines/nixery/envs_test.go
···
··· 1 + package nixery 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + ) 8 + 9 + func TestConstructEnvs(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + in map[string]string 13 + want EnvVars 14 + }{ 15 + { 16 + name: "empty input", 17 + in: make(map[string]string), 18 + want: EnvVars{}, 19 + }, 20 + { 21 + name: "single env var", 22 + in: map[string]string{"FOO": "bar"}, 23 + want: EnvVars{"FOO=bar"}, 24 + }, 25 + { 26 + name: "multiple env vars", 27 + in: map[string]string{"FOO": "bar", "BAZ": "qux"}, 28 + want: EnvVars{"FOO=bar", "BAZ=qux"}, 29 + }, 30 + } 31 + for _, tt := range tests { 32 + t.Run(tt.name, func(t *testing.T) { 33 + got := ConstructEnvs(tt.in) 34 + if got == nil { 35 + got = EnvVars{} 36 + } 37 + assert.ElementsMatch(t, tt.want, got) 38 + }) 39 + } 40 + } 41 + 42 + func TestAddEnv(t *testing.T) { 43 + ev := EnvVars{} 44 + ev.AddEnv("FOO", "bar") 45 + ev.AddEnv("BAZ", "qux") 46 + want := EnvVars{"FOO=bar", "BAZ=qux"} 47 + assert.ElementsMatch(t, want, ev) 48 + }
+7
spindle/engines/nixery/errors.go
···
··· 1 + package nixery 2 + 3 + import "errors" 4 + 5 + var ( 6 + ErrOOMKilled = errors.New("oom killed") 7 + )
+126
spindle/engines/nixery/setup_steps.go
···
··· 1 + package nixery 2 + 3 + import ( 4 + "fmt" 5 + "path" 6 + "strings" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + "tangled.sh/tangled.sh/core/workflow" 10 + ) 11 + 12 + func nixConfStep() Step { 13 + setupCmd := `mkdir -p /etc/nix 14 + echo 'extra-experimental-features = nix-command flakes' >> /etc/nix/nix.conf 15 + echo 'build-users-group = ' >> /etc/nix/nix.conf` 16 + return Step{ 17 + command: setupCmd, 18 + name: "Configure Nix", 19 + } 20 + } 21 + 22 + // cloneOptsAsSteps processes clone options and adds corresponding steps 23 + // to the beginning of the workflow's step list if cloning is not skipped. 24 + // 25 + // the steps to do here are: 26 + // - git init 27 + // - git remote add origin <url> 28 + // - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha> 29 + // - git checkout FETCH_HEAD 30 + func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step { 31 + if twf.Clone.Skip { 32 + return Step{} 33 + } 34 + 35 + var commands []string 36 + 37 + // initialize git repo in workspace 38 + commands = append(commands, "git init") 39 + 40 + // add repo as git remote 41 + scheme := "https://" 42 + if dev { 43 + scheme = "http://" 44 + tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal") 45 + } 46 + url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo) 47 + commands = append(commands, fmt.Sprintf("git remote add origin %s", url)) 48 + 49 + // run git fetch 50 + { 51 + var fetchArgs []string 52 + 53 + // default clone depth is 1 54 + depth := 1 55 + if twf.Clone.Depth > 1 { 56 + depth = int(twf.Clone.Depth) 57 + } 58 + fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth)) 59 + 60 + // optionally recurse submodules 61 + if twf.Clone.Submodules { 62 + fetchArgs = append(fetchArgs, "--recurse-submodules=yes") 63 + } 64 + 65 + // set remote to fetch from 66 + fetchArgs = append(fetchArgs, "origin") 67 + 68 + // set revision to checkout 69 + switch workflow.TriggerKind(tr.Kind) { 70 + case workflow.TriggerKindManual: 71 + // TODO: unimplemented 72 + case workflow.TriggerKindPush: 73 + fetchArgs = append(fetchArgs, tr.Push.NewSha) 74 + case workflow.TriggerKindPullRequest: 75 + fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha) 76 + } 77 + 78 + commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " "))) 79 + } 80 + 81 + // run git checkout 82 + commands = append(commands, "git checkout FETCH_HEAD") 83 + 84 + cloneStep := Step{ 85 + command: strings.Join(commands, "\n"), 86 + name: "Clone repository into workspace", 87 + } 88 + return cloneStep 89 + } 90 + 91 + // dependencyStep processes dependencies defined in the workflow. 92 + // For dependencies using a custom registry (i.e. not nixpkgs), it collects 93 + // all packages and adds a single 'nix profile install' step to the 94 + // beginning of the workflow's step list. 95 + func dependencyStep(deps map[string][]string) *Step { 96 + var customPackages []string 97 + 98 + for registry, packages := range deps { 99 + if registry == "nixpkgs" { 100 + continue 101 + } 102 + 103 + if len(packages) == 0 { 104 + customPackages = append(customPackages, registry) 105 + } 106 + // collect packages from custom registries 107 + for _, pkg := range packages { 108 + customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg)) 109 + } 110 + } 111 + 112 + if len(customPackages) > 0 { 113 + installCmd := "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install" 114 + cmd := fmt.Sprintf("%s %s", installCmd, strings.Join(customPackages, " ")) 115 + installStep := Step{ 116 + command: cmd, 117 + name: "Install custom dependencies", 118 + environment: map[string]string{ 119 + "NIX_NO_COLOR": "1", 120 + "NIX_SHOW_DOWNLOAD_PROGRESS": "0", 121 + }, 122 + } 123 + return &installStep 124 + } 125 + return nil 126 + }
+8 -4
spindle/ingester.go
··· 40 41 switch e.Commit.Collection { 42 case tangled.SpindleMemberNSID: 43 - s.ingestMember(ctx, e) 44 case tangled.RepoNSID: 45 - s.ingestRepo(ctx, e) 46 case tangled.RepoCollaboratorNSID: 47 - s.ingestCollaborator(ctx, e) 48 } 49 50 - return err 51 } 52 } 53
··· 40 41 switch e.Commit.Collection { 42 case tangled.SpindleMemberNSID: 43 + err = s.ingestMember(ctx, e) 44 case tangled.RepoNSID: 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) 52 + } 53 + 54 + return nil 55 } 56 } 57
+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 func NewControlLogLine(idx int, step Step) LogLine { 105 return LogLine{ 106 Kind: LogKindControl, 107 - Content: step.Name, 108 StepId: idx, 109 - StepKind: step.Kind, 110 - StepCommand: step.Command, 111 } 112 }
··· 104 func NewControlLogLine(idx int, step Step) LogLine { 105 return LogLine{ 106 Kind: LogKindControl, 107 + Content: step.Name(), 108 StepId: idx, 109 + StepKind: step.Kind(), 110 + StepCommand: step.Command(), 111 } 112 }
+8 -103
spindle/models/pipeline.go
··· 1 package models 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 type Pipeline struct { 11 RepoOwner string 12 RepoName string 13 - Workflows []Workflow 14 } 15 16 - type Step struct { 17 - Command string 18 - Name string 19 - Environment map[string]string 20 - Kind StepKind 21 } 22 23 type StepKind int ··· 30 ) 31 32 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) 123 }
··· 1 package models 2 3 type Pipeline struct { 4 RepoOwner string 5 RepoName string 6 + Workflows map[Engine][]Workflow 7 } 8 9 + type Step interface { 10 + Name() string 11 + Command() string 12 + Kind() StepKind 13 } 14 15 type StepKind int ··· 22 ) 23 24 type Workflow struct { 25 + Steps []Step 26 + Name string 27 + Data any 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/sqlite.go
··· 24 } 25 26 func NewSQLiteManager(dbPath string, opts ...SqliteManagerOpt) (*SqliteManager, error) { 27 - db, err := sql.Open("sqlite3", dbPath) 28 if err != nil { 29 return nil, fmt.Errorf("failed to open sqlite database: %w", err) 30 }
··· 24 } 25 26 func NewSQLiteManager(dbPath string, opts ...SqliteManagerOpt) (*SqliteManager, error) { 27 + db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1") 28 if err != nil { 29 return nil, fmt.Errorf("failed to open sqlite database: %w", err) 30 }
+48 -14
spindle/server.go
··· 20 "tangled.sh/tangled.sh/core/spindle/config" 21 "tangled.sh/tangled.sh/core/spindle/db" 22 "tangled.sh/tangled.sh/core/spindle/engine" 23 "tangled.sh/tangled.sh/core/spindle/models" 24 "tangled.sh/tangled.sh/core/spindle/queue" 25 "tangled.sh/tangled.sh/core/spindle/secrets" 26 "tangled.sh/tangled.sh/core/spindle/xrpc" 27 ) 28 29 //go:embed motd ··· 39 e *rbac.Enforcer 40 l *slog.Logger 41 n *notifier.Notifier 42 - eng *engine.Engine 43 jq *queue.Queue 44 cfg *config.Config 45 ks *eventconsumer.Consumer ··· 93 return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 94 } 95 96 - eng, err := engine.New(ctx, cfg, d, &n, vault) 97 if err != nil { 98 return err 99 } ··· 128 db: d, 129 l: logger, 130 n: &n, 131 - eng: eng, 132 jq: jq, 133 cfg: cfg, 134 res: resolver, ··· 212 func (s *Spindle) XrpcRouter() http.Handler { 213 logger := s.l.With("route", "xrpc") 214 215 x := xrpc.Xrpc{ 216 - Logger: logger, 217 - Db: s.db, 218 - Enforcer: s.e, 219 - Engine: s.eng, 220 - Config: s.cfg, 221 - Resolver: s.res, 222 - Vault: s.vault, 223 } 224 225 return x.Router() ··· 261 Rkey: msg.Rkey, 262 } 263 264 for _, w := range tpl.Workflows { 265 if w != nil { 266 - err := s.db.StatusPending(models.WorkflowId{ 267 PipelineId: pipelineId, 268 Name: w.Name, 269 }, s.n) ··· 273 } 274 } 275 276 - spl := models.ToPipeline(tpl, *s.cfg) 277 - 278 ok := s.jq.Enqueue(queue.Job{ 279 Run: func() error { 280 - s.eng.StartWorkflows(ctx, spl, pipelineId) 281 return nil 282 }, 283 OnFail: func(jobError error) {
··· 20 "tangled.sh/tangled.sh/core/spindle/config" 21 "tangled.sh/tangled.sh/core/spindle/db" 22 "tangled.sh/tangled.sh/core/spindle/engine" 23 + "tangled.sh/tangled.sh/core/spindle/engines/nixery" 24 "tangled.sh/tangled.sh/core/spindle/models" 25 "tangled.sh/tangled.sh/core/spindle/queue" 26 "tangled.sh/tangled.sh/core/spindle/secrets" 27 "tangled.sh/tangled.sh/core/spindle/xrpc" 28 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 29 ) 30 31 //go:embed motd ··· 41 e *rbac.Enforcer 42 l *slog.Logger 43 n *notifier.Notifier 44 + engs map[string]models.Engine 45 jq *queue.Queue 46 cfg *config.Config 47 ks *eventconsumer.Consumer ··· 95 return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 96 } 97 98 + nixeryEng, err := nixery.New(ctx, cfg) 99 if err != nil { 100 return err 101 } ··· 130 db: d, 131 l: logger, 132 n: &n, 133 + engs: map[string]models.Engine{"nixery": nixeryEng}, 134 jq: jq, 135 cfg: cfg, 136 res: resolver, ··· 214 func (s *Spindle) XrpcRouter() http.Handler { 215 logger := s.l.With("route", "xrpc") 216 217 + serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String()) 218 + 219 x := xrpc.Xrpc{ 220 + Logger: logger, 221 + Db: s.db, 222 + Enforcer: s.e, 223 + Engines: s.engs, 224 + Config: s.cfg, 225 + Resolver: s.res, 226 + Vault: s.vault, 227 + ServiceAuth: serviceAuth, 228 } 229 230 return x.Router() ··· 266 Rkey: msg.Rkey, 267 } 268 269 + workflows := make(map[models.Engine][]models.Workflow) 270 + 271 for _, w := range tpl.Workflows { 272 if w != nil { 273 + if _, ok := s.engs[w.Engine]; !ok { 274 + err = s.db.StatusFailed(models.WorkflowId{ 275 + PipelineId: pipelineId, 276 + Name: w.Name, 277 + }, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n) 278 + if err != nil { 279 + return err 280 + } 281 + 282 + continue 283 + } 284 + 285 + eng := s.engs[w.Engine] 286 + 287 + if _, ok := workflows[eng]; !ok { 288 + workflows[eng] = []models.Workflow{} 289 + } 290 + 291 + ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl) 292 + if err != nil { 293 + return err 294 + } 295 + 296 + workflows[eng] = append(workflows[eng], *ewf) 297 + 298 + err = s.db.StatusPending(models.WorkflowId{ 299 PipelineId: pipelineId, 300 Name: w.Name, 301 }, s.n) ··· 305 } 306 } 307 308 ok := s.jq.Enqueue(queue.Job{ 309 Run: func() error { 310 + engine.StartWorkflows(s.l, s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 311 + RepoOwner: tpl.TriggerMetadata.Repo.Did, 312 + RepoName: tpl.TriggerMetadata.Repo.Repo, 313 + Workflows: workflows, 314 + }, pipelineId) 315 return nil 316 }, 317 OnFail: func(jobError error) {
+32 -2
spindle/stream.go
··· 6 "fmt" 7 "io" 8 "net/http" 9 "strconv" 10 "time" 11 12 - "tangled.sh/tangled.sh/core/spindle/engine" 13 "tangled.sh/tangled.sh/core/spindle/models" 14 15 "github.com/go-chi/chi/v5" ··· 143 } 144 isFinished := models.StatusKind(status.Status).IsFinish() 145 146 - filePath := engine.LogFilePath(s.cfg.Pipelines.LogDir, wid) 147 148 config := tail.Config{ 149 Follow: !isFinished,
··· 6 "fmt" 7 "io" 8 "net/http" 9 + "os" 10 "strconv" 11 "time" 12 13 "tangled.sh/tangled.sh/core/spindle/models" 14 15 "github.com/go-chi/chi/v5" ··· 143 } 144 isFinished := models.StatusKind(status.Status).IsFinish() 145 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 + } 177 178 config := tail.Config{ 179 Follow: !isFinished,
+11 -10
spindle/xrpc/add_secret.go
··· 13 "tangled.sh/tangled.sh/core/api/tangled" 14 "tangled.sh/tangled.sh/core/rbac" 15 "tangled.sh/tangled.sh/core/spindle/secrets" 16 ) 17 18 func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) { 19 l := x.Logger 20 - fail := func(e XrpcError) { 21 l.Error("failed", "kind", e.Tag, "error", e.Message) 22 writeError(w, e, http.StatusBadRequest) 23 } 24 25 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 if !ok { 27 - fail(MissingActorDidError) 28 return 29 } 30 31 var data tangled.RepoAddSecret_Input 32 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 - fail(GenericError(err)) 34 return 35 } 36 37 if err := secrets.ValidateKey(data.Key); err != nil { 38 - fail(GenericError(err)) 39 return 40 } 41 42 // unfortunately we have to resolve repo-at here 43 repoAt, err := syntax.ParseATURI(data.Repo) 44 if err != nil { 45 - fail(InvalidRepoError(data.Repo)) 46 return 47 } 48 49 // resolve this aturi to extract the repo record 50 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 51 if err != nil || ident.Handle.IsInvalidHandle() { 52 - fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 53 return 54 } 55 56 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 57 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 58 if err != nil { 59 - fail(GenericError(err)) 60 return 61 } 62 63 repo := resp.Value.Val.(*tangled.Repo) 64 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 65 if err != nil { 66 - fail(GenericError(err)) 67 return 68 } 69 70 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 71 l.Error("insufficent permissions", "did", actorDid.String()) 72 - writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 73 return 74 } 75 ··· 83 err = x.Vault.AddSecret(r.Context(), secret) 84 if err != nil { 85 l.Error("failed to add secret to vault", "did", actorDid.String(), "err", err) 86 - writeError(w, GenericError(err), http.StatusInternalServerError) 87 return 88 } 89
··· 13 "tangled.sh/tangled.sh/core/api/tangled" 14 "tangled.sh/tangled.sh/core/rbac" 15 "tangled.sh/tangled.sh/core/spindle/secrets" 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 ) 18 19 func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) { 20 l := x.Logger 21 + fail := func(e xrpcerr.XrpcError) { 22 l.Error("failed", "kind", e.Tag, "error", e.Message) 23 writeError(w, e, http.StatusBadRequest) 24 } 25 26 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 return 30 } 31 32 var data tangled.RepoAddSecret_Input 33 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 + fail(xrpcerr.GenericError(err)) 35 return 36 } 37 38 if err := secrets.ValidateKey(data.Key); err != nil { 39 + fail(xrpcerr.GenericError(err)) 40 return 41 } 42 43 // unfortunately we have to resolve repo-at here 44 repoAt, err := syntax.ParseATURI(data.Repo) 45 if err != nil { 46 + fail(xrpcerr.InvalidRepoError(data.Repo)) 47 return 48 } 49 50 // resolve this aturi to extract the repo record 51 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 52 if err != nil || ident.Handle.IsInvalidHandle() { 53 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 54 return 55 } 56 57 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 58 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 59 if err != nil { 60 + fail(xrpcerr.GenericError(err)) 61 return 62 } 63 64 repo := resp.Value.Val.(*tangled.Repo) 65 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 66 if err != nil { 67 + fail(xrpcerr.GenericError(err)) 68 return 69 } 70 71 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 72 l.Error("insufficent permissions", "did", actorDid.String()) 73 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 74 return 75 } 76 ··· 84 err = x.Vault.AddSecret(r.Context(), secret) 85 if err != nil { 86 l.Error("failed to add secret to vault", "did", actorDid.String(), "err", err) 87 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 88 return 89 } 90
+10 -9
spindle/xrpc/list_secrets.go
··· 13 "tangled.sh/tangled.sh/core/api/tangled" 14 "tangled.sh/tangled.sh/core/rbac" 15 "tangled.sh/tangled.sh/core/spindle/secrets" 16 ) 17 18 func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) { 19 l := x.Logger 20 - fail := func(e XrpcError) { 21 l.Error("failed", "kind", e.Tag, "error", e.Message) 22 writeError(w, e, http.StatusBadRequest) 23 } 24 25 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 if !ok { 27 - fail(MissingActorDidError) 28 return 29 } 30 31 repoParam := r.URL.Query().Get("repo") 32 if repoParam == "" { 33 - fail(GenericError(fmt.Errorf("empty params"))) 34 return 35 } 36 37 // unfortunately we have to resolve repo-at here 38 repoAt, err := syntax.ParseATURI(repoParam) 39 if err != nil { 40 - fail(InvalidRepoError(repoParam)) 41 return 42 } 43 44 // resolve this aturi to extract the repo record 45 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 46 if err != nil || ident.Handle.IsInvalidHandle() { 47 - fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 48 return 49 } 50 51 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 52 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 53 if err != nil { 54 - fail(GenericError(err)) 55 return 56 } 57 58 repo := resp.Value.Val.(*tangled.Repo) 59 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 60 if err != nil { 61 - fail(GenericError(err)) 62 return 63 } 64 65 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 66 l.Error("insufficent permissions", "did", actorDid.String()) 67 - writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 68 return 69 } 70 71 ls, err := x.Vault.GetSecretsLocked(r.Context(), secrets.DidSlashRepo(didPath)) 72 if err != nil { 73 l.Error("failed to get secret from vault", "did", actorDid.String(), "err", err) 74 - writeError(w, GenericError(err), http.StatusInternalServerError) 75 return 76 } 77
··· 13 "tangled.sh/tangled.sh/core/api/tangled" 14 "tangled.sh/tangled.sh/core/rbac" 15 "tangled.sh/tangled.sh/core/spindle/secrets" 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 ) 18 19 func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) { 20 l := x.Logger 21 + fail := func(e xrpcerr.XrpcError) { 22 l.Error("failed", "kind", e.Tag, "error", e.Message) 23 writeError(w, e, http.StatusBadRequest) 24 } 25 26 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 return 30 } 31 32 repoParam := r.URL.Query().Get("repo") 33 if repoParam == "" { 34 + fail(xrpcerr.GenericError(fmt.Errorf("empty params"))) 35 return 36 } 37 38 // unfortunately we have to resolve repo-at here 39 repoAt, err := syntax.ParseATURI(repoParam) 40 if err != nil { 41 + fail(xrpcerr.InvalidRepoError(repoParam)) 42 return 43 } 44 45 // resolve this aturi to extract the repo record 46 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 47 if err != nil || ident.Handle.IsInvalidHandle() { 48 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 49 return 50 } 51 52 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 53 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 54 if err != nil { 55 + fail(xrpcerr.GenericError(err)) 56 return 57 } 58 59 repo := resp.Value.Val.(*tangled.Repo) 60 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 61 if err != nil { 62 + fail(xrpcerr.GenericError(err)) 63 return 64 } 65 66 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 67 l.Error("insufficent permissions", "did", actorDid.String()) 68 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 69 return 70 } 71 72 ls, err := x.Vault.GetSecretsLocked(r.Context(), secrets.DidSlashRepo(didPath)) 73 if err != nil { 74 l.Error("failed to get secret from vault", "did", actorDid.String(), "err", err) 75 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 76 return 77 } 78
+10 -9
spindle/xrpc/remove_secret.go
··· 12 "tangled.sh/tangled.sh/core/api/tangled" 13 "tangled.sh/tangled.sh/core/rbac" 14 "tangled.sh/tangled.sh/core/spindle/secrets" 15 ) 16 17 func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) { 18 l := x.Logger 19 - fail := func(e XrpcError) { 20 l.Error("failed", "kind", e.Tag, "error", e.Message) 21 writeError(w, e, http.StatusBadRequest) 22 } 23 24 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 25 if !ok { 26 - fail(MissingActorDidError) 27 return 28 } 29 30 var data tangled.RepoRemoveSecret_Input 31 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 32 - fail(GenericError(err)) 33 return 34 } 35 36 // unfortunately we have to resolve repo-at here 37 repoAt, err := syntax.ParseATURI(data.Repo) 38 if err != nil { 39 - fail(InvalidRepoError(data.Repo)) 40 return 41 } 42 43 // resolve this aturi to extract the repo record 44 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 45 if err != nil || ident.Handle.IsInvalidHandle() { 46 - fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 47 return 48 } 49 50 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 51 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 52 if err != nil { 53 - fail(GenericError(err)) 54 return 55 } 56 57 repo := resp.Value.Val.(*tangled.Repo) 58 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 59 if err != nil { 60 - fail(GenericError(err)) 61 return 62 } 63 64 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 65 l.Error("insufficent permissions", "did", actorDid.String()) 66 - writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 67 return 68 } 69 ··· 74 err = x.Vault.RemoveSecret(r.Context(), secret) 75 if err != nil { 76 l.Error("failed to remove secret from vault", "did", actorDid.String(), "err", err) 77 - writeError(w, GenericError(err), http.StatusInternalServerError) 78 return 79 } 80
··· 12 "tangled.sh/tangled.sh/core/api/tangled" 13 "tangled.sh/tangled.sh/core/rbac" 14 "tangled.sh/tangled.sh/core/spindle/secrets" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 ) 17 18 func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) { 19 l := x.Logger 20 + fail := func(e xrpcerr.XrpcError) { 21 l.Error("failed", "kind", e.Tag, "error", e.Message) 22 writeError(w, e, http.StatusBadRequest) 23 } 24 25 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 if !ok { 27 + fail(xrpcerr.MissingActorDidError) 28 return 29 } 30 31 var data tangled.RepoRemoveSecret_Input 32 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 + fail(xrpcerr.GenericError(err)) 34 return 35 } 36 37 // unfortunately we have to resolve repo-at here 38 repoAt, err := syntax.ParseATURI(data.Repo) 39 if err != nil { 40 + fail(xrpcerr.InvalidRepoError(data.Repo)) 41 return 42 } 43 44 // resolve this aturi to extract the repo record 45 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 46 if err != nil || ident.Handle.IsInvalidHandle() { 47 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 48 return 49 } 50 51 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 52 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 53 if err != nil { 54 + fail(xrpcerr.GenericError(err)) 55 return 56 } 57 58 repo := resp.Value.Val.(*tangled.Repo) 59 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 60 if err != nil { 61 + fail(xrpcerr.GenericError(err)) 62 return 63 } 64 65 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 66 l.Error("insufficent permissions", "did", actorDid.String()) 67 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 68 return 69 } 70 ··· 75 err = x.Vault.RemoveSecret(r.Context(), secret) 76 if err != nil { 77 l.Error("failed to remove secret from vault", "did", actorDid.String(), "err", err) 78 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 79 return 80 } 81
+15 -110
spindle/xrpc/xrpc.go
··· 1 package xrpc 2 3 import ( 4 - "context" 5 _ "embed" 6 "encoding/json" 7 - "fmt" 8 "log/slog" 9 "net/http" 10 - "strings" 11 12 - "github.com/bluesky-social/indigo/atproto/auth" 13 "github.com/go-chi/chi/v5" 14 15 "tangled.sh/tangled.sh/core/api/tangled" ··· 17 "tangled.sh/tangled.sh/core/rbac" 18 "tangled.sh/tangled.sh/core/spindle/config" 19 "tangled.sh/tangled.sh/core/spindle/db" 20 - "tangled.sh/tangled.sh/core/spindle/engine" 21 "tangled.sh/tangled.sh/core/spindle/secrets" 22 ) 23 24 const ActorDid string = "ActorDid" 25 26 type Xrpc struct { 27 - Logger *slog.Logger 28 - Db *db.DB 29 - Enforcer *rbac.Enforcer 30 - Engine *engine.Engine 31 - Config *config.Config 32 - Resolver *idresolver.Resolver 33 - Vault secrets.Manager 34 } 35 36 func (x *Xrpc) Router() http.Handler { 37 r := chi.NewRouter() 38 39 - r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 40 - r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 41 - r.With(x.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 42 43 return r 44 } 45 46 - func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler { 47 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 48 - l := x.Logger.With("url", r.URL) 49 - 50 - token := r.Header.Get("Authorization") 51 - token = strings.TrimPrefix(token, "Bearer ") 52 - 53 - s := auth.ServiceAuthValidator{ 54 - Audience: x.Config.Server.Did().String(), 55 - Dir: x.Resolver.Directory(), 56 - } 57 - 58 - did, err := s.Validate(r.Context(), token, nil) 59 - if err != nil { 60 - l.Error("signature verification failed", "err", err) 61 - writeError(w, AuthError(err), http.StatusForbidden) 62 - return 63 - } 64 - 65 - r = r.WithContext( 66 - context.WithValue(r.Context(), ActorDid, did), 67 - ) 68 - 69 - next.ServeHTTP(w, r) 70 - }) 71 - } 72 - 73 - type XrpcError struct { 74 - Tag string `json:"error"` 75 - Message string `json:"message"` 76 - } 77 - 78 - func NewXrpcError(opts ...ErrOpt) XrpcError { 79 - x := XrpcError{} 80 - for _, o := range opts { 81 - o(&x) 82 - } 83 - 84 - return x 85 - } 86 - 87 - type ErrOpt = func(xerr *XrpcError) 88 - 89 - func WithTag(tag string) ErrOpt { 90 - return func(xerr *XrpcError) { 91 - xerr.Tag = tag 92 - } 93 - } 94 - 95 - func WithMessage[S ~string](s S) ErrOpt { 96 - return func(xerr *XrpcError) { 97 - xerr.Message = string(s) 98 - } 99 - } 100 - 101 - func WithError(e error) ErrOpt { 102 - return func(xerr *XrpcError) { 103 - xerr.Message = e.Error() 104 - } 105 - } 106 - 107 - var MissingActorDidError = NewXrpcError( 108 - WithTag("MissingActorDid"), 109 - WithMessage("actor DID not supplied"), 110 - ) 111 - 112 - var AuthError = func(err error) XrpcError { 113 - return NewXrpcError( 114 - WithTag("Auth"), 115 - WithError(fmt.Errorf("signature verification failed: %w", err)), 116 - ) 117 - } 118 - 119 - var InvalidRepoError = func(r string) XrpcError { 120 - return NewXrpcError( 121 - WithTag("InvalidRepo"), 122 - WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)), 123 - ) 124 - } 125 - 126 - func GenericError(err error) XrpcError { 127 - return NewXrpcError( 128 - WithTag("Generic"), 129 - WithError(err), 130 - ) 131 - } 132 - 133 - var AccessControlError = func(d string) XrpcError { 134 - return NewXrpcError( 135 - WithTag("AccessControl"), 136 - WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)), 137 - ) 138 - } 139 - 140 // this is slightly different from http_util::write_error to follow the spec: 141 // 142 // the json object returned must include an "error" and a "message" 143 - func writeError(w http.ResponseWriter, e XrpcError, status int) { 144 w.Header().Set("Content-Type", "application/json") 145 w.WriteHeader(status) 146 json.NewEncoder(w).Encode(e)
··· 1 package xrpc 2 3 import ( 4 _ "embed" 5 "encoding/json" 6 "log/slog" 7 "net/http" 8 9 "github.com/go-chi/chi/v5" 10 11 "tangled.sh/tangled.sh/core/api/tangled" ··· 13 "tangled.sh/tangled.sh/core/rbac" 14 "tangled.sh/tangled.sh/core/spindle/config" 15 "tangled.sh/tangled.sh/core/spindle/db" 16 + "tangled.sh/tangled.sh/core/spindle/models" 17 "tangled.sh/tangled.sh/core/spindle/secrets" 18 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 19 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 20 ) 21 22 const ActorDid string = "ActorDid" 23 24 type Xrpc struct { 25 + Logger *slog.Logger 26 + Db *db.DB 27 + Enforcer *rbac.Enforcer 28 + Engines map[string]models.Engine 29 + Config *config.Config 30 + Resolver *idresolver.Resolver 31 + Vault secrets.Manager 32 + ServiceAuth *serviceauth.ServiceAuth 33 } 34 35 func (x *Xrpc) Router() http.Handler { 36 r := chi.NewRouter() 37 38 + r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 39 + r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 40 + r.With(x.ServiceAuth.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 41 42 return r 43 } 44 45 // this is slightly different from http_util::write_error to follow the spec: 46 // 47 // the json object returned must include an "error" and a "message" 48 + func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 49 w.Header().Set("Content-Type", "application/json") 50 w.WriteHeader(status) 51 json.NewEncoder(w).Encode(e)
+17 -36
workflow/compile.go
··· 1 package workflow 2 3 import ( 4 "fmt" 5 6 "tangled.sh/tangled.sh/core/api/tangled" ··· 63 return fmt.Sprintf("warning: %s: %s: %s", w.Path, w.Type, w.Reason) 64 } 65 66 type WarningKind string 67 68 var ( ··· 95 for _, wf := range p { 96 cw := compiler.compileWorkflow(wf) 97 98 - // empty workflows are not added to the pipeline 99 - if len(cw.Steps) == 0 { 100 continue 101 } 102 103 - cp.Workflows = append(cp.Workflows, &cw) 104 } 105 106 return cp 107 } 108 109 - func (compiler *Compiler) compileWorkflow(w Workflow) tangled.Pipeline_Workflow { 110 - cw := tangled.Pipeline_Workflow{} 111 112 if !w.Match(compiler.Trigger) { 113 compiler.Diagnostics.AddWarning( ··· 115 WorkflowSkipped, 116 fmt.Sprintf("did not match trigger %s", compiler.Trigger.Kind), 117 ) 118 - return cw 119 - } 120 - 121 - if len(w.Steps) == 0 { 122 - compiler.Diagnostics.AddWarning( 123 - w.Name, 124 - WorkflowSkipped, 125 - "empty workflow", 126 - ) 127 - return cw 128 } 129 130 // validate clone options 131 compiler.analyzeCloneOptions(w) 132 133 cw.Name = w.Name 134 - cw.Dependencies = w.Dependencies.AsRecord() 135 - for _, s := range w.Steps { 136 - step := tangled.Pipeline_Step{ 137 - Command: s.Command, 138 - Name: s.Name, 139 - } 140 - for k, v := range s.Environment { 141 - e := &tangled.Pipeline_Pair{ 142 - Key: k, 143 - Value: v, 144 - } 145 - step.Environment = append(step.Environment, e) 146 - } 147 - cw.Steps = append(cw.Steps, &step) 148 } 149 - for k, v := range w.Environment { 150 - e := &tangled.Pipeline_Pair{ 151 - Key: k, 152 - Value: v, 153 - } 154 - cw.Environment = append(cw.Environment, e) 155 - } 156 157 o := w.CloneOpts.AsRecord() 158 cw.Clone = &o
··· 1 package workflow 2 3 import ( 4 + "errors" 5 "fmt" 6 7 "tangled.sh/tangled.sh/core/api/tangled" ··· 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 + 71 type WarningKind string 72 73 var ( ··· 100 for _, wf := range p { 101 cw := compiler.compileWorkflow(wf) 102 103 + if cw == nil { 104 continue 105 } 106 107 + cp.Workflows = append(cp.Workflows, cw) 108 } 109 110 return cp 111 } 112 113 + func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow { 114 + cw := &tangled.Pipeline_Workflow{} 115 116 if !w.Match(compiler.Trigger) { 117 compiler.Diagnostics.AddWarning( ··· 119 WorkflowSkipped, 120 fmt.Sprintf("did not match trigger %s", compiler.Trigger.Kind), 121 ) 122 + return nil 123 } 124 125 // validate clone options 126 compiler.analyzeCloneOptions(w) 127 128 cw.Name = w.Name 129 + 130 + if w.Engine == "" { 131 + compiler.Diagnostics.AddError(w.Name, MissingEngine) 132 + return nil 133 } 134 + 135 + cw.Engine = w.Engine 136 + cw.Raw = w.Raw 137 138 o := w.CloneOpts.AsRecord() 139 cw.Clone = &o
+23 -29
workflow/compile_test.go
··· 26 27 func TestCompileWorkflow_MatchingWorkflowWithSteps(t *testing.T) { 28 wf := Workflow{ 29 - Name: ".tangled/workflows/test.yml", 30 - When: when, 31 - Steps: []Step{ 32 - {Name: "Test", Command: "go test ./..."}, 33 - }, 34 CloneOpts: CloneOpts{}, // default true 35 } 36 ··· 43 assert.False(t, c.Diagnostics.IsErr()) 44 } 45 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 func TestCompileWorkflow_TriggerMismatch(t *testing.T) { 62 wf := Workflow{ 63 - Name: ".tangled/workflows/mismatch.yml", 64 When: []Constraint{ 65 { 66 Event: []string{"push"}, 67 Branch: []string{"master"}, // different branch 68 }, 69 }, 70 - Steps: []Step{ 71 - {Name: "Lint", Command: "golint ./..."}, 72 - }, 73 } 74 75 c := Compiler{Trigger: trigger} ··· 82 83 func TestCompileWorkflow_CloneFalseWithShallowTrue(t *testing.T) { 84 wf := Workflow{ 85 - Name: ".tangled/workflows/clone_skip.yml", 86 - When: when, 87 - Steps: []Step{ 88 - {Name: "Skip", Command: "echo skip"}, 89 - }, 90 CloneOpts: CloneOpts{ 91 Skip: true, 92 Depth: 1, ··· 101 assert.Len(t, c.Diagnostics.Warnings, 1) 102 assert.Equal(t, InvalidConfiguration, c.Diagnostics.Warnings[0].Type) 103 }
··· 26 27 func TestCompileWorkflow_MatchingWorkflowWithSteps(t *testing.T) { 28 wf := Workflow{ 29 + Name: ".tangled/workflows/test.yml", 30 + Engine: "nixery", 31 + When: when, 32 CloneOpts: CloneOpts{}, // default true 33 } 34 ··· 41 assert.False(t, c.Diagnostics.IsErr()) 42 } 43 44 func TestCompileWorkflow_TriggerMismatch(t *testing.T) { 45 wf := Workflow{ 46 + Name: ".tangled/workflows/mismatch.yml", 47 + Engine: "nixery", 48 When: []Constraint{ 49 { 50 Event: []string{"push"}, 51 Branch: []string{"master"}, // different branch 52 }, 53 }, 54 } 55 56 c := Compiler{Trigger: trigger} ··· 63 64 func TestCompileWorkflow_CloneFalseWithShallowTrue(t *testing.T) { 65 wf := Workflow{ 66 + Name: ".tangled/workflows/clone_skip.yml", 67 + Engine: "nixery", 68 + When: when, 69 CloneOpts: CloneOpts{ 70 Skip: true, 71 Depth: 1, ··· 80 assert.Len(t, c.Diagnostics.Warnings, 1) 81 assert.Equal(t, InvalidConfiguration, c.Diagnostics.Warnings[0].Type) 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 25 // this is simply a structural representation of the workflow file 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"` 33 } 34 35 Constraint struct { 36 Event StringList `yaml:"event"` 37 Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events 38 } 39 - 40 - Dependencies map[string][]string 41 42 CloneOpts struct { 43 Skip bool `yaml:"skip"` 44 Depth int `yaml:"depth"` 45 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 } 53 54 StringList []string ··· 77 } 78 79 wf.Name = name 80 81 return wf, nil 82 } ··· 173 } 174 175 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 } 196 197 func (c CloneOpts) AsRecord() tangled.Pipeline_CloneOpts {
··· 24 25 // this is simply a structural representation of the workflow file 26 Workflow struct { 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:"-"` 32 } 33 34 Constraint struct { 35 Event StringList `yaml:"event"` 36 Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events 37 } 38 39 CloneOpts struct { 40 Skip bool `yaml:"skip"` 41 Depth int `yaml:"depth"` 42 IncludeSubmodules bool `yaml:"submodules"` 43 } 44 45 StringList []string ··· 68 } 69 70 wf.Name = name 71 + wf.Raw = string(contents) 72 73 return wf, nil 74 } ··· 165 } 166 167 return errors.New("failed to unmarshal StringOrSlice") 168 } 169 170 func (c CloneOpts) AsRecord() tangled.Pipeline_CloneOpts {
+1 -86
workflow/def_test.go
··· 10 yamlData := ` 11 when: 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 ./...` 25 26 wf, err := FromFile("test.yml", []byte(yamlData)) 27 assert.NoError(t, err, "YAML should unmarshal without error") ··· 30 assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch) 31 assert.ElementsMatch(t, []string{"push", "pull_request"}, wf.When[0].Event) 32 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 assert.False(t, wf.CloneOpts.Skip, "Skip should default to false") 42 } 43 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 func TestUnmarshalCloneFalse(t *testing.T) { 72 yamlData := ` 73 when: ··· 75 76 clone: 77 skip: true 78 - 79 - dependencies: 80 - nixpkgs: 81 - - python3 82 - 83 - steps: 84 - - name: Notify 85 - command: | 86 - python3 ./notify.py 87 ` 88 89 wf, err := FromFile("test.yml", []byte(yamlData)) ··· 93 94 assert.True(t, wf.CloneOpts.Skip, "Skip should be false") 95 } 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 - }
··· 10 yamlData := ` 11 when: 12 - event: ["push", "pull_request"] 13 + branch: ["main", "develop"]` 14 15 wf, err := FromFile("test.yml", []byte(yamlData)) 16 assert.NoError(t, err, "YAML should unmarshal without error") ··· 19 assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch) 20 assert.ElementsMatch(t, []string{"push", "pull_request"}, wf.When[0].Event) 21 22 assert.False(t, wf.CloneOpts.Skip, "Skip should default to false") 23 } 24 25 func TestUnmarshalCloneFalse(t *testing.T) { 26 yamlData := ` 27 when: ··· 29 30 clone: 31 skip: true 32 ` 33 34 wf, err := FromFile("test.yml", []byte(yamlData)) ··· 38 39 assert.True(t, wf.CloneOpts.Skip, "Skip should be false") 40 }
+110
xrpc/errors/errors.go
···
··· 1 + package errors 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + ) 7 + 8 + type XrpcError struct { 9 + Tag string `json:"error"` 10 + Message string `json:"message"` 11 + } 12 + 13 + func (x XrpcError) Error() string { 14 + if x.Message != "" { 15 + return fmt.Sprintf("%s: %s", x.Tag, x.Message) 16 + } 17 + return x.Tag 18 + } 19 + 20 + func NewXrpcError(opts ...ErrOpt) XrpcError { 21 + x := XrpcError{} 22 + for _, o := range opts { 23 + o(&x) 24 + } 25 + 26 + return x 27 + } 28 + 29 + type ErrOpt = func(xerr *XrpcError) 30 + 31 + func WithTag(tag string) ErrOpt { 32 + return func(xerr *XrpcError) { 33 + xerr.Tag = tag 34 + } 35 + } 36 + 37 + func WithMessage[S ~string](s S) ErrOpt { 38 + return func(xerr *XrpcError) { 39 + xerr.Message = string(s) 40 + } 41 + } 42 + 43 + func WithError(e error) ErrOpt { 44 + return func(xerr *XrpcError) { 45 + xerr.Message = e.Error() 46 + } 47 + } 48 + 49 + var MissingActorDidError = NewXrpcError( 50 + WithTag("MissingActorDid"), 51 + WithMessage("actor DID not supplied"), 52 + ) 53 + 54 + var AuthError = func(err error) XrpcError { 55 + return NewXrpcError( 56 + WithTag("Auth"), 57 + WithError(fmt.Errorf("signature verification failed: %w", err)), 58 + ) 59 + } 60 + 61 + var InvalidRepoError = func(r string) XrpcError { 62 + return NewXrpcError( 63 + WithTag("InvalidRepo"), 64 + WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)), 65 + ) 66 + } 67 + 68 + var GitError = func(e error) XrpcError { 69 + return NewXrpcError( 70 + WithTag("Git"), 71 + WithError(fmt.Errorf("git error: %w", e)), 72 + ) 73 + } 74 + 75 + var AccessControlError = func(d string) XrpcError { 76 + return NewXrpcError( 77 + WithTag("AccessControl"), 78 + WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)), 79 + ) 80 + } 81 + 82 + var RepoExistsError = func(r string) XrpcError { 83 + return NewXrpcError( 84 + WithTag("RepoExists"), 85 + WithError(fmt.Errorf("repo already exists: %s", r)), 86 + ) 87 + } 88 + 89 + var RecordExistsError = func(r string) XrpcError { 90 + return NewXrpcError( 91 + WithTag("RecordExists"), 92 + WithError(fmt.Errorf("repo already exists: %s", r)), 93 + ) 94 + } 95 + 96 + func GenericError(err error) XrpcError { 97 + return NewXrpcError( 98 + WithTag("Generic"), 99 + WithError(err), 100 + ) 101 + } 102 + 103 + func Unmarshal(errStr string) (XrpcError, error) { 104 + var xerr XrpcError 105 + err := json.Unmarshal([]byte(errStr), &xerr) 106 + if err != nil { 107 + return XrpcError{}, fmt.Errorf("failed to unmarshal XrpcError: %w", err) 108 + } 109 + return xerr, nil 110 + }
+65
xrpc/serviceauth/service_auth.go
···
··· 1 + package serviceauth 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "log/slog" 7 + "net/http" 8 + "strings" 9 + 10 + "github.com/bluesky-social/indigo/atproto/auth" 11 + "tangled.sh/tangled.sh/core/idresolver" 12 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 + ) 14 + 15 + const ActorDid string = "ActorDid" 16 + 17 + type ServiceAuth struct { 18 + logger *slog.Logger 19 + resolver *idresolver.Resolver 20 + audienceDid string 21 + } 22 + 23 + func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth { 24 + return &ServiceAuth{ 25 + logger: logger, 26 + resolver: resolver, 27 + audienceDid: audienceDid, 28 + } 29 + } 30 + 31 + func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler { 32 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 + l := sa.logger.With("url", r.URL) 34 + 35 + token := r.Header.Get("Authorization") 36 + token = strings.TrimPrefix(token, "Bearer ") 37 + 38 + s := auth.ServiceAuthValidator{ 39 + Audience: sa.audienceDid, 40 + Dir: sa.resolver.Directory(), 41 + } 42 + 43 + did, err := s.Validate(r.Context(), token, nil) 44 + if err != nil { 45 + l.Error("signature verification failed", "err", err) 46 + writeError(w, xrpcerr.AuthError(err), http.StatusForbidden) 47 + return 48 + } 49 + 50 + r = r.WithContext( 51 + context.WithValue(r.Context(), ActorDid, did), 52 + ) 53 + 54 + next.ServeHTTP(w, r) 55 + }) 56 + } 57 + 58 + // this is slightly different from http_util::write_error to follow the spec: 59 + // 60 + // the json object returned must include an "error" and a "message" 61 + func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 62 + w.Header().Set("Content-Type", "application/json") 63 + w.WriteHeader(status) 64 + json.NewEncoder(w).Encode(e) 65 + }