feat(backend): add sqlite backend

Ayman Bagabas created

Change summary

go.mod                          |  16 
go.sum                          |  76 ++
server/backend/backend.go       |   4 
server/backend/file/file.go     | 940 -----------------------------------
server/backend/file/repo.go     |  73 --
server/backend/repo.go          |  24 
server/backend/settings.go      |   4 
server/backend/sqlite/db.go     | 115 ++++
server/backend/sqlite/error.go  |  11 
server/backend/sqlite/repo.go   |  88 +++
server/backend/sqlite/sql.go    |  60 ++
server/backend/sqlite/sqlite.go | 598 ++++++++++++++++++++++
server/backend/sqlite/user.go   | 326 ++++++++++++
server/backend/user.go          |  55 ++
server/cmd/admin.go             |  88 ---
server/cmd/cmd.go               |  57 +
server/cmd/collab.go            |  27 
server/cmd/create.go            |   3 
server/cmd/import.go            |  42 +
server/cmd/info.go              |  31 +
server/cmd/list.go              |   2 
server/cmd/pubkey.go            |  85 +++
server/cmd/repo.go              |  29 +
server/cmd/set_username.go      |  22 
server/cmd/settings.go          |   4 
server/cmd/user.go              | 193 +++++++
server/cron/cron.go             |   2 
server/daemon.go                |   2 
server/daemon_test.go           |   5 
server/http.go                  |  37 
server/jobs.go                  |   2 
server/server.go                |   9 
server/session.go               |   2 
server/session_test.go          |   5 
server/ssh.go                   |  28 
ui/pages/selection/selection.go |   2 
36 files changed, 1,863 insertions(+), 1,204 deletions(-)

Detailed changes

go.mod 🔗

@@ -25,6 +25,7 @@ require (
 	github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103
 	github.com/gobwas/glob v0.2.3
 	github.com/gogs/git-module v1.8.1
+	github.com/jmoiron/sqlx v1.3.5
 	github.com/lrstanley/bubblezone v0.0.0-20220716194435-3cb8c52f6a8f
 	github.com/muesli/mango-cobra v1.2.0
 	github.com/muesli/roff v0.1.0
@@ -35,6 +36,7 @@ require (
 	golang.org/x/crypto v0.7.0
 	golang.org/x/sync v0.1.0
 	gopkg.in/yaml.v3 v3.0.1
+	modernc.org/sqlite v1.21.1
 )
 
 require (
@@ -49,8 +51,10 @@ require (
 	github.com/dlclark/regexp2 v1.4.0 // indirect
 	github.com/go-logfmt/logfmt v0.6.0 // indirect
 	github.com/golang/protobuf v1.5.2 // indirect
+	github.com/google/uuid v1.3.0 // indirect
 	github.com/gorilla/css v1.0.0 // indirect
 	github.com/inconshreveable/mousetrap v1.0.1 // indirect
+	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
 	github.com/mattn/go-isatty v0.0.18 // indirect
 	github.com/mattn/go-localereader v0.0.1 // indirect
@@ -67,14 +71,26 @@ require (
 	github.com/prometheus/client_model v0.3.0 // indirect
 	github.com/prometheus/common v0.37.0 // indirect
 	github.com/prometheus/procfs v0.8.0 // indirect
+	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 	github.com/rivo/uniseg v0.2.0 // indirect
 	github.com/sahilm/fuzzy v0.1.0 // indirect
 	github.com/spf13/pflag v1.0.5 // indirect
 	github.com/yuin/goldmark v1.5.2 // indirect
 	github.com/yuin/goldmark-emoji v1.0.1 // indirect
+	golang.org/x/mod v0.8.0 // indirect
 	golang.org/x/net v0.8.0 // indirect
 	golang.org/x/sys v0.6.0 // indirect
 	golang.org/x/term v0.6.0 // indirect
 	golang.org/x/text v0.8.0 // indirect
+	golang.org/x/tools v0.6.0 // indirect
 	google.golang.org/protobuf v1.28.1 // indirect
+	lukechampine.com/uint128 v1.2.0 // indirect
+	modernc.org/cc/v3 v3.40.0 // indirect
+	modernc.org/ccgo/v3 v3.16.13 // indirect
+	modernc.org/libc v1.22.3 // indirect
+	modernc.org/mathutil v1.5.0 // indirect
+	modernc.org/memory v1.5.0 // indirect
+	modernc.org/opt v0.1.3 // indirect
+	modernc.org/strutil v1.1.3 // indirect
+	modernc.org/token v1.0.1 // indirect
 )

go.sum 🔗

@@ -93,8 +93,11 @@ github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103/go.mod h1:0Vm2/8
 github.com/charmbracelet/wish v1.1.0 h1:0ArX9SOG70saqd23NYjoS56oLPVNgqcQegkz1Lw+4zY=
 github.com/charmbracelet/wish v1.1.0/go.mod h1:yHbm0hs/qX4lFE7nrhAcXjFYc8bxMIfSqJOfOYfwyYo=
 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
+github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
+github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=
 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
+github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
 github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
@@ -107,6 +110,7 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
 github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
+github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
@@ -134,6 +138,8 @@ github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG
 github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
 github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
+github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
+github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
 github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
 github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
@@ -177,6 +183,7 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@@ -192,7 +199,11 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf
 github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
 github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
 github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
+github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
+github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
 github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
@@ -201,12 +212,15 @@ github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ
 github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 github.com/hashicorp/golang-lru/v2 v2.0.2/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
 github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
+github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
 github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg=
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
 github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
 github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
 github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
+github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
+github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
 github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
 github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
 github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@@ -216,8 +230,11 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1
 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
 github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
 github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
+github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
+github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
 github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
@@ -230,6 +247,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=
+github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lrstanley/bubblezone v0.0.0-20220716194435-3cb8c52f6a8f h1:FjWlbnOxKSZpFlNhsx6xFy/OnkdYTAYTuoulojPdZ9o=
 github.com/lrstanley/bubblezone v0.0.0-20220716194435-3cb8c52f6a8f/go.mod h1:CxaUrg7Y6DmnquTpb1Rgxib+u+NcRxrDi8m/mR1poTM=
 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
@@ -250,6 +269,9 @@ github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRC
 github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
 github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
+github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
+github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
 github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
 github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75 h1:Pijfgr7ZuvX7QIQiEwLdRVr3RoMG+i0SbBO1Qu+7yVk=
@@ -324,6 +346,9 @@ github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1
 github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
 github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
 github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
+github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -423,6 +448,7 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
+golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -452,6 +478,7 @@ golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/
 golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
@@ -480,6 +507,7 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -517,6 +545,7 @@ golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -530,7 +559,9 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -603,8 +634,10 @@ golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roY
 golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
 golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
 golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
+golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -713,6 +746,49 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
 honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
+lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
+lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
+lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
+modernc.org/cc/v3 v3.37.0/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
+modernc.org/cc/v3 v3.38.1/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
+modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
+modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
+modernc.org/ccgo/v3 v3.0.0-20220904174949-82d86e1b6d56/go.mod h1:YSXjPL62P2AMSxBphRHPn7IkzhVHqkvOnRKAKh+W6ZI=
+modernc.org/ccgo/v3 v3.0.0-20220910160915-348f15de615a/go.mod h1:8p47QxPkdugex9J4n9P2tLZ9bK01yngIVp00g4nomW0=
+modernc.org/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8/go.mod h1:fUB3Vn0nVPReA+7IG7yZDfjv1TMWjhQP8gCxrFAtL5g=
+modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
+modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
+modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
+modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
+modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
+modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
+modernc.org/libc v1.17.4/go.mod h1:WNg2ZH56rDEwdropAJeZPQkXmDwh+JCA1s/htl6r2fA=
+modernc.org/libc v1.18.0/go.mod h1:vj6zehR5bfc98ipowQOM2nIDUZnVew/wNC/2tOGS+q0=
+modernc.org/libc v1.19.0/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
+modernc.org/libc v1.20.3/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
+modernc.org/libc v1.21.2/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=
+modernc.org/libc v1.21.4/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=
+modernc.org/libc v1.22.3 h1:D/g6O5ftAfavceqlLOFwaZuA5KYafKwmr30A6iSqoyY=
+modernc.org/libc v1.22.3/go.mod h1:MQrloYP209xa2zHome2a8HLiLm6k0UT8CoHpV74tOFw=
+modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
+modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
+modernc.org/memory v1.3.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
+modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
+modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
+modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
+modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
+modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
+modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
+modernc.org/sqlite v1.21.1 h1:GyDFqNnESLOhwwDRaHGdp2jKLDzpyT/rNLglX3ZkMSU=
+modernc.org/sqlite v1.21.1/go.mod h1:XwQ0wZPIh1iKb5mkvCJ3szzbhk+tykC8ZWqTRTgYRwI=
+modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
+modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
+modernc.org/tcl v1.15.1 h1:mOQwiEK4p7HruMZcwKTZPw/aqtGM4aY00uzWhlKKYws=
+modernc.org/tcl v1.15.1/go.mod h1:aEjeGJX2gz1oWKOLDVZ2tnEWLUrIn8H+GFu+akoDhqs=
+modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
+modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+modernc.org/z v1.7.0 h1:xkDw/KepgEjeizO2sNco+hqYkU12taxQFqPEmgm1GWE=
+modernc.org/z v1.7.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ=
 rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
 rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
 rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=

server/backend/backend.go 🔗

@@ -9,10 +9,12 @@ import (
 // Backend is an interface that handles repositories management and any
 // non-Git related operations.
 type Backend interface {
-	ServerBackend
+	SettingsBackend
 	RepositoryStore
 	RepositoryMetadata
 	RepositoryAccess
+	UserStore
+	UserAccess
 }
 
 // ParseAuthorizedKey parses an authorized key string into a public key.

server/backend/file/file.go 🔗

@@ -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
-}

server/backend/file/repo.go 🔗

@@ -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)
-}

server/backend/repo.go 🔗

@@ -2,15 +2,14 @@ package backend
 
 import (
 	"github.com/charmbracelet/soft-serve/git"
-	"golang.org/x/crypto/ssh"
 )
 
 // RepositoryOptions are options for creating a new repository.
 type RepositoryOptions struct {
 	Private     bool
-	Mirror      string
 	Description string
 	ProjectName string
+	Mirror      bool
 }
 
 // RepositoryStore is an interface for managing repositories.
@@ -21,10 +20,14 @@ type RepositoryStore interface {
 	Repositories() ([]Repository, error)
 	// CreateRepository creates a new repository.
 	CreateRepository(name string, opts RepositoryOptions) (Repository, error)
+	// ImportRepository creates a new repository from a Git repository.
+	ImportRepository(name string, remote string, opts RepositoryOptions) (Repository, error)
 	// DeleteRepository deletes a repository.
 	DeleteRepository(name string) error
 	// RenameRepository renames a repository.
 	RenameRepository(oldName, newName string) error
+	// InitializeHooks initializes the hooks for the given repository.
+	InitializeHooks(repo string) error
 }
 
 // RepositoryMetadata is an interface for managing repository metadata.
@@ -47,24 +50,13 @@ type RepositoryMetadata interface {
 
 // RepositoryAccess is an interface for managing repository access.
 type RepositoryAccess interface {
-	// AccessLevel returns the access level for the given repository and key.
-	AccessLevel(repo string, pk ssh.PublicKey) AccessLevel
-	// IsCollaborator returns true if the authorized key is a collaborator on the repository.
-	IsCollaborator(pk ssh.PublicKey, repo string) bool
+	IsCollaborator(repo string, username string) bool
 	// AddCollaborator adds the authorized key as a collaborator on the repository.
-	AddCollaborator(pk ssh.PublicKey, memo string, repo string) error
+	AddCollaborator(repo string, username string) error
 	// RemoveCollaborator removes the authorized key as a collaborator on the repository.
-	RemoveCollaborator(pk ssh.PublicKey, repo string) error
+	RemoveCollaborator(repo string, username string) error
 	// Collaborators returns a list of all collaborators on the repository.
 	Collaborators(repo string) ([]string, error)
-	// IsAdmin returns true if the authorized key is an admin.
-	IsAdmin(pk ssh.PublicKey) bool
-	// AddAdmin adds the authorized key as an admin.
-	AddAdmin(pk ssh.PublicKey, memo string) error
-	// RemoveAdmin removes the authorized key as an admin.
-	RemoveAdmin(pk ssh.PublicKey) error
-	// Admins returns a list of all admins.
-	Admins() ([]string, error)
 }
 
 // Repository is a Git repository interface.

server/backend/server.go → server/backend/settings.go 🔗

@@ -1,7 +1,7 @@
 package backend
 
-// ServerBackend is an interface that handles server configuration.
-type ServerBackend interface {
+// SettingsBackend is an interface that handles server configuration.
+type SettingsBackend interface {
 	// AnonAccess returns the access level for anonymous users.
 	AnonAccess() AccessLevel
 	// SetAnonAccess sets the access level for anonymous users.

server/backend/sqlite/db.go 🔗

@@ -0,0 +1,115 @@
+package sqlite
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+	"fmt"
+
+	"github.com/charmbracelet/soft-serve/server/backend"
+	"github.com/jmoiron/sqlx"
+	"golang.org/x/crypto/bcrypt"
+	"modernc.org/sqlite"
+	sqlite3 "modernc.org/sqlite/lib"
+)
+
+// Close closes the database.
+func (d *SqliteBackend) Close() error {
+	return d.db.Close()
+}
+
+// init creates the database.
+func (d *SqliteBackend) init() error {
+	return wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+		if _, err := tx.Exec(sqlCreateSettingsTable); err != nil {
+			return err
+		}
+		if _, err := tx.Exec(sqlCreateUserTable); err != nil {
+			return err
+		}
+		if _, err := tx.Exec(sqlCreatePublicKeyTable); err != nil {
+			return err
+		}
+		if _, err := tx.Exec(sqlCreateRepoTable); err != nil {
+			return err
+		}
+		if _, err := tx.Exec(sqlCreateCollabTable); err != nil {
+			return err
+		}
+
+		// Set default settings.
+		if _, err := tx.Exec("INSERT OR IGNORE INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", "allow_keyless", true); err != nil {
+			return err
+		}
+		if _, err := tx.Exec("INSERT OR IGNORE INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", "anon_access", backend.ReadOnlyAccess.String()); err != nil {
+			return err
+		}
+
+		return nil
+	})
+}
+
+func wrapDbErr(err error) error {
+	if err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			return ErrNoRecord
+		}
+		if liteErr, ok := err.(*sqlite.Error); ok {
+			code := liteErr.Code()
+			if code == sqlite3.SQLITE_CONSTRAINT_PRIMARYKEY ||
+				code == sqlite3.SQLITE_CONSTRAINT_UNIQUE {
+				return ErrDuplicateKey
+			}
+		}
+	}
+	return err
+}
+
+func wrapTx(db *sqlx.DB, ctx context.Context, fn func(tx *sqlx.Tx) error) error {
+	tx, err := db.BeginTxx(ctx, nil)
+	if err != nil {
+		return fmt.Errorf("failed to begin transaction: %w", err)
+	}
+
+	if err := fn(tx); err != nil {
+		return rollback(tx, err)
+	}
+
+	if err := tx.Commit(); err != nil {
+		if errors.Is(err, sql.ErrTxDone) {
+			// this is ok because whoever did finish the tx should have also written the error already.
+			return nil
+		}
+		return fmt.Errorf("failed to commit transaction: %w", err)
+	}
+
+	return nil
+}
+
+func rollback(tx *sqlx.Tx, err error) error {
+	if rerr := tx.Rollback(); rerr != nil {
+		if errors.Is(rerr, sql.ErrTxDone) {
+			return err
+		}
+		return fmt.Errorf("failed to rollback: %s: %w", err.Error(), rerr)
+	}
+
+	return err
+}
+
+func hashPassword(password string) (string, error) {
+	hash, err := bcrypt.GenerateFromPassword([]byte(password+"soft-serve-v1"), 14)
+	if err != nil {
+		return "", fmt.Errorf("failed to hash password: %w", err)
+	}
+
+	return string(hash), nil
+}
+
+func checkPassword(hash, password string) error {
+	if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password+"soft-serve-v1")); err != nil {
+		return fmt.Errorf("failed to check password: %w", err)
+	}
+
+	return nil
+}

server/backend/sqlite/error.go 🔗

@@ -0,0 +1,11 @@
+package sqlite
+
+import "errors"
+
+var (
+	// ErrDuplicateKey is returned when a unique constraint is violated.
+	ErrDuplicateKey = errors.New("record already exists")
+
+	// ErrNoRecord is returned when a record is not found.
+	ErrNoRecord = errors.New("record not found")
+)

server/backend/sqlite/repo.go 🔗

@@ -0,0 +1,88 @@
+package sqlite
+
+import (
+	"context"
+
+	"github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/server/backend"
+	"github.com/jmoiron/sqlx"
+)
+
+var _ backend.Repository = (*Repo)(nil)
+
+// Repo is a Git repository with metadata stored in a SQLite database.
+type Repo struct {
+	name string
+	path string
+	db   *sqlx.DB
+}
+
+// Description returns the repository's description.
+//
+// It implements backend.Repository.
+func (r *Repo) Description() string {
+	var desc string
+	if err := wrapTx(r.db, context.Background(), func(tx *sqlx.Tx) error {
+		return tx.Get(&desc, "SELECT description FROM repo WHERE name = ?", r.name)
+	}); err != nil {
+		return ""
+	}
+
+	return desc
+}
+
+// IsMirror returns whether the repository is a mirror.
+//
+// It implements backend.Repository.
+func (r *Repo) IsMirror() bool {
+	var mirror bool
+	if err := wrapTx(r.db, context.Background(), func(tx *sqlx.Tx) error {
+		return tx.Get(&mirror, "SELECT mirror FROM repo WHERE name = ?", r.name)
+	}); err != nil {
+		return false
+	}
+
+	return mirror
+}
+
+// IsPrivate returns whether the repository is private.
+//
+// It implements backend.Repository.
+func (r *Repo) IsPrivate() bool {
+	var private bool
+	if err := wrapTx(r.db, context.Background(), func(tx *sqlx.Tx) error {
+		return tx.Get(&private, "SELECT private FROM repo WHERE name = ?", r.name)
+	}); err != nil {
+		return false
+	}
+
+	return private
+}
+
+// Name returns the repository's name.
+//
+// It implements backend.Repository.
+func (r *Repo) Name() string {
+	return r.name
+}
+
+// Open opens the repository.
+//
+// It implements backend.Repository.
+func (r *Repo) Open() (*git.Repository, error) {
+	return git.Open(r.path)
+}
+
+// ProjectName returns the repository's project name.
+//
+// It implements backend.Repository.
+func (r *Repo) ProjectName() string {
+	var name string
+	if err := wrapTx(r.db, context.Background(), func(tx *sqlx.Tx) error {
+		return tx.Get(&name, "SELECT project_name FROM repo WHERE name = ?", r.name)
+	}); err != nil {
+		return ""
+	}
+
+	return name
+}

server/backend/sqlite/sql.go 🔗

@@ -0,0 +1,60 @@
+package sqlite
+
+var (
+	sqlCreateSettingsTable = `CREATE TABLE IF NOT EXISTS settings (
+		id INTEGER PRIMARY KEY AUTOINCREMENT,
+		key TEXT NOT NULL UNIQUE,
+		value TEXT NOT NULL,
+		created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+		updated_at DATETIME NOT NULL
+	);`
+
+	sqlCreateUserTable = `CREATE TABLE IF NOT EXISTS user (
+		id INTEGER PRIMARY KEY AUTOINCREMENT,
+		username TEXT UNIQUE,
+		admin BOOLEAN NOT NULL,
+		created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+		updated_at DATETIME NOT NULL
+	);`
+
+	sqlCreatePublicKeyTable = `CREATE TABLE IF NOT EXISTS public_key (
+		id INTEGER PRIMARY KEY AUTOINCREMENT,
+		user_id INTEGER NOT NULL,
+		public_key TEXT NOT NULL,
+		created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+		updated_at DATETIME NOT NULL,
+		UNIQUE (user_id, public_key),
+		CONSTRAINT user_id_fk
+		FOREIGN KEY(user_id) REFERENCES user(id)
+		ON DELETE CASCADE
+		ON UPDATE CASCADE
+	);`
+
+	sqlCreateRepoTable = `CREATE TABLE IF NOT EXISTS repo (
+		id INTEGER PRIMARY KEY AUTOINCREMENT,
+		name TEXT NOT NULL UNIQUE,
+		project_name TEXT NOT NULL,
+		description TEXT NOT NULL,
+		private BOOLEAN NOT NULL,
+		mirror BOOLEAN NOT NULL,
+		created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+		updated_at DATETIME NOT NULL
+	);`
+
+	sqlCreateCollabTable = `CREATE TABLE IF NOT EXISTS collab (
+		id INTEGER PRIMARY KEY AUTOINCREMENT,
+		user_id INTEGER NOT NULL,
+		repo_id INTEGER NOT NULL,
+		created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+		updated_at DATETIME NOT NULL,
+		UNIQUE (user_id, repo_id),
+		CONSTRAINT user_id_fk
+		FOREIGN KEY(user_id) REFERENCES user(id)
+		ON DELETE CASCADE
+		ON UPDATE CASCADE,
+		CONSTRAINT repo_id_fk
+		FOREIGN KEY(repo_id) REFERENCES repo(id)
+		ON DELETE CASCADE
+		ON UPDATE CASCADE
+	);`
+)

server/backend/sqlite/sqlite.go 🔗

@@ -0,0 +1,598 @@
+package sqlite
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strconv"
+	"text/template"
+
+	"github.com/charmbracelet/log"
+	"github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/server/backend"
+	"github.com/charmbracelet/soft-serve/server/utils"
+	"github.com/jmoiron/sqlx"
+	_ "modernc.org/sqlite"
+)
+
+var (
+	logger = log.WithPrefix("backend.sqlite")
+)
+
+// SqliteBackend is a backend that uses a SQLite database as a Soft Serve
+// backend.
+type SqliteBackend struct {
+	dp               string
+	db               *sqlx.DB
+	AdditionalAdmins []string
+}
+
+var _ backend.Backend = (*SqliteBackend)(nil)
+
+func (d *SqliteBackend) reposPath() string {
+	return filepath.Join(d.dp, "repos")
+}
+
+// NewSqliteBackend creates a new SqliteBackend.
+func NewSqliteBackend(dataPath string) (*SqliteBackend, error) {
+	db, err := sqlx.Connect("sqlite", filepath.Join(dataPath, "soft-serve.db"+
+		"?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)"))
+	if err != nil {
+		return nil, err
+	}
+
+	d := &SqliteBackend{
+		dp: dataPath,
+		db: db,
+	}
+
+	if err := d.init(); err != nil {
+		return nil, err
+	}
+
+	return d, d.db.Ping()
+}
+
+// AllowKeyless returns whether or not keyless access is allowed.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) AllowKeyless() bool {
+	var allow bool
+	if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+		return tx.Get(&allow, "SELECT value FROM settings WHERE key = ?;", "allow_keyless")
+	}); err != nil {
+		return false
+	}
+
+	return allow
+}
+
+// AnonAccess returns the level of anonymous access.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) AnonAccess() backend.AccessLevel {
+	var level string
+	if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+		return tx.Get(&level, "SELECT value FROM settings WHERE key = ?;", "anon_access")
+	}); err != nil {
+		return backend.NoAccess
+	}
+
+	return backend.ParseAccessLevel(level)
+}
+
+// SetAllowKeyless sets whether or not keyless access is allowed.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) SetAllowKeyless(allow bool) error {
+	return wrapDbErr(
+		wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+			_, err := tx.Exec("UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?;", "allow_keyless", strconv.FormatBool(allow))
+			return err
+		}),
+	)
+}
+
+// SetAnonAccess sets the level of anonymous access.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) SetAnonAccess(level backend.AccessLevel) error {
+	return wrapDbErr(
+		wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+			_, err := tx.Exec("UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?;", "anon_access", level.String())
+			return err
+		}),
+	)
+}
+
+// CreateRepository creates a new repository.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) CreateRepository(name string, opts backend.RepositoryOptions) (backend.Repository, error) {
+	name = utils.SanitizeRepo(name)
+	repo := name + ".git"
+	rp := filepath.Join(d.reposPath(), repo)
+
+	cleanup := func() error {
+		return os.RemoveAll(rp)
+	}
+
+	rr, err := git.Init(rp, true)
+	if err != nil {
+		logger.Debug("failed to create repository", "err", err)
+		cleanup() // nolint: errcheck
+		return nil, err
+	}
+
+	if err := rr.UpdateServerInfo(); err != nil {
+		logger.Debug("failed to update server info", "err", err)
+		cleanup() // nolint: errcheck
+		return nil, err
+	}
+
+	if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+		_, err := tx.Exec(`INSERT INTO repo (name, project_name, description, private, mirror, updated_at)
+			VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`,
+			name, opts.ProjectName, opts.Description, opts.Private, opts.Mirror)
+		return err
+	}); err != nil {
+		logger.Debug("failed to create repository in database", "err", err)
+		return nil, wrapDbErr(err)
+	}
+
+	r := &Repo{
+		name: name,
+		path: rp,
+		db:   d.db,
+	}
+
+	return r, d.InitializeHooks(name)
+}
+
+// ImportRepository imports a repository from remote.
+func (d *SqliteBackend) ImportRepository(name string, remote string, opts backend.RepositoryOptions) (backend.Repository, error) {
+	name = utils.SanitizeRepo(name)
+	repo := name + ".git"
+	rp := filepath.Join(d.reposPath(), repo)
+
+	copts := git.CloneOptions{
+		Mirror: opts.Mirror,
+	}
+	if err := git.Clone(remote, rp, copts); err != nil {
+		logger.Debug("failed to clone repository", "err", err, "mirror", opts.Mirror, "remote", remote, "path", rp)
+		return nil, err
+	}
+
+	return d.CreateRepository(name, opts)
+}
+
+// DeleteRepository deletes a repository.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) DeleteRepository(name string) error {
+	name = utils.SanitizeRepo(name)
+	repo := name + ".git"
+	rp := filepath.Join(d.reposPath(), repo)
+	if _, err := os.Stat(rp); err != nil {
+		return os.ErrNotExist
+	}
+
+	if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+		_, err := tx.Exec("DELETE FROM repo WHERE name = ?;", name)
+		return err
+	}); err != nil {
+		return wrapDbErr(err)
+	}
+
+	return os.RemoveAll(rp)
+}
+
+// RenameRepository renames a repository.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) RenameRepository(oldName string, newName string) error {
+	oldName = utils.SanitizeRepo(oldName)
+	newName = utils.SanitizeRepo(newName)
+	oldRepo := oldName + ".git"
+	newRepo := newName + ".git"
+	op := filepath.Join(d.reposPath(), oldRepo)
+	np := filepath.Join(d.reposPath(), newRepo)
+	if _, err := os.Stat(op); err != nil {
+		return fmt.Errorf("repository %s does not exist", oldName)
+	}
+
+	if _, err := os.Stat(np); err == nil {
+		return fmt.Errorf("repository %s already exists", newName)
+	}
+
+	if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+		_, err := tx.Exec("UPDATE repo SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;", newName, oldName)
+		return err
+	}); err != nil {
+		return wrapDbErr(err)
+	}
+
+	return os.Rename(op, np)
+}
+
+// Repositories returns a list of all repositories.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) Repositories() ([]backend.Repository, error) {
+	repos := make([]backend.Repository, 0)
+	if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+		rows, err := tx.Query("SELECT name FROM repo")
+		if err != nil {
+			return err
+		}
+
+		defer rows.Close() // nolint: errcheck
+		for rows.Next() {
+			var name string
+			if err := rows.Scan(&name); err != nil {
+				return err
+			}
+
+			repos = append(repos, &Repo{
+				name: name,
+				path: filepath.Join(d.reposPath(), name+".git"),
+				db:   d.db,
+			})
+		}
+
+		return nil
+	}); err != nil {
+		return nil, wrapDbErr(err)
+	}
+
+	return repos, nil
+}
+
+// Repository returns a repository by name.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) Repository(repo string) (backend.Repository, error) {
+	repo = utils.SanitizeRepo(repo)
+	rp := filepath.Join(d.reposPath(), repo+".git")
+	if _, err := os.Stat(rp); err != nil {
+		return nil, os.ErrNotExist
+	}
+
+	var count int
+	if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+		return tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo)
+	}); err != nil {
+		return nil, wrapDbErr(err)
+	}
+
+	if count == 0 {
+		logger.Warn("repository exists but not found in database", "repo", repo)
+		return nil, fmt.Errorf("repository does not exist")
+	}
+
+	return &Repo{
+		name: repo,
+		path: rp,
+		db:   d.db,
+	}, nil
+}
+
+// Description returns the description of a repository.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) Description(repo string) string {
+	repo = utils.SanitizeRepo(repo)
+	var desc string
+	if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+		return tx.Get(&desc, "SELECT description FROM repo WHERE name = ?", repo)
+	}); err != nil {
+		return ""
+	}
+
+	return desc
+}
+
+// IsMirror returns true if the repository is a mirror.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) IsMirror(repo string) bool {
+	repo = utils.SanitizeRepo(repo)
+	var mirror bool
+	if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+		return tx.Get(&mirror, "SELECT mirror FROM repo WHERE name = ?", repo)
+	}); err != nil {
+		return false
+	}
+
+	return mirror
+}
+
+// IsPrivate returns true if the repository is private.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) IsPrivate(repo string) bool {
+	repo = utils.SanitizeRepo(repo)
+	var private bool
+	if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+		return tx.Get(&private, "SELECT private FROM repo WHERE name = ?", repo)
+	}); err != nil {
+		return false
+	}
+
+	return private
+}
+
+// ProjectName returns the project name of a repository.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) ProjectName(repo string) string {
+	repo = utils.SanitizeRepo(repo)
+	var name string
+	if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+		return tx.Get(&name, "SELECT project_name FROM repo WHERE name = ?", repo)
+	}); err != nil {
+		return ""
+	}
+
+	return name
+}
+
+// SetDescription sets the description of a repository.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) SetDescription(repo string, desc string) error {
+	repo = utils.SanitizeRepo(repo)
+	return wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+		_, err := tx.Exec("UPDATE repo SET description = ? WHERE name = ?", desc, repo)
+		return err
+	})
+}
+
+// SetPrivate sets the private flag of a repository.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) SetPrivate(repo string, private bool) error {
+	repo = utils.SanitizeRepo(repo)
+	return wrapDbErr(
+		wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+			_, err := tx.Exec("UPDATE repo SET private = ? WHERE name = ?", private, repo)
+			return err
+		}),
+	)
+}
+
+// SetProjectName sets the project name of a repository.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) SetProjectName(repo string, name string) error {
+	repo = utils.SanitizeRepo(repo)
+	return wrapDbErr(
+		wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+			_, err := tx.Exec("UPDATE repo SET project_name = ? WHERE name = ?", name, repo)
+			return err
+		}),
+	)
+}
+
+// AddCollaborator adds a collaborator to a repository.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) AddCollaborator(repo string, username string) error {
+	repo = utils.SanitizeRepo(repo)
+	return wrapDbErr(wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+		_, err := tx.Exec(`INSERT INTO collab (user_id, repo_id, updated_at)
+			VALUES (
+			(SELECT id FROM user WHERE username = ?),
+			(SELECT id FROM repo WHERE name = ?),
+			CURRENT_TIMESTAMP
+			);`, username, repo)
+		return err
+	}),
+	)
+}
+
+// Collaborators returns a list of collaborators for a repository.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) Collaborators(repo string) ([]string, error) {
+	repo = utils.SanitizeRepo(repo)
+	var users []string
+	if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+		return tx.Select(&users, `SELECT name FROM user
+			INNER JOIN collab ON user.id = collab.user_id
+			INNER JOIN repo ON repo.id = collab.repo_id
+			WHERE repo.name = ?`, repo)
+	}); err != nil {
+		return nil, wrapDbErr(err)
+	}
+
+	return users, nil
+}
+
+// IsCollaborator returns true if the user is a collaborator of the repository.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) IsCollaborator(repo string, username string) bool {
+	repo = utils.SanitizeRepo(repo)
+	var count int
+	if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+		return tx.Get(&count, `SELECT COUNT(*) FROM user
+			INNER JOIN collab ON user.id = collab.user_id
+			INNER JOIN repo ON repo.id = collab.repo_id
+			WHERE repo.name = ? AND user.username = ?`, repo, username)
+	}); err != nil {
+		return false
+	}
+
+	return count > 0
+}
+
+// RemoveCollaborator removes a collaborator from a repository.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) RemoveCollaborator(repo string, username string) error {
+	repo = utils.SanitizeRepo(repo)
+	return wrapDbErr(
+		wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+			_, err := tx.Exec(`DELETE FROM collab
+			WHERE user_id = (SELECT id FROM user WHERE username = ?)
+			AND repo_id = (SELECT id FROM repo WHERE name = ?)`, username, repo)
+			return err
+		}),
+	)
+}
+
+var (
+	hookNames = []string{"pre-receive", "update", "post-update", "post-receive"}
+	hookTpls  = []string{
+		// for pre-receive
+		`#!/usr/bin/env bash
+# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
+data=$(cat)
+exitcodes=""
+hookname=$(basename $0)
+GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
+for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
+  test -x "${hook}" && test -f "${hook}" || continue
+  echo "${data}" | "${hook}"
+  exitcodes="${exitcodes} $?"
+done
+for i in ${exitcodes}; do
+  [ ${i} -eq 0 ] || exit ${i}
+done
+`,
+
+		// for update
+		`#!/usr/bin/env bash
+# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
+exitcodes=""
+hookname=$(basename $0)
+GIT_DIR=${GIT_DIR:-$(dirname $0/..)}
+for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
+  test -x "${hook}" && test -f "${hook}" || continue
+  "${hook}" $1 $2 $3
+  exitcodes="${exitcodes} $?"
+done
+for i in ${exitcodes}; do
+  [ ${i} -eq 0 ] || exit ${i}
+done
+`,
+
+		// for post-update
+		`#!/usr/bin/env bash
+# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
+data=$(cat)
+exitcodes=""
+hookname=$(basename $0)
+GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
+for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
+  test -x "${hook}" && test -f "${hook}" || continue
+  "${hook}" $@
+  exitcodes="${exitcodes} $?"
+done
+for i in ${exitcodes}; do
+  [ ${i} -eq 0 ] || exit ${i}
+done
+`,
+
+		// for post-receive
+		`#!/usr/bin/env bash
+# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
+data=$(cat)
+exitcodes=""
+hookname=$(basename $0)
+GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
+for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
+  test -x "${hook}" && test -f "${hook}" || continue
+  echo "${data}" | "${hook}"
+  exitcodes="${exitcodes} $?"
+done
+for i in ${exitcodes}; do
+  [ ${i} -eq 0 ] || exit ${i}
+done
+`,
+	}
+)
+
+// InitializeHooks updates the hooks for the given repository.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) InitializeHooks(repo string) error {
+	hookTmpl, err := template.New("hook").Parse(`#!/usr/bin/env bash
+# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
+{{ range $_, $env := .Envs }}
+{{ $env }} \{{ end }}
+{{ .Executable }} hook --config "{{ .Config }}" {{ .Hook }} {{ .Args }}
+`)
+	if err != nil {
+		return err
+	}
+
+	repo = utils.SanitizeRepo(repo) + ".git"
+	hooksPath := filepath.Join(d.reposPath(), repo, "hooks")
+	if err := os.MkdirAll(hooksPath, 0755); err != nil {
+		return err
+	}
+
+	ex, err := os.Executable()
+	if err != nil {
+		return err
+	}
+
+	dp, err := filepath.Abs(d.dp)
+	if err != nil {
+		return fmt.Errorf("failed to get absolute path for data path: %w", err)
+	}
+
+	cp := filepath.Join(dp, "config.yaml")
+	envs := []string{}
+	for i, hook := range hookNames {
+		var data bytes.Buffer
+		var args string
+		hp := filepath.Join(hooksPath, hook)
+		if err := os.WriteFile(hp, []byte(hookTpls[i]), 0755); err != nil {
+			return err
+		}
+
+		// Create hook.d directory.
+		hp += ".d"
+		if err := os.MkdirAll(hp, 0755); err != nil {
+			return err
+		}
+
+		if hook == "update" {
+			args = "$1 $2 $3"
+		} else if hook == "post-update" {
+			args = "$@"
+		}
+
+		err = hookTmpl.Execute(&data, struct {
+			Executable string
+			Hook       string
+			Args       string
+			Envs       []string
+			Config     string
+		}{
+			Executable: ex,
+			Hook:       hook,
+			Args:       args,
+			Envs:       envs,
+			Config:     cp,
+		})
+		if err != nil {
+			logger.Error("failed to execute hook template", "err", err)
+			continue
+		}
+
+		hp = filepath.Join(hp, "soft-serve")
+		err = os.WriteFile(hp, data.Bytes(), 0755) //nolint:gosec
+		if err != nil {
+			logger.Error("failed to write hook", "err", err)
+			continue
+		}
+	}
+
+	return nil
+}

server/backend/sqlite/user.go 🔗

@@ -0,0 +1,326 @@
+package sqlite
+
+import (
+	"context"
+
+	"github.com/charmbracelet/soft-serve/server/backend"
+	"github.com/jmoiron/sqlx"
+	"golang.org/x/crypto/ssh"
+)
+
+// User represents a user.
+type User struct {
+	username string
+	db       *sqlx.DB
+}
+
+var _ backend.User = (*User)(nil)
+
+// IsAdmin returns whether the user is an admin.
+//
+// It implements backend.User.
+func (u *User) IsAdmin() bool {
+	var admin bool
+	if err := wrapTx(u.db, context.Background(), func(tx *sqlx.Tx) error {
+		return tx.Get(&admin, "SELECT admin FROM user WHERE username = ?", u.username)
+	}); err != nil {
+		return false
+	}
+
+	return admin
+}
+
+// PublicKeys returns the user's public keys.
+//
+// It implements backend.User.
+func (u *User) PublicKeys() []ssh.PublicKey {
+	var keys []ssh.PublicKey
+	if err := wrapTx(u.db, context.Background(), func(tx *sqlx.Tx) error {
+		var keyStrings []string
+		if err := tx.Select(&keyStrings, `SELECT public_key
+			FROM public_key
+			INNER JOIN user ON user.id = public_key.user_id
+			WHERE user.username = ?;`, u.username); err != nil {
+			return err
+		}
+
+		for _, keyString := range keyStrings {
+			key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyString))
+			if err != nil {
+				return err
+			}
+			keys = append(keys, key)
+		}
+
+		return nil
+	}); err != nil {
+		return nil
+	}
+
+	return keys
+}
+
+// Username returns the user's username.
+//
+// It implements backend.User.
+func (u *User) Username() string {
+	return u.username
+}
+
+// AccessLevel returns the access level of a user for a repository.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) AccessLevel(repo string, username string) backend.AccessLevel {
+	anon := d.AnonAccess()
+	user, _ := d.User(username)
+	// If the user is an admin, they have admin access.
+	if user != nil && user.IsAdmin() {
+		return backend.AdminAccess
+	}
+
+	// If the repository exists, check if the user is a collaborator.
+	r, _ := d.Repository(repo)
+	if r != nil {
+		// If the user is a collaborator, they have read/write access.
+		if d.IsCollaborator(repo, username) {
+			if anon > backend.ReadWriteAccess {
+				return anon
+			}
+			return backend.ReadWriteAccess
+		}
+
+		// If the repository is private, the user has no access.
+		if r.IsPrivate() {
+			return backend.NoAccess
+		}
+
+		// Otherwise, the user has read-only access.
+		return backend.ReadOnlyAccess
+	}
+
+	// If the repository doesn't exist, the user has read/write access.
+	if user != nil {
+		// If the repository doesn't exist, the user has read/write access.
+		if anon > backend.ReadWriteAccess {
+			return anon
+		}
+
+		return backend.ReadWriteAccess
+	}
+
+	// If the user doesn't exist, give them the anonymous access level.
+	return anon
+}
+
+// AccessLevelByPublicKey returns the access level of a user's public key for a repository.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) AccessLevelByPublicKey(repo string, pk ssh.PublicKey) backend.AccessLevel {
+	ak := backend.MarshalAuthorizedKey(pk)
+	for _, k := range d.AdditionalAdmins {
+		if k == ak {
+			return backend.AdminAccess
+		}
+	}
+
+	user, _ := d.UserByPublicKey(pk)
+	if user != nil {
+		return d.AccessLevel(repo, user.Username())
+	}
+
+	return d.AccessLevel(repo, "")
+}
+
+// AddPublicKey adds a public key to a user.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) AddPublicKey(username string, pk ssh.PublicKey) error {
+	return wrapDbErr(
+		wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+			var userID int
+			if err := tx.Get(&userID, "SELECT id FROM user WHERE username = ?", username); err != nil {
+				return err
+			}
+
+			_, err := tx.Exec(`INSERT INTO public_key (user_id, public_key, updated_at)
+			VALUES (?, ?, CURRENT_TIMESTAMP);`, userID, backend.MarshalAuthorizedKey(pk))
+			return err
+		}),
+	)
+}
+
+// CreateUser creates a new user.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) CreateUser(username string, opts backend.UserOptions) (backend.User, error) {
+	var user *User
+	if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+		into := "INSERT INTO user (username"
+		values := "VALUES (?"
+		args := []interface{}{username}
+		if opts.Admin {
+			into += ", admin"
+			values += ", ?"
+			args = append(args, opts.Admin)
+		}
+		into += ", updated_at)"
+		values += ", CURRENT_TIMESTAMP)"
+
+		r, err := tx.Exec(into+" "+values, args...)
+		if err != nil {
+			return err
+		}
+
+		if len(opts.PublicKeys) > 0 {
+			userID, err := r.LastInsertId()
+			if err != nil {
+				return err
+			}
+
+			for _, pk := range opts.PublicKeys {
+				if _, err := tx.Exec(`INSERT INTO public_key (user_id, public_key, updated_at)
+					VALUES (?, ?, CURRENT_TIMESTAMP);`, userID, backend.MarshalAuthorizedKey(pk)); err != nil {
+					return err
+				}
+			}
+		}
+
+		user = &User{
+			db:       d.db,
+			username: username,
+		}
+		return nil
+	}); err != nil {
+		return nil, wrapDbErr(err)
+	}
+
+	return user, nil
+}
+
+// DeleteUser deletes a user.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) DeleteUser(username string) error {
+	return wrapDbErr(
+		wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+			_, err := tx.Exec("DELETE FROM user WHERE username = ?", username)
+			return err
+		}),
+	)
+}
+
+// RemovePublicKey removes a public key from a user.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) RemovePublicKey(username string, pk ssh.PublicKey) error {
+	return wrapDbErr(
+		wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+			_, err := tx.Exec(`DELETE FROM public_key
+			WHERE user_id = (SELECT id FROM user WHERE username = ?)
+			AND public_key = ?;`, username, backend.MarshalAuthorizedKey(pk))
+			return err
+		}),
+	)
+}
+
+// ListPublicKeys lists the public keys of a user.
+func (d *SqliteBackend) ListPublicKeys(username string) ([]ssh.PublicKey, error) {
+	keys := make([]ssh.PublicKey, 0)
+	if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+		var keyStrings []string
+		if err := tx.Select(&keyStrings, `SELECT public_key
+			FROM public_key
+			INNER JOIN user ON user.id = public_key.user_id
+			WHERE user.username = ?;`, username); err != nil {
+			return err
+		}
+
+		for _, keyString := range keyStrings {
+			key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyString))
+			if err != nil {
+				return err
+			}
+			keys = append(keys, key)
+		}
+
+		return nil
+	}); err != nil {
+		return nil, wrapDbErr(err)
+	}
+
+	return keys, nil
+}
+
+// SetUsername sets the username of a user.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) SetUsername(username string, newUsername string) error {
+	return wrapDbErr(
+		wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+			_, err := tx.Exec("UPDATE user SET username = ? WHERE username = ?", newUsername, username)
+			return err
+		}),
+	)
+}
+
+// SetAdmin sets the admin flag of a user.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) SetAdmin(username string, admin bool) error {
+	return wrapDbErr(
+		wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+			_, err := tx.Exec("UPDATE user SET admin = ? WHERE username = ?", admin, username)
+			return err
+		}),
+	)
+}
+
+// User finds a user by username.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) User(username string) (backend.User, error) {
+	if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+		return tx.Get(&username, "SELECT username FROM user WHERE username = ?", username)
+	}); err != nil {
+		return nil, wrapDbErr(err)
+	}
+
+	return &User{
+		db:       d.db,
+		username: username,
+	}, nil
+}
+
+// UserByPublicKey finds a user by public key.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) UserByPublicKey(pk ssh.PublicKey) (backend.User, error) {
+	var username string
+	if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+		return tx.Get(&username, `SELECT user.username
+			FROM public_key
+			INNER JOIN user ON user.id = public_key.user_id
+			WHERE public_key.public_key = ?;`, backend.MarshalAuthorizedKey(pk))
+	}); err != nil {
+		return nil, wrapDbErr(err)
+	}
+
+	return &User{
+		db:       d.db,
+		username: username,
+	}, nil
+}
+
+// Users returns all users.
+//
+// It implements backend.Backend.
+func (d *SqliteBackend) Users() ([]string, error) {
+	var users []string
+	if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
+		return tx.Select(&users, "SELECT username FROM user")
+	}); err != nil {
+		return nil, wrapDbErr(err)
+	}
+
+	return users, nil
+}

server/backend/user.go 🔗

@@ -0,0 +1,55 @@
+package backend
+
+import (
+	"golang.org/x/crypto/ssh"
+)
+
+// User is an interface representing a user.
+type User interface {
+	// Username returns the user's username.
+	Username() string
+	// IsAdmin returns whether the user is an admin.
+	IsAdmin() bool
+	// PublicKeys returns the user's public keys.
+	PublicKeys() []ssh.PublicKey
+}
+
+// UserAccess is an interface that handles user access to repositories.
+type UserAccess interface {
+	// AccessLevel returns the access level of the username to the repository.
+	AccessLevel(repo string, username string) AccessLevel
+	// AccessLevelByPublicKey returns the access level of the public key to the repository.
+	AccessLevelByPublicKey(repo string, pk ssh.PublicKey) AccessLevel
+}
+
+// UserStore is an interface for managing users.
+type UserStore interface {
+	// User finds the given user.
+	User(username string) (User, error)
+	// UserByPublicKey finds the user with the given public key.
+	UserByPublicKey(pk ssh.PublicKey) (User, error)
+	// Users returns a list of all users.
+	Users() ([]string, error)
+	// CreateUser creates a new user.
+	CreateUser(username string, opts UserOptions) (User, error)
+	// DeleteUser deletes a user.
+	DeleteUser(username string) error
+	// SetUsername sets the username of the user.
+	SetUsername(oldUsername string, newUsername string) error
+	// SetAdmin sets whether the user is an admin.
+	SetAdmin(username string, admin bool) error
+	// AddPublicKey adds a public key to the user.
+	AddPublicKey(username string, pk ssh.PublicKey) error
+	// RemovePublicKey removes a public key from the user.
+	RemovePublicKey(username string, pk ssh.PublicKey) error
+	// ListPublicKeys lists the public keys of the user.
+	ListPublicKeys(username string) ([]ssh.PublicKey, error)
+}
+
+// UserOptions are options for creating a user.
+type UserOptions struct {
+	// Admin is whether the user is an admin.
+	Admin bool
+	// PublicKeys are the user's public keys.
+	PublicKeys []ssh.PublicKey
+}

server/cmd/admin.go 🔗

@@ -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
-}

server/cmd/cmd.go 🔗

@@ -45,32 +45,36 @@ var (
 )
 
 // rootCommand is the root command for the server.
-func rootCommand() *cobra.Command {
+func rootCommand(cfg *config.Config, s ssh.Session) *cobra.Command {
 	rootCmd := &cobra.Command{
 		Use:          "soft",
 		Short:        "Soft Serve is a self-hostable Git server for the command line.",
 		SilenceUsage: true,
 	}
+
 	// TODO: use command usage template to include hostname and port
 	rootCmd.CompletionOptions.DisableDefaultCmd = true
 	rootCmd.AddCommand(
-		adminCommand(),
-		blobCommand(),
-		branchCommand(),
-		collabCommand(),
-		createCommand(),
-		deleteCommand(),
-		descriptionCommand(),
 		hookCommand(),
-		listCommand(),
-		privateCommand(),
-		projectName(),
-		renameCommand(),
-		settingCommand(),
-		tagCommand(),
-		treeCommand(),
+		repoCommand(),
 	)
 
+	user, _ := cfg.Backend.UserByPublicKey(s.PublicKey())
+	if user != nil {
+		if user.IsAdmin() {
+			rootCmd.AddCommand(
+				settingsCommand(),
+				userCommand(),
+			)
+		}
+
+		rootCmd.AddCommand(
+			infoCommand(),
+			pubkeyCommand(),
+			setUsernameCommand(),
+		)
+	}
+
 	return rootCmd
 }
 
@@ -88,18 +92,31 @@ func checkIfReadable(cmd *cobra.Command, args []string) error {
 	}
 	cfg, s := fromContext(cmd)
 	rn := utils.SanitizeRepo(repo)
-	auth := cfg.Backend.AccessLevel(rn, s.PublicKey())
+	auth := cfg.Backend.AccessLevelByPublicKey(rn, s.PublicKey())
 	if auth < backend.ReadOnlyAccess {
 		return ErrUnauthorized
 	}
 	return nil
 }
 
-func checkIfAdmin(cmd *cobra.Command, args []string) error {
+func checkIfAdmin(cmd *cobra.Command, _ []string) error {
 	cfg, s := fromContext(cmd)
-	if !cfg.Backend.IsAdmin(s.PublicKey()) {
+	ak := backend.MarshalAuthorizedKey(s.PublicKey())
+	for _, k := range cfg.InitialAdminKeys {
+		if k == ak {
+			return nil
+		}
+	}
+
+	user, err := cfg.Backend.UserByPublicKey(s.PublicKey())
+	if err != nil {
+		return err
+	}
+
+	if !user.IsAdmin() {
 		return ErrUnauthorized
 	}
+
 	return nil
 }
 
@@ -110,7 +127,7 @@ func checkIfCollab(cmd *cobra.Command, args []string) error {
 	}
 	cfg, s := fromContext(cmd)
 	rn := utils.SanitizeRepo(repo)
-	auth := cfg.Backend.AccessLevel(rn, s.PublicKey())
+	auth := cfg.Backend.AccessLevelByPublicKey(rn, s.PublicKey())
 	if auth < backend.ReadWriteAccess {
 		return ErrUnauthorized
 	}
@@ -141,7 +158,7 @@ func Middleware(cfg *config.Config, hooks hooks.Hooks) wish.Middleware {
 				ctx = context.WithValue(ctx, SessionCtxKey, s)
 				ctx = context.WithValue(ctx, HooksCtxKey, hooks)
 
-				rootCmd := rootCommand()
+				rootCmd := rootCommand(cfg, s)
 				rootCmd.SetArgs(args)
 				if len(args) == 0 {
 					// otherwise it'll default to os.Args, which is not what we want.

server/cmd/collab.go 🔗

@@ -1,16 +1,13 @@
 package cmd
 
 import (
-	"strings"
-
-	"github.com/charmbracelet/soft-serve/server/backend"
 	"github.com/spf13/cobra"
 )
 
 func collabCommand() *cobra.Command {
 	cmd := &cobra.Command{
 		Use:     "collab",
-		Aliases: []string{"collaborator", "collaborators"},
+		Aliases: []string{"collabs", "collaborator", "collaborators"},
 		Short:   "Manage collaborators",
 	}
 
@@ -25,19 +22,16 @@ func collabCommand() *cobra.Command {
 
 func collabAddCommand() *cobra.Command {
 	cmd := &cobra.Command{
-		Use:               "add REPOSITORY AUTHORIZED_KEY",
+		Use:               "add REPOSITORY USERNAME",
 		Short:             "Add a collaborator to a repo",
-		Args:              cobra.MinimumNArgs(2),
+		Args:              cobra.ExactArgs(2),
 		PersistentPreRunE: checkIfAdmin,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			cfg, _ := fromContext(cmd)
 			repo := args[0]
-			pk, c, err := backend.ParseAuthorizedKey(strings.Join(args[1:], " "))
-			if err != nil {
-				return err
-			}
+			username := args[1]
 
-			return cfg.Backend.AddCollaborator(pk, c, repo)
+			return cfg.Backend.AddCollaborator(repo, username)
 		},
 	}
 
@@ -46,19 +40,16 @@ func collabAddCommand() *cobra.Command {
 
 func collabRemoveCommand() *cobra.Command {
 	cmd := &cobra.Command{
-		Use:               "remove REPOSITORY AUTHORIZED_KEY",
-		Args:              cobra.MinimumNArgs(2),
+		Use:               "remove REPOSITORY USERNAME",
+		Args:              cobra.ExactArgs(2),
 		Short:             "Remove a collaborator from a repo",
 		PersistentPreRunE: checkIfAdmin,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			cfg, _ := fromContext(cmd)
 			repo := args[0]
-			pk, _, err := backend.ParseAuthorizedKey(strings.Join(args[1:], " "))
-			if err != nil {
-				return err
-			}
+			username := args[1]
 
-			return cfg.Backend.RemoveCollaborator(pk, repo)
+			return cfg.Backend.RemoveCollaborator(repo, username)
 		},
 	}
 

server/cmd/create.go 🔗

@@ -9,7 +9,6 @@ import (
 func createCommand() *cobra.Command {
 	var private bool
 	var description string
-	var mirror string
 	var projectName string
 
 	cmd := &cobra.Command{
@@ -22,7 +21,6 @@ func createCommand() *cobra.Command {
 			name := args[0]
 			if _, err := cfg.Backend.CreateRepository(name, backend.RepositoryOptions{
 				Private:     private,
-				Mirror:      mirror,
 				Description: description,
 				ProjectName: projectName,
 			}); err != nil {
@@ -34,7 +32,6 @@ func createCommand() *cobra.Command {
 
 	cmd.Flags().BoolVarP(&private, "private", "p", false, "make the repository private")
 	cmd.Flags().StringVarP(&description, "description", "d", "", "set the repository description")
-	cmd.Flags().StringVarP(&mirror, "mirror", "m", "", "set the mirror repository")
 	cmd.Flags().StringVarP(&projectName, "name", "n", "", "set the project name")
 
 	return cmd

server/cmd/import.go 🔗

@@ -0,0 +1,42 @@
+package cmd
+
+import (
+	"github.com/charmbracelet/soft-serve/server/backend"
+	"github.com/spf13/cobra"
+)
+
+// importCommand is the command for creating a new repository.
+func importCommand() *cobra.Command {
+	var private bool
+	var description string
+	var projectName string
+	var mirror bool
+
+	cmd := &cobra.Command{
+		Use:               "import REPOSITORY REMOTE",
+		Short:             "Import a new repository from remote",
+		Args:              cobra.ExactArgs(2),
+		PersistentPreRunE: checkIfAdmin,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, _ := fromContext(cmd)
+			name := args[0]
+			remote := args[1]
+			if _, err := cfg.Backend.ImportRepository(name, remote, backend.RepositoryOptions{
+				Private:     private,
+				Description: description,
+				ProjectName: projectName,
+				Mirror:      mirror,
+			}); err != nil {
+				return err
+			}
+			return nil
+		},
+	}
+
+	cmd.Flags().BoolVarP(&mirror, "mirror", "m", false, "mirror the repository")
+	cmd.Flags().BoolVarP(&private, "private", "p", false, "make the repository private")
+	cmd.Flags().StringVarP(&description, "description", "d", "", "set the repository description")
+	cmd.Flags().StringVarP(&projectName, "name", "n", "", "set the project name")
+
+	return cmd
+}

server/cmd/info.go 🔗

@@ -0,0 +1,31 @@
+package cmd
+
+import (
+	"github.com/charmbracelet/soft-serve/server/backend"
+	"github.com/spf13/cobra"
+)
+
+func infoCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "info",
+		Short: "Show your info",
+		Args:  cobra.NoArgs,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, s := fromContext(cmd)
+			user, err := cfg.Backend.UserByPublicKey(s.PublicKey())
+			if err != nil {
+				return err
+			}
+
+			cmd.Printf("Username: %s\n", user.Username())
+			cmd.Printf("Admin: %t\n", user.IsAdmin())
+			cmd.Printf("Public keys:\n")
+			for _, pk := range user.PublicKeys() {
+				cmd.Printf("  %s\n", backend.MarshalAuthorizedKey(pk))
+			}
+			return nil
+		},
+	}
+
+	return cmd
+}

server/cmd/list.go 🔗

@@ -19,7 +19,7 @@ func listCommand() *cobra.Command {
 				return err
 			}
 			for _, r := range repos {
-				if cfg.Backend.AccessLevel(r.Name(), s.PublicKey()) >= backend.ReadOnlyAccess {
+				if cfg.Backend.AccessLevelByPublicKey(r.Name(), s.PublicKey()) >= backend.ReadOnlyAccess {
 					cmd.Println(r.Name())
 				}
 			}

server/cmd/pubkey.go 🔗

@@ -0,0 +1,85 @@
+package cmd
+
+import (
+	"strings"
+
+	"github.com/charmbracelet/soft-serve/server/backend"
+	"github.com/spf13/cobra"
+)
+
+func pubkeyCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:     "pubkey",
+		Aliases: []string{"pubkeys", "publickey", "publickeys"},
+		Short:   "Manage your public keys",
+	}
+
+	pubkeyAddCommand := &cobra.Command{
+		Use:   "add AUTHORIZED_KEY",
+		Short: "Add a public key",
+		Args:  cobra.MinimumNArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, s := fromContext(cmd)
+			user, err := cfg.Backend.UserByPublicKey(s.PublicKey())
+			if err != nil {
+				return err
+			}
+
+			pk, _, err := backend.ParseAuthorizedKey(strings.Join(args, " "))
+			if err != nil {
+				return err
+			}
+
+			return cfg.Backend.AddPublicKey(user.Username(), pk)
+		},
+	}
+
+	pubkeyRemoveCommand := &cobra.Command{
+		Use:   "remove AUTHORIZED_KEY",
+		Args:  cobra.MinimumNArgs(1),
+		Short: "Remove a public key",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, s := fromContext(cmd)
+			user, err := cfg.Backend.UserByPublicKey(s.PublicKey())
+			if err != nil {
+				return err
+			}
+
+			pk, _, err := backend.ParseAuthorizedKey(strings.Join(args, " "))
+			if err != nil {
+				return err
+			}
+
+			return cfg.Backend.RemovePublicKey(user.Username(), pk)
+		},
+	}
+
+	pubkeyListCommand := &cobra.Command{
+		Use:     "list",
+		Aliases: []string{"ls"},
+		Short:   "List public keys",
+		Args:    cobra.NoArgs,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, s := fromContext(cmd)
+			user, err := cfg.Backend.UserByPublicKey(s.PublicKey())
+			if err != nil {
+				return err
+			}
+
+			pks := user.PublicKeys()
+			for _, pk := range pks {
+				cmd.Println(backend.MarshalAuthorizedKey(pk))
+			}
+
+			return nil
+		},
+	}
+
+	cmd.AddCommand(
+		pubkeyAddCommand,
+		pubkeyRemoveCommand,
+		pubkeyListCommand,
+	)
+
+	return cmd
+}

server/cmd/repo.go 🔗

@@ -0,0 +1,29 @@
+package cmd
+
+import "github.com/spf13/cobra"
+
+func repoCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:     "repo",
+		Aliases: []string{"repos", "repository", "repositories"},
+		Short:   "Manage repositories",
+	}
+
+	cmd.AddCommand(
+		blobCommand(),
+		branchCommand(),
+		collabCommand(),
+		createCommand(),
+		deleteCommand(),
+		descriptionCommand(),
+		importCommand(),
+		listCommand(),
+		privateCommand(),
+		projectName(),
+		renameCommand(),
+		tagCommand(),
+		treeCommand(),
+	)
+
+	return cmd
+}

server/cmd/set_username.go 🔗

@@ -0,0 +1,22 @@
+package cmd
+
+import "github.com/spf13/cobra"
+
+func setUsernameCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "set-username USERNAME",
+		Short: "Set your username",
+		Args:  cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, s := fromContext(cmd)
+			user, err := cfg.Backend.UserByPublicKey(s.PublicKey())
+			if err != nil {
+				return err
+			}
+
+			return cfg.Backend.SetUsername(user.Username(), args[0])
+		},
+	}
+
+	return cmd
+}

server/cmd/setting.go → server/cmd/settings.go 🔗

@@ -8,9 +8,9 @@ import (
 	"github.com/spf13/cobra"
 )
 
-func settingCommand() *cobra.Command {
+func settingsCommand() *cobra.Command {
 	cmd := &cobra.Command{
-		Use:   "setting",
+		Use:   "settings",
 		Short: "Manage server settings",
 	}
 

server/cmd/user.go 🔗

@@ -0,0 +1,193 @@
+package cmd
+
+import (
+	"sort"
+	"strings"
+
+	"github.com/charmbracelet/log"
+	"github.com/charmbracelet/soft-serve/server/backend"
+	"github.com/spf13/cobra"
+	"golang.org/x/crypto/ssh"
+)
+
+func userCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:     "user",
+		Aliases: []string{"users"},
+		Short:   "Manage users",
+	}
+
+	var admin bool
+	var key string
+	userAddCommand := &cobra.Command{
+		Use:               "add USERNAME",
+		Short:             "Add a user",
+		Args:              cobra.ExactArgs(1),
+		PersistentPreRunE: checkIfAdmin,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, _ := fromContext(cmd)
+			username := args[0]
+			pk, _, err := backend.ParseAuthorizedKey(key)
+			if err != nil {
+				return err
+			}
+
+			opts := backend.UserOptions{
+				Admin:      admin,
+				PublicKeys: []ssh.PublicKey{pk},
+			}
+
+			_, err = cfg.Backend.CreateUser(username, opts)
+			return err
+		},
+	}
+
+	userAddCommand.Flags().BoolVarP(&admin, "admin", "a", false, "make the user an admin")
+	userAddCommand.Flags().StringVarP(&key, "key", "k", "", "add a public key to the user")
+
+	userRemoveCommand := &cobra.Command{
+		Use:               "remove USERNAME",
+		Short:             "Remove a user",
+		Args:              cobra.ExactArgs(1),
+		PersistentPreRunE: checkIfAdmin,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, _ := fromContext(cmd)
+			username := args[0]
+
+			return cfg.Backend.DeleteUser(username)
+		},
+	}
+
+	userListCommand := &cobra.Command{
+		Use:               "list",
+		Aliases:           []string{"ls"},
+		Short:             "List users",
+		Args:              cobra.NoArgs,
+		PersistentPreRunE: checkIfAdmin,
+		RunE: func(cmd *cobra.Command, _ []string) error {
+			cfg, _ := fromContext(cmd)
+			users, err := cfg.Backend.Users()
+			if err != nil {
+				return err
+			}
+
+			sort.Strings(users)
+			for _, u := range users {
+				cmd.Println(u)
+			}
+
+			return nil
+		},
+	}
+
+	userAddPubkeyCommand := &cobra.Command{
+		Use:               "add-pubkey USERNAME AUTHORIZED_KEY",
+		Short:             "Add a public key to a user",
+		Args:              cobra.MinimumNArgs(2),
+		PersistentPreRunE: checkIfAdmin,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, _ := fromContext(cmd)
+			username := args[0]
+			pubkey := strings.Join(args[1:], " ")
+			pk, _, err := backend.ParseAuthorizedKey(pubkey)
+			if err != nil {
+				return err
+			}
+
+			return cfg.Backend.AddPublicKey(username, pk)
+		},
+	}
+
+	userRemovePubkeyCommand := &cobra.Command{
+		Use:               "remove-pubkey USERNAME AUTHORIZED_KEY",
+		Short:             "Remove a public key from a user",
+		Args:              cobra.MinimumNArgs(2),
+		PersistentPreRunE: checkIfAdmin,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, _ := fromContext(cmd)
+			username := args[0]
+			pubkey := strings.Join(args[1:], " ")
+			log.Debugf("key is %q", pubkey)
+			pk, _, err := backend.ParseAuthorizedKey(pubkey)
+			if err != nil {
+				return err
+			}
+
+			return cfg.Backend.RemovePublicKey(username, pk)
+		},
+	}
+
+	userSetAdminCommand := &cobra.Command{
+		Use:               "set-admin USERNAME [true|false]",
+		Short:             "Make a user an admin",
+		Args:              cobra.ExactArgs(2),
+		PersistentPreRunE: checkIfAdmin,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, _ := fromContext(cmd)
+			username := args[0]
+
+			return cfg.Backend.SetAdmin(username, args[1] == "true")
+		},
+	}
+
+	userInfoCommand := &cobra.Command{
+		Use:               "info USERNAME",
+		Short:             "Show information about a user",
+		Args:              cobra.ExactArgs(1),
+		PersistentPreRunE: checkIfAdmin,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, s := fromContext(cmd)
+			ak := backend.MarshalAuthorizedKey(s.PublicKey())
+			username := args[0]
+
+			user, err := cfg.Backend.User(username)
+			if err != nil {
+				return err
+			}
+
+			isAdmin := user.IsAdmin()
+			for _, k := range cfg.InitialAdminKeys {
+				if ak == k {
+					isAdmin = true
+					break
+				}
+			}
+
+			cmd.Printf("Username: %s\n", user.Username())
+			cmd.Printf("Admin: %t\n", isAdmin)
+			cmd.Printf("Public keys:\n")
+			for _, pk := range user.PublicKeys() {
+				cmd.Printf("  %s\n", backend.MarshalAuthorizedKey(pk))
+			}
+
+			return nil
+		},
+	}
+
+	userSetUsernameCommand := &cobra.Command{
+		Use:               "set-username USERNAME NEW_USERNAME",
+		Short:             "Change a user's username",
+		Args:              cobra.ExactArgs(2),
+		PersistentPreRunE: checkIfAdmin,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, _ := fromContext(cmd)
+			username := args[0]
+			newUsername := args[1]
+
+			return cfg.Backend.SetUsername(username, newUsername)
+		},
+	}
+
+	cmd.AddCommand(
+		userAddCommand,
+		userAddPubkeyCommand,
+		userInfoCommand,
+		userListCommand,
+		userRemoveCommand,
+		userRemovePubkeyCommand,
+		userSetAdminCommand,
+		userSetUsernameCommand,
+	)
+
+	return cmd
+}

server/cron/cron.go 🔗

@@ -29,7 +29,7 @@ type cronLogger struct {
 
 // Info logs routine messages about cron's operation.
 func (l cronLogger) Info(msg string, keysAndValues ...interface{}) {
-	l.logger.Info(msg, keysAndValues...)
+	l.logger.Debug(msg, keysAndValues...)
 }
 
 // Error logs an error condition.

server/daemon.go 🔗

@@ -233,7 +233,7 @@ func (d *GitDaemon) handleClient(conn net.Conn) {
 			return
 		}
 
-		auth := d.cfg.Backend.AccessLevel(name, nil)
+		auth := d.cfg.Backend.AccessLevel(name, "")
 		if auth < backend.ReadOnlyAccess {
 			fatal(c, ErrNotAuthed)
 			return

server/daemon_test.go 🔗

@@ -8,12 +8,11 @@ import (
 	"log"
 	"net"
 	"os"
-	"path/filepath"
 	"strings"
 	"testing"
 	"time"
 
-	"github.com/charmbracelet/soft-serve/server/backend/file"
+	"github.com/charmbracelet/soft-serve/server/backend/sqlite"
 	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/go-git/go-git/v5/plumbing/format/pktline"
 )
@@ -31,7 +30,7 @@ func TestMain(m *testing.M) {
 	os.Setenv("SOFT_SERVE_GIT_MAX_TIMEOUT", "100")
 	os.Setenv("SOFT_SERVE_GIT_IDLE_TIMEOUT", "1")
 	os.Setenv("SOFT_SERVE_GIT_LISTEN_ADDR", fmt.Sprintf(":%d", randomPort()))
-	fb, err := file.NewFileBackend(filepath.Join(tmp, "repos"))
+	fb, err := sqlite.NewSqliteBackend(tmp)
 	if err != nil {
 		log.Fatal(err)
 	}

server/http.go 🔗

@@ -137,7 +137,7 @@ func (s *HTTPServer) Shutdown(ctx context.Context) error {
 // Pattern is a pattern for matching a URL.
 // It matches against GET requests.
 type Pattern struct {
-	match func(*url.URL) *Match
+	match func(*url.URL) *match
 }
 
 // NewPattern returns a new Pattern with the given matcher.
@@ -164,64 +164,65 @@ func (p *Pattern) Match(r *http.Request) *http.Request {
 }
 
 // Matcher finds a match in a *url.URL.
-type Matcher = func(*url.URL) *Match
+type Matcher = func(*url.URL) *match
 
 var (
-	getInfoRefs = func(u *url.URL) *Match {
+	getInfoRefs = func(u *url.URL) *match {
 		return matchSuffix(u.Path, "/info/refs")
 	}
 
-	getHead = func(u *url.URL) *Match {
+	getHead = func(u *url.URL) *match {
 		return matchSuffix(u.Path, "/HEAD")
 	}
 
-	getAlternates = func(u *url.URL) *Match {
+	getAlternates = func(u *url.URL) *match {
 		return matchSuffix(u.Path, "/objects/info/alternates")
 	}
 
-	getHTTPAlternates = func(u *url.URL) *Match {
+	getHTTPAlternates = func(u *url.URL) *match {
 		return matchSuffix(u.Path, "/objects/info/http-alternates")
 	}
 
-	getInfoPacks = func(u *url.URL) *Match {
+	getInfoPacks = func(u *url.URL) *match {
 		return matchSuffix(u.Path, "/objects/info/packs")
 	}
 
 	getInfoFileRegexp = regexp.MustCompile(".*?(/objects/info/[^/]*)$")
-	getInfoFile       = func(u *url.URL) *Match {
+	getInfoFile       = func(u *url.URL) *match {
 		return findStringSubmatch(u.Path, getInfoFileRegexp)
 	}
 
 	getLooseObjectRegexp = regexp.MustCompile(".*?(/objects/[0-9a-f]{2}/[0-9a-f]{38})$")
-	getLooseObject       = func(u *url.URL) *Match {
+	getLooseObject       = func(u *url.URL) *match {
 		return findStringSubmatch(u.Path, getLooseObjectRegexp)
 	}
 
 	getPackFileRegexp = regexp.MustCompile(`.*?(/objects/pack/pack-[0-9a-f]{40}\.pack)$`)
-	getPackFile       = func(u *url.URL) *Match {
+	getPackFile       = func(u *url.URL) *match {
 		return findStringSubmatch(u.Path, getPackFileRegexp)
 	}
 
 	getIdxFileRegexp = regexp.MustCompile(`.*?(/objects/pack/pack-[0-9a-f]{40}\.idx)$`)
-	getIdxFile       = func(u *url.URL) *Match {
+	getIdxFile       = func(u *url.URL) *match {
 		return findStringSubmatch(u.Path, getIdxFileRegexp)
 	}
 )
 
-type Match struct {
+// match represents a match for a URL.
+type match struct {
 	RepoPath, FilePath string
 }
 
-func matchSuffix(path, suffix string) *Match {
+func matchSuffix(path, suffix string) *match {
 	if !strings.HasSuffix(path, suffix) {
 		return nil
 	}
 	repoPath := strings.Replace(path, suffix, "", 1)
 	filePath := strings.Replace(path, repoPath+"/", "", 1)
-	return &Match{repoPath, filePath}
+	return &match{repoPath, filePath}
 }
 
-func findStringSubmatch(path string, prefix *regexp.Regexp) *Match {
+func findStringSubmatch(path string, prefix *regexp.Regexp) *match {
 	m := prefix.FindStringSubmatch(path)
 	if m == nil {
 		return nil
@@ -229,7 +230,7 @@ func findStringSubmatch(path string, prefix *regexp.Regexp) *Match {
 	suffix := m[1]
 	repoPath := strings.Replace(path, suffix, "", 1)
 	filePath := strings.Replace(path, repoPath+"/", "", 1)
-	return &Match{repoPath, filePath}
+	return &match{repoPath, filePath}
 }
 
 var repoIndexHTMLTpl = template.Must(template.New("index").Parse(`<!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

server/jobs.go 🔗

@@ -23,7 +23,7 @@ func mirrorJob(b backend.Backend) func() {
 
 		for _, repo := range repos {
 			if repo.IsMirror() {
-				logger.Debug("updating mirror", "repo", repo.Name())
+				logger.Info("updating mirror", "repo", repo.Name())
 				r, err := repo.Open()
 				if err != nil {
 					logger.Error("error opening repository", "repo", repo.Name(), "err", err)

server/server.go 🔗

@@ -9,7 +9,7 @@ import (
 	"github.com/charmbracelet/log"
 
 	"github.com/charmbracelet/soft-serve/server/backend"
-	"github.com/charmbracelet/soft-serve/server/backend/file"
+	"github.com/charmbracelet/soft-serve/server/backend/sqlite"
 	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/charmbracelet/soft-serve/server/cron"
 	"github.com/charmbracelet/ssh"
@@ -39,13 +39,14 @@ type Server struct {
 func NewServer(cfg *config.Config) (*Server, error) {
 	var err error
 	if cfg.Backend == nil {
-		fb, err := file.NewFileBackend(cfg.DataPath)
+		sb, err := sqlite.NewSqliteBackend(cfg.DataPath)
 		if err != nil {
 			logger.Fatal(err)
 		}
+
 		// Add the initial admin keys to the list of admins.
-		fb.AdditionalAdmins = cfg.InitialAdminKeys
-		cfg = cfg.WithBackend(fb)
+		sb.AdditionalAdmins = cfg.InitialAdminKeys
+		cfg = cfg.WithBackend(sb)
 
 		// Create internal key.
 		_, err = keygen.NewWithWrite(

server/session.go 🔗

@@ -39,7 +39,7 @@ func SessionHandler(cfg *config.Config) bm.ProgramHandler {
 		initialRepo := ""
 		if len(cmd) == 1 {
 			initialRepo = cmd[0]
-			auth := cfg.Backend.AccessLevel(initialRepo, s.PublicKey())
+			auth := cfg.Backend.AccessLevelByPublicKey(initialRepo, s.PublicKey())
 			if auth < backend.ReadOnlyAccess {
 				wish.Fatalln(s, cm.ErrUnauthorized)
 				return nil

server/session_test.go 🔗

@@ -5,11 +5,10 @@ import (
 	"fmt"
 	"log"
 	"os"
-	"path/filepath"
 	"testing"
 	"time"
 
-	"github.com/charmbracelet/soft-serve/server/backend/file"
+	"github.com/charmbracelet/soft-serve/server/backend/sqlite"
 	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/charmbracelet/ssh"
 	bm "github.com/charmbracelet/wish/bubbletea"
@@ -52,7 +51,7 @@ func setup(tb testing.TB) *gossh.Session {
 		is.NoErr(os.Unsetenv("SOFT_SERVE_SSH_LISTEN_ADDR"))
 		is.NoErr(os.RemoveAll(dp))
 	})
-	fb, err := file.NewFileBackend(filepath.Join(dp, "repos"))
+	fb, err := sqlite.NewSqliteBackend(dp)
 	if err != nil {
 		log.Fatal(err)
 	}

server/ssh.go 🔗

@@ -32,7 +32,7 @@ var (
 		Subsystem: "ssh",
 		Name:      "public_key_auth_total",
 		Help:      "The total number of public key auth requests",
-	}, []string{"key", "user", "access", "allowed"})
+	}, []string{"key", "user", "allowed"})
 
 	keyboardInteractiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 		Namespace: "soft_serve",
@@ -136,12 +136,26 @@ func (s *SSHServer) Shutdown(ctx context.Context) error {
 }
 
 // PublicKeyAuthHandler handles public key authentication.
-func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool {
-	ac := s.cfg.Backend.AccessLevel("", pk)
-	allowed := ac >= backend.ReadOnlyAccess
+func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) (allowed bool) {
 	ak := backend.MarshalAuthorizedKey(pk)
-	publicKeyCounter.WithLabelValues(ak, ctx.User(), ac.String(), strconv.FormatBool(allowed)).Inc()
-	return allowed
+	defer func() {
+		publicKeyCounter.WithLabelValues(ak, ctx.User(), strconv.FormatBool(allowed)).Inc()
+	}()
+	for _, k := range s.cfg.InitialAdminKeys {
+		if k == ak {
+			allowed = true
+			return
+		}
+	}
+
+	user, _ := s.cfg.Backend.UserByPublicKey(pk)
+	if user == nil {
+		logger.Debug("public key auth user not found")
+		return s.cfg.Backend.AnonAccess() >= backend.ReadOnlyAccess
+	}
+
+	allowed = s.cfg.Backend.AccessLevel("", user.Username()) >= backend.ReadOnlyAccess
+	return
 }
 
 // KeyboardInteractiveHandler handles keyboard interactive authentication.
@@ -167,7 +181,7 @@ func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
 					name := utils.SanitizeRepo(cmd[1])
 					pk := s.PublicKey()
 					ak := backend.MarshalAuthorizedKey(pk)
-					access := cfg.Backend.AccessLevel(name, pk)
+					access := cfg.Backend.AccessLevelByPublicKey(name, pk)
 					// git bare repositories should end in ".git"
 					// https://git-scm.com/docs/gitrepository-layout
 					repo := name + ".git"

ui/pages/selection/selection.go 🔗

@@ -198,7 +198,7 @@ func (s *Selection) Init() tea.Cmd {
 	}
 	sortedItems := make(Items, 0)
 	for _, r := range repos {
-		al := cfg.Backend.AccessLevel(r.Name(), pk)
+		al := cfg.Backend.AccessLevelByPublicKey(r.Name(), pk)
 		if al >= backend.ReadOnlyAccess {
 			item, err := NewItem(r, cfg)
 			if err != nil {