Detailed changes
@@ -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
)
@@ -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=
@@ -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.
@@ -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
-}
@@ -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)
-}
@@ -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.
@@ -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.
@@ -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
+}
@@ -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")
+)
@@ -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
+}
@@ -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
+ );`
+)
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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
-}
@@ -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.
@@ -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)
},
}
@@ -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
@@ -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
+}
@@ -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
+}
@@ -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())
}
}
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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",
}
@@ -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
+}
@@ -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.
@@ -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
@@ -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)
}
@@ -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(`<!DOCTYPE html>
@@ -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
@@ -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)
@@ -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(
@@ -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
@@ -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)
}
@@ -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"
@@ -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 {