2a0d05a440663f00d5219d6848afb6cf7f096315
- Author
- Ayman Bagabas <ayman.bagabas@gmail.com>
- Committer
- Ayman Bagabas <ayman.bagabas@gmail.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/go.mod b/go.mod
2index a21aed2bca8fd2d0af6aee82874a61d37b86f15a..33acc20812d6494b3becd8bf33005b65d2a51626 100644
3--- a/go.mod
4+++ b/go.mod
5@@ -25,6 +25,7 @@ require (
6 github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103
7 github.com/gobwas/glob v0.2.3
8 github.com/gogs/git-module v1.8.1
9+ github.com/jmoiron/sqlx v1.3.5
10 github.com/lrstanley/bubblezone v0.0.0-20220716194435-3cb8c52f6a8f
11 github.com/muesli/mango-cobra v1.2.0
12 github.com/muesli/roff v0.1.0
13@@ -35,6 +36,7 @@ require (
14 golang.org/x/crypto v0.7.0
15 golang.org/x/sync v0.1.0
16 gopkg.in/yaml.v3 v3.0.1
17+ modernc.org/sqlite v1.21.1
18 )
19
20 require (
21@@ -49,8 +51,10 @@ require (
22 github.com/dlclark/regexp2 v1.4.0 // indirect
23 github.com/go-logfmt/logfmt v0.6.0 // indirect
24 github.com/golang/protobuf v1.5.2 // indirect
25+ github.com/google/uuid v1.3.0 // indirect
26 github.com/gorilla/css v1.0.0 // indirect
27 github.com/inconshreveable/mousetrap v1.0.1 // indirect
28+ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
29 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
30 github.com/mattn/go-isatty v0.0.18 // indirect
31 github.com/mattn/go-localereader v0.0.1 // indirect
32@@ -67,14 +71,26 @@ require (
33 github.com/prometheus/client_model v0.3.0 // indirect
34 github.com/prometheus/common v0.37.0 // indirect
35 github.com/prometheus/procfs v0.8.0 // indirect
36+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
37 github.com/rivo/uniseg v0.2.0 // indirect
38 github.com/sahilm/fuzzy v0.1.0 // indirect
39 github.com/spf13/pflag v1.0.5 // indirect
40 github.com/yuin/goldmark v1.5.2 // indirect
41 github.com/yuin/goldmark-emoji v1.0.1 // indirect
42+ golang.org/x/mod v0.8.0 // indirect
43 golang.org/x/net v0.8.0 // indirect
44 golang.org/x/sys v0.6.0 // indirect
45 golang.org/x/term v0.6.0 // indirect
46 golang.org/x/text v0.8.0 // indirect
47+ golang.org/x/tools v0.6.0 // indirect
48 google.golang.org/protobuf v1.28.1 // indirect
49+ lukechampine.com/uint128 v1.2.0 // indirect
50+ modernc.org/cc/v3 v3.40.0 // indirect
51+ modernc.org/ccgo/v3 v3.16.13 // indirect
52+ modernc.org/libc v1.22.3 // indirect
53+ modernc.org/mathutil v1.5.0 // indirect
54+ modernc.org/memory v1.5.0 // indirect
55+ modernc.org/opt v0.1.3 // indirect
56+ modernc.org/strutil v1.1.3 // indirect
57+ modernc.org/token v1.0.1 // indirect
58 )
59diff --git a/go.sum b/go.sum
60index 8587ada7d55d021c2bf620476510d3c4d68d2f0e..f1d281db3f42f8f34bd08653b8d7bd3ac0d618e7 100644
61--- a/go.sum
62+++ b/go.sum
63@@ -93,8 +93,11 @@ github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103/go.mod h1:0Vm2/8
64 github.com/charmbracelet/wish v1.1.0 h1:0ArX9SOG70saqd23NYjoS56oLPVNgqcQegkz1Lw+4zY=
65 github.com/charmbracelet/wish v1.1.0/go.mod h1:yHbm0hs/qX4lFE7nrhAcXjFYc8bxMIfSqJOfOYfwyYo=
66 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
67+github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
68 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
69+github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=
70 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
71+github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
72 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
73 github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
74 github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
75@@ -107,6 +110,7 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
76 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
77 github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
78 github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
79+github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
80 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
81 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
82 github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
83@@ -134,6 +138,8 @@ github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG
84 github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
85 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
86 github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
87+github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
88+github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
89 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
90 github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
91 github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
92@@ -177,6 +183,7 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
93 github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
94 github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
95 github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
96+github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
97 github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
98 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
99 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
100@@ -192,7 +199,11 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf
101 github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
102 github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
103 github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
104+github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
105+github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
106 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
107+github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
108+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
109 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
110 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
111 github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
112@@ -201,12 +212,15 @@ github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ
113 github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
114 github.com/hashicorp/golang-lru/v2 v2.0.2/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
115 github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
116+github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
117 github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg=
118 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
119 github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
120 github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
121 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
122 github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
123+github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
124+github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
125 github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
126 github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
127 github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
128@@ -216,8 +230,11 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1
129 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
130 github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
131 github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
132+github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
133+github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
134 github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
135 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
136+github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
137 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
138 github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
139 github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
140@@ -230,6 +247,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
141 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
142 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
143 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
144+github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=
145+github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
146 github.com/lrstanley/bubblezone v0.0.0-20220716194435-3cb8c52f6a8f h1:FjWlbnOxKSZpFlNhsx6xFy/OnkdYTAYTuoulojPdZ9o=
147 github.com/lrstanley/bubblezone v0.0.0-20220716194435-3cb8c52f6a8f/go.mod h1:CxaUrg7Y6DmnquTpb1Rgxib+u+NcRxrDi8m/mR1poTM=
148 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
149@@ -250,6 +269,9 @@ github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRC
150 github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
151 github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
152 github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
153+github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
154+github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
155+github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
156 github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
157 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
158 github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75 h1:Pijfgr7ZuvX7QIQiEwLdRVr3RoMG+i0SbBO1Qu+7yVk=
159@@ -324,6 +346,9 @@ github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1
160 github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
161 github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
162 github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
163diff --git a/server/backend/backend.go b/server/backend/backend.go
164index 5d771e35720b41012ad29c2e7c684ff62335fac2..e95e6b74a88108fdb09d8b2055cf8e06f7f01c17 100644
165--- a/server/backend/backend.go
166+++ b/server/backend/backend.go
167@@ -9,10 +9,12 @@ import (
168 // Backend is an interface that handles repositories management and any
169 // non-Git related operations.
170 type Backend interface {
171- ServerBackend
172+ SettingsBackend
173 RepositoryStore
174 RepositoryMetadata
175 RepositoryAccess
176+ UserStore
177+ UserAccess
178 }
179
180 // ParseAuthorizedKey parses an authorized key string into a public key.
181diff --git a/server/backend/file/file.go b/server/backend/file/file.go
182deleted file mode 100644
183index 230a83592762c9950e083750ac8bb2e0dffa3596..0000000000000000000000000000000000000000
184--- a/server/backend/file/file.go
185+++ /dev/null
186@@ -1,940 +0,0 @@
187-// Package file implements a backend that uses the filesystem to store non-Git related data
188-//
189-// The following files and directories are used:
190-//
191-// - anon-access: contains the access level for anonymous users
192-// - allow-keyless: contains a boolean value indicating whether or not keyless access is allowed
193-// - admins: contains a list of authorized keys for admin users
194-// - host: contains the server's server hostname
195-// - name: contains the server's name
196-// - port: contains the server's port
197-// - repos: is a the directory containing all Git repositories
198-//
199-// Each repository has the following files and directories:
200-// - collaborators: contains a list of authorized keys for collaborators
201-// - description: contains the repository's description
202-// - private: when present, indicates that the repository is private
203-// - git-daemon-export-ok: when present, indicates that the repository is public
204-// - project-name: contains the repository's project name
205-package file
206-
207-import (
208- "bufio"
209- "bytes"
210- "errors"
211- "fmt"
212- "html/template"
213- "io"
214- "io/fs"
215- "os"
216- "path/filepath"
217- "strconv"
218- "strings"
219-
220- "github.com/charmbracelet/log"
221- "github.com/charmbracelet/soft-serve/git"
222- "github.com/charmbracelet/soft-serve/server/backend"
223- "github.com/charmbracelet/soft-serve/server/utils"
224- "github.com/charmbracelet/ssh"
225- gossh "golang.org/x/crypto/ssh"
226-)
227-
228-// sub file and directory names.
229-const (
230- anonAccess = "anon-access"
231- allowKeyless = "allow-keyless"
232- admins = "admins"
233- repos = "repos"
234- collabs = "collaborators"
235- description = "description"
236- exportOk = "git-daemon-export-ok"
237- private = "private"
238- projectName = "project-name"
239- settings = "settings"
240- mirror = "mirror"
241-)
242-
243-var (
244- logger = log.WithPrefix("backend.file")
245-
246- defaults = map[string]string{
247- anonAccess: backend.ReadOnlyAccess.String(),
248- allowKeyless: "true",
249- }
250-)
251-
252-var _ backend.Backend = &FileBackend{}
253-
254-// FileBackend is a backend that uses the filesystem.
255-type FileBackend struct { // nolint:revive
256- // path is the path to the directory containing the repositories and config
257- // files.
258- path string
259-
260- // repos is a map of repositories.
261- repos map[string]*Repo
262-
263- // AdditionalAdmins additional admins to the server.
264- AdditionalAdmins []string
265-}
266-
267-func (fb *FileBackend) reposPath() string {
268- return filepath.Join(fb.path, repos)
269-}
270-
271-func (fb *FileBackend) settingsPath() string {
272- return filepath.Join(fb.path, settings)
273-}
274-
275-func (fb *FileBackend) adminsPath() string {
276- return filepath.Join(fb.settingsPath(), admins)
277-}
278-
279-func (fb *FileBackend) collabsPath(repo string) string {
280- repo = utils.SanitizeRepo(repo) + ".git"
281- return filepath.Join(fb.reposPath(), repo, collabs)
282-}
283-
284-func readOneLine(path string) (string, error) {
285- f, err := os.Open(path)
286diff --git a/server/backend/file/repo.go b/server/backend/file/repo.go
287deleted file mode 100644
288index 93d7dab9bba91f5318006929a9225fac2d97b8e4..0000000000000000000000000000000000000000
289--- a/server/backend/file/repo.go
290+++ /dev/null
291@@ -1,73 +0,0 @@
292-package file
293-
294-import (
295- "os"
296- "path/filepath"
297- "strings"
298-
299- "github.com/charmbracelet/soft-serve/git"
300- "github.com/charmbracelet/soft-serve/server/backend"
301-)
302-
303-var _ backend.Repository = (*Repo)(nil)
304-
305-// Repo is a filesystem Git repository.
306-//
307-// It implemenets backend.Repository.
308-type Repo struct {
309- root string
310- path string
311-}
312-
313-// Name returns the repository's name.
314-//
315-// It implements backend.Repository.
316-func (r *Repo) Name() string {
317- name := strings.TrimSuffix(strings.TrimPrefix(r.path, r.root), ".git")
318- return strings.TrimPrefix(name, "/")
319-}
320-
321-// ProjectName returns the repository's project name.
322-func (r *Repo) ProjectName() string {
323- pn, err := readOneLine(filepath.Join(r.path, projectName))
324- if err != nil {
325- return ""
326- }
327-
328- return strings.TrimSpace(pn)
329-}
330-
331-// Description returns the repository's description.
332-//
333-// It implements backend.Repository.
334-func (r *Repo) Description() string {
335- desc, err := readAll(filepath.Join(r.path, description))
336- if err != nil {
337- return ""
338- }
339-
340- return strings.TrimSpace(desc)
341-}
342-
343-// IsPrivate returns whether the repository is private.
344-//
345-// It implements backend.Repository.
346-func (r *Repo) IsPrivate() bool {
347- _, err := os.Stat(filepath.Join(r.path, private))
348- return err == nil
349-}
350-
351-// IsMirror returns whether the repository is a mirror.
352-//
353-// It implements backend.Repository.
354-func (r *Repo) IsMirror() bool {
355- _, err := os.Stat(filepath.Join(r.path, mirror))
356- return err == nil
357-}
358-
359-// Open returns the underlying git.Repository.
360-//
361-// It implements backend.Repository.
362-func (r *Repo) Open() (*git.Repository, error) {
363- return git.Open(r.path)
364-}
365diff --git a/server/backend/repo.go b/server/backend/repo.go
366index b429a7a0132480f701e3fa74b0e90c3f11713271..2f72164e9e27d50f5d1e063db30d06be4d3f62b0 100644
367--- a/server/backend/repo.go
368+++ b/server/backend/repo.go
369@@ -2,15 +2,14 @@ package backend
370
371 import (
372 "github.com/charmbracelet/soft-serve/git"
373- "golang.org/x/crypto/ssh"
374 )
375
376 // RepositoryOptions are options for creating a new repository.
377 type RepositoryOptions struct {
378 Private bool
379- Mirror string
380 Description string
381 ProjectName string
382+ Mirror bool
383 }
384
385 // RepositoryStore is an interface for managing repositories.
386@@ -21,10 +20,14 @@ type RepositoryStore interface {
387 Repositories() ([]Repository, error)
388 // CreateRepository creates a new repository.
389 CreateRepository(name string, opts RepositoryOptions) (Repository, error)
390+ // ImportRepository creates a new repository from a Git repository.
391+ ImportRepository(name string, remote string, opts RepositoryOptions) (Repository, error)
392 // DeleteRepository deletes a repository.
393 DeleteRepository(name string) error
394 // RenameRepository renames a repository.
395 RenameRepository(oldName, newName string) error
396+ // InitializeHooks initializes the hooks for the given repository.
397+ InitializeHooks(repo string) error
398 }
399
400 // RepositoryMetadata is an interface for managing repository metadata.
401@@ -47,24 +50,13 @@ type RepositoryMetadata interface {
402
403 // RepositoryAccess is an interface for managing repository access.
404 type RepositoryAccess interface {
405- // AccessLevel returns the access level for the given repository and key.
406- AccessLevel(repo string, pk ssh.PublicKey) AccessLevel
407- // IsCollaborator returns true if the authorized key is a collaborator on the repository.
408- IsCollaborator(pk ssh.PublicKey, repo string) bool
409+ IsCollaborator(repo string, username string) bool
410 // AddCollaborator adds the authorized key as a collaborator on the repository.
411- AddCollaborator(pk ssh.PublicKey, memo string, repo string) error
412+ AddCollaborator(repo string, username string) error
413 // RemoveCollaborator removes the authorized key as a collaborator on the repository.
414- RemoveCollaborator(pk ssh.PublicKey, repo string) error
415+ RemoveCollaborator(repo string, username string) error
416 // Collaborators returns a list of all collaborators on the repository.
417 Collaborators(repo string) ([]string, error)
418- // IsAdmin returns true if the authorized key is an admin.
419- IsAdmin(pk ssh.PublicKey) bool
420- // AddAdmin adds the authorized key as an admin.
421- AddAdmin(pk ssh.PublicKey, memo string) error
422- // RemoveAdmin removes the authorized key as an admin.
423- RemoveAdmin(pk ssh.PublicKey) error
424- // Admins returns a list of all admins.
425- Admins() ([]string, error)
426 }
427
428 // Repository is a Git repository interface.
429diff --git a/server/backend/server.go b/server/backend/settings.go
430rename from server/backend/server.go
431rename to server/backend/settings.go
432index 72198c018d445afabc5b2a5e049e742894c1fb5c..c3e8a79023f250aeb7410bb46d76d8dacf261bc5 100644
433--- a/server/backend/server.go
434+++ b/server/backend/settings.go
435@@ -1,7 +1,7 @@
436 package backend
437
438-// ServerBackend is an interface that handles server configuration.
439-type ServerBackend interface {
440+// SettingsBackend is an interface that handles server configuration.
441+type SettingsBackend interface {
442 // AnonAccess returns the access level for anonymous users.
443 AnonAccess() AccessLevel
444 // SetAnonAccess sets the access level for anonymous users.
445diff --git a/server/backend/sqlite/db.go b/server/backend/sqlite/db.go
446new file mode 100644
447index 0000000000000000000000000000000000000000..f3ed49d74cf459a155cb558023d682ca2b541f02
448--- /dev/null
449+++ b/server/backend/sqlite/db.go
450@@ -0,0 +1,115 @@
451+package sqlite
452+
453+import (
454+ "context"
455+ "database/sql"
456+ "errors"
457+ "fmt"
458+
459+ "github.com/charmbracelet/soft-serve/server/backend"
460+ "github.com/jmoiron/sqlx"
461+ "golang.org/x/crypto/bcrypt"
462+ "modernc.org/sqlite"
463+ sqlite3 "modernc.org/sqlite/lib"
464+)
465+
466+// Close closes the database.
467+func (d *SqliteBackend) Close() error {
468+ return d.db.Close()
469+}
470+
471+// init creates the database.
472+func (d *SqliteBackend) init() error {
473+ return wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
474+ if _, err := tx.Exec(sqlCreateSettingsTable); err != nil {
475+ return err
476+ }
477+ if _, err := tx.Exec(sqlCreateUserTable); err != nil {
478+ return err
479+ }
480+ if _, err := tx.Exec(sqlCreatePublicKeyTable); err != nil {
481+ return err
482+ }
483+ if _, err := tx.Exec(sqlCreateRepoTable); err != nil {
484+ return err
485+ }
486+ if _, err := tx.Exec(sqlCreateCollabTable); err != nil {
487+ return err
488+ }
489+
490+ // Set default settings.
491+ if _, err := tx.Exec("INSERT OR IGNORE INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", "allow_keyless", true); err != nil {
492+ return err
493+ }
494+ if _, err := tx.Exec("INSERT OR IGNORE INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", "anon_access", backend.ReadOnlyAccess.String()); err != nil {
495+ return err
496+ }
497+
498+ return nil
499+ })
500+}
501+
502+func wrapDbErr(err error) error {
503+ if err != nil {
504+ if errors.Is(err, sql.ErrNoRows) {
505+ return ErrNoRecord
506+ }
507+ if liteErr, ok := err.(*sqlite.Error); ok {
508+ code := liteErr.Code()
509+ if code == sqlite3.SQLITE_CONSTRAINT_PRIMARYKEY ||
510+ code == sqlite3.SQLITE_CONSTRAINT_UNIQUE {
511+ return ErrDuplicateKey
512+ }
513+ }
514+ }
515+ return err
516+}
517+
518+func wrapTx(db *sqlx.DB, ctx context.Context, fn func(tx *sqlx.Tx) error) error {
519+ tx, err := db.BeginTxx(ctx, nil)
520+ if err != nil {
521+ return fmt.Errorf("failed to begin transaction: %w", err)
522+ }
523+
524+ if err := fn(tx); err != nil {
525+ return rollback(tx, err)
526+ }
527+
528+ if err := tx.Commit(); err != nil {
529+ if errors.Is(err, sql.ErrTxDone) {
530+ // this is ok because whoever did finish the tx should have also written the error already.
531+ return nil
532+ }
533+ return fmt.Errorf("failed to commit transaction: %w", err)
534+ }
535+
536+ return nil
537+}
538+
539+func rollback(tx *sqlx.Tx, err error) error {
540+ if rerr := tx.Rollback(); rerr != nil {
541+ if errors.Is(rerr, sql.ErrTxDone) {
542+ return err
543+ }
544+ return fmt.Errorf("failed to rollback: %s: %w", err.Error(), rerr)
545+ }
546+
547+ return err
548+}
549+
550diff --git a/server/backend/sqlite/error.go b/server/backend/sqlite/error.go
551new file mode 100644
552index 0000000000000000000000000000000000000000..72020de5be13e1a22bb5d4d836b026e0c963f940
553--- /dev/null
554+++ b/server/backend/sqlite/error.go
555@@ -0,0 +1,11 @@
556+package sqlite
557+
558+import "errors"
559+
560+var (
561+ // ErrDuplicateKey is returned when a unique constraint is violated.
562+ ErrDuplicateKey = errors.New("record already exists")
563+
564+ // ErrNoRecord is returned when a record is not found.
565+ ErrNoRecord = errors.New("record not found")
566+)
567diff --git a/server/backend/sqlite/repo.go b/server/backend/sqlite/repo.go
568new file mode 100644
569index 0000000000000000000000000000000000000000..bdd4384e714d17d6b528147c342b9cda54fccf29
570--- /dev/null
571+++ b/server/backend/sqlite/repo.go
572@@ -0,0 +1,88 @@
573+package sqlite
574+
575+import (
576+ "context"
577+
578+ "github.com/charmbracelet/soft-serve/git"
579+ "github.com/charmbracelet/soft-serve/server/backend"
580+ "github.com/jmoiron/sqlx"
581+)
582+
583+var _ backend.Repository = (*Repo)(nil)
584+
585+// Repo is a Git repository with metadata stored in a SQLite database.
586+type Repo struct {
587+ name string
588+ path string
589+ db *sqlx.DB
590+}
591+
592+// Description returns the repository's description.
593+//
594+// It implements backend.Repository.
595+func (r *Repo) Description() string {
596+ var desc string
597+ if err := wrapTx(r.db, context.Background(), func(tx *sqlx.Tx) error {
598+ return tx.Get(&desc, "SELECT description FROM repo WHERE name = ?", r.name)
599+ }); err != nil {
600+ return ""
601+ }
602+
603+ return desc
604+}
605+
606+// IsMirror returns whether the repository is a mirror.
607+//
608+// It implements backend.Repository.
609+func (r *Repo) IsMirror() bool {
610+ var mirror bool
611+ if err := wrapTx(r.db, context.Background(), func(tx *sqlx.Tx) error {
612+ return tx.Get(&mirror, "SELECT mirror FROM repo WHERE name = ?", r.name)
613+ }); err != nil {
614+ return false
615+ }
616+
617+ return mirror
618+}
619+
620+// IsPrivate returns whether the repository is private.
621+//
622+// It implements backend.Repository.
623+func (r *Repo) IsPrivate() bool {
624+ var private bool
625+ if err := wrapTx(r.db, context.Background(), func(tx *sqlx.Tx) error {
626+ return tx.Get(&private, "SELECT private FROM repo WHERE name = ?", r.name)
627+ }); err != nil {
628+ return false
629+ }
630+
631+ return private
632+}
633+
634+// Name returns the repository's name.
635+//
636+// It implements backend.Repository.
637+func (r *Repo) Name() string {
638+ return r.name
639+}
640+
641+// Open opens the repository.
642+//
643+// It implements backend.Repository.
644+func (r *Repo) Open() (*git.Repository, error) {
645+ return git.Open(r.path)
646+}
647+
648+// ProjectName returns the repository's project name.
649+//
650+// It implements backend.Repository.
651+func (r *Repo) ProjectName() string {
652+ var name string
653+ if err := wrapTx(r.db, context.Background(), func(tx *sqlx.Tx) error {
654+ return tx.Get(&name, "SELECT project_name FROM repo WHERE name = ?", r.name)
655+ }); err != nil {
656+ return ""
657+ }
658+
659+ return name
660+}
661diff --git a/server/backend/sqlite/sql.go b/server/backend/sqlite/sql.go
662new file mode 100644
663index 0000000000000000000000000000000000000000..8158cac5ae65dda25092946025cb282bb487fc37
664--- /dev/null
665+++ b/server/backend/sqlite/sql.go
666@@ -0,0 +1,60 @@
667+package sqlite
668+
669+var (
670+ sqlCreateSettingsTable = `CREATE TABLE IF NOT EXISTS settings (
671+ id INTEGER PRIMARY KEY AUTOINCREMENT,
672+ key TEXT NOT NULL UNIQUE,
673+ value TEXT NOT NULL,
674+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
675+ updated_at DATETIME NOT NULL
676+ );`
677+
678+ sqlCreateUserTable = `CREATE TABLE IF NOT EXISTS user (
679+ id INTEGER PRIMARY KEY AUTOINCREMENT,
680+ username TEXT UNIQUE,
681+ admin BOOLEAN NOT NULL,
682+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
683+ updated_at DATETIME NOT NULL
684+ );`
685+
686+ sqlCreatePublicKeyTable = `CREATE TABLE IF NOT EXISTS public_key (
687+ id INTEGER PRIMARY KEY AUTOINCREMENT,
688+ user_id INTEGER NOT NULL,
689+ public_key TEXT NOT NULL,
690+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
691+ updated_at DATETIME NOT NULL,
692+ UNIQUE (user_id, public_key),
693+ CONSTRAINT user_id_fk
694+ FOREIGN KEY(user_id) REFERENCES user(id)
695+ ON DELETE CASCADE
696+ ON UPDATE CASCADE
697+ );`
698+
699+ sqlCreateRepoTable = `CREATE TABLE IF NOT EXISTS repo (
700+ id INTEGER PRIMARY KEY AUTOINCREMENT,
701+ name TEXT NOT NULL UNIQUE,
702+ project_name TEXT NOT NULL,
703+ description TEXT NOT NULL,
704+ private BOOLEAN NOT NULL,
705+ mirror BOOLEAN NOT NULL,
706+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
707+ updated_at DATETIME NOT NULL
708+ );`
709+
710+ sqlCreateCollabTable = `CREATE TABLE IF NOT EXISTS collab (
711+ id INTEGER PRIMARY KEY AUTOINCREMENT,
712+ user_id INTEGER NOT NULL,
713+ repo_id INTEGER NOT NULL,
714+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
715+ updated_at DATETIME NOT NULL,
716+ UNIQUE (user_id, repo_id),
717+ CONSTRAINT user_id_fk
718+ FOREIGN KEY(user_id) REFERENCES user(id)
719+ ON DELETE CASCADE
720+ ON UPDATE CASCADE,
721+ CONSTRAINT repo_id_fk
722+ FOREIGN KEY(repo_id) REFERENCES repo(id)
723+ ON DELETE CASCADE
724+ ON UPDATE CASCADE
725+ );`
726+)
727diff --git a/server/backend/sqlite/sqlite.go b/server/backend/sqlite/sqlite.go
728new file mode 100644
729index 0000000000000000000000000000000000000000..158993db2e5dd37fd8859e6b6bd657fe8ff8b0bb
730--- /dev/null
731+++ b/server/backend/sqlite/sqlite.go
732@@ -0,0 +1,598 @@
733+package sqlite
734+
735+import (
736+ "bytes"
737+ "context"
738+ "fmt"
739+ "os"
740+ "path/filepath"
741+ "strconv"
742+ "text/template"
743+
744+ "github.com/charmbracelet/log"
745+ "github.com/charmbracelet/soft-serve/git"
746+ "github.com/charmbracelet/soft-serve/server/backend"
747+ "github.com/charmbracelet/soft-serve/server/utils"
748+ "github.com/jmoiron/sqlx"
749+ _ "modernc.org/sqlite"
750+)
751+
752+var (
753+ logger = log.WithPrefix("backend.sqlite")
754+)
755+
756+// SqliteBackend is a backend that uses a SQLite database as a Soft Serve
757+// backend.
758+type SqliteBackend struct {
759+ dp string
760+ db *sqlx.DB
761+ AdditionalAdmins []string
762+}
763+
764+var _ backend.Backend = (*SqliteBackend)(nil)
765+
766+func (d *SqliteBackend) reposPath() string {
767+ return filepath.Join(d.dp, "repos")
768+}
769+
770+// NewSqliteBackend creates a new SqliteBackend.
771+func NewSqliteBackend(dataPath string) (*SqliteBackend, error) {
772+ db, err := sqlx.Connect("sqlite", filepath.Join(dataPath, "soft-serve.db"+
773+ "?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)"))
774+ if err != nil {
775+ return nil, err
776+ }
777+
778+ d := &SqliteBackend{
779+ dp: dataPath,
780+ db: db,
781+ }
782+
783+ if err := d.init(); err != nil {
784+ return nil, err
785+ }
786+
787+ return d, d.db.Ping()
788+}
789+
790+// AllowKeyless returns whether or not keyless access is allowed.
791+//
792+// It implements backend.Backend.
793+func (d *SqliteBackend) AllowKeyless() bool {
794+ var allow bool
795+ if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
796+ return tx.Get(&allow, "SELECT value FROM settings WHERE key = ?;", "allow_keyless")
797+ }); err != nil {
798+ return false
799+ }
800+
801+ return allow
802+}
803+
804+// AnonAccess returns the level of anonymous access.
805+//
806+// It implements backend.Backend.
807+func (d *SqliteBackend) AnonAccess() backend.AccessLevel {
808+ var level string
809+ if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
810+ return tx.Get(&level, "SELECT value FROM settings WHERE key = ?;", "anon_access")
811+ }); err != nil {
812+ return backend.NoAccess
813+ }
814+
815+ return backend.ParseAccessLevel(level)
816+}
817+
818+// SetAllowKeyless sets whether or not keyless access is allowed.
819+//
820+// It implements backend.Backend.
821+func (d *SqliteBackend) SetAllowKeyless(allow bool) error {
822+ return wrapDbErr(
823+ wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
824+ _, err := tx.Exec("UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?;", "allow_keyless", strconv.FormatBool(allow))
825+ return err
826+ }),
827+ )
828+}
829+
830+// SetAnonAccess sets the level of anonymous access.
831+//
832diff --git a/server/backend/sqlite/user.go b/server/backend/sqlite/user.go
833new file mode 100644
834index 0000000000000000000000000000000000000000..764820d0c0a3172250b823b1d5999ed293b041a6
835--- /dev/null
836+++ b/server/backend/sqlite/user.go
837@@ -0,0 +1,326 @@
838+package sqlite
839+
840+import (
841+ "context"
842+
843+ "github.com/charmbracelet/soft-serve/server/backend"
844+ "github.com/jmoiron/sqlx"
845+ "golang.org/x/crypto/ssh"
846+)
847+
848+// User represents a user.
849+type User struct {
850+ username string
851+ db *sqlx.DB
852+}
853+
854+var _ backend.User = (*User)(nil)
855+
856+// IsAdmin returns whether the user is an admin.
857+//
858+// It implements backend.User.
859+func (u *User) IsAdmin() bool {
860+ var admin bool
861+ if err := wrapTx(u.db, context.Background(), func(tx *sqlx.Tx) error {
862+ return tx.Get(&admin, "SELECT admin FROM user WHERE username = ?", u.username)
863+ }); err != nil {
864+ return false
865+ }
866+
867+ return admin
868+}
869+
870+// PublicKeys returns the user's public keys.
871+//
872+// It implements backend.User.
873+func (u *User) PublicKeys() []ssh.PublicKey {
874+ var keys []ssh.PublicKey
875+ if err := wrapTx(u.db, context.Background(), func(tx *sqlx.Tx) error {
876+ var keyStrings []string
877+ if err := tx.Select(&keyStrings, `SELECT public_key
878+ FROM public_key
879+ INNER JOIN user ON user.id = public_key.user_id
880+ WHERE user.username = ?;`, u.username); err != nil {
881+ return err
882+ }
883+
884+ for _, keyString := range keyStrings {
885+ key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyString))
886+ if err != nil {
887+ return err
888+ }
889+ keys = append(keys, key)
890+ }
891+
892+ return nil
893+ }); err != nil {
894+ return nil
895+ }
896+
897+ return keys
898+}
899+
900+// Username returns the user's username.
901+//
902+// It implements backend.User.
903+func (u *User) Username() string {
904+ return u.username
905+}
906+
907+// AccessLevel returns the access level of a user for a repository.
908+//
909+// It implements backend.Backend.
910+func (d *SqliteBackend) AccessLevel(repo string, username string) backend.AccessLevel {
911+ anon := d.AnonAccess()
912+ user, _ := d.User(username)
913+ // If the user is an admin, they have admin access.
914+ if user != nil && user.IsAdmin() {
915+ return backend.AdminAccess
916+ }
917+
918+ // If the repository exists, check if the user is a collaborator.
919+ r, _ := d.Repository(repo)
920+ if r != nil {
921+ // If the user is a collaborator, they have read/write access.
922+ if d.IsCollaborator(repo, username) {
923+ if anon > backend.ReadWriteAccess {
924+ return anon
925+ }
926+ return backend.ReadWriteAccess
927+ }
928+
929+ // If the repository is private, the user has no access.
930+ if r.IsPrivate() {
931+ return backend.NoAccess
932+ }
933+
934+ // Otherwise, the user has read-only access.
935+ return backend.ReadOnlyAccess
936+ }
937diff --git a/server/backend/user.go b/server/backend/user.go
938new file mode 100644
939index 0000000000000000000000000000000000000000..b0b5f1115dbe3760dbf6972c0eceaeb7f5fbf79a
940--- /dev/null
941+++ b/server/backend/user.go
942@@ -0,0 +1,55 @@
943+package backend
944+
945+import (
946+ "golang.org/x/crypto/ssh"
947+)
948+
949+// User is an interface representing a user.
950+type User interface {
951+ // Username returns the user's username.
952+ Username() string
953+ // IsAdmin returns whether the user is an admin.
954+ IsAdmin() bool
955+ // PublicKeys returns the user's public keys.
956+ PublicKeys() []ssh.PublicKey
957+}
958+
959+// UserAccess is an interface that handles user access to repositories.
960+type UserAccess interface {
961+ // AccessLevel returns the access level of the username to the repository.
962+ AccessLevel(repo string, username string) AccessLevel
963+ // AccessLevelByPublicKey returns the access level of the public key to the repository.
964+ AccessLevelByPublicKey(repo string, pk ssh.PublicKey) AccessLevel
965+}
966+
967+// UserStore is an interface for managing users.
968+type UserStore interface {
969+ // User finds the given user.
970+ User(username string) (User, error)
971+ // UserByPublicKey finds the user with the given public key.
972+ UserByPublicKey(pk ssh.PublicKey) (User, error)
973+ // Users returns a list of all users.
974+ Users() ([]string, error)
975+ // CreateUser creates a new user.
976+ CreateUser(username string, opts UserOptions) (User, error)
977+ // DeleteUser deletes a user.
978+ DeleteUser(username string) error
979+ // SetUsername sets the username of the user.
980+ SetUsername(oldUsername string, newUsername string) error
981+ // SetAdmin sets whether the user is an admin.
982+ SetAdmin(username string, admin bool) error
983+ // AddPublicKey adds a public key to the user.
984+ AddPublicKey(username string, pk ssh.PublicKey) error
985+ // RemovePublicKey removes a public key from the user.
986+ RemovePublicKey(username string, pk ssh.PublicKey) error
987+ // ListPublicKeys lists the public keys of the user.
988+ ListPublicKeys(username string) ([]ssh.PublicKey, error)
989+}
990+
991+// UserOptions are options for creating a user.
992+type UserOptions struct {
993+ // Admin is whether the user is an admin.
994+ Admin bool
995+ // PublicKeys are the user's public keys.
996+ PublicKeys []ssh.PublicKey
997+}
998diff --git a/server/cmd/admin.go b/server/cmd/admin.go
999deleted file mode 100644
1000index 687117edfcb27e81555c87c100a795d3eb6f67a5..0000000000000000000000000000000000000000
1001--- a/server/cmd/admin.go
1002+++ /dev/null
1003@@ -1,88 +0,0 @@
1004-package cmd
1005-
1006-import (
1007- "strings"
1008-
1009- "github.com/charmbracelet/soft-serve/server/backend"
1010- "github.com/spf13/cobra"
1011-)
1012-
1013-func adminCommand() *cobra.Command {
1014- cmd := &cobra.Command{
1015- Use: "admin",
1016- Aliases: []string{"admins"},
1017- Short: "Manage admins",
1018- }
1019-
1020- cmd.AddCommand(
1021- adminAddCommand(),
1022- adminRemoveCommand(),
1023- adminListCommand(),
1024- )
1025-
1026- return cmd
1027-}
1028-
1029-func adminAddCommand() *cobra.Command {
1030- cmd := &cobra.Command{
1031- Use: "add AUTHORIZED_KEY",
1032- Short: "Add an admin",
1033- Args: cobra.MinimumNArgs(1),
1034- PersistentPreRunE: checkIfAdmin,
1035- RunE: func(cmd *cobra.Command, args []string) error {
1036- cfg, _ := fromContext(cmd)
1037- pk, c, err := backend.ParseAuthorizedKey(strings.Join(args, " "))
1038- if err != nil {
1039- return err
1040- }
1041-
1042- return cfg.Backend.AddAdmin(pk, c)
1043- },
1044- }
1045-
1046- return cmd
1047-}
1048-
1049-func adminRemoveCommand() *cobra.Command {
1050- cmd := &cobra.Command{
1051- Use: "remove AUTHORIZED_KEY",
1052- Args: cobra.MinimumNArgs(1),
1053- Short: "Remove an admin",
1054- PersistentPreRunE: checkIfAdmin,
1055- RunE: func(cmd *cobra.Command, args []string) error {
1056- cfg, _ := fromContext(cmd)
1057- pk, _, err := backend.ParseAuthorizedKey(strings.Join(args, " "))
1058- if err != nil {
1059- return err
1060- }
1061-
1062- return cfg.Backend.RemoveAdmin(pk)
1063- },
1064- }
1065-
1066- return cmd
1067-}
1068-
1069-func adminListCommand() *cobra.Command {
1070- cmd := &cobra.Command{
1071- Use: "list",
1072- Args: cobra.NoArgs,
1073- Short: "List admins",
1074- PersistentPreRunE: checkIfAdmin,
1075- RunE: func(cmd *cobra.Command, _ []string) error {
1076- cfg, _ := fromContext(cmd)
1077- admins, err := cfg.Backend.Admins()
1078- if err != nil {
1079- return err
1080- }
1081-
1082- for _, admin := range admins {
1083- cmd.Println(admin)
1084- }
1085-
1086- return nil
1087- },
1088- }
1089-
1090- return cmd
1091-}
1092diff --git a/server/cmd/cmd.go b/server/cmd/cmd.go
1093index f6fcdb13164b478c9488d1f9b46a5104bacd43bf..6ba903f196db3ccacea4c6541599f8a0c0d30d86 100644
1094--- a/server/cmd/cmd.go
1095+++ b/server/cmd/cmd.go
1096@@ -45,32 +45,36 @@ var (
1097 )
1098
1099 // rootCommand is the root command for the server.
1100-func rootCommand() *cobra.Command {
1101+func rootCommand(cfg *config.Config, s ssh.Session) *cobra.Command {
1102 rootCmd := &cobra.Command{
1103 Use: "soft",
1104 Short: "Soft Serve is a self-hostable Git server for the command line.",
1105 SilenceUsage: true,
1106 }
1107+
1108 // TODO: use command usage template to include hostname and port
1109 rootCmd.CompletionOptions.DisableDefaultCmd = true
1110 rootCmd.AddCommand(
1111- adminCommand(),
1112- blobCommand(),
1113- branchCommand(),
1114- collabCommand(),
1115- createCommand(),
1116- deleteCommand(),
1117- descriptionCommand(),
1118 hookCommand(),
1119- listCommand(),
1120- privateCommand(),
1121- projectName(),
1122- renameCommand(),
1123- settingCommand(),
1124- tagCommand(),
1125- treeCommand(),
1126+ repoCommand(),
1127 )
1128
1129+ user, _ := cfg.Backend.UserByPublicKey(s.PublicKey())
1130+ if user != nil {
1131+ if user.IsAdmin() {
1132+ rootCmd.AddCommand(
1133+ settingsCommand(),
1134+ userCommand(),
1135+ )
1136+ }
1137+
1138+ rootCmd.AddCommand(
1139+ infoCommand(),
1140+ pubkeyCommand(),
1141+ setUsernameCommand(),
1142+ )
1143+ }
1144+
1145 return rootCmd
1146 }
1147
1148@@ -88,18 +92,31 @@ func checkIfReadable(cmd *cobra.Command, args []string) error {
1149 }
1150 cfg, s := fromContext(cmd)
1151 rn := utils.SanitizeRepo(repo)
1152- auth := cfg.Backend.AccessLevel(rn, s.PublicKey())
1153+ auth := cfg.Backend.AccessLevelByPublicKey(rn, s.PublicKey())
1154 if auth < backend.ReadOnlyAccess {
1155 return ErrUnauthorized
1156 }
1157 return nil
1158 }
1159
1160-func checkIfAdmin(cmd *cobra.Command, args []string) error {
1161+func checkIfAdmin(cmd *cobra.Command, _ []string) error {
1162 cfg, s := fromContext(cmd)
1163- if !cfg.Backend.IsAdmin(s.PublicKey()) {
1164+ ak := backend.MarshalAuthorizedKey(s.PublicKey())
1165+ for _, k := range cfg.InitialAdminKeys {
1166+ if k == ak {
1167+ return nil
1168+ }
1169+ }
1170+
1171+ user, err := cfg.Backend.UserByPublicKey(s.PublicKey())
1172+ if err != nil {
1173+ return err
1174+ }
1175+
1176+ if !user.IsAdmin() {
1177 return ErrUnauthorized
1178 }
1179+
1180 return nil
1181 }
1182
1183@@ -110,7 +127,7 @@ func checkIfCollab(cmd *cobra.Command, args []string) error {
1184 }
1185 cfg, s := fromContext(cmd)
1186 rn := utils.SanitizeRepo(repo)
1187- auth := cfg.Backend.AccessLevel(rn, s.PublicKey())
1188+ auth := cfg.Backend.AccessLevelByPublicKey(rn, s.PublicKey())
1189 if auth < backend.ReadWriteAccess {
1190 return ErrUnauthorized
1191 }
1192@@ -141,7 +158,7 @@ func Middleware(cfg *config.Config, hooks hooks.Hooks) wish.Middleware {
1193 ctx = context.WithValue(ctx, SessionCtxKey, s)
1194 ctx = context.WithValue(ctx, HooksCtxKey, hooks)
1195
1196diff --git a/server/cmd/collab.go b/server/cmd/collab.go
1197index 15cdd0b1792d7e3942a78d97b8758dd82e966963..9426a975a98b22e10b66b836daf47e489e0dcf9c 100644
1198--- a/server/cmd/collab.go
1199+++ b/server/cmd/collab.go
1200@@ -1,16 +1,13 @@
1201 package cmd
1202
1203 import (
1204- "strings"
1205-
1206- "github.com/charmbracelet/soft-serve/server/backend"
1207 "github.com/spf13/cobra"
1208 )
1209
1210 func collabCommand() *cobra.Command {
1211 cmd := &cobra.Command{
1212 Use: "collab",
1213- Aliases: []string{"collaborator", "collaborators"},
1214+ Aliases: []string{"collabs", "collaborator", "collaborators"},
1215 Short: "Manage collaborators",
1216 }
1217
1218@@ -25,19 +22,16 @@ func collabCommand() *cobra.Command {
1219
1220 func collabAddCommand() *cobra.Command {
1221 cmd := &cobra.Command{
1222- Use: "add REPOSITORY AUTHORIZED_KEY",
1223+ Use: "add REPOSITORY USERNAME",
1224 Short: "Add a collaborator to a repo",
1225- Args: cobra.MinimumNArgs(2),
1226+ Args: cobra.ExactArgs(2),
1227 PersistentPreRunE: checkIfAdmin,
1228 RunE: func(cmd *cobra.Command, args []string) error {
1229 cfg, _ := fromContext(cmd)
1230 repo := args[0]
1231- pk, c, err := backend.ParseAuthorizedKey(strings.Join(args[1:], " "))
1232- if err != nil {
1233- return err
1234- }
1235+ username := args[1]
1236
1237- return cfg.Backend.AddCollaborator(pk, c, repo)
1238+ return cfg.Backend.AddCollaborator(repo, username)
1239 },
1240 }
1241
1242@@ -46,19 +40,16 @@ func collabAddCommand() *cobra.Command {
1243
1244 func collabRemoveCommand() *cobra.Command {
1245 cmd := &cobra.Command{
1246- Use: "remove REPOSITORY AUTHORIZED_KEY",
1247- Args: cobra.MinimumNArgs(2),
1248+ Use: "remove REPOSITORY USERNAME",
1249+ Args: cobra.ExactArgs(2),
1250 Short: "Remove a collaborator from a repo",
1251 PersistentPreRunE: checkIfAdmin,
1252 RunE: func(cmd *cobra.Command, args []string) error {
1253 cfg, _ := fromContext(cmd)
1254 repo := args[0]
1255- pk, _, err := backend.ParseAuthorizedKey(strings.Join(args[1:], " "))
1256- if err != nil {
1257- return err
1258- }
1259+ username := args[1]
1260
1261- return cfg.Backend.RemoveCollaborator(pk, repo)
1262+ return cfg.Backend.RemoveCollaborator(repo, username)
1263 },
1264 }
1265
1266diff --git a/server/cmd/create.go b/server/cmd/create.go
1267index 891ad94f9a51301b9a502e7101c7a745f34e6da2..259406d1424078f9d6e9646e509dba3bf989e026 100644
1268--- a/server/cmd/create.go
1269+++ b/server/cmd/create.go
1270@@ -9,7 +9,6 @@ import (
1271 func createCommand() *cobra.Command {
1272 var private bool
1273 var description string
1274- var mirror string
1275 var projectName string
1276
1277 cmd := &cobra.Command{
1278@@ -22,7 +21,6 @@ func createCommand() *cobra.Command {
1279 name := args[0]
1280 if _, err := cfg.Backend.CreateRepository(name, backend.RepositoryOptions{
1281 Private: private,
1282- Mirror: mirror,
1283 Description: description,
1284 ProjectName: projectName,
1285 }); err != nil {
1286@@ -34,7 +32,6 @@ func createCommand() *cobra.Command {
1287
1288 cmd.Flags().BoolVarP(&private, "private", "p", false, "make the repository private")
1289 cmd.Flags().StringVarP(&description, "description", "d", "", "set the repository description")
1290- cmd.Flags().StringVarP(&mirror, "mirror", "m", "", "set the mirror repository")
1291 cmd.Flags().StringVarP(&projectName, "name", "n", "", "set the project name")
1292
1293 return cmd
1294diff --git a/server/cmd/import.go b/server/cmd/import.go
1295new file mode 100644
1296index 0000000000000000000000000000000000000000..7135239f15dac10fc9ab8bf1b24d9c30cb9f2568
1297--- /dev/null
1298+++ b/server/cmd/import.go
1299@@ -0,0 +1,42 @@
1300+package cmd
1301+
1302+import (
1303+ "github.com/charmbracelet/soft-serve/server/backend"
1304+ "github.com/spf13/cobra"
1305+)
1306+
1307+// importCommand is the command for creating a new repository.
1308+func importCommand() *cobra.Command {
1309+ var private bool
1310+ var description string
1311+ var projectName string
1312+ var mirror bool
1313+
1314+ cmd := &cobra.Command{
1315+ Use: "import REPOSITORY REMOTE",
1316+ Short: "Import a new repository from remote",
1317+ Args: cobra.ExactArgs(2),
1318+ PersistentPreRunE: checkIfAdmin,
1319+ RunE: func(cmd *cobra.Command, args []string) error {
1320+ cfg, _ := fromContext(cmd)
1321+ name := args[0]
1322+ remote := args[1]
1323+ if _, err := cfg.Backend.ImportRepository(name, remote, backend.RepositoryOptions{
1324+ Private: private,
1325+ Description: description,
1326+ ProjectName: projectName,
1327+ Mirror: mirror,
1328+ }); err != nil {
1329+ return err
1330+ }
1331+ return nil
1332+ },
1333+ }
1334+
1335+ cmd.Flags().BoolVarP(&mirror, "mirror", "m", false, "mirror the repository")
1336+ cmd.Flags().BoolVarP(&private, "private", "p", false, "make the repository private")
1337+ cmd.Flags().StringVarP(&description, "description", "d", "", "set the repository description")
1338+ cmd.Flags().StringVarP(&projectName, "name", "n", "", "set the project name")
1339+
1340+ return cmd
1341+}
1342diff --git a/server/cmd/info.go b/server/cmd/info.go
1343new file mode 100644
1344index 0000000000000000000000000000000000000000..60d716d72dbfea017627928b3dff8ecc99338f36
1345--- /dev/null
1346+++ b/server/cmd/info.go
1347@@ -0,0 +1,31 @@
1348+package cmd
1349+
1350+import (
1351+ "github.com/charmbracelet/soft-serve/server/backend"
1352+ "github.com/spf13/cobra"
1353+)
1354+
1355+func infoCommand() *cobra.Command {
1356+ cmd := &cobra.Command{
1357+ Use: "info",
1358+ Short: "Show your info",
1359+ Args: cobra.NoArgs,
1360+ RunE: func(cmd *cobra.Command, args []string) error {
1361+ cfg, s := fromContext(cmd)
1362+ user, err := cfg.Backend.UserByPublicKey(s.PublicKey())
1363+ if err != nil {
1364+ return err
1365+ }
1366+
1367+ cmd.Printf("Username: %s\n", user.Username())
1368+ cmd.Printf("Admin: %t\n", user.IsAdmin())
1369+ cmd.Printf("Public keys:\n")
1370+ for _, pk := range user.PublicKeys() {
1371+ cmd.Printf(" %s\n", backend.MarshalAuthorizedKey(pk))
1372+ }
1373+ return nil
1374+ },
1375+ }
1376+
1377+ return cmd
1378+}
1379diff --git a/server/cmd/list.go b/server/cmd/list.go
1380index 7b6626c56ea0b7d1a472f944318620089d50610f..4b5a2a57cc06024a7a41b07723e21079a00e47e9 100644
1381--- a/server/cmd/list.go
1382+++ b/server/cmd/list.go
1383@@ -19,7 +19,7 @@ func listCommand() *cobra.Command {
1384 return err
1385 }
1386 for _, r := range repos {
1387- if cfg.Backend.AccessLevel(r.Name(), s.PublicKey()) >= backend.ReadOnlyAccess {
1388+ if cfg.Backend.AccessLevelByPublicKey(r.Name(), s.PublicKey()) >= backend.ReadOnlyAccess {
1389 cmd.Println(r.Name())
1390 }
1391 }
1392diff --git a/server/cmd/pubkey.go b/server/cmd/pubkey.go
1393new file mode 100644
1394index 0000000000000000000000000000000000000000..7c1bea9b34b0507f14c2709e9b500e993b697d08
1395--- /dev/null
1396+++ b/server/cmd/pubkey.go
1397@@ -0,0 +1,85 @@
1398+package cmd
1399+
1400+import (
1401+ "strings"
1402+
1403+ "github.com/charmbracelet/soft-serve/server/backend"
1404+ "github.com/spf13/cobra"
1405+)
1406+
1407+func pubkeyCommand() *cobra.Command {
1408+ cmd := &cobra.Command{
1409+ Use: "pubkey",
1410+ Aliases: []string{"pubkeys", "publickey", "publickeys"},
1411+ Short: "Manage your public keys",
1412+ }
1413+
1414+ pubkeyAddCommand := &cobra.Command{
1415+ Use: "add AUTHORIZED_KEY",
1416+ Short: "Add a public key",
1417+ Args: cobra.MinimumNArgs(1),
1418+ RunE: func(cmd *cobra.Command, args []string) error {
1419+ cfg, s := fromContext(cmd)
1420+ user, err := cfg.Backend.UserByPublicKey(s.PublicKey())
1421+ if err != nil {
1422+ return err
1423+ }
1424+
1425+ pk, _, err := backend.ParseAuthorizedKey(strings.Join(args, " "))
1426+ if err != nil {
1427+ return err
1428+ }
1429+
1430+ return cfg.Backend.AddPublicKey(user.Username(), pk)
1431+ },
1432+ }
1433+
1434+ pubkeyRemoveCommand := &cobra.Command{
1435+ Use: "remove AUTHORIZED_KEY",
1436+ Args: cobra.MinimumNArgs(1),
1437+ Short: "Remove a public key",
1438+ RunE: func(cmd *cobra.Command, args []string) error {
1439+ cfg, s := fromContext(cmd)
1440+ user, err := cfg.Backend.UserByPublicKey(s.PublicKey())
1441+ if err != nil {
1442+ return err
1443+ }
1444+
1445+ pk, _, err := backend.ParseAuthorizedKey(strings.Join(args, " "))
1446+ if err != nil {
1447+ return err
1448+ }
1449+
1450+ return cfg.Backend.RemovePublicKey(user.Username(), pk)
1451+ },
1452+ }
1453+
1454+ pubkeyListCommand := &cobra.Command{
1455+ Use: "list",
1456+ Aliases: []string{"ls"},
1457+ Short: "List public keys",
1458+ Args: cobra.NoArgs,
1459+ RunE: func(cmd *cobra.Command, args []string) error {
1460+ cfg, s := fromContext(cmd)
1461+ user, err := cfg.Backend.UserByPublicKey(s.PublicKey())
1462+ if err != nil {
1463+ return err
1464+ }
1465+
1466+ pks := user.PublicKeys()
1467+ for _, pk := range pks {
1468+ cmd.Println(backend.MarshalAuthorizedKey(pk))
1469+ }
1470+
1471+ return nil
1472+ },
1473+ }
1474+
1475+ cmd.AddCommand(
1476+ pubkeyAddCommand,
1477+ pubkeyRemoveCommand,
1478+ pubkeyListCommand,
1479+ )
1480+
1481+ return cmd
1482+}
1483diff --git a/server/cmd/repo.go b/server/cmd/repo.go
1484new file mode 100644
1485index 0000000000000000000000000000000000000000..577ab30304ed4092d0e1a035bda06c713fec42b4
1486--- /dev/null
1487+++ b/server/cmd/repo.go
1488@@ -0,0 +1,29 @@
1489+package cmd
1490+
1491+import "github.com/spf13/cobra"
1492+
1493+func repoCommand() *cobra.Command {
1494+ cmd := &cobra.Command{
1495+ Use: "repo",
1496+ Aliases: []string{"repos", "repository", "repositories"},
1497+ Short: "Manage repositories",
1498+ }
1499+
1500+ cmd.AddCommand(
1501+ blobCommand(),
1502+ branchCommand(),
1503+ collabCommand(),
1504+ createCommand(),
1505+ deleteCommand(),
1506+ descriptionCommand(),
1507+ importCommand(),
1508+ listCommand(),
1509+ privateCommand(),
1510+ projectName(),
1511+ renameCommand(),
1512+ tagCommand(),
1513+ treeCommand(),
1514+ )
1515+
1516+ return cmd
1517+}
1518diff --git a/server/cmd/set_username.go b/server/cmd/set_username.go
1519new file mode 100644
1520index 0000000000000000000000000000000000000000..152403a57c68fa18035f7392b2bb67e7b06c977d
1521--- /dev/null
1522+++ b/server/cmd/set_username.go
1523@@ -0,0 +1,22 @@
1524+package cmd
1525+
1526+import "github.com/spf13/cobra"
1527+
1528+func setUsernameCommand() *cobra.Command {
1529+ cmd := &cobra.Command{
1530+ Use: "set-username USERNAME",
1531+ Short: "Set your username",
1532+ Args: cobra.ExactArgs(1),
1533+ RunE: func(cmd *cobra.Command, args []string) error {
1534+ cfg, s := fromContext(cmd)
1535+ user, err := cfg.Backend.UserByPublicKey(s.PublicKey())
1536+ if err != nil {
1537+ return err
1538+ }
1539+
1540+ return cfg.Backend.SetUsername(user.Username(), args[0])
1541+ },
1542+ }
1543+
1544+ return cmd
1545+}
1546diff --git a/server/cmd/setting.go b/server/cmd/settings.go
1547rename from server/cmd/setting.go
1548rename to server/cmd/settings.go
1549index eaa55942ef0884abef3cd0b24a96f02b53cd1a7c..95ef01a24c9adfbb14f8687e6bc2ccd3b412dee4 100644
1550--- a/server/cmd/setting.go
1551+++ b/server/cmd/settings.go
1552@@ -8,9 +8,9 @@ import (
1553 "github.com/spf13/cobra"
1554 )
1555
1556-func settingCommand() *cobra.Command {
1557+func settingsCommand() *cobra.Command {
1558 cmd := &cobra.Command{
1559- Use: "setting",
1560+ Use: "settings",
1561 Short: "Manage server settings",
1562 }
1563
1564diff --git a/server/cmd/user.go b/server/cmd/user.go
1565new file mode 100644
1566index 0000000000000000000000000000000000000000..364f54b3c7c7ac0937523c49ca04e017e123671e
1567--- /dev/null
1568+++ b/server/cmd/user.go
1569@@ -0,0 +1,193 @@
1570+package cmd
1571+
1572+import (
1573+ "sort"
1574+ "strings"
1575+
1576+ "github.com/charmbracelet/log"
1577+ "github.com/charmbracelet/soft-serve/server/backend"
1578+ "github.com/spf13/cobra"
1579+ "golang.org/x/crypto/ssh"
1580+)
1581+
1582+func userCommand() *cobra.Command {
1583+ cmd := &cobra.Command{
1584+ Use: "user",
1585+ Aliases: []string{"users"},
1586+ Short: "Manage users",
1587+ }
1588+
1589+ var admin bool
1590+ var key string
1591+ userAddCommand := &cobra.Command{
1592+ Use: "add USERNAME",
1593+ Short: "Add a user",
1594+ Args: cobra.ExactArgs(1),
1595+ PersistentPreRunE: checkIfAdmin,
1596+ RunE: func(cmd *cobra.Command, args []string) error {
1597+ cfg, _ := fromContext(cmd)
1598+ username := args[0]
1599+ pk, _, err := backend.ParseAuthorizedKey(key)
1600+ if err != nil {
1601+ return err
1602+ }
1603+
1604+ opts := backend.UserOptions{
1605+ Admin: admin,
1606+ PublicKeys: []ssh.PublicKey{pk},
1607+ }
1608+
1609+ _, err = cfg.Backend.CreateUser(username, opts)
1610+ return err
1611+ },
1612+ }
1613+
1614+ userAddCommand.Flags().BoolVarP(&admin, "admin", "a", false, "make the user an admin")
1615+ userAddCommand.Flags().StringVarP(&key, "key", "k", "", "add a public key to the user")
1616+
1617+ userRemoveCommand := &cobra.Command{
1618+ Use: "remove USERNAME",
1619+ Short: "Remove a user",
1620+ Args: cobra.ExactArgs(1),
1621+ PersistentPreRunE: checkIfAdmin,
1622+ RunE: func(cmd *cobra.Command, args []string) error {
1623+ cfg, _ := fromContext(cmd)
1624+ username := args[0]
1625+
1626+ return cfg.Backend.DeleteUser(username)
1627+ },
1628+ }
1629+
1630+ userListCommand := &cobra.Command{
1631+ Use: "list",
1632+ Aliases: []string{"ls"},
1633+ Short: "List users",
1634+ Args: cobra.NoArgs,
1635+ PersistentPreRunE: checkIfAdmin,
1636+ RunE: func(cmd *cobra.Command, _ []string) error {
1637+ cfg, _ := fromContext(cmd)
1638+ users, err := cfg.Backend.Users()
1639+ if err != nil {
1640+ return err
1641+ }
1642+
1643+ sort.Strings(users)
1644+ for _, u := range users {
1645+ cmd.Println(u)
1646+ }
1647+
1648+ return nil
1649+ },
1650+ }
1651+
1652+ userAddPubkeyCommand := &cobra.Command{
1653+ Use: "add-pubkey USERNAME AUTHORIZED_KEY",
1654+ Short: "Add a public key to a user",
1655+ Args: cobra.MinimumNArgs(2),
1656+ PersistentPreRunE: checkIfAdmin,
1657+ RunE: func(cmd *cobra.Command, args []string) error {
1658+ cfg, _ := fromContext(cmd)
1659+ username := args[0]
1660+ pubkey := strings.Join(args[1:], " ")
1661+ pk, _, err := backend.ParseAuthorizedKey(pubkey)
1662+ if err != nil {
1663+ return err
1664+ }
1665+
1666+ return cfg.Backend.AddPublicKey(username, pk)
1667+ },
1668+ }
1669diff --git a/server/cron/cron.go b/server/cron/cron.go
1670index 9f98d5a69c709c624f93738bc407f510b20a45c1..b86b0eacf6b96c6d6e52c86a7de984efac504dbb 100644
1671--- a/server/cron/cron.go
1672+++ b/server/cron/cron.go
1673@@ -29,7 +29,7 @@ type cronLogger struct {
1674
1675 // Info logs routine messages about cron's operation.
1676 func (l cronLogger) Info(msg string, keysAndValues ...interface{}) {
1677- l.logger.Info(msg, keysAndValues...)
1678+ l.logger.Debug(msg, keysAndValues...)
1679 }
1680
1681 // Error logs an error condition.
1682diff --git a/server/daemon.go b/server/daemon.go
1683index 047bf4b6cd8bf4b5d69836893e469e0deb9e909e..007e3c68e3f19930471ca9afe06964e93cf90642 100644
1684--- a/server/daemon.go
1685+++ b/server/daemon.go
1686@@ -233,7 +233,7 @@ func (d *GitDaemon) handleClient(conn net.Conn) {
1687 return
1688 }
1689
1690- auth := d.cfg.Backend.AccessLevel(name, nil)
1691+ auth := d.cfg.Backend.AccessLevel(name, "")
1692 if auth < backend.ReadOnlyAccess {
1693 fatal(c, ErrNotAuthed)
1694 return
1695diff --git a/server/daemon_test.go b/server/daemon_test.go
1696index d7816537297e1c516dc3e95e3ab2bbbc034a56ac..79b30d9959347ea179df6dd2970bd7280fc77e45 100644
1697--- a/server/daemon_test.go
1698+++ b/server/daemon_test.go
1699@@ -8,12 +8,11 @@ import (
1700 "log"
1701 "net"
1702 "os"
1703- "path/filepath"
1704 "strings"
1705 "testing"
1706 "time"
1707
1708- "github.com/charmbracelet/soft-serve/server/backend/file"
1709+ "github.com/charmbracelet/soft-serve/server/backend/sqlite"
1710 "github.com/charmbracelet/soft-serve/server/config"
1711 "github.com/go-git/go-git/v5/plumbing/format/pktline"
1712 )
1713@@ -31,7 +30,7 @@ func TestMain(m *testing.M) {
1714 os.Setenv("SOFT_SERVE_GIT_MAX_TIMEOUT", "100")
1715 os.Setenv("SOFT_SERVE_GIT_IDLE_TIMEOUT", "1")
1716 os.Setenv("SOFT_SERVE_GIT_LISTEN_ADDR", fmt.Sprintf(":%d", randomPort()))
1717- fb, err := file.NewFileBackend(filepath.Join(tmp, "repos"))
1718+ fb, err := sqlite.NewSqliteBackend(tmp)
1719 if err != nil {
1720 log.Fatal(err)
1721 }
1722diff --git a/server/http.go b/server/http.go
1723index 30f4547d9a3c44ff68d275e40020a2c3e12aeb23..fc9fc6d5680a8eb34190a0b4c2919bf049ca0589 100644
1724--- a/server/http.go
1725+++ b/server/http.go
1726@@ -137,7 +137,7 @@ func (s *HTTPServer) Shutdown(ctx context.Context) error {
1727 // Pattern is a pattern for matching a URL.
1728 // It matches against GET requests.
1729 type Pattern struct {
1730- match func(*url.URL) *Match
1731+ match func(*url.URL) *match
1732 }
1733
1734 // NewPattern returns a new Pattern with the given matcher.
1735@@ -164,64 +164,65 @@ func (p *Pattern) Match(r *http.Request) *http.Request {
1736 }
1737
1738 // Matcher finds a match in a *url.URL.
1739-type Matcher = func(*url.URL) *Match
1740+type Matcher = func(*url.URL) *match
1741
1742 var (
1743- getInfoRefs = func(u *url.URL) *Match {
1744+ getInfoRefs = func(u *url.URL) *match {
1745 return matchSuffix(u.Path, "/info/refs")
1746 }
1747
1748- getHead = func(u *url.URL) *Match {
1749+ getHead = func(u *url.URL) *match {
1750 return matchSuffix(u.Path, "/HEAD")
1751 }
1752
1753- getAlternates = func(u *url.URL) *Match {
1754+ getAlternates = func(u *url.URL) *match {
1755 return matchSuffix(u.Path, "/objects/info/alternates")
1756 }
1757
1758- getHTTPAlternates = func(u *url.URL) *Match {
1759+ getHTTPAlternates = func(u *url.URL) *match {
1760 return matchSuffix(u.Path, "/objects/info/http-alternates")
1761 }
1762
1763- getInfoPacks = func(u *url.URL) *Match {
1764+ getInfoPacks = func(u *url.URL) *match {
1765 return matchSuffix(u.Path, "/objects/info/packs")
1766 }
1767
1768 getInfoFileRegexp = regexp.MustCompile(".*?(/objects/info/[^/]*)$")
1769- getInfoFile = func(u *url.URL) *Match {
1770+ getInfoFile = func(u *url.URL) *match {
1771 return findStringSubmatch(u.Path, getInfoFileRegexp)
1772 }
1773
1774 getLooseObjectRegexp = regexp.MustCompile(".*?(/objects/[0-9a-f]{2}/[0-9a-f]{38})$")
1775- getLooseObject = func(u *url.URL) *Match {
1776+ getLooseObject = func(u *url.URL) *match {
1777 return findStringSubmatch(u.Path, getLooseObjectRegexp)
1778 }
1779
1780 getPackFileRegexp = regexp.MustCompile(`.*?(/objects/pack/pack-[0-9a-f]{40}\.pack)$`)
1781- getPackFile = func(u *url.URL) *Match {
1782+ getPackFile = func(u *url.URL) *match {
1783 return findStringSubmatch(u.Path, getPackFileRegexp)
1784 }
1785
1786 getIdxFileRegexp = regexp.MustCompile(`.*?(/objects/pack/pack-[0-9a-f]{40}\.idx)$`)
1787- getIdxFile = func(u *url.URL) *Match {
1788+ getIdxFile = func(u *url.URL) *match {
1789 return findStringSubmatch(u.Path, getIdxFileRegexp)
1790 }
1791 )
1792
1793-type Match struct {
1794+// match represents a match for a URL.
1795+type match struct {
1796 RepoPath, FilePath string
1797 }
1798
1799-func matchSuffix(path, suffix string) *Match {
1800+func matchSuffix(path, suffix string) *match {
1801 if !strings.HasSuffix(path, suffix) {
1802 return nil
1803 }
1804 repoPath := strings.Replace(path, suffix, "", 1)
1805 filePath := strings.Replace(path, repoPath+"/", "", 1)
1806- return &Match{repoPath, filePath}
1807+ return &match{repoPath, filePath}
1808 }
1809
1810-func findStringSubmatch(path string, prefix *regexp.Regexp) *Match {
1811+func findStringSubmatch(path string, prefix *regexp.Regexp) *match {
1812 m := prefix.FindStringSubmatch(path)
1813 if m == nil {
1814 return nil
1815@@ -229,7 +230,7 @@ func findStringSubmatch(path string, prefix *regexp.Regexp) *Match {
1816 suffix := m[1]
1817 repoPath := strings.Replace(path, suffix, "", 1)
1818 filePath := strings.Replace(path, repoPath+"/", "", 1)
1819- return &Match{repoPath, filePath}
1820+ return &match{repoPath, filePath}
1821 }
1822
1823 var repoIndexHTMLTpl = template.Must(template.New("index").Parse(`<!DOCTYPE html>
1824@@ -258,7 +259,7 @@ func (s *HTTPServer) handleIndex(w http.ResponseWriter, r *http.Request) {
1825 return
1826diff --git a/server/jobs.go b/server/jobs.go
1827index d9621b8b064ffc4c0d3aee0202217f4d9f7b4085..df5fb0cc0d875ad01167207f141cad939ea524c2 100644
1828--- a/server/jobs.go
1829+++ b/server/jobs.go
1830@@ -23,7 +23,7 @@ func mirrorJob(b backend.Backend) func() {
1831
1832 for _, repo := range repos {
1833 if repo.IsMirror() {
1834- logger.Debug("updating mirror", "repo", repo.Name())
1835+ logger.Info("updating mirror", "repo", repo.Name())
1836 r, err := repo.Open()
1837 if err != nil {
1838 logger.Error("error opening repository", "repo", repo.Name(), "err", err)
1839diff --git a/server/server.go b/server/server.go
1840index bcb5d904d75367ba4c35a2d81d72751eeb120c92..eac408ebd670fc72eb17070c287a2d21b9bc119d 100644
1841--- a/server/server.go
1842+++ b/server/server.go
1843@@ -9,7 +9,7 @@ import (
1844 "github.com/charmbracelet/log"
1845
1846 "github.com/charmbracelet/soft-serve/server/backend"
1847- "github.com/charmbracelet/soft-serve/server/backend/file"
1848+ "github.com/charmbracelet/soft-serve/server/backend/sqlite"
1849 "github.com/charmbracelet/soft-serve/server/config"
1850 "github.com/charmbracelet/soft-serve/server/cron"
1851 "github.com/charmbracelet/ssh"
1852@@ -39,13 +39,14 @@ type Server struct {
1853 func NewServer(cfg *config.Config) (*Server, error) {
1854 var err error
1855 if cfg.Backend == nil {
1856- fb, err := file.NewFileBackend(cfg.DataPath)
1857+ sb, err := sqlite.NewSqliteBackend(cfg.DataPath)
1858 if err != nil {
1859 logger.Fatal(err)
1860 }
1861+
1862 // Add the initial admin keys to the list of admins.
1863- fb.AdditionalAdmins = cfg.InitialAdminKeys
1864- cfg = cfg.WithBackend(fb)
1865+ sb.AdditionalAdmins = cfg.InitialAdminKeys
1866+ cfg = cfg.WithBackend(sb)
1867
1868 // Create internal key.
1869 _, err = keygen.NewWithWrite(
1870diff --git a/server/session.go b/server/session.go
1871index 6c1a655c5bd6a6588d2308affd659e0332a968cd..cd7ddd272c08f2264666d761ac0ae6c92e649344 100644
1872--- a/server/session.go
1873+++ b/server/session.go
1874@@ -39,7 +39,7 @@ func SessionHandler(cfg *config.Config) bm.ProgramHandler {
1875 initialRepo := ""
1876 if len(cmd) == 1 {
1877 initialRepo = cmd[0]
1878- auth := cfg.Backend.AccessLevel(initialRepo, s.PublicKey())
1879+ auth := cfg.Backend.AccessLevelByPublicKey(initialRepo, s.PublicKey())
1880 if auth < backend.ReadOnlyAccess {
1881 wish.Fatalln(s, cm.ErrUnauthorized)
1882 return nil
1883diff --git a/server/session_test.go b/server/session_test.go
1884index 542d312c6a5c7778dda1a45c4cea12fe42871091..1e6b6d8b781ad7b6f804f7dad2feefa3a84bf8f8 100644
1885--- a/server/session_test.go
1886+++ b/server/session_test.go
1887@@ -5,11 +5,10 @@ import (
1888 "fmt"
1889 "log"
1890 "os"
1891- "path/filepath"
1892 "testing"
1893 "time"
1894
1895- "github.com/charmbracelet/soft-serve/server/backend/file"
1896+ "github.com/charmbracelet/soft-serve/server/backend/sqlite"
1897 "github.com/charmbracelet/soft-serve/server/config"
1898 "github.com/charmbracelet/ssh"
1899 bm "github.com/charmbracelet/wish/bubbletea"
1900@@ -52,7 +51,7 @@ func setup(tb testing.TB) *gossh.Session {
1901 is.NoErr(os.Unsetenv("SOFT_SERVE_SSH_LISTEN_ADDR"))
1902 is.NoErr(os.RemoveAll(dp))
1903 })
1904- fb, err := file.NewFileBackend(filepath.Join(dp, "repos"))
1905+ fb, err := sqlite.NewSqliteBackend(dp)
1906 if err != nil {
1907 log.Fatal(err)
1908 }
1909diff --git a/server/ssh.go b/server/ssh.go
1910index 06916813d2cf7dc922b36202663f48ceb227a79f..9f6da44cfe8005f27db0ab4da3b97dda2a7e80f8 100644
1911--- a/server/ssh.go
1912+++ b/server/ssh.go
1913@@ -32,7 +32,7 @@ var (
1914 Subsystem: "ssh",
1915 Name: "public_key_auth_total",
1916 Help: "The total number of public key auth requests",
1917- }, []string{"key", "user", "access", "allowed"})
1918+ }, []string{"key", "user", "allowed"})
1919
1920 keyboardInteractiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{
1921 Namespace: "soft_serve",
1922@@ -136,12 +136,26 @@ func (s *SSHServer) Shutdown(ctx context.Context) error {
1923 }
1924
1925 // PublicKeyAuthHandler handles public key authentication.
1926-func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool {
1927- ac := s.cfg.Backend.AccessLevel("", pk)
1928- allowed := ac >= backend.ReadOnlyAccess
1929+func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) (allowed bool) {
1930 ak := backend.MarshalAuthorizedKey(pk)
1931- publicKeyCounter.WithLabelValues(ak, ctx.User(), ac.String(), strconv.FormatBool(allowed)).Inc()
1932- return allowed
1933+ defer func() {
1934+ publicKeyCounter.WithLabelValues(ak, ctx.User(), strconv.FormatBool(allowed)).Inc()
1935+ }()
1936+ for _, k := range s.cfg.InitialAdminKeys {
1937+ if k == ak {
1938+ allowed = true
1939+ return
1940+ }
1941+ }
1942+
1943+ user, _ := s.cfg.Backend.UserByPublicKey(pk)
1944+ if user == nil {
1945+ logger.Debug("public key auth user not found")
1946+ return s.cfg.Backend.AnonAccess() >= backend.ReadOnlyAccess
1947+ }
1948+
1949+ allowed = s.cfg.Backend.AccessLevel("", user.Username()) >= backend.ReadOnlyAccess
1950+ return
1951 }
1952
1953 // KeyboardInteractiveHandler handles keyboard interactive authentication.
1954@@ -167,7 +181,7 @@ func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
1955 name := utils.SanitizeRepo(cmd[1])
1956 pk := s.PublicKey()
1957 ak := backend.MarshalAuthorizedKey(pk)
1958- access := cfg.Backend.AccessLevel(name, pk)
1959+ access := cfg.Backend.AccessLevelByPublicKey(name, pk)
1960 // git bare repositories should end in ".git"
1961 // https://git-scm.com/docs/gitrepository-layout
1962 repo := name + ".git"
1963diff --git a/ui/pages/selection/selection.go b/ui/pages/selection/selection.go
1964index 944251ebf5b04141115800f6487c9752d193edc9..97e47ff9708d67612a754dcc9a7735b03f5576a1 100644
1965--- a/ui/pages/selection/selection.go
1966+++ b/ui/pages/selection/selection.go
1967@@ -198,7 +198,7 @@ func (s *Selection) Init() tea.Cmd {
1968 }
1969 sortedItems := make(Items, 0)
1970 for _, r := range repos {
1971- al := cfg.Backend.AccessLevel(r.Name(), pk)
1972+ al := cfg.Backend.AccessLevelByPublicKey(r.Name(), pk)
1973 if al >= backend.ReadOnlyAccess {
1974 item, err := NewItem(r, cfg)
1975 if err != nil {