2a0d05a440663f00d5219d6848afb6cf7f096315

Author
Ayman Bagabas <ayman.bagabas@gmail.com>
Committer
Ayman Bagabas <ayman.bagabas@gmail.com>
Date

Message

feat(backend): add sqlite backend

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 {