diff --git a/go.mod b/go.mod index a21aed2bca8fd2d0af6aee82874a61d37b86f15a..33acc20812d6494b3becd8bf33005b65d2a51626 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103 github.com/gobwas/glob v0.2.3 github.com/gogs/git-module v1.8.1 + github.com/jmoiron/sqlx v1.3.5 github.com/lrstanley/bubblezone v0.0.0-20220716194435-3cb8c52f6a8f github.com/muesli/mango-cobra v1.2.0 github.com/muesli/roff v0.1.0 @@ -35,6 +36,7 @@ require ( golang.org/x/crypto v0.7.0 golang.org/x/sync v0.1.0 gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.21.1 ) require ( @@ -49,8 +51,10 @@ require ( github.com/dlclark/regexp2 v1.4.0 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/golang/protobuf v1.5.2 // indirect + github.com/google/uuid v1.3.0 // indirect github.com/gorilla/css v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.0.1 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.18 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -67,14 +71,26 @@ require ( github.com/prometheus/client_model v0.3.0 // indirect github.com/prometheus/common v0.37.0 // indirect github.com/prometheus/procfs v0.8.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/sahilm/fuzzy v0.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/yuin/goldmark v1.5.2 // indirect github.com/yuin/goldmark-emoji v1.0.1 // indirect + golang.org/x/mod v0.8.0 // indirect golang.org/x/net v0.8.0 // indirect golang.org/x/sys v0.6.0 // indirect golang.org/x/term v0.6.0 // indirect golang.org/x/text v0.8.0 // indirect + golang.org/x/tools v0.6.0 // indirect google.golang.org/protobuf v1.28.1 // indirect + lukechampine.com/uint128 v1.2.0 // indirect + modernc.org/cc/v3 v3.40.0 // indirect + modernc.org/ccgo/v3 v3.16.13 // indirect + modernc.org/libc v1.22.3 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/opt v0.1.3 // indirect + modernc.org/strutil v1.1.3 // indirect + modernc.org/token v1.0.1 // indirect ) diff --git a/go.sum b/go.sum index 8587ada7d55d021c2bf620476510d3c4d68d2f0e..f1d281db3f42f8f34bd08653b8d7bd3ac0d618e7 100644 --- a/go.sum +++ b/go.sum @@ -93,8 +93,11 @@ github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103/go.mod h1:0Vm2/8 github.com/charmbracelet/wish v1.1.0 h1:0ArX9SOG70saqd23NYjoS56oLPVNgqcQegkz1Lw+4zY= github.com/charmbracelet/wish v1.1.0/go.mod h1:yHbm0hs/qX4lFE7nrhAcXjFYc8bxMIfSqJOfOYfwyYo= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= @@ -107,6 +110,7 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= @@ -134,6 +138,8 @@ github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= @@ -177,6 +183,7 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -192,7 +199,11 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= @@ -201,12 +212,15 @@ github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru/v2 v2.0.2/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -216,8 +230,11 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= @@ -230,6 +247,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lrstanley/bubblezone v0.0.0-20220716194435-3cb8c52f6a8f h1:FjWlbnOxKSZpFlNhsx6xFy/OnkdYTAYTuoulojPdZ9o= github.com/lrstanley/bubblezone v0.0.0-20220716194435-3cb8c52f6a8f/go.mod h1:CxaUrg7Y6DmnquTpb1Rgxib+u+NcRxrDi8m/mR1poTM= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -250,6 +269,9 @@ github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRC github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= +github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75 h1:Pijfgr7ZuvX7QIQiEwLdRVr3RoMG+i0SbBO1Qu+7yVk= @@ -324,6 +346,9 @@ github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1 github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo= github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -423,6 +448,7 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= +golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -452,6 +478,7 @@ golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= @@ -480,6 +507,7 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -517,6 +545,7 @@ golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -530,7 +559,9 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -603,8 +634,10 @@ golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= +golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -713,6 +746,49 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= +lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +modernc.org/cc/v3 v3.37.0/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20= +modernc.org/cc/v3 v3.38.1/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20= +modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= +modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= +modernc.org/ccgo/v3 v3.0.0-20220904174949-82d86e1b6d56/go.mod h1:YSXjPL62P2AMSxBphRHPn7IkzhVHqkvOnRKAKh+W6ZI= +modernc.org/ccgo/v3 v3.0.0-20220910160915-348f15de615a/go.mod h1:8p47QxPkdugex9J4n9P2tLZ9bK01yngIVp00g4nomW0= +modernc.org/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8/go.mod h1:fUB3Vn0nVPReA+7IG7yZDfjv1TMWjhQP8gCxrFAtL5g= +modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw= +modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= +modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= +modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= +modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= +modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= +modernc.org/libc v1.17.4/go.mod h1:WNg2ZH56rDEwdropAJeZPQkXmDwh+JCA1s/htl6r2fA= +modernc.org/libc v1.18.0/go.mod h1:vj6zehR5bfc98ipowQOM2nIDUZnVew/wNC/2tOGS+q0= +modernc.org/libc v1.19.0/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0= +modernc.org/libc v1.20.3/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0= +modernc.org/libc v1.21.2/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI= +modernc.org/libc v1.21.4/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI= +modernc.org/libc v1.22.3 h1:D/g6O5ftAfavceqlLOFwaZuA5KYafKwmr30A6iSqoyY= +modernc.org/libc v1.22.3/go.mod h1:MQrloYP209xa2zHome2a8HLiLm6k0UT8CoHpV74tOFw= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.3.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sqlite v1.21.1 h1:GyDFqNnESLOhwwDRaHGdp2jKLDzpyT/rNLglX3ZkMSU= +modernc.org/sqlite v1.21.1/go.mod h1:XwQ0wZPIh1iKb5mkvCJ3szzbhk+tykC8ZWqTRTgYRwI= +modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= +modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= +modernc.org/tcl v1.15.1 h1:mOQwiEK4p7HruMZcwKTZPw/aqtGM4aY00uzWhlKKYws= +modernc.org/tcl v1.15.1/go.mod h1:aEjeGJX2gz1oWKOLDVZ2tnEWLUrIn8H+GFu+akoDhqs= +modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg= +modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +modernc.org/z v1.7.0 h1:xkDw/KepgEjeizO2sNco+hqYkU12taxQFqPEmgm1GWE= +modernc.org/z v1.7.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= diff --git a/server/backend/backend.go b/server/backend/backend.go index 5d771e35720b41012ad29c2e7c684ff62335fac2..e95e6b74a88108fdb09d8b2055cf8e06f7f01c17 100644 --- a/server/backend/backend.go +++ b/server/backend/backend.go @@ -9,10 +9,12 @@ import ( // Backend is an interface that handles repositories management and any // non-Git related operations. type Backend interface { - ServerBackend + SettingsBackend RepositoryStore RepositoryMetadata RepositoryAccess + UserStore + UserAccess } // ParseAuthorizedKey parses an authorized key string into a public key. diff --git a/server/backend/file/file.go b/server/backend/file/file.go deleted file mode 100644 index 230a83592762c9950e083750ac8bb2e0dffa3596..0000000000000000000000000000000000000000 --- a/server/backend/file/file.go +++ /dev/null @@ -1,940 +0,0 @@ -// Package file implements a backend that uses the filesystem to store non-Git related data -// -// The following files and directories are used: -// -// - anon-access: contains the access level for anonymous users -// - allow-keyless: contains a boolean value indicating whether or not keyless access is allowed -// - admins: contains a list of authorized keys for admin users -// - host: contains the server's server hostname -// - name: contains the server's name -// - port: contains the server's port -// - repos: is a the directory containing all Git repositories -// -// Each repository has the following files and directories: -// - collaborators: contains a list of authorized keys for collaborators -// - description: contains the repository's description -// - private: when present, indicates that the repository is private -// - git-daemon-export-ok: when present, indicates that the repository is public -// - project-name: contains the repository's project name -package file - -import ( - "bufio" - "bytes" - "errors" - "fmt" - "html/template" - "io" - "io/fs" - "os" - "path/filepath" - "strconv" - "strings" - - "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/utils" - "github.com/charmbracelet/ssh" - gossh "golang.org/x/crypto/ssh" -) - -// sub file and directory names. -const ( - anonAccess = "anon-access" - allowKeyless = "allow-keyless" - admins = "admins" - repos = "repos" - collabs = "collaborators" - description = "description" - exportOk = "git-daemon-export-ok" - private = "private" - projectName = "project-name" - settings = "settings" - mirror = "mirror" -) - -var ( - logger = log.WithPrefix("backend.file") - - defaults = map[string]string{ - anonAccess: backend.ReadOnlyAccess.String(), - allowKeyless: "true", - } -) - -var _ backend.Backend = &FileBackend{} - -// FileBackend is a backend that uses the filesystem. -type FileBackend struct { // nolint:revive - // path is the path to the directory containing the repositories and config - // files. - path string - - // repos is a map of repositories. - repos map[string]*Repo - - // AdditionalAdmins additional admins to the server. - AdditionalAdmins []string -} - -func (fb *FileBackend) reposPath() string { - return filepath.Join(fb.path, repos) -} - -func (fb *FileBackend) settingsPath() string { - return filepath.Join(fb.path, settings) -} - -func (fb *FileBackend) adminsPath() string { - return filepath.Join(fb.settingsPath(), admins) -} - -func (fb *FileBackend) collabsPath(repo string) string { - repo = utils.SanitizeRepo(repo) + ".git" - return filepath.Join(fb.reposPath(), repo, collabs) -} - -func readOneLine(path string) (string, error) { - f, err := os.Open(path) - if err != nil { - return "", err - } - defer f.Close() // nolint:errcheck - s := bufio.NewScanner(f) - s.Scan() - return s.Text(), s.Err() -} - -func readAll(path string) (string, error) { - f, err := os.Open(path) - if err != nil { - return "", err - } - - bts, err := io.ReadAll(f) - return string(bts), err -} - -// exists returns true if the given path exists. -func exists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - -// NewFileBackend creates a new FileBackend. -func NewFileBackend(path string) (*FileBackend, error) { - fb := &FileBackend{path: path} - for _, dir := range []string{repos, settings, collabs} { - if err := os.MkdirAll(filepath.Join(path, dir), 0755); err != nil { - return nil, err - } - } - - for _, file := range []string{admins, anonAccess, allowKeyless} { - fp := filepath.Join(fb.settingsPath(), file) - _, err := os.Stat(fp) - if errors.Is(err, fs.ErrNotExist) { - f, err := os.Create(fp) - if err != nil { - return nil, err - } - if c, ok := defaults[file]; ok { - io.WriteString(f, c) // nolint:errcheck - } - _ = f.Close() - } - } - - if err := fb.initRepos(); err != nil { - return nil, err - } - - return fb, nil -} - -// AccessLevel returns the access level for the given public key and repo. -// -// It implements backend.AccessMethod. -func (fb *FileBackend) AccessLevel(repo string, pk gossh.PublicKey) backend.AccessLevel { - private := fb.IsPrivate(repo) - anon := fb.AnonAccess() - if pk != nil { - // Check if the key is an admin. - if fb.IsAdmin(pk) { - return backend.AdminAccess - } - - // Check if the key is a collaborator. - if fb.IsCollaborator(pk, repo) { - if anon > backend.ReadWriteAccess { - return anon - } - return backend.ReadWriteAccess - } - - // Check if repo is private. - if !private { - if anon > backend.ReadOnlyAccess { - return anon - } - return backend.ReadOnlyAccess - } - } - - if private { - return backend.NoAccess - } - - return anon -} - -// AddAdmin adds a public key to the list of server admins. -// -// It implements backend.Backend. -func (fb *FileBackend) AddAdmin(pk gossh.PublicKey, memo string) error { - // Skip if the key already exists. - if fb.IsAdmin(pk) { - return fmt.Errorf("key already exists") - } - - ak := backend.MarshalAuthorizedKey(pk) - f, err := os.OpenFile(fb.adminsPath(), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) - if err != nil { - logger.Debug("failed to open admin keys file", "err", err, "path", fb.adminsPath()) - return err - } - - defer f.Close() //nolint:errcheck - if memo != "" { - memo = " " + memo - } - _, err = fmt.Fprintf(f, "%s%s\n", ak, memo) - return err -} - -// AddCollaborator adds a public key to the list of collaborators for the given repo. -// -// It implements backend.Backend. -func (fb *FileBackend) AddCollaborator(pk gossh.PublicKey, memo string, repo string) error { - name := utils.SanitizeRepo(repo) - repo = name + ".git" - // Check if repo exists - if !exists(filepath.Join(fb.reposPath(), repo)) { - return fmt.Errorf("repository %s does not exist", repo) - } - - // Skip if the key already exists. - if fb.IsCollaborator(pk, repo) { - return fmt.Errorf("key already exists") - } - - ak := backend.MarshalAuthorizedKey(pk) - if err := os.MkdirAll(filepath.Dir(fb.collabsPath(repo)), 0755); err != nil { - logger.Debug("failed to create collaborators directory", - "err", err, "path", filepath.Dir(fb.collabsPath(repo))) - return err - } - - f, err := os.OpenFile(fb.collabsPath(repo), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) - if err != nil { - logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo)) - return err - } - - defer f.Close() //nolint:errcheck - if memo != "" { - memo = " " + memo - } - _, err = fmt.Fprintf(f, "%s%s\n", ak, memo) - return err -} - -// Admins returns a list of public keys that are admins. -// -// It implements backend.Backend. -func (fb *FileBackend) Admins() ([]string, error) { - admins := make([]string, 0) - f, err := os.Open(fb.adminsPath()) - if err != nil { - logger.Debug("failed to open admin keys file", "err", err, "path", fb.adminsPath()) - return nil, err - } - - defer f.Close() //nolint:errcheck - s := bufio.NewScanner(f) - for s.Scan() { - admins = append(admins, s.Text()) - } - - return admins, s.Err() -} - -// Collaborators returns a list of public keys that are collaborators for the given repo. -// -// It implements backend.Backend. -func (fb *FileBackend) Collaborators(repo string) ([]string, error) { - name := utils.SanitizeRepo(repo) - repo = name + ".git" - // Check if repo exists - if !exists(filepath.Join(fb.reposPath(), repo)) { - return nil, fmt.Errorf("repository %s does not exist", repo) - } - - collabs := make([]string, 0) - f, err := os.Open(fb.collabsPath(repo)) - if err != nil && errors.Is(err, os.ErrNotExist) { - return collabs, nil - } - if err != nil { - logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo)) - return nil, err - } - - defer f.Close() //nolint:errcheck - s := bufio.NewScanner(f) - for s.Scan() { - collabs = append(collabs, s.Text()) - } - - return collabs, s.Err() -} - -// RemoveAdmin removes a public key from the list of server admins. -// -// It implements backend.Backend. -func (fb *FileBackend) RemoveAdmin(pk gossh.PublicKey) error { - f, err := os.OpenFile(fb.adminsPath(), os.O_RDWR, 0644) - if err != nil { - logger.Debug("failed to open admin keys file", "err", err, "path", fb.adminsPath()) - return err - } - - defer f.Close() //nolint:errcheck - s := bufio.NewScanner(f) - lines := make([]string, 0) - for s.Scan() { - apk, _, err := backend.ParseAuthorizedKey(s.Text()) - if err != nil { - logger.Debug("failed to parse admin key", "err", err, "path", fb.adminsPath()) - continue - } - - if !ssh.KeysEqual(apk, pk) { - lines = append(lines, s.Text()) - } - } - - if err := s.Err(); err != nil { - logger.Debug("failed to scan admin keys file", "err", err, "path", fb.adminsPath()) - return err - } - - if err := f.Truncate(0); err != nil { - logger.Debug("failed to truncate admin keys file", "err", err, "path", fb.adminsPath()) - return err - } - - if _, err := f.Seek(0, 0); err != nil { - logger.Debug("failed to seek admin keys file", "err", err, "path", fb.adminsPath()) - return err - } - - w := bufio.NewWriter(f) - for _, line := range lines { - if _, err := fmt.Fprintln(w, line); err != nil { - logger.Debug("failed to write admin keys file", "err", err, "path", fb.adminsPath()) - return err - } - } - - return w.Flush() -} - -// RemoveCollaborator removes a public key from the list of collaborators for the given repo. -// -// It implements backend.Backend. -func (fb *FileBackend) RemoveCollaborator(pk gossh.PublicKey, repo string) error { - name := utils.SanitizeRepo(repo) - repo = name + ".git" - // Check if repo exists - if !exists(filepath.Join(fb.reposPath(), repo)) { - return fmt.Errorf("repository %s does not exist", repo) - } - - f, err := os.OpenFile(fb.collabsPath(repo), os.O_RDWR, 0644) - if err != nil && errors.Is(err, os.ErrNotExist) { - return nil - } - - if err != nil { - logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo)) - return err - } - - defer f.Close() //nolint:errcheck - s := bufio.NewScanner(f) - lines := make([]string, 0) - for s.Scan() { - apk, _, err := backend.ParseAuthorizedKey(s.Text()) - if err != nil { - logger.Debug("failed to parse collaborator key", "err", err, "path", fb.collabsPath(repo)) - continue - } - - if !ssh.KeysEqual(apk, pk) { - lines = append(lines, s.Text()) - } - } - - if err := s.Err(); err != nil { - logger.Debug("failed to scan collaborators file", "err", err, "path", fb.collabsPath(repo)) - return err - } - - if err := f.Truncate(0); err != nil { - logger.Debug("failed to truncate collaborators file", "err", err, "path", fb.collabsPath(repo)) - return err - } - - if _, err := f.Seek(0, 0); err != nil { - logger.Debug("failed to seek collaborators file", "err", err, "path", fb.collabsPath(repo)) - return err - } - - w := bufio.NewWriter(f) - for _, line := range lines { - if _, err := fmt.Fprintln(w, line); err != nil { - logger.Debug("failed to write collaborators file", "err", err, "path", fb.collabsPath(repo)) - return err - } - } - - return w.Flush() -} - -// AllowKeyless returns true if keyless access is allowed. -// -// It implements backend.Backend. -func (fb *FileBackend) AllowKeyless() bool { - line, err := readOneLine(filepath.Join(fb.settingsPath(), allowKeyless)) - if err != nil { - logger.Debug("failed to read allow-keyless file", "err", err) - return false - } - - return line == "true" -} - -// AnonAccess returns the level of anonymous access allowed. -// -// It implements backend.Backend. -func (fb *FileBackend) AnonAccess() backend.AccessLevel { - line, err := readOneLine(filepath.Join(fb.settingsPath(), anonAccess)) - if err != nil { - logger.Debug("failed to read anon-access file", "err", err) - return backend.NoAccess - } - - al := backend.ParseAccessLevel(line) - if al < 0 { - return backend.NoAccess - } - - return al -} - -// Description returns the description of the given repo. -// -// It implements backend.Backend. -func (fb *FileBackend) Description(repo string) string { - repo = utils.SanitizeRepo(repo) + ".git" - r := &Repo{path: filepath.Join(fb.reposPath(), repo), root: fb.reposPath()} - return r.Description() -} - -// IsAdmin checks if the given public key is a server admin. -// -// It implements backend.Backend. -func (fb *FileBackend) IsAdmin(pk gossh.PublicKey) bool { - // Check if the key is an additional admin. - ak := backend.MarshalAuthorizedKey(pk) - for _, admin := range fb.AdditionalAdmins { - if ak == admin { - return true - } - } - - f, err := os.Open(fb.adminsPath()) - if err != nil { - logger.Debug("failed to open admins file", "err", err, "path", fb.adminsPath()) - return false - } - - defer f.Close() //nolint:errcheck - s := bufio.NewScanner(f) - for s.Scan() { - apk, _, err := backend.ParseAuthorizedKey(s.Text()) - if err != nil { - continue - } - if ssh.KeysEqual(apk, pk) { - return true - } - } - - return false -} - -// IsCollaborator returns true if the given public key is a collaborator on the -// given repo. -// -// It implements backend.Backend. -func (fb *FileBackend) IsCollaborator(pk gossh.PublicKey, repo string) bool { - repo = utils.SanitizeRepo(repo) + ".git" - _, err := os.Stat(fb.collabsPath(repo)) - if err != nil { - return false - } - - f, err := os.Open(fb.collabsPath(repo)) - if err != nil { - logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo)) - return false - } - - defer f.Close() //nolint:errcheck - s := bufio.NewScanner(f) - for s.Scan() { - apk, _, err := backend.ParseAuthorizedKey(s.Text()) - if err != nil { - continue - } - if ssh.KeysEqual(apk, pk) { - return true - } - } - - return false -} - -// IsPrivate returns true if the given repo is private. -// -// It implements backend.Backend. -func (fb *FileBackend) IsPrivate(repo string) bool { - repo = utils.SanitizeRepo(repo) + ".git" - r := &Repo{path: filepath.Join(fb.reposPath(), repo), root: fb.reposPath()} - return r.IsPrivate() -} - -// SetAllowKeyless sets whether or not to allow keyless access. -// -// It implements backend.Backend. -func (fb *FileBackend) SetAllowKeyless(allow bool) error { - return os.WriteFile(filepath.Join(fb.settingsPath(), allowKeyless), []byte(strconv.FormatBool(allow)), 0600) -} - -// SetAnonAccess sets the anonymous access level. -// -// It implements backend.Backend. -func (fb *FileBackend) SetAnonAccess(level backend.AccessLevel) error { - return os.WriteFile(filepath.Join(fb.settingsPath(), anonAccess), []byte(level.String()), 0600) -} - -// SetDescription sets the description of the given repo. -// -// It implements backend.Backend. -func (fb *FileBackend) SetDescription(repo string, desc string) error { - repo = utils.SanitizeRepo(repo) + ".git" - return os.WriteFile(filepath.Join(fb.reposPath(), repo, description), []byte(desc), 0600) -} - -// SetPrivate sets the private status of the given repo. -// -// It implements backend.Backend. -func (fb *FileBackend) SetPrivate(repo string, priv bool) error { - repo = utils.SanitizeRepo(repo) + ".git" - daemonExport := filepath.Join(fb.reposPath(), repo, exportOk) - if priv { - _ = os.Remove(daemonExport) - f, err := os.Create(filepath.Join(fb.reposPath(), repo, private)) - if err != nil { - return fmt.Errorf("failed to create private file: %w", err) - } - - _ = f.Close() //nolint:errcheck - } else { - // Create git-daemon-export-ok file if repo is public. - f, err := os.Create(daemonExport) - if err != nil { - logger.Warn("failed to create git-daemon-export-ok file", "err", err) - } else { - _ = f.Close() //nolint:errcheck - } - } - return nil -} - -// ProjectName returns the project name. -// -// It implements backend.Backend. -func (fb *FileBackend) ProjectName(repo string) string { - repo = utils.SanitizeRepo(repo) + ".git" - r := &Repo{path: filepath.Join(fb.reposPath(), repo), root: fb.reposPath()} - return r.ProjectName() -} - -// SetProjectName sets the project name of the given repo. -// -// It implements backend.Backend. -func (fb *FileBackend) SetProjectName(repo string, name string) error { - repo = utils.SanitizeRepo(repo) + ".git" - return os.WriteFile(filepath.Join(fb.reposPath(), repo, projectName), []byte(name), 0600) -} - -// IsMirror returns true if the given repo is a mirror. -func (fb *FileBackend) IsMirror(repo string) bool { - repo = utils.SanitizeRepo(repo) + ".git" - r := &Repo{path: filepath.Join(fb.reposPath(), repo), root: fb.reposPath()} - return r.IsMirror() -} - -// CreateRepository creates a new repository. -// -// Created repositories are always bare. -// -// It implements backend.Backend. -func (fb *FileBackend) CreateRepository(repo string, opts backend.RepositoryOptions) (backend.Repository, error) { - name := utils.SanitizeRepo(repo) - repo = name + ".git" - rp := filepath.Join(fb.reposPath(), repo) - if _, err := os.Stat(rp); err == nil { - return nil, os.ErrExist - } - - if opts.Mirror != "" { - if err := git.Clone(opts.Mirror, rp, git.CloneOptions{ - Mirror: true, - }); err != nil { - logger.Debug("failed to clone mirror repository", "err", err) - return nil, err - } - - if err := os.WriteFile(filepath.Join(rp, mirror), nil, 0600); err != nil { - logger.Debug("failed to create mirror file", "err", err) - return nil, err - } - } - - rr, err := git.Init(rp, true) - if err != nil { - logger.Debug("failed to create repository", "err", err) - return nil, err - } - - if err := rr.UpdateServerInfo(); err != nil { - logger.Debug("failed to update server info", "err", err) - return nil, err - } - - if err := fb.SetPrivate(repo, opts.Private); err != nil { - logger.Debug("failed to set private status", "err", err) - return nil, err - } - - if err := fb.SetDescription(repo, opts.Description); err != nil { - logger.Debug("failed to set description", "err", err) - return nil, err - } - - if err := fb.SetProjectName(repo, opts.ProjectName); err != nil { - logger.Debug("failed to set project name", "err", err) - return nil, err - } - - r := &Repo{path: rp, root: fb.reposPath()} - // Add to cache. - fb.repos[name] = r - return r, fb.InitializeHooks(name) -} - -// DeleteRepository deletes the given repository. -// -// It implements backend.Backend. -func (fb *FileBackend) DeleteRepository(repo string) error { - name := utils.SanitizeRepo(repo) - delete(fb.repos, name) - repo = name + ".git" - return os.RemoveAll(filepath.Join(fb.reposPath(), repo)) -} - -// RenameRepository renames the given repository. -// -// It implements backend.Backend. -func (fb *FileBackend) RenameRepository(oldName string, newName string) error { - oldName = utils.SanitizeRepo(oldName) - oldRepo := filepath.Join(fb.reposPath(), oldName+".git") - newName = utils.SanitizeRepo(newName) - newRepo := filepath.Join(fb.reposPath(), newName+".git") - if _, err := os.Stat(oldRepo); errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("repository %q does not exist", strings.TrimSuffix(filepath.Base(oldRepo), ".git")) - } - if _, err := os.Stat(newRepo); err == nil { - return fmt.Errorf("repository %q already exists", strings.TrimSuffix(filepath.Base(newRepo), ".git")) - } - - if err := os.Rename(oldRepo, newRepo); err != nil { - return err - } - - // Update cache. - if r, ok := fb.repos[oldName]; ok { - r.path = newRepo - delete(fb.repos, oldName) - fb.repos[newName] = r - } - - return nil -} - -// Repository finds the given repository. -// -// It implements backend.Backend. -func (fb *FileBackend) Repository(repo string) (backend.Repository, error) { - name := utils.SanitizeRepo(repo) - if r, ok := fb.repos[name]; ok { - return r, nil - } - - repo = name + ".git" - rp := filepath.Join(fb.reposPath(), repo) - _, err := os.Stat(rp) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil, os.ErrNotExist - } - return nil, err - } - - return &Repo{path: rp, root: fb.reposPath()}, nil -} - -// Returns true if path is a directory containing an `objects` directory and a -// `HEAD` file. -func isGitDir(path string) bool { - stat, err := os.Stat(filepath.Join(path, "objects")) - if err != nil { - return false - } - if !stat.IsDir() { - return false - } - - stat, err = os.Stat(filepath.Join(path, "HEAD")) - if err != nil { - return false - } - if stat.IsDir() { - return false - } - - return true -} - -// initRepos initializes the repository cache. -func (fb *FileBackend) initRepos() error { - fb.repos = make(map[string]*Repo) - repos := make([]backend.Repository, 0) - err := filepath.WalkDir(fb.reposPath(), func(path string, d fs.DirEntry, _ error) error { - // Skip non-directories. - if !d.IsDir() { - return nil - } - - // Skip non-repositories. - if !strings.HasSuffix(path, ".git") { - return nil - } - - if isGitDir(path) { - r := &Repo{path: path, root: fb.reposPath()} - fb.repos[r.Name()] = r - repos = append(repos, r) - if err := fb.InitializeHooks(r.Name()); err != nil { - logger.Warn("failed to initialize hooks", "err", err, "repo", r.Name()) - } - } - - return nil - }) - if err != nil { - return err - } - - return nil -} - -// Repositories returns a list of all repositories. -// -// It implements backend.Backend. -func (fb *FileBackend) Repositories() ([]backend.Repository, error) { - repos := make([]backend.Repository, 0) - for _, r := range fb.repos { - repos = append(repos, r) - } - - return repos, nil -} - -var ( - hookNames = []string{"pre-receive", "update", "post-update", "post-receive"} - hookTpls = []string{ - // for pre-receive - `#!/usr/bin/env bash -# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY -data=$(cat) -exitcodes="" -hookname=$(basename $0) -GIT_DIR=${GIT_DIR:-$(dirname $0)/..} -for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do - test -x "${hook}" && test -f "${hook}" || continue - echo "${data}" | "${hook}" - exitcodes="${exitcodes} $?" -done -for i in ${exitcodes}; do - [ ${i} -eq 0 ] || exit ${i} -done -`, - - // for update - `#!/usr/bin/env bash -# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY -exitcodes="" -hookname=$(basename $0) -GIT_DIR=${GIT_DIR:-$(dirname $0/..)} -for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do - test -x "${hook}" && test -f "${hook}" || continue - "${hook}" $1 $2 $3 - exitcodes="${exitcodes} $?" -done -for i in ${exitcodes}; do - [ ${i} -eq 0 ] || exit ${i} -done -`, - - // for post-update - `#!/usr/bin/env bash -# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY -data=$(cat) -exitcodes="" -hookname=$(basename $0) -GIT_DIR=${GIT_DIR:-$(dirname $0)/..} -for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do - test -x "${hook}" && test -f "${hook}" || continue - "${hook}" $@ - exitcodes="${exitcodes} $?" -done -for i in ${exitcodes}; do - [ ${i} -eq 0 ] || exit ${i} -done -`, - - // for post-receive - `#!/usr/bin/env bash -# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY -data=$(cat) -exitcodes="" -hookname=$(basename $0) -GIT_DIR=${GIT_DIR:-$(dirname $0)/..} -for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do - test -x "${hook}" && test -f "${hook}" || continue - echo "${data}" | "${hook}" - exitcodes="${exitcodes} $?" -done -for i in ${exitcodes}; do - [ ${i} -eq 0 ] || exit ${i} -done -`, - } -) - -// InitializeHooks updates the hooks for the given repository. -// -// It implements backend.Backend. -func (fb *FileBackend) InitializeHooks(repo string) error { - hookTmpl, err := template.New("hook").Parse(`#!/usr/bin/env bash -# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY -{{ range $_, $env := .Envs }} -{{ $env }} \{{ end }} -{{ .Executable }} hook --config "{{ .Config }}" {{ .Hook }} {{ .Args }} -`) - if err != nil { - return err - } - - repo = utils.SanitizeRepo(repo) + ".git" - hooksPath := filepath.Join(fb.reposPath(), repo, "hooks") - if err := os.MkdirAll(hooksPath, 0755); err != nil { - return err - } - - ex, err := os.Executable() - if err != nil { - return err - } - - dp, err := filepath.Abs(fb.path) - if err != nil { - return fmt.Errorf("failed to get absolute path for data path: %w", err) - } - - cp := filepath.Join(dp, "config.yaml") - envs := []string{} - for i, hook := range hookNames { - var data bytes.Buffer - var args string - hp := filepath.Join(hooksPath, hook) - if err := os.WriteFile(hp, []byte(hookTpls[i]), 0755); err != nil { - return err - } - - // Create hook.d directory. - hp += ".d" - if err := os.MkdirAll(hp, 0755); err != nil { - return err - } - - if hook == "update" { - args = "$1 $2 $3" - } else if hook == "post-update" { - args = "$@" - } - - err = hookTmpl.Execute(&data, struct { - Executable string - Hook string - Args string - Envs []string - Config string - }{ - Executable: ex, - Hook: hook, - Args: args, - Envs: envs, - Config: cp, - }) - if err != nil { - logger.Error("failed to execute hook template", "err", err) - continue - } - - hp = filepath.Join(hp, "soft-serve") - err = os.WriteFile(hp, data.Bytes(), 0755) //nolint:gosec - if err != nil { - logger.Error("failed to write hook", "err", err) - continue - } - } - - return nil -} diff --git a/server/backend/file/repo.go b/server/backend/file/repo.go deleted file mode 100644 index 93d7dab9bba91f5318006929a9225fac2d97b8e4..0000000000000000000000000000000000000000 --- a/server/backend/file/repo.go +++ /dev/null @@ -1,73 +0,0 @@ -package file - -import ( - "os" - "path/filepath" - "strings" - - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/backend" -) - -var _ backend.Repository = (*Repo)(nil) - -// Repo is a filesystem Git repository. -// -// It implemenets backend.Repository. -type Repo struct { - root string - path string -} - -// Name returns the repository's name. -// -// It implements backend.Repository. -func (r *Repo) Name() string { - name := strings.TrimSuffix(strings.TrimPrefix(r.path, r.root), ".git") - return strings.TrimPrefix(name, "/") -} - -// ProjectName returns the repository's project name. -func (r *Repo) ProjectName() string { - pn, err := readOneLine(filepath.Join(r.path, projectName)) - if err != nil { - return "" - } - - return strings.TrimSpace(pn) -} - -// Description returns the repository's description. -// -// It implements backend.Repository. -func (r *Repo) Description() string { - desc, err := readAll(filepath.Join(r.path, description)) - if err != nil { - return "" - } - - return strings.TrimSpace(desc) -} - -// IsPrivate returns whether the repository is private. -// -// It implements backend.Repository. -func (r *Repo) IsPrivate() bool { - _, err := os.Stat(filepath.Join(r.path, private)) - return err == nil -} - -// IsMirror returns whether the repository is a mirror. -// -// It implements backend.Repository. -func (r *Repo) IsMirror() bool { - _, err := os.Stat(filepath.Join(r.path, mirror)) - return err == nil -} - -// Open returns the underlying git.Repository. -// -// It implements backend.Repository. -func (r *Repo) Open() (*git.Repository, error) { - return git.Open(r.path) -} diff --git a/server/backend/repo.go b/server/backend/repo.go index b429a7a0132480f701e3fa74b0e90c3f11713271..2f72164e9e27d50f5d1e063db30d06be4d3f62b0 100644 --- a/server/backend/repo.go +++ b/server/backend/repo.go @@ -2,15 +2,14 @@ package backend import ( "github.com/charmbracelet/soft-serve/git" - "golang.org/x/crypto/ssh" ) // RepositoryOptions are options for creating a new repository. type RepositoryOptions struct { Private bool - Mirror string Description string ProjectName string + Mirror bool } // RepositoryStore is an interface for managing repositories. @@ -21,10 +20,14 @@ type RepositoryStore interface { Repositories() ([]Repository, error) // CreateRepository creates a new repository. CreateRepository(name string, opts RepositoryOptions) (Repository, error) + // ImportRepository creates a new repository from a Git repository. + ImportRepository(name string, remote string, opts RepositoryOptions) (Repository, error) // DeleteRepository deletes a repository. DeleteRepository(name string) error // RenameRepository renames a repository. RenameRepository(oldName, newName string) error + // InitializeHooks initializes the hooks for the given repository. + InitializeHooks(repo string) error } // RepositoryMetadata is an interface for managing repository metadata. @@ -47,24 +50,13 @@ type RepositoryMetadata interface { // RepositoryAccess is an interface for managing repository access. type RepositoryAccess interface { - // AccessLevel returns the access level for the given repository and key. - AccessLevel(repo string, pk ssh.PublicKey) AccessLevel - // IsCollaborator returns true if the authorized key is a collaborator on the repository. - IsCollaborator(pk ssh.PublicKey, repo string) bool + IsCollaborator(repo string, username string) bool // AddCollaborator adds the authorized key as a collaborator on the repository. - AddCollaborator(pk ssh.PublicKey, memo string, repo string) error + AddCollaborator(repo string, username string) error // RemoveCollaborator removes the authorized key as a collaborator on the repository. - RemoveCollaborator(pk ssh.PublicKey, repo string) error + RemoveCollaborator(repo string, username string) error // Collaborators returns a list of all collaborators on the repository. Collaborators(repo string) ([]string, error) - // IsAdmin returns true if the authorized key is an admin. - IsAdmin(pk ssh.PublicKey) bool - // AddAdmin adds the authorized key as an admin. - AddAdmin(pk ssh.PublicKey, memo string) error - // RemoveAdmin removes the authorized key as an admin. - RemoveAdmin(pk ssh.PublicKey) error - // Admins returns a list of all admins. - Admins() ([]string, error) } // Repository is a Git repository interface. diff --git a/server/backend/server.go b/server/backend/settings.go similarity index 79% rename from server/backend/server.go rename to server/backend/settings.go index 72198c018d445afabc5b2a5e049e742894c1fb5c..c3e8a79023f250aeb7410bb46d76d8dacf261bc5 100644 --- a/server/backend/server.go +++ b/server/backend/settings.go @@ -1,7 +1,7 @@ package backend -// ServerBackend is an interface that handles server configuration. -type ServerBackend interface { +// SettingsBackend is an interface that handles server configuration. +type SettingsBackend interface { // AnonAccess returns the access level for anonymous users. AnonAccess() AccessLevel // SetAnonAccess sets the access level for anonymous users. diff --git a/server/backend/sqlite/db.go b/server/backend/sqlite/db.go new file mode 100644 index 0000000000000000000000000000000000000000..f3ed49d74cf459a155cb558023d682ca2b541f02 --- /dev/null +++ b/server/backend/sqlite/db.go @@ -0,0 +1,115 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/charmbracelet/soft-serve/server/backend" + "github.com/jmoiron/sqlx" + "golang.org/x/crypto/bcrypt" + "modernc.org/sqlite" + sqlite3 "modernc.org/sqlite/lib" +) + +// Close closes the database. +func (d *SqliteBackend) Close() error { + return d.db.Close() +} + +// init creates the database. +func (d *SqliteBackend) init() error { + return wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + if _, err := tx.Exec(sqlCreateSettingsTable); err != nil { + return err + } + if _, err := tx.Exec(sqlCreateUserTable); err != nil { + return err + } + if _, err := tx.Exec(sqlCreatePublicKeyTable); err != nil { + return err + } + if _, err := tx.Exec(sqlCreateRepoTable); err != nil { + return err + } + if _, err := tx.Exec(sqlCreateCollabTable); err != nil { + return err + } + + // Set default settings. + if _, err := tx.Exec("INSERT OR IGNORE INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", "allow_keyless", true); err != nil { + return err + } + if _, err := tx.Exec("INSERT OR IGNORE INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", "anon_access", backend.ReadOnlyAccess.String()); err != nil { + return err + } + + return nil + }) +} + +func wrapDbErr(err error) error { + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrNoRecord + } + if liteErr, ok := err.(*sqlite.Error); ok { + code := liteErr.Code() + if code == sqlite3.SQLITE_CONSTRAINT_PRIMARYKEY || + code == sqlite3.SQLITE_CONSTRAINT_UNIQUE { + return ErrDuplicateKey + } + } + } + return err +} + +func wrapTx(db *sqlx.DB, ctx context.Context, fn func(tx *sqlx.Tx) error) error { + tx, err := db.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + + if err := fn(tx); err != nil { + return rollback(tx, err) + } + + if err := tx.Commit(); err != nil { + if errors.Is(err, sql.ErrTxDone) { + // this is ok because whoever did finish the tx should have also written the error already. + return nil + } + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} + +func rollback(tx *sqlx.Tx, err error) error { + if rerr := tx.Rollback(); rerr != nil { + if errors.Is(rerr, sql.ErrTxDone) { + return err + } + return fmt.Errorf("failed to rollback: %s: %w", err.Error(), rerr) + } + + return err +} + +func hashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password+"soft-serve-v1"), 14) + if err != nil { + return "", fmt.Errorf("failed to hash password: %w", err) + } + + return string(hash), nil +} + +func checkPassword(hash, password string) error { + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password+"soft-serve-v1")); err != nil { + return fmt.Errorf("failed to check password: %w", err) + } + + return nil +} diff --git a/server/backend/sqlite/error.go b/server/backend/sqlite/error.go new file mode 100644 index 0000000000000000000000000000000000000000..72020de5be13e1a22bb5d4d836b026e0c963f940 --- /dev/null +++ b/server/backend/sqlite/error.go @@ -0,0 +1,11 @@ +package sqlite + +import "errors" + +var ( + // ErrDuplicateKey is returned when a unique constraint is violated. + ErrDuplicateKey = errors.New("record already exists") + + // ErrNoRecord is returned when a record is not found. + ErrNoRecord = errors.New("record not found") +) diff --git a/server/backend/sqlite/repo.go b/server/backend/sqlite/repo.go new file mode 100644 index 0000000000000000000000000000000000000000..bdd4384e714d17d6b528147c342b9cda54fccf29 --- /dev/null +++ b/server/backend/sqlite/repo.go @@ -0,0 +1,88 @@ +package sqlite + +import ( + "context" + + "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/server/backend" + "github.com/jmoiron/sqlx" +) + +var _ backend.Repository = (*Repo)(nil) + +// Repo is a Git repository with metadata stored in a SQLite database. +type Repo struct { + name string + path string + db *sqlx.DB +} + +// Description returns the repository's description. +// +// It implements backend.Repository. +func (r *Repo) Description() string { + var desc string + if err := wrapTx(r.db, context.Background(), func(tx *sqlx.Tx) error { + return tx.Get(&desc, "SELECT description FROM repo WHERE name = ?", r.name) + }); err != nil { + return "" + } + + return desc +} + +// IsMirror returns whether the repository is a mirror. +// +// It implements backend.Repository. +func (r *Repo) IsMirror() bool { + var mirror bool + if err := wrapTx(r.db, context.Background(), func(tx *sqlx.Tx) error { + return tx.Get(&mirror, "SELECT mirror FROM repo WHERE name = ?", r.name) + }); err != nil { + return false + } + + return mirror +} + +// IsPrivate returns whether the repository is private. +// +// It implements backend.Repository. +func (r *Repo) IsPrivate() bool { + var private bool + if err := wrapTx(r.db, context.Background(), func(tx *sqlx.Tx) error { + return tx.Get(&private, "SELECT private FROM repo WHERE name = ?", r.name) + }); err != nil { + return false + } + + return private +} + +// Name returns the repository's name. +// +// It implements backend.Repository. +func (r *Repo) Name() string { + return r.name +} + +// Open opens the repository. +// +// It implements backend.Repository. +func (r *Repo) Open() (*git.Repository, error) { + return git.Open(r.path) +} + +// ProjectName returns the repository's project name. +// +// It implements backend.Repository. +func (r *Repo) ProjectName() string { + var name string + if err := wrapTx(r.db, context.Background(), func(tx *sqlx.Tx) error { + return tx.Get(&name, "SELECT project_name FROM repo WHERE name = ?", r.name) + }); err != nil { + return "" + } + + return name +} diff --git a/server/backend/sqlite/sql.go b/server/backend/sqlite/sql.go new file mode 100644 index 0000000000000000000000000000000000000000..8158cac5ae65dda25092946025cb282bb487fc37 --- /dev/null +++ b/server/backend/sqlite/sql.go @@ -0,0 +1,60 @@ +package sqlite + +var ( + sqlCreateSettingsTable = `CREATE TABLE IF NOT EXISTS settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, + value TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL + );` + + sqlCreateUserTable = `CREATE TABLE IF NOT EXISTS user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE, + admin BOOLEAN NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL + );` + + sqlCreatePublicKeyTable = `CREATE TABLE IF NOT EXISTS public_key ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + public_key TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL, + UNIQUE (user_id, public_key), + CONSTRAINT user_id_fk + FOREIGN KEY(user_id) REFERENCES user(id) + ON DELETE CASCADE + ON UPDATE CASCADE + );` + + sqlCreateRepoTable = `CREATE TABLE IF NOT EXISTS repo ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + project_name TEXT NOT NULL, + description TEXT NOT NULL, + private BOOLEAN NOT NULL, + mirror BOOLEAN NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL + );` + + sqlCreateCollabTable = `CREATE TABLE IF NOT EXISTS collab ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + repo_id INTEGER NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL, + UNIQUE (user_id, repo_id), + CONSTRAINT user_id_fk + FOREIGN KEY(user_id) REFERENCES user(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT repo_id_fk + FOREIGN KEY(repo_id) REFERENCES repo(id) + ON DELETE CASCADE + ON UPDATE CASCADE + );` +) diff --git a/server/backend/sqlite/sqlite.go b/server/backend/sqlite/sqlite.go new file mode 100644 index 0000000000000000000000000000000000000000..158993db2e5dd37fd8859e6b6bd657fe8ff8b0bb --- /dev/null +++ b/server/backend/sqlite/sqlite.go @@ -0,0 +1,598 @@ +package sqlite + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strconv" + "text/template" + + "github.com/charmbracelet/log" + "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/server/backend" + "github.com/charmbracelet/soft-serve/server/utils" + "github.com/jmoiron/sqlx" + _ "modernc.org/sqlite" +) + +var ( + logger = log.WithPrefix("backend.sqlite") +) + +// SqliteBackend is a backend that uses a SQLite database as a Soft Serve +// backend. +type SqliteBackend struct { + dp string + db *sqlx.DB + AdditionalAdmins []string +} + +var _ backend.Backend = (*SqliteBackend)(nil) + +func (d *SqliteBackend) reposPath() string { + return filepath.Join(d.dp, "repos") +} + +// NewSqliteBackend creates a new SqliteBackend. +func NewSqliteBackend(dataPath string) (*SqliteBackend, error) { + db, err := sqlx.Connect("sqlite", filepath.Join(dataPath, "soft-serve.db"+ + "?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)")) + if err != nil { + return nil, err + } + + d := &SqliteBackend{ + dp: dataPath, + db: db, + } + + if err := d.init(); err != nil { + return nil, err + } + + return d, d.db.Ping() +} + +// AllowKeyless returns whether or not keyless access is allowed. +// +// It implements backend.Backend. +func (d *SqliteBackend) AllowKeyless() bool { + var allow bool + if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + return tx.Get(&allow, "SELECT value FROM settings WHERE key = ?;", "allow_keyless") + }); err != nil { + return false + } + + return allow +} + +// AnonAccess returns the level of anonymous access. +// +// It implements backend.Backend. +func (d *SqliteBackend) AnonAccess() backend.AccessLevel { + var level string + if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + return tx.Get(&level, "SELECT value FROM settings WHERE key = ?;", "anon_access") + }); err != nil { + return backend.NoAccess + } + + return backend.ParseAccessLevel(level) +} + +// SetAllowKeyless sets whether or not keyless access is allowed. +// +// It implements backend.Backend. +func (d *SqliteBackend) SetAllowKeyless(allow bool) error { + return wrapDbErr( + wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + _, err := tx.Exec("UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?;", "allow_keyless", strconv.FormatBool(allow)) + return err + }), + ) +} + +// SetAnonAccess sets the level of anonymous access. +// +// It implements backend.Backend. +func (d *SqliteBackend) SetAnonAccess(level backend.AccessLevel) error { + return wrapDbErr( + wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + _, err := tx.Exec("UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?;", "anon_access", level.String()) + return err + }), + ) +} + +// CreateRepository creates a new repository. +// +// It implements backend.Backend. +func (d *SqliteBackend) CreateRepository(name string, opts backend.RepositoryOptions) (backend.Repository, error) { + name = utils.SanitizeRepo(name) + repo := name + ".git" + rp := filepath.Join(d.reposPath(), repo) + + cleanup := func() error { + return os.RemoveAll(rp) + } + + rr, err := git.Init(rp, true) + if err != nil { + logger.Debug("failed to create repository", "err", err) + cleanup() // nolint: errcheck + return nil, err + } + + if err := rr.UpdateServerInfo(); err != nil { + logger.Debug("failed to update server info", "err", err) + cleanup() // nolint: errcheck + return nil, err + } + + if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + _, err := tx.Exec(`INSERT INTO repo (name, project_name, description, private, mirror, updated_at) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`, + name, opts.ProjectName, opts.Description, opts.Private, opts.Mirror) + return err + }); err != nil { + logger.Debug("failed to create repository in database", "err", err) + return nil, wrapDbErr(err) + } + + r := &Repo{ + name: name, + path: rp, + db: d.db, + } + + return r, d.InitializeHooks(name) +} + +// ImportRepository imports a repository from remote. +func (d *SqliteBackend) ImportRepository(name string, remote string, opts backend.RepositoryOptions) (backend.Repository, error) { + name = utils.SanitizeRepo(name) + repo := name + ".git" + rp := filepath.Join(d.reposPath(), repo) + + copts := git.CloneOptions{ + Mirror: opts.Mirror, + } + if err := git.Clone(remote, rp, copts); err != nil { + logger.Debug("failed to clone repository", "err", err, "mirror", opts.Mirror, "remote", remote, "path", rp) + return nil, err + } + + return d.CreateRepository(name, opts) +} + +// DeleteRepository deletes a repository. +// +// It implements backend.Backend. +func (d *SqliteBackend) DeleteRepository(name string) error { + name = utils.SanitizeRepo(name) + repo := name + ".git" + rp := filepath.Join(d.reposPath(), repo) + if _, err := os.Stat(rp); err != nil { + return os.ErrNotExist + } + + if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + _, err := tx.Exec("DELETE FROM repo WHERE name = ?;", name) + return err + }); err != nil { + return wrapDbErr(err) + } + + return os.RemoveAll(rp) +} + +// RenameRepository renames a repository. +// +// It implements backend.Backend. +func (d *SqliteBackend) RenameRepository(oldName string, newName string) error { + oldName = utils.SanitizeRepo(oldName) + newName = utils.SanitizeRepo(newName) + oldRepo := oldName + ".git" + newRepo := newName + ".git" + op := filepath.Join(d.reposPath(), oldRepo) + np := filepath.Join(d.reposPath(), newRepo) + if _, err := os.Stat(op); err != nil { + return fmt.Errorf("repository %s does not exist", oldName) + } + + if _, err := os.Stat(np); err == nil { + return fmt.Errorf("repository %s already exists", newName) + } + + if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + _, err := tx.Exec("UPDATE repo SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;", newName, oldName) + return err + }); err != nil { + return wrapDbErr(err) + } + + return os.Rename(op, np) +} + +// Repositories returns a list of all repositories. +// +// It implements backend.Backend. +func (d *SqliteBackend) Repositories() ([]backend.Repository, error) { + repos := make([]backend.Repository, 0) + if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + rows, err := tx.Query("SELECT name FROM repo") + if err != nil { + return err + } + + defer rows.Close() // nolint: errcheck + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return err + } + + repos = append(repos, &Repo{ + name: name, + path: filepath.Join(d.reposPath(), name+".git"), + db: d.db, + }) + } + + return nil + }); err != nil { + return nil, wrapDbErr(err) + } + + return repos, nil +} + +// Repository returns a repository by name. +// +// It implements backend.Backend. +func (d *SqliteBackend) Repository(repo string) (backend.Repository, error) { + repo = utils.SanitizeRepo(repo) + rp := filepath.Join(d.reposPath(), repo+".git") + if _, err := os.Stat(rp); err != nil { + return nil, os.ErrNotExist + } + + var count int + if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + return tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo) + }); err != nil { + return nil, wrapDbErr(err) + } + + if count == 0 { + logger.Warn("repository exists but not found in database", "repo", repo) + return nil, fmt.Errorf("repository does not exist") + } + + return &Repo{ + name: repo, + path: rp, + db: d.db, + }, nil +} + +// Description returns the description of a repository. +// +// It implements backend.Backend. +func (d *SqliteBackend) Description(repo string) string { + repo = utils.SanitizeRepo(repo) + var desc string + if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + return tx.Get(&desc, "SELECT description FROM repo WHERE name = ?", repo) + }); err != nil { + return "" + } + + return desc +} + +// IsMirror returns true if the repository is a mirror. +// +// It implements backend.Backend. +func (d *SqliteBackend) IsMirror(repo string) bool { + repo = utils.SanitizeRepo(repo) + var mirror bool + if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + return tx.Get(&mirror, "SELECT mirror FROM repo WHERE name = ?", repo) + }); err != nil { + return false + } + + return mirror +} + +// IsPrivate returns true if the repository is private. +// +// It implements backend.Backend. +func (d *SqliteBackend) IsPrivate(repo string) bool { + repo = utils.SanitizeRepo(repo) + var private bool + if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + return tx.Get(&private, "SELECT private FROM repo WHERE name = ?", repo) + }); err != nil { + return false + } + + return private +} + +// ProjectName returns the project name of a repository. +// +// It implements backend.Backend. +func (d *SqliteBackend) ProjectName(repo string) string { + repo = utils.SanitizeRepo(repo) + var name string + if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + return tx.Get(&name, "SELECT project_name FROM repo WHERE name = ?", repo) + }); err != nil { + return "" + } + + return name +} + +// SetDescription sets the description of a repository. +// +// It implements backend.Backend. +func (d *SqliteBackend) SetDescription(repo string, desc string) error { + repo = utils.SanitizeRepo(repo) + return wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + _, err := tx.Exec("UPDATE repo SET description = ? WHERE name = ?", desc, repo) + return err + }) +} + +// SetPrivate sets the private flag of a repository. +// +// It implements backend.Backend. +func (d *SqliteBackend) SetPrivate(repo string, private bool) error { + repo = utils.SanitizeRepo(repo) + return wrapDbErr( + wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + _, err := tx.Exec("UPDATE repo SET private = ? WHERE name = ?", private, repo) + return err + }), + ) +} + +// SetProjectName sets the project name of a repository. +// +// It implements backend.Backend. +func (d *SqliteBackend) SetProjectName(repo string, name string) error { + repo = utils.SanitizeRepo(repo) + return wrapDbErr( + wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + _, err := tx.Exec("UPDATE repo SET project_name = ? WHERE name = ?", name, repo) + return err + }), + ) +} + +// AddCollaborator adds a collaborator to a repository. +// +// It implements backend.Backend. +func (d *SqliteBackend) AddCollaborator(repo string, username string) error { + repo = utils.SanitizeRepo(repo) + return wrapDbErr(wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + _, err := tx.Exec(`INSERT INTO collab (user_id, repo_id, updated_at) + VALUES ( + (SELECT id FROM user WHERE username = ?), + (SELECT id FROM repo WHERE name = ?), + CURRENT_TIMESTAMP + );`, username, repo) + return err + }), + ) +} + +// Collaborators returns a list of collaborators for a repository. +// +// It implements backend.Backend. +func (d *SqliteBackend) Collaborators(repo string) ([]string, error) { + repo = utils.SanitizeRepo(repo) + var users []string + if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + return tx.Select(&users, `SELECT name FROM user + INNER JOIN collab ON user.id = collab.user_id + INNER JOIN repo ON repo.id = collab.repo_id + WHERE repo.name = ?`, repo) + }); err != nil { + return nil, wrapDbErr(err) + } + + return users, nil +} + +// IsCollaborator returns true if the user is a collaborator of the repository. +// +// It implements backend.Backend. +func (d *SqliteBackend) IsCollaborator(repo string, username string) bool { + repo = utils.SanitizeRepo(repo) + var count int + if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + return tx.Get(&count, `SELECT COUNT(*) FROM user + INNER JOIN collab ON user.id = collab.user_id + INNER JOIN repo ON repo.id = collab.repo_id + WHERE repo.name = ? AND user.username = ?`, repo, username) + }); err != nil { + return false + } + + return count > 0 +} + +// RemoveCollaborator removes a collaborator from a repository. +// +// It implements backend.Backend. +func (d *SqliteBackend) RemoveCollaborator(repo string, username string) error { + repo = utils.SanitizeRepo(repo) + return wrapDbErr( + wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + _, err := tx.Exec(`DELETE FROM collab + WHERE user_id = (SELECT id FROM user WHERE username = ?) + AND repo_id = (SELECT id FROM repo WHERE name = ?)`, username, repo) + return err + }), + ) +} + +var ( + hookNames = []string{"pre-receive", "update", "post-update", "post-receive"} + hookTpls = []string{ + // for pre-receive + `#!/usr/bin/env bash +# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY +data=$(cat) +exitcodes="" +hookname=$(basename $0) +GIT_DIR=${GIT_DIR:-$(dirname $0)/..} +for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do + test -x "${hook}" && test -f "${hook}" || continue + echo "${data}" | "${hook}" + exitcodes="${exitcodes} $?" +done +for i in ${exitcodes}; do + [ ${i} -eq 0 ] || exit ${i} +done +`, + + // for update + `#!/usr/bin/env bash +# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY +exitcodes="" +hookname=$(basename $0) +GIT_DIR=${GIT_DIR:-$(dirname $0/..)} +for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do + test -x "${hook}" && test -f "${hook}" || continue + "${hook}" $1 $2 $3 + exitcodes="${exitcodes} $?" +done +for i in ${exitcodes}; do + [ ${i} -eq 0 ] || exit ${i} +done +`, + + // for post-update + `#!/usr/bin/env bash +# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY +data=$(cat) +exitcodes="" +hookname=$(basename $0) +GIT_DIR=${GIT_DIR:-$(dirname $0)/..} +for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do + test -x "${hook}" && test -f "${hook}" || continue + "${hook}" $@ + exitcodes="${exitcodes} $?" +done +for i in ${exitcodes}; do + [ ${i} -eq 0 ] || exit ${i} +done +`, + + // for post-receive + `#!/usr/bin/env bash +# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY +data=$(cat) +exitcodes="" +hookname=$(basename $0) +GIT_DIR=${GIT_DIR:-$(dirname $0)/..} +for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do + test -x "${hook}" && test -f "${hook}" || continue + echo "${data}" | "${hook}" + exitcodes="${exitcodes} $?" +done +for i in ${exitcodes}; do + [ ${i} -eq 0 ] || exit ${i} +done +`, + } +) + +// InitializeHooks updates the hooks for the given repository. +// +// It implements backend.Backend. +func (d *SqliteBackend) InitializeHooks(repo string) error { + hookTmpl, err := template.New("hook").Parse(`#!/usr/bin/env bash +# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY +{{ range $_, $env := .Envs }} +{{ $env }} \{{ end }} +{{ .Executable }} hook --config "{{ .Config }}" {{ .Hook }} {{ .Args }} +`) + if err != nil { + return err + } + + repo = utils.SanitizeRepo(repo) + ".git" + hooksPath := filepath.Join(d.reposPath(), repo, "hooks") + if err := os.MkdirAll(hooksPath, 0755); err != nil { + return err + } + + ex, err := os.Executable() + if err != nil { + return err + } + + dp, err := filepath.Abs(d.dp) + if err != nil { + return fmt.Errorf("failed to get absolute path for data path: %w", err) + } + + cp := filepath.Join(dp, "config.yaml") + envs := []string{} + for i, hook := range hookNames { + var data bytes.Buffer + var args string + hp := filepath.Join(hooksPath, hook) + if err := os.WriteFile(hp, []byte(hookTpls[i]), 0755); err != nil { + return err + } + + // Create hook.d directory. + hp += ".d" + if err := os.MkdirAll(hp, 0755); err != nil { + return err + } + + if hook == "update" { + args = "$1 $2 $3" + } else if hook == "post-update" { + args = "$@" + } + + err = hookTmpl.Execute(&data, struct { + Executable string + Hook string + Args string + Envs []string + Config string + }{ + Executable: ex, + Hook: hook, + Args: args, + Envs: envs, + Config: cp, + }) + if err != nil { + logger.Error("failed to execute hook template", "err", err) + continue + } + + hp = filepath.Join(hp, "soft-serve") + err = os.WriteFile(hp, data.Bytes(), 0755) //nolint:gosec + if err != nil { + logger.Error("failed to write hook", "err", err) + continue + } + } + + return nil +} diff --git a/server/backend/sqlite/user.go b/server/backend/sqlite/user.go new file mode 100644 index 0000000000000000000000000000000000000000..764820d0c0a3172250b823b1d5999ed293b041a6 --- /dev/null +++ b/server/backend/sqlite/user.go @@ -0,0 +1,326 @@ +package sqlite + +import ( + "context" + + "github.com/charmbracelet/soft-serve/server/backend" + "github.com/jmoiron/sqlx" + "golang.org/x/crypto/ssh" +) + +// User represents a user. +type User struct { + username string + db *sqlx.DB +} + +var _ backend.User = (*User)(nil) + +// IsAdmin returns whether the user is an admin. +// +// It implements backend.User. +func (u *User) IsAdmin() bool { + var admin bool + if err := wrapTx(u.db, context.Background(), func(tx *sqlx.Tx) error { + return tx.Get(&admin, "SELECT admin FROM user WHERE username = ?", u.username) + }); err != nil { + return false + } + + return admin +} + +// PublicKeys returns the user's public keys. +// +// It implements backend.User. +func (u *User) PublicKeys() []ssh.PublicKey { + var keys []ssh.PublicKey + if err := wrapTx(u.db, context.Background(), func(tx *sqlx.Tx) error { + var keyStrings []string + if err := tx.Select(&keyStrings, `SELECT public_key + FROM public_key + INNER JOIN user ON user.id = public_key.user_id + WHERE user.username = ?;`, u.username); err != nil { + return err + } + + for _, keyString := range keyStrings { + key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyString)) + if err != nil { + return err + } + keys = append(keys, key) + } + + return nil + }); err != nil { + return nil + } + + return keys +} + +// Username returns the user's username. +// +// It implements backend.User. +func (u *User) Username() string { + return u.username +} + +// AccessLevel returns the access level of a user for a repository. +// +// It implements backend.Backend. +func (d *SqliteBackend) AccessLevel(repo string, username string) backend.AccessLevel { + anon := d.AnonAccess() + user, _ := d.User(username) + // If the user is an admin, they have admin access. + if user != nil && user.IsAdmin() { + return backend.AdminAccess + } + + // If the repository exists, check if the user is a collaborator. + r, _ := d.Repository(repo) + if r != nil { + // If the user is a collaborator, they have read/write access. + if d.IsCollaborator(repo, username) { + if anon > backend.ReadWriteAccess { + return anon + } + return backend.ReadWriteAccess + } + + // If the repository is private, the user has no access. + if r.IsPrivate() { + return backend.NoAccess + } + + // Otherwise, the user has read-only access. + return backend.ReadOnlyAccess + } + + // If the repository doesn't exist, the user has read/write access. + if user != nil { + // If the repository doesn't exist, the user has read/write access. + if anon > backend.ReadWriteAccess { + return anon + } + + return backend.ReadWriteAccess + } + + // If the user doesn't exist, give them the anonymous access level. + return anon +} + +// AccessLevelByPublicKey returns the access level of a user's public key for a repository. +// +// It implements backend.Backend. +func (d *SqliteBackend) AccessLevelByPublicKey(repo string, pk ssh.PublicKey) backend.AccessLevel { + ak := backend.MarshalAuthorizedKey(pk) + for _, k := range d.AdditionalAdmins { + if k == ak { + return backend.AdminAccess + } + } + + user, _ := d.UserByPublicKey(pk) + if user != nil { + return d.AccessLevel(repo, user.Username()) + } + + return d.AccessLevel(repo, "") +} + +// AddPublicKey adds a public key to a user. +// +// It implements backend.Backend. +func (d *SqliteBackend) AddPublicKey(username string, pk ssh.PublicKey) error { + return wrapDbErr( + wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + var userID int + if err := tx.Get(&userID, "SELECT id FROM user WHERE username = ?", username); err != nil { + return err + } + + _, err := tx.Exec(`INSERT INTO public_key (user_id, public_key, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP);`, userID, backend.MarshalAuthorizedKey(pk)) + return err + }), + ) +} + +// CreateUser creates a new user. +// +// It implements backend.Backend. +func (d *SqliteBackend) CreateUser(username string, opts backend.UserOptions) (backend.User, error) { + var user *User + if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + into := "INSERT INTO user (username" + values := "VALUES (?" + args := []interface{}{username} + if opts.Admin { + into += ", admin" + values += ", ?" + args = append(args, opts.Admin) + } + into += ", updated_at)" + values += ", CURRENT_TIMESTAMP)" + + r, err := tx.Exec(into+" "+values, args...) + if err != nil { + return err + } + + if len(opts.PublicKeys) > 0 { + userID, err := r.LastInsertId() + if err != nil { + return err + } + + for _, pk := range opts.PublicKeys { + if _, err := tx.Exec(`INSERT INTO public_key (user_id, public_key, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP);`, userID, backend.MarshalAuthorizedKey(pk)); err != nil { + return err + } + } + } + + user = &User{ + db: d.db, + username: username, + } + return nil + }); err != nil { + return nil, wrapDbErr(err) + } + + return user, nil +} + +// DeleteUser deletes a user. +// +// It implements backend.Backend. +func (d *SqliteBackend) DeleteUser(username string) error { + return wrapDbErr( + wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + _, err := tx.Exec("DELETE FROM user WHERE username = ?", username) + return err + }), + ) +} + +// RemovePublicKey removes a public key from a user. +// +// It implements backend.Backend. +func (d *SqliteBackend) RemovePublicKey(username string, pk ssh.PublicKey) error { + return wrapDbErr( + wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + _, err := tx.Exec(`DELETE FROM public_key + WHERE user_id = (SELECT id FROM user WHERE username = ?) + AND public_key = ?;`, username, backend.MarshalAuthorizedKey(pk)) + return err + }), + ) +} + +// ListPublicKeys lists the public keys of a user. +func (d *SqliteBackend) ListPublicKeys(username string) ([]ssh.PublicKey, error) { + keys := make([]ssh.PublicKey, 0) + if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + var keyStrings []string + if err := tx.Select(&keyStrings, `SELECT public_key + FROM public_key + INNER JOIN user ON user.id = public_key.user_id + WHERE user.username = ?;`, username); err != nil { + return err + } + + for _, keyString := range keyStrings { + key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyString)) + if err != nil { + return err + } + keys = append(keys, key) + } + + return nil + }); err != nil { + return nil, wrapDbErr(err) + } + + return keys, nil +} + +// SetUsername sets the username of a user. +// +// It implements backend.Backend. +func (d *SqliteBackend) SetUsername(username string, newUsername string) error { + return wrapDbErr( + wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + _, err := tx.Exec("UPDATE user SET username = ? WHERE username = ?", newUsername, username) + return err + }), + ) +} + +// SetAdmin sets the admin flag of a user. +// +// It implements backend.Backend. +func (d *SqliteBackend) SetAdmin(username string, admin bool) error { + return wrapDbErr( + wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + _, err := tx.Exec("UPDATE user SET admin = ? WHERE username = ?", admin, username) + return err + }), + ) +} + +// User finds a user by username. +// +// It implements backend.Backend. +func (d *SqliteBackend) User(username string) (backend.User, error) { + if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + return tx.Get(&username, "SELECT username FROM user WHERE username = ?", username) + }); err != nil { + return nil, wrapDbErr(err) + } + + return &User{ + db: d.db, + username: username, + }, nil +} + +// UserByPublicKey finds a user by public key. +// +// It implements backend.Backend. +func (d *SqliteBackend) UserByPublicKey(pk ssh.PublicKey) (backend.User, error) { + var username string + if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + return tx.Get(&username, `SELECT user.username + FROM public_key + INNER JOIN user ON user.id = public_key.user_id + WHERE public_key.public_key = ?;`, backend.MarshalAuthorizedKey(pk)) + }); err != nil { + return nil, wrapDbErr(err) + } + + return &User{ + db: d.db, + username: username, + }, nil +} + +// Users returns all users. +// +// It implements backend.Backend. +func (d *SqliteBackend) Users() ([]string, error) { + var users []string + if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { + return tx.Select(&users, "SELECT username FROM user") + }); err != nil { + return nil, wrapDbErr(err) + } + + return users, nil +} diff --git a/server/backend/user.go b/server/backend/user.go new file mode 100644 index 0000000000000000000000000000000000000000..b0b5f1115dbe3760dbf6972c0eceaeb7f5fbf79a --- /dev/null +++ b/server/backend/user.go @@ -0,0 +1,55 @@ +package backend + +import ( + "golang.org/x/crypto/ssh" +) + +// User is an interface representing a user. +type User interface { + // Username returns the user's username. + Username() string + // IsAdmin returns whether the user is an admin. + IsAdmin() bool + // PublicKeys returns the user's public keys. + PublicKeys() []ssh.PublicKey +} + +// UserAccess is an interface that handles user access to repositories. +type UserAccess interface { + // AccessLevel returns the access level of the username to the repository. + AccessLevel(repo string, username string) AccessLevel + // AccessLevelByPublicKey returns the access level of the public key to the repository. + AccessLevelByPublicKey(repo string, pk ssh.PublicKey) AccessLevel +} + +// UserStore is an interface for managing users. +type UserStore interface { + // User finds the given user. + User(username string) (User, error) + // UserByPublicKey finds the user with the given public key. + UserByPublicKey(pk ssh.PublicKey) (User, error) + // Users returns a list of all users. + Users() ([]string, error) + // CreateUser creates a new user. + CreateUser(username string, opts UserOptions) (User, error) + // DeleteUser deletes a user. + DeleteUser(username string) error + // SetUsername sets the username of the user. + SetUsername(oldUsername string, newUsername string) error + // SetAdmin sets whether the user is an admin. + SetAdmin(username string, admin bool) error + // AddPublicKey adds a public key to the user. + AddPublicKey(username string, pk ssh.PublicKey) error + // RemovePublicKey removes a public key from the user. + RemovePublicKey(username string, pk ssh.PublicKey) error + // ListPublicKeys lists the public keys of the user. + ListPublicKeys(username string) ([]ssh.PublicKey, error) +} + +// UserOptions are options for creating a user. +type UserOptions struct { + // Admin is whether the user is an admin. + Admin bool + // PublicKeys are the user's public keys. + PublicKeys []ssh.PublicKey +} diff --git a/server/cmd/admin.go b/server/cmd/admin.go deleted file mode 100644 index 687117edfcb27e81555c87c100a795d3eb6f67a5..0000000000000000000000000000000000000000 --- a/server/cmd/admin.go +++ /dev/null @@ -1,88 +0,0 @@ -package cmd - -import ( - "strings" - - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/spf13/cobra" -) - -func adminCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "admin", - Aliases: []string{"admins"}, - Short: "Manage admins", - } - - cmd.AddCommand( - adminAddCommand(), - adminRemoveCommand(), - adminListCommand(), - ) - - return cmd -} - -func adminAddCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "add AUTHORIZED_KEY", - Short: "Add an admin", - Args: cobra.MinimumNArgs(1), - PersistentPreRunE: checkIfAdmin, - RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) - pk, c, err := backend.ParseAuthorizedKey(strings.Join(args, " ")) - if err != nil { - return err - } - - return cfg.Backend.AddAdmin(pk, c) - }, - } - - return cmd -} - -func adminRemoveCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "remove AUTHORIZED_KEY", - Args: cobra.MinimumNArgs(1), - Short: "Remove an admin", - PersistentPreRunE: checkIfAdmin, - RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) - pk, _, err := backend.ParseAuthorizedKey(strings.Join(args, " ")) - if err != nil { - return err - } - - return cfg.Backend.RemoveAdmin(pk) - }, - } - - return cmd -} - -func adminListCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "list", - Args: cobra.NoArgs, - Short: "List admins", - PersistentPreRunE: checkIfAdmin, - RunE: func(cmd *cobra.Command, _ []string) error { - cfg, _ := fromContext(cmd) - admins, err := cfg.Backend.Admins() - if err != nil { - return err - } - - for _, admin := range admins { - cmd.Println(admin) - } - - return nil - }, - } - - return cmd -} diff --git a/server/cmd/cmd.go b/server/cmd/cmd.go index f6fcdb13164b478c9488d1f9b46a5104bacd43bf..6ba903f196db3ccacea4c6541599f8a0c0d30d86 100644 --- a/server/cmd/cmd.go +++ b/server/cmd/cmd.go @@ -45,32 +45,36 @@ var ( ) // rootCommand is the root command for the server. -func rootCommand() *cobra.Command { +func rootCommand(cfg *config.Config, s ssh.Session) *cobra.Command { rootCmd := &cobra.Command{ Use: "soft", Short: "Soft Serve is a self-hostable Git server for the command line.", SilenceUsage: true, } + // TODO: use command usage template to include hostname and port rootCmd.CompletionOptions.DisableDefaultCmd = true rootCmd.AddCommand( - adminCommand(), - blobCommand(), - branchCommand(), - collabCommand(), - createCommand(), - deleteCommand(), - descriptionCommand(), hookCommand(), - listCommand(), - privateCommand(), - projectName(), - renameCommand(), - settingCommand(), - tagCommand(), - treeCommand(), + repoCommand(), ) + user, _ := cfg.Backend.UserByPublicKey(s.PublicKey()) + if user != nil { + if user.IsAdmin() { + rootCmd.AddCommand( + settingsCommand(), + userCommand(), + ) + } + + rootCmd.AddCommand( + infoCommand(), + pubkeyCommand(), + setUsernameCommand(), + ) + } + return rootCmd } @@ -88,18 +92,31 @@ func checkIfReadable(cmd *cobra.Command, args []string) error { } cfg, s := fromContext(cmd) rn := utils.SanitizeRepo(repo) - auth := cfg.Backend.AccessLevel(rn, s.PublicKey()) + auth := cfg.Backend.AccessLevelByPublicKey(rn, s.PublicKey()) if auth < backend.ReadOnlyAccess { return ErrUnauthorized } return nil } -func checkIfAdmin(cmd *cobra.Command, args []string) error { +func checkIfAdmin(cmd *cobra.Command, _ []string) error { cfg, s := fromContext(cmd) - if !cfg.Backend.IsAdmin(s.PublicKey()) { + ak := backend.MarshalAuthorizedKey(s.PublicKey()) + for _, k := range cfg.InitialAdminKeys { + if k == ak { + return nil + } + } + + user, err := cfg.Backend.UserByPublicKey(s.PublicKey()) + if err != nil { + return err + } + + if !user.IsAdmin() { return ErrUnauthorized } + return nil } @@ -110,7 +127,7 @@ func checkIfCollab(cmd *cobra.Command, args []string) error { } cfg, s := fromContext(cmd) rn := utils.SanitizeRepo(repo) - auth := cfg.Backend.AccessLevel(rn, s.PublicKey()) + auth := cfg.Backend.AccessLevelByPublicKey(rn, s.PublicKey()) if auth < backend.ReadWriteAccess { return ErrUnauthorized } @@ -141,7 +158,7 @@ func Middleware(cfg *config.Config, hooks hooks.Hooks) wish.Middleware { ctx = context.WithValue(ctx, SessionCtxKey, s) ctx = context.WithValue(ctx, HooksCtxKey, hooks) - rootCmd := rootCommand() + rootCmd := rootCommand(cfg, s) rootCmd.SetArgs(args) if len(args) == 0 { // otherwise it'll default to os.Args, which is not what we want. diff --git a/server/cmd/collab.go b/server/cmd/collab.go index 15cdd0b1792d7e3942a78d97b8758dd82e966963..9426a975a98b22e10b66b836daf47e489e0dcf9c 100644 --- a/server/cmd/collab.go +++ b/server/cmd/collab.go @@ -1,16 +1,13 @@ package cmd import ( - "strings" - - "github.com/charmbracelet/soft-serve/server/backend" "github.com/spf13/cobra" ) func collabCommand() *cobra.Command { cmd := &cobra.Command{ Use: "collab", - Aliases: []string{"collaborator", "collaborators"}, + Aliases: []string{"collabs", "collaborator", "collaborators"}, Short: "Manage collaborators", } @@ -25,19 +22,16 @@ func collabCommand() *cobra.Command { func collabAddCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "add REPOSITORY AUTHORIZED_KEY", + Use: "add REPOSITORY USERNAME", Short: "Add a collaborator to a repo", - Args: cobra.MinimumNArgs(2), + Args: cobra.ExactArgs(2), PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { cfg, _ := fromContext(cmd) repo := args[0] - pk, c, err := backend.ParseAuthorizedKey(strings.Join(args[1:], " ")) - if err != nil { - return err - } + username := args[1] - return cfg.Backend.AddCollaborator(pk, c, repo) + return cfg.Backend.AddCollaborator(repo, username) }, } @@ -46,19 +40,16 @@ func collabAddCommand() *cobra.Command { func collabRemoveCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "remove REPOSITORY AUTHORIZED_KEY", - Args: cobra.MinimumNArgs(2), + Use: "remove REPOSITORY USERNAME", + Args: cobra.ExactArgs(2), Short: "Remove a collaborator from a repo", PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { cfg, _ := fromContext(cmd) repo := args[0] - pk, _, err := backend.ParseAuthorizedKey(strings.Join(args[1:], " ")) - if err != nil { - return err - } + username := args[1] - return cfg.Backend.RemoveCollaborator(pk, repo) + return cfg.Backend.RemoveCollaborator(repo, username) }, } diff --git a/server/cmd/create.go b/server/cmd/create.go index 891ad94f9a51301b9a502e7101c7a745f34e6da2..259406d1424078f9d6e9646e509dba3bf989e026 100644 --- a/server/cmd/create.go +++ b/server/cmd/create.go @@ -9,7 +9,6 @@ import ( func createCommand() *cobra.Command { var private bool var description string - var mirror string var projectName string cmd := &cobra.Command{ @@ -22,7 +21,6 @@ func createCommand() *cobra.Command { name := args[0] if _, err := cfg.Backend.CreateRepository(name, backend.RepositoryOptions{ Private: private, - Mirror: mirror, Description: description, ProjectName: projectName, }); err != nil { @@ -34,7 +32,6 @@ func createCommand() *cobra.Command { cmd.Flags().BoolVarP(&private, "private", "p", false, "make the repository private") cmd.Flags().StringVarP(&description, "description", "d", "", "set the repository description") - cmd.Flags().StringVarP(&mirror, "mirror", "m", "", "set the mirror repository") cmd.Flags().StringVarP(&projectName, "name", "n", "", "set the project name") return cmd diff --git a/server/cmd/import.go b/server/cmd/import.go new file mode 100644 index 0000000000000000000000000000000000000000..7135239f15dac10fc9ab8bf1b24d9c30cb9f2568 --- /dev/null +++ b/server/cmd/import.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "github.com/charmbracelet/soft-serve/server/backend" + "github.com/spf13/cobra" +) + +// importCommand is the command for creating a new repository. +func importCommand() *cobra.Command { + var private bool + var description string + var projectName string + var mirror bool + + cmd := &cobra.Command{ + Use: "import REPOSITORY REMOTE", + Short: "Import a new repository from remote", + Args: cobra.ExactArgs(2), + PersistentPreRunE: checkIfAdmin, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _ := fromContext(cmd) + name := args[0] + remote := args[1] + if _, err := cfg.Backend.ImportRepository(name, remote, backend.RepositoryOptions{ + Private: private, + Description: description, + ProjectName: projectName, + Mirror: mirror, + }); err != nil { + return err + } + return nil + }, + } + + cmd.Flags().BoolVarP(&mirror, "mirror", "m", false, "mirror the repository") + cmd.Flags().BoolVarP(&private, "private", "p", false, "make the repository private") + cmd.Flags().StringVarP(&description, "description", "d", "", "set the repository description") + cmd.Flags().StringVarP(&projectName, "name", "n", "", "set the project name") + + return cmd +} diff --git a/server/cmd/info.go b/server/cmd/info.go new file mode 100644 index 0000000000000000000000000000000000000000..60d716d72dbfea017627928b3dff8ecc99338f36 --- /dev/null +++ b/server/cmd/info.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "github.com/charmbracelet/soft-serve/server/backend" + "github.com/spf13/cobra" +) + +func infoCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "info", + Short: "Show your info", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, s := fromContext(cmd) + user, err := cfg.Backend.UserByPublicKey(s.PublicKey()) + if err != nil { + return err + } + + cmd.Printf("Username: %s\n", user.Username()) + cmd.Printf("Admin: %t\n", user.IsAdmin()) + cmd.Printf("Public keys:\n") + for _, pk := range user.PublicKeys() { + cmd.Printf(" %s\n", backend.MarshalAuthorizedKey(pk)) + } + return nil + }, + } + + return cmd +} diff --git a/server/cmd/list.go b/server/cmd/list.go index 7b6626c56ea0b7d1a472f944318620089d50610f..4b5a2a57cc06024a7a41b07723e21079a00e47e9 100644 --- a/server/cmd/list.go +++ b/server/cmd/list.go @@ -19,7 +19,7 @@ func listCommand() *cobra.Command { return err } for _, r := range repos { - if cfg.Backend.AccessLevel(r.Name(), s.PublicKey()) >= backend.ReadOnlyAccess { + if cfg.Backend.AccessLevelByPublicKey(r.Name(), s.PublicKey()) >= backend.ReadOnlyAccess { cmd.Println(r.Name()) } } diff --git a/server/cmd/pubkey.go b/server/cmd/pubkey.go new file mode 100644 index 0000000000000000000000000000000000000000..7c1bea9b34b0507f14c2709e9b500e993b697d08 --- /dev/null +++ b/server/cmd/pubkey.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "strings" + + "github.com/charmbracelet/soft-serve/server/backend" + "github.com/spf13/cobra" +) + +func pubkeyCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "pubkey", + Aliases: []string{"pubkeys", "publickey", "publickeys"}, + Short: "Manage your public keys", + } + + pubkeyAddCommand := &cobra.Command{ + Use: "add AUTHORIZED_KEY", + Short: "Add a public key", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, s := fromContext(cmd) + user, err := cfg.Backend.UserByPublicKey(s.PublicKey()) + if err != nil { + return err + } + + pk, _, err := backend.ParseAuthorizedKey(strings.Join(args, " ")) + if err != nil { + return err + } + + return cfg.Backend.AddPublicKey(user.Username(), pk) + }, + } + + pubkeyRemoveCommand := &cobra.Command{ + Use: "remove AUTHORIZED_KEY", + Args: cobra.MinimumNArgs(1), + Short: "Remove a public key", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, s := fromContext(cmd) + user, err := cfg.Backend.UserByPublicKey(s.PublicKey()) + if err != nil { + return err + } + + pk, _, err := backend.ParseAuthorizedKey(strings.Join(args, " ")) + if err != nil { + return err + } + + return cfg.Backend.RemovePublicKey(user.Username(), pk) + }, + } + + pubkeyListCommand := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List public keys", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, s := fromContext(cmd) + user, err := cfg.Backend.UserByPublicKey(s.PublicKey()) + if err != nil { + return err + } + + pks := user.PublicKeys() + for _, pk := range pks { + cmd.Println(backend.MarshalAuthorizedKey(pk)) + } + + return nil + }, + } + + cmd.AddCommand( + pubkeyAddCommand, + pubkeyRemoveCommand, + pubkeyListCommand, + ) + + return cmd +} diff --git a/server/cmd/repo.go b/server/cmd/repo.go new file mode 100644 index 0000000000000000000000000000000000000000..577ab30304ed4092d0e1a035bda06c713fec42b4 --- /dev/null +++ b/server/cmd/repo.go @@ -0,0 +1,29 @@ +package cmd + +import "github.com/spf13/cobra" + +func repoCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "repo", + Aliases: []string{"repos", "repository", "repositories"}, + Short: "Manage repositories", + } + + cmd.AddCommand( + blobCommand(), + branchCommand(), + collabCommand(), + createCommand(), + deleteCommand(), + descriptionCommand(), + importCommand(), + listCommand(), + privateCommand(), + projectName(), + renameCommand(), + tagCommand(), + treeCommand(), + ) + + return cmd +} diff --git a/server/cmd/set_username.go b/server/cmd/set_username.go new file mode 100644 index 0000000000000000000000000000000000000000..152403a57c68fa18035f7392b2bb67e7b06c977d --- /dev/null +++ b/server/cmd/set_username.go @@ -0,0 +1,22 @@ +package cmd + +import "github.com/spf13/cobra" + +func setUsernameCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "set-username USERNAME", + Short: "Set your username", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, s := fromContext(cmd) + user, err := cfg.Backend.UserByPublicKey(s.PublicKey()) + if err != nil { + return err + } + + return cfg.Backend.SetUsername(user.Username(), args[0]) + }, + } + + return cmd +} diff --git a/server/cmd/setting.go b/server/cmd/settings.go similarity index 96% rename from server/cmd/setting.go rename to server/cmd/settings.go index eaa55942ef0884abef3cd0b24a96f02b53cd1a7c..95ef01a24c9adfbb14f8687e6bc2ccd3b412dee4 100644 --- a/server/cmd/setting.go +++ b/server/cmd/settings.go @@ -8,9 +8,9 @@ import ( "github.com/spf13/cobra" ) -func settingCommand() *cobra.Command { +func settingsCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "setting", + Use: "settings", Short: "Manage server settings", } diff --git a/server/cmd/user.go b/server/cmd/user.go new file mode 100644 index 0000000000000000000000000000000000000000..364f54b3c7c7ac0937523c49ca04e017e123671e --- /dev/null +++ b/server/cmd/user.go @@ -0,0 +1,193 @@ +package cmd + +import ( + "sort" + "strings" + + "github.com/charmbracelet/log" + "github.com/charmbracelet/soft-serve/server/backend" + "github.com/spf13/cobra" + "golang.org/x/crypto/ssh" +) + +func userCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "user", + Aliases: []string{"users"}, + Short: "Manage users", + } + + var admin bool + var key string + userAddCommand := &cobra.Command{ + Use: "add USERNAME", + Short: "Add a user", + Args: cobra.ExactArgs(1), + PersistentPreRunE: checkIfAdmin, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _ := fromContext(cmd) + username := args[0] + pk, _, err := backend.ParseAuthorizedKey(key) + if err != nil { + return err + } + + opts := backend.UserOptions{ + Admin: admin, + PublicKeys: []ssh.PublicKey{pk}, + } + + _, err = cfg.Backend.CreateUser(username, opts) + return err + }, + } + + userAddCommand.Flags().BoolVarP(&admin, "admin", "a", false, "make the user an admin") + userAddCommand.Flags().StringVarP(&key, "key", "k", "", "add a public key to the user") + + userRemoveCommand := &cobra.Command{ + Use: "remove USERNAME", + Short: "Remove a user", + Args: cobra.ExactArgs(1), + PersistentPreRunE: checkIfAdmin, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _ := fromContext(cmd) + username := args[0] + + return cfg.Backend.DeleteUser(username) + }, + } + + userListCommand := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List users", + Args: cobra.NoArgs, + PersistentPreRunE: checkIfAdmin, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, _ := fromContext(cmd) + users, err := cfg.Backend.Users() + if err != nil { + return err + } + + sort.Strings(users) + for _, u := range users { + cmd.Println(u) + } + + return nil + }, + } + + userAddPubkeyCommand := &cobra.Command{ + Use: "add-pubkey USERNAME AUTHORIZED_KEY", + Short: "Add a public key to a user", + Args: cobra.MinimumNArgs(2), + PersistentPreRunE: checkIfAdmin, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _ := fromContext(cmd) + username := args[0] + pubkey := strings.Join(args[1:], " ") + pk, _, err := backend.ParseAuthorizedKey(pubkey) + if err != nil { + return err + } + + return cfg.Backend.AddPublicKey(username, pk) + }, + } + + userRemovePubkeyCommand := &cobra.Command{ + Use: "remove-pubkey USERNAME AUTHORIZED_KEY", + Short: "Remove a public key from a user", + Args: cobra.MinimumNArgs(2), + PersistentPreRunE: checkIfAdmin, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _ := fromContext(cmd) + username := args[0] + pubkey := strings.Join(args[1:], " ") + log.Debugf("key is %q", pubkey) + pk, _, err := backend.ParseAuthorizedKey(pubkey) + if err != nil { + return err + } + + return cfg.Backend.RemovePublicKey(username, pk) + }, + } + + userSetAdminCommand := &cobra.Command{ + Use: "set-admin USERNAME [true|false]", + Short: "Make a user an admin", + Args: cobra.ExactArgs(2), + PersistentPreRunE: checkIfAdmin, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _ := fromContext(cmd) + username := args[0] + + return cfg.Backend.SetAdmin(username, args[1] == "true") + }, + } + + userInfoCommand := &cobra.Command{ + Use: "info USERNAME", + Short: "Show information about a user", + Args: cobra.ExactArgs(1), + PersistentPreRunE: checkIfAdmin, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, s := fromContext(cmd) + ak := backend.MarshalAuthorizedKey(s.PublicKey()) + username := args[0] + + user, err := cfg.Backend.User(username) + if err != nil { + return err + } + + isAdmin := user.IsAdmin() + for _, k := range cfg.InitialAdminKeys { + if ak == k { + isAdmin = true + break + } + } + + cmd.Printf("Username: %s\n", user.Username()) + cmd.Printf("Admin: %t\n", isAdmin) + cmd.Printf("Public keys:\n") + for _, pk := range user.PublicKeys() { + cmd.Printf(" %s\n", backend.MarshalAuthorizedKey(pk)) + } + + return nil + }, + } + + userSetUsernameCommand := &cobra.Command{ + Use: "set-username USERNAME NEW_USERNAME", + Short: "Change a user's username", + Args: cobra.ExactArgs(2), + PersistentPreRunE: checkIfAdmin, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _ := fromContext(cmd) + username := args[0] + newUsername := args[1] + + return cfg.Backend.SetUsername(username, newUsername) + }, + } + + cmd.AddCommand( + userAddCommand, + userAddPubkeyCommand, + userInfoCommand, + userListCommand, + userRemoveCommand, + userRemovePubkeyCommand, + userSetAdminCommand, + userSetUsernameCommand, + ) + + return cmd +} diff --git a/server/cron/cron.go b/server/cron/cron.go index 9f98d5a69c709c624f93738bc407f510b20a45c1..b86b0eacf6b96c6d6e52c86a7de984efac504dbb 100644 --- a/server/cron/cron.go +++ b/server/cron/cron.go @@ -29,7 +29,7 @@ type cronLogger struct { // Info logs routine messages about cron's operation. func (l cronLogger) Info(msg string, keysAndValues ...interface{}) { - l.logger.Info(msg, keysAndValues...) + l.logger.Debug(msg, keysAndValues...) } // Error logs an error condition. diff --git a/server/daemon.go b/server/daemon.go index 047bf4b6cd8bf4b5d69836893e469e0deb9e909e..007e3c68e3f19930471ca9afe06964e93cf90642 100644 --- a/server/daemon.go +++ b/server/daemon.go @@ -233,7 +233,7 @@ func (d *GitDaemon) handleClient(conn net.Conn) { return } - auth := d.cfg.Backend.AccessLevel(name, nil) + auth := d.cfg.Backend.AccessLevel(name, "") if auth < backend.ReadOnlyAccess { fatal(c, ErrNotAuthed) return diff --git a/server/daemon_test.go b/server/daemon_test.go index d7816537297e1c516dc3e95e3ab2bbbc034a56ac..79b30d9959347ea179df6dd2970bd7280fc77e45 100644 --- a/server/daemon_test.go +++ b/server/daemon_test.go @@ -8,12 +8,11 @@ import ( "log" "net" "os" - "path/filepath" "strings" "testing" "time" - "github.com/charmbracelet/soft-serve/server/backend/file" + "github.com/charmbracelet/soft-serve/server/backend/sqlite" "github.com/charmbracelet/soft-serve/server/config" "github.com/go-git/go-git/v5/plumbing/format/pktline" ) @@ -31,7 +30,7 @@ func TestMain(m *testing.M) { os.Setenv("SOFT_SERVE_GIT_MAX_TIMEOUT", "100") os.Setenv("SOFT_SERVE_GIT_IDLE_TIMEOUT", "1") os.Setenv("SOFT_SERVE_GIT_LISTEN_ADDR", fmt.Sprintf(":%d", randomPort())) - fb, err := file.NewFileBackend(filepath.Join(tmp, "repos")) + fb, err := sqlite.NewSqliteBackend(tmp) if err != nil { log.Fatal(err) } diff --git a/server/http.go b/server/http.go index 30f4547d9a3c44ff68d275e40020a2c3e12aeb23..fc9fc6d5680a8eb34190a0b4c2919bf049ca0589 100644 --- a/server/http.go +++ b/server/http.go @@ -137,7 +137,7 @@ func (s *HTTPServer) Shutdown(ctx context.Context) error { // Pattern is a pattern for matching a URL. // It matches against GET requests. type Pattern struct { - match func(*url.URL) *Match + match func(*url.URL) *match } // NewPattern returns a new Pattern with the given matcher. @@ -164,64 +164,65 @@ func (p *Pattern) Match(r *http.Request) *http.Request { } // Matcher finds a match in a *url.URL. -type Matcher = func(*url.URL) *Match +type Matcher = func(*url.URL) *match var ( - getInfoRefs = func(u *url.URL) *Match { + getInfoRefs = func(u *url.URL) *match { return matchSuffix(u.Path, "/info/refs") } - getHead = func(u *url.URL) *Match { + getHead = func(u *url.URL) *match { return matchSuffix(u.Path, "/HEAD") } - getAlternates = func(u *url.URL) *Match { + getAlternates = func(u *url.URL) *match { return matchSuffix(u.Path, "/objects/info/alternates") } - getHTTPAlternates = func(u *url.URL) *Match { + getHTTPAlternates = func(u *url.URL) *match { return matchSuffix(u.Path, "/objects/info/http-alternates") } - getInfoPacks = func(u *url.URL) *Match { + getInfoPacks = func(u *url.URL) *match { return matchSuffix(u.Path, "/objects/info/packs") } getInfoFileRegexp = regexp.MustCompile(".*?(/objects/info/[^/]*)$") - getInfoFile = func(u *url.URL) *Match { + getInfoFile = func(u *url.URL) *match { return findStringSubmatch(u.Path, getInfoFileRegexp) } getLooseObjectRegexp = regexp.MustCompile(".*?(/objects/[0-9a-f]{2}/[0-9a-f]{38})$") - getLooseObject = func(u *url.URL) *Match { + getLooseObject = func(u *url.URL) *match { return findStringSubmatch(u.Path, getLooseObjectRegexp) } getPackFileRegexp = regexp.MustCompile(`.*?(/objects/pack/pack-[0-9a-f]{40}\.pack)$`) - getPackFile = func(u *url.URL) *Match { + getPackFile = func(u *url.URL) *match { return findStringSubmatch(u.Path, getPackFileRegexp) } getIdxFileRegexp = regexp.MustCompile(`.*?(/objects/pack/pack-[0-9a-f]{40}\.idx)$`) - getIdxFile = func(u *url.URL) *Match { + getIdxFile = func(u *url.URL) *match { return findStringSubmatch(u.Path, getIdxFileRegexp) } ) -type Match struct { +// match represents a match for a URL. +type match struct { RepoPath, FilePath string } -func matchSuffix(path, suffix string) *Match { +func matchSuffix(path, suffix string) *match { if !strings.HasSuffix(path, suffix) { return nil } repoPath := strings.Replace(path, suffix, "", 1) filePath := strings.Replace(path, repoPath+"/", "", 1) - return &Match{repoPath, filePath} + return &match{repoPath, filePath} } -func findStringSubmatch(path string, prefix *regexp.Regexp) *Match { +func findStringSubmatch(path string, prefix *regexp.Regexp) *match { m := prefix.FindStringSubmatch(path) if m == nil { return nil @@ -229,7 +230,7 @@ func findStringSubmatch(path string, prefix *regexp.Regexp) *Match { suffix := m[1] repoPath := strings.Replace(path, suffix, "", 1) filePath := strings.Replace(path, repoPath+"/", "", 1) - return &Match{repoPath, filePath} + return &match{repoPath, filePath} } var repoIndexHTMLTpl = template.Must(template.New("index").Parse(` @@ -258,7 +259,7 @@ func (s *HTTPServer) handleIndex(w http.ResponseWriter, r *http.Request) { return } - access := s.cfg.Backend.AccessLevel(repo, nil) + access := s.cfg.Backend.AccessLevel(repo, "") if access < backend.ReadOnlyAccess { http.NotFound(w, r) return @@ -300,7 +301,7 @@ func (s *HTTPServer) handleGit(w http.ResponseWriter, r *http.Request) { return } - access := s.cfg.Backend.AccessLevel(repo, nil) + access := s.cfg.Backend.AccessLevel(repo, "") if access < backend.ReadOnlyAccess { http.Error(w, "Unauthorized", http.StatusUnauthorized) return diff --git a/server/jobs.go b/server/jobs.go index d9621b8b064ffc4c0d3aee0202217f4d9f7b4085..df5fb0cc0d875ad01167207f141cad939ea524c2 100644 --- a/server/jobs.go +++ b/server/jobs.go @@ -23,7 +23,7 @@ func mirrorJob(b backend.Backend) func() { for _, repo := range repos { if repo.IsMirror() { - logger.Debug("updating mirror", "repo", repo.Name()) + logger.Info("updating mirror", "repo", repo.Name()) r, err := repo.Open() if err != nil { logger.Error("error opening repository", "repo", repo.Name(), "err", err) diff --git a/server/server.go b/server/server.go index bcb5d904d75367ba4c35a2d81d72751eeb120c92..eac408ebd670fc72eb17070c287a2d21b9bc119d 100644 --- a/server/server.go +++ b/server/server.go @@ -9,7 +9,7 @@ import ( "github.com/charmbracelet/log" "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/backend/file" + "github.com/charmbracelet/soft-serve/server/backend/sqlite" "github.com/charmbracelet/soft-serve/server/config" "github.com/charmbracelet/soft-serve/server/cron" "github.com/charmbracelet/ssh" @@ -39,13 +39,14 @@ type Server struct { func NewServer(cfg *config.Config) (*Server, error) { var err error if cfg.Backend == nil { - fb, err := file.NewFileBackend(cfg.DataPath) + sb, err := sqlite.NewSqliteBackend(cfg.DataPath) if err != nil { logger.Fatal(err) } + // Add the initial admin keys to the list of admins. - fb.AdditionalAdmins = cfg.InitialAdminKeys - cfg = cfg.WithBackend(fb) + sb.AdditionalAdmins = cfg.InitialAdminKeys + cfg = cfg.WithBackend(sb) // Create internal key. _, err = keygen.NewWithWrite( diff --git a/server/session.go b/server/session.go index 6c1a655c5bd6a6588d2308affd659e0332a968cd..cd7ddd272c08f2264666d761ac0ae6c92e649344 100644 --- a/server/session.go +++ b/server/session.go @@ -39,7 +39,7 @@ func SessionHandler(cfg *config.Config) bm.ProgramHandler { initialRepo := "" if len(cmd) == 1 { initialRepo = cmd[0] - auth := cfg.Backend.AccessLevel(initialRepo, s.PublicKey()) + auth := cfg.Backend.AccessLevelByPublicKey(initialRepo, s.PublicKey()) if auth < backend.ReadOnlyAccess { wish.Fatalln(s, cm.ErrUnauthorized) return nil diff --git a/server/session_test.go b/server/session_test.go index 542d312c6a5c7778dda1a45c4cea12fe42871091..1e6b6d8b781ad7b6f804f7dad2feefa3a84bf8f8 100644 --- a/server/session_test.go +++ b/server/session_test.go @@ -5,11 +5,10 @@ import ( "fmt" "log" "os" - "path/filepath" "testing" "time" - "github.com/charmbracelet/soft-serve/server/backend/file" + "github.com/charmbracelet/soft-serve/server/backend/sqlite" "github.com/charmbracelet/soft-serve/server/config" "github.com/charmbracelet/ssh" bm "github.com/charmbracelet/wish/bubbletea" @@ -52,7 +51,7 @@ func setup(tb testing.TB) *gossh.Session { is.NoErr(os.Unsetenv("SOFT_SERVE_SSH_LISTEN_ADDR")) is.NoErr(os.RemoveAll(dp)) }) - fb, err := file.NewFileBackend(filepath.Join(dp, "repos")) + fb, err := sqlite.NewSqliteBackend(dp) if err != nil { log.Fatal(err) } diff --git a/server/ssh.go b/server/ssh.go index 06916813d2cf7dc922b36202663f48ceb227a79f..9f6da44cfe8005f27db0ab4da3b97dda2a7e80f8 100644 --- a/server/ssh.go +++ b/server/ssh.go @@ -32,7 +32,7 @@ var ( Subsystem: "ssh", Name: "public_key_auth_total", Help: "The total number of public key auth requests", - }, []string{"key", "user", "access", "allowed"}) + }, []string{"key", "user", "allowed"}) keyboardInteractiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "soft_serve", @@ -136,12 +136,26 @@ func (s *SSHServer) Shutdown(ctx context.Context) error { } // PublicKeyAuthHandler handles public key authentication. -func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool { - ac := s.cfg.Backend.AccessLevel("", pk) - allowed := ac >= backend.ReadOnlyAccess +func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) (allowed bool) { ak := backend.MarshalAuthorizedKey(pk) - publicKeyCounter.WithLabelValues(ak, ctx.User(), ac.String(), strconv.FormatBool(allowed)).Inc() - return allowed + defer func() { + publicKeyCounter.WithLabelValues(ak, ctx.User(), strconv.FormatBool(allowed)).Inc() + }() + for _, k := range s.cfg.InitialAdminKeys { + if k == ak { + allowed = true + return + } + } + + user, _ := s.cfg.Backend.UserByPublicKey(pk) + if user == nil { + logger.Debug("public key auth user not found") + return s.cfg.Backend.AnonAccess() >= backend.ReadOnlyAccess + } + + allowed = s.cfg.Backend.AccessLevel("", user.Username()) >= backend.ReadOnlyAccess + return } // KeyboardInteractiveHandler handles keyboard interactive authentication. @@ -167,7 +181,7 @@ func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware { name := utils.SanitizeRepo(cmd[1]) pk := s.PublicKey() ak := backend.MarshalAuthorizedKey(pk) - access := cfg.Backend.AccessLevel(name, pk) + access := cfg.Backend.AccessLevelByPublicKey(name, pk) // git bare repositories should end in ".git" // https://git-scm.com/docs/gitrepository-layout repo := name + ".git" diff --git a/ui/pages/selection/selection.go b/ui/pages/selection/selection.go index 944251ebf5b04141115800f6487c9752d193edc9..97e47ff9708d67612a754dcc9a7735b03f5576a1 100644 --- a/ui/pages/selection/selection.go +++ b/ui/pages/selection/selection.go @@ -198,7 +198,7 @@ func (s *Selection) Init() tea.Cmd { } sortedItems := make(Items, 0) for _, r := range repos { - al := cfg.Backend.AccessLevel(r.Name(), pk) + al := cfg.Backend.AccessLevelByPublicKey(r.Name(), pk) if al >= backend.ReadOnlyAccess { item, err := NewItem(r, cfg) if err != nil {