Goit

Simple and lightweight Git web server
git clone http://git.omkov.net/Goit
Log | Tree | Refs | README | Download

AuthorJakob Wakeling <[email protected]>
Date2023-07-17 09:54:54
Commitae5fc19f6ebb7260278962f9099a6f7aa4b6d577

Implement user password authentication

Diffstat

A .gitignore | 2 ++
A README.md | 8 ++++++++
A go.mod | 31 +++++++++++++++++++++++++++++++
A go.sum | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A main.go | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A res/admin_user_index.html | 31 +++++++++++++++++++++++++++++++
A res/repo_create.html | 20 ++++++++++++++++++++
A res/repo_index.html | 31 +++++++++++++++++++++++++++++++
A res/res.go | 21 +++++++++++++++++++++
A res/style.css | 8 ++++++++
A res/user_create.html | 22 ++++++++++++++++++++++
A res/user_login.html | 18 ++++++++++++++++++
A src/admin.go | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A src/auth.go | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A src/goit.go | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A src/repo.go | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A src/user.go | 121 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A src/util.go | 34 ++++++++++++++++++++++++++++++++++

18 files changed, 967 insertions, 0 deletions

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2cc5ddb
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/.vscode/
+/bin/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..f334d0d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,8 @@
+# Goit
+
+A simple and lightweight Git web server.
+
+## Meta
+
+Copyright (C) 2023, Jakob Wakeling  
+All rights reserved.
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..7974e86
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,31 @@
+module github.com/Jamozed/Goit
+
+go 1.20
+
+require (
+	github.com/go-git/go-git/v5 v5.7.0
+	github.com/gorilla/mux v1.8.0
+	github.com/mattn/go-sqlite3 v1.14.17
+	golang.org/x/crypto v0.9.0
+)
+
+require (
+	github.com/Microsoft/go-winio v0.5.2 // indirect
+	github.com/ProtonMail/go-crypto v0.0.0-20230518184743-7afd39499903 // indirect
+	github.com/acomagu/bufpipe v1.0.4 // indirect
+	github.com/cloudflare/circl v1.3.3 // indirect
+	github.com/emirpasic/gods v1.18.1 // indirect
+	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
+	github.com/go-git/go-billy/v5 v5.4.1 // indirect
+	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
+	github.com/imdario/mergo v0.3.15 // indirect
+	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
+	github.com/kevinburke/ssh_config v1.2.0 // indirect
+	github.com/pjbgf/sha1cd v0.3.0 // indirect
+	github.com/sergi/go-diff v1.1.0 // indirect
+	github.com/skeema/knownhosts v1.1.1 // indirect
+	github.com/xanzy/ssh-agent v0.3.3 // indirect
+	golang.org/x/net v0.10.0 // indirect
+	golang.org/x/sys v0.8.0 // indirect
+	gopkg.in/warnings.v0 v0.1.2 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..2b14a0d
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,130 @@
+github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA=
+github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
+github.com/ProtonMail/go-crypto v0.0.0-20230518184743-7afd39499903 h1:ZK3C5DtzV2nVAQTx5S5jQvMeDqWtD1By5mOoyY/xJek=
+github.com/ProtonMail/go-crypto v0.0.0-20230518184743-7afd39499903/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE=
+github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ=
+github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
+github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
+github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
+github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs=
+github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/elazarl/goproxy v0.0.0-20221015165544-a0805db90819 h1:RIB4cRk+lBqKK3Oy0r2gRX4ui7tuhiZq2SuTtTCi0/0=
+github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
+github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
+github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
+github.com/go-git/go-billy/v5 v5.4.1 h1:Uwp5tDRkPr+l/TnbHOQzp+tmJfLceOlbVucgpTz8ix4=
+github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg=
+github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f h1:Pz0DHeFij3XFhoBRGUDPzSJ+w2UcK5/0JvF8DRI58r8=
+github.com/go-git/go-git/v5 v5.7.0 h1:t9AudWVLmqzlo+4bqdf7GY+46SUuRsx59SboFxkq2aE=
+github.com/go-git/go-git/v5 v5.7.0/go.mod h1:coJHKEOk5kUClpsNlXrUvPrDxY3w3gjHvhcZd8Fodw8=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
+github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
+github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM=
+github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
+github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
+github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A=
+github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
+github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
+github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
+github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
+github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
+github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
+github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/skeema/knownhosts v1.1.1 h1:MTk78x9FPgDFVFkDLTrsnnfCJl7g1C/nnKvePgrIngE=
+github.com/skeema/knownhosts v1.1.1/go.mod h1:g4fPeYpque7P0xefxtGzV81ihjC8sX2IqpAoNkjxbMo=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
+github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
+golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
+golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
+golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
+golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
+gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
+gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..6110bde
--- /dev/null
+++ b/main.go
@@ -0,0 +1,76 @@
+// main.go
+// Copyright (C) 2023, Jakob Wakeling
+// All rights reserved.
+
+package main
+
+import (
+	"log"
+	"net/http"
+	"time"
+
+	"github.com/Jamozed/Goit/res"
+	goit "github.com/Jamozed/Goit/src"
+	"github.com/gorilla/mux"
+)
+
+func main() {
+	g, err := goit.InitGoit()
+	if err != nil {
+		log.Fatalln("[InitGoit]", err.Error())
+	} else {
+		defer g.Close()
+	}
+
+	mx := mux.NewRouter()
+	mx.StrictSlash(true)
+
+	mx.Path("/").HandlerFunc(g.HandleIndex)
+	mx.Path("/user/login").Methods("GET", "POST").HandlerFunc(g.HandleUserLogin)
+	mx.Path("/user/logout").Methods("GET", "POST").HandlerFunc(g.HandleUserLogout)
+	// mx.Path("/user/settings").Methods("GET").HandlerFunc()
+	mx.Path("/repo/create").Methods("GET", "POST").HandlerFunc(g.HandleRepoCreate)
+	// mx.Path("/repo/delete").Methods("POST").HandlerFunc()
+	// mx.Path("/admin/settings").Methods("GET").HandlerFunc()
+	mx.Path("/admin/user").Methods("GET").HandlerFunc(g.HandleAdminUserIndex)
+	// mx.Path("/admin/repos").Methods("GET").HandlerFunc()
+	mx.Path("/admin/user/create").Methods("GET", "POST").HandlerFunc(g.HandleAdminUserCreate)
+	// mx.Path("/admin/user/edit").Methods("GET", "POST").HandlerFunc()
+
+	rm := mx.Path("/{repo}/").Subrouter()
+	// rm.Path("/").Methods("GET").HandlerFunc()
+	// rm.Path("/log").Methods("GET").HandlerFunc()
+	// rm.Path("/tree").Methods("GET").HandlerFunc()
+	// rm.Path("/refs").Methods("GET").HandlerFunc()
+
+	mx.Path("/static/style.css").Methods(http.MethodGet).HandlerFunc(handleStyle)
+
+	mx.PathPrefix("/").HandlerFunc(http.NotFound)
+	rm.PathPrefix("/").HandlerFunc(http.NotFound)
+
+	/* Create a ticker to periodically cleanup expired sessions */
+	tick := time.NewTicker(1 * time.Hour)
+	go func() {
+		for range tick.C {
+			goit.CleanupSessions()
+		}
+	}()
+
+	if err := http.ListenAndServe(":8080", logHttp(mx)); err != nil {
+		log.Fatalln("[HTTP]", err)
+	}
+}
+
+func logHttp(handler http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		log.Println("[HTTP]", r.RemoteAddr, r.Method, r.URL)
+		handler.ServeHTTP(w, r)
+	})
+}
+
+func handleStyle(w http.ResponseWriter, r *http.Request) {
+	w.Header().Add("Content-Type", "text/css")
+	if _, err := w.Write([]byte(res.Style)); err != nil {
+		log.Println("[handleStyle]", err.Error())
+	}
+}
diff --git a/res/admin_user_index.html b/res/admin_user_index.html
new file mode 100644
index 0000000..ed84ecd
--- /dev/null
+++ b/res/admin_user_index.html
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<head>
+	<meta charset="UTF-8">
+	<title>Users</title>
+	<meta name="viewport" content="width=device-width, initial-scale=1.0">
+	<link rel="stylesheet" type="text/css" href="/static/style.css">
+</head>
+<body>
+	<table>
+		<thead>
+			<tr>
+				<td><b>ID</b></td>
+				<td><b>Username</b></td>
+				<td><b>Full Name</b></td>
+				<td><b>Is Admin</b></td>
+				<td></td>
+			</tr>
+		</thead>
+		<tbody>
+		{{range .Users}}
+			<tr>
+				<td>{{.Id}}</td>
+				<td>{{.Name}}</td>
+				<td>{{.FullName}}</td>
+				<td>{{.IsAdmin}}</td>
+				<td><a>Edit</a></td>
+			</tr>
+		{{end}}
+		</tbody>
+	</table>
+</body>
diff --git a/res/repo_create.html b/res/repo_create.html
new file mode 100644
index 0000000..dd04bad
--- /dev/null
+++ b/res/repo_create.html
@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<head>
+	<meta charset="UTF-8">
+	<title>Create Repository</title>
+	<meta name="viewport" content="width=device-width, initial-scale=1.0">
+	<link rel="stylesheet" href="/static/style.css">
+</head>
+<body>
+	<form action="/repo/create" method="post">
+		<label for="reponame">Name:</label>
+		<input type="text" name="reponame"><br>
+		<label for="visibility">Visibility:</label>
+		<select name="visibility">
+			<option value="public">Public</option>
+			<option value="private">Private</option>
+		</select><br>
+		<input type="submit" value="Create">
+	</form>
+	<p>{{.Msg}}</p>
+</body>
diff --git a/res/repo_index.html b/res/repo_index.html
new file mode 100644
index 0000000..450097b
--- /dev/null
+++ b/res/repo_index.html
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<head>
+	<meta charset="UTF-8">
+	<title>Repositories</title>
+	<meta name="viewport" content="width=device-width, initial-scale=1.0">
+	<link rel="stylesheet" type="text/css" href="/static/style.css">
+</head>
+<body>
+	<table>
+		<thead>
+			<tr>
+				<td><b>Name</b></td>
+				<td><b>Description</b></td>
+				<td><b>Owner</b></td>
+				<td><b>Visibility</b></td>
+				<td><b>Last Commit</b></td>
+			</tr>
+		</thead>
+		<tbody>
+		{{range .Repos}}
+			<tr>
+				<td><a href="/{{.Name}}/">{{.Name}}</a></td>
+				<td>{{.Description}}</td>
+				<td>{{.Owner}}</td>
+				<td>{{.Visibility}}</td>
+				<td>{{.LastCommit}}</td>
+			</tr>
+		{{end}}
+		</tbody>
+	</table>
+</body>
diff --git a/res/res.go b/res/res.go
new file mode 100644
index 0000000..8819dec
--- /dev/null
+++ b/res/res.go
@@ -0,0 +1,21 @@
+package res
+
+import _ "embed"
+
+//go:embed repo_index.html
+var RepoIndex string
+
+//go:embed user_login.html
+var UserLogin string
+
+//go:embed repo_create.html
+var RepoCreate string
+
+//go:embed user_create.html
+var UserCreate string
+
+//go:embed admin_user_index.html
+var AdminUserIndex string
+
+//go:embed style.css
+var Style string
diff --git a/res/style.css b/res/style.css
new file mode 100644
index 0000000..2f78065
--- /dev/null
+++ b/res/style.css
@@ -0,0 +1,8 @@
+html { background-color: #111111; color: #888888; height: 100%; }
+body { font-family: monospace; margin: 0; width: 100%; }
+a { color: #FF7E00; text-decoration: none; }
+a:hover { text-decoration: underline; }
+
+table { margin: 1em; }
+table td { padding: 0 0.4em; }
+table tr:hover td { background-color: #222222; }
diff --git a/res/user_create.html b/res/user_create.html
new file mode 100644
index 0000000..b04c0c3
--- /dev/null
+++ b/res/user_create.html
@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<head>
+	<meta charset="UTF-8">
+	<title>Create User</title>
+	<meta name="viewport" content="width=device-width, initial-scale=1.0">
+	<link rel="stylesheet" type="text/css" href="/static/style.css">
+</head>
+<body>
+	<h1>Create User</h1>
+	<form action="/admin/user/create" method="post">
+		<label for="username">Username:</label>
+		<input type="text" name="username"><br>
+		<label for="fullname">Full Name:</label>
+		<input type="text" name="fullname"><br>
+		<label for="password">Password:</label>
+		<input type="password" name="password"><br>
+		<label for="admin">Admin:</label>
+		<input type="checkbox" name="admin" value="true"><br>
+		<input type="submit" value="Create">
+	</form>
+	<p>{{.Msg}}</p>
+</body>
diff --git a/res/user_login.html b/res/user_login.html
new file mode 100644
index 0000000..1ade9a1
--- /dev/null
+++ b/res/user_login.html
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<head>
+	<meta charset="UTF-8">
+	<title>Login</title>
+	<meta name="viewport" content="width=device-width, initial-scale=1.0">
+	<link rel="stylesheet" href="/static/style.css">
+</head>
+<body>
+	<h1>Login</h1>
+	<form action="/user/login" method="post">
+		<label for="username">Username:</label>
+		<input type="text" name="username"><br>
+		<label for="password">Password:</label>
+		<input type="password" name="password"><br>
+		<input type="submit" value="Login">
+	</form>
+	<p>{{.Msg}}</p>
+</body>
diff --git a/src/admin.go b/src/admin.go
new file mode 100644
index 0000000..6f5a75e
--- /dev/null
+++ b/src/admin.go
@@ -0,0 +1,114 @@
+// admin.go
+// Copyright (C) 2023, Jakob Wakeling
+// All rights reserved.
+
+package goit
+
+import (
+	"fmt"
+	"html/template"
+	"log"
+	"net/http"
+	"strings"
+
+	"github.com/Jamozed/Goit/res"
+)
+
+var (
+	adminUserIndex *template.Template
+)
+
+func init() {
+	adminUserIndex = template.Must(template.New("admin_user_index").Parse(res.AdminUserIndex))
+}
+
+func (g *Goit) HandleAdminUserIndex(w http.ResponseWriter, r *http.Request) {
+	if ok, uid := AuthHttp(r); !ok {
+		http.NotFound(w, r)
+		return
+	} else if user, err := g.GetUser(uid); err != nil {
+		log.Println("[Admin:User:Create:Auth]", err.Error())
+		http.NotFound(w, r)
+		return
+	} else if !user.IsAdmin {
+		http.NotFound(w, r)
+		return
+	}
+
+	if rows, err := g.db.Query("SELECT id, name, name_full, is_admin FROM users"); err != nil {
+		log.Println("[Admin:User:Index:SELECT]", err.Error())
+		http.Error(w, "500 internal server error", http.StatusInternalServerError)
+	} else {
+		defer rows.Close()
+
+		type row struct{ Id, Name, FullName, IsAdmin string }
+		users := []row{}
+
+		for rows.Next() {
+			u := User{}
+
+			if err := rows.Scan(&u.Id, &u.Name, &u.NameFull, &u.IsAdmin); err != nil {
+				log.Println("[Admin:User:Index:SELECT:Scan]", err.Error())
+			} else {
+				users = append(users, row{fmt.Sprint(u.Id), u.Name, u.NameFull, If(u.IsAdmin, "true", "false")})
+			}
+		}
+
+		if err := rows.Err(); err != nil {
+			log.Println("[Admin:User:Index:SELECT:Err]", err.Error())
+			http.Error(w, "500 internal server error", http.StatusInternalServerError)
+		} else {
+			adminUserIndex.Execute(w, struct{ Users []row }{users})
+		}
+	}
+}
+
+func (g *Goit) HandleAdminUserCreate(w http.ResponseWriter, r *http.Request) {
+	if ok, uid := AuthHttp(r); !ok {
+		http.NotFound(w, r)
+		return
+	} else if user, err := g.GetUser(uid); err != nil {
+		log.Println("[Admin:User:Create:Auth]", err.Error())
+		http.NotFound(w, r)
+		return
+	} else if !user.IsAdmin {
+		http.NotFound(w, r)
+		return
+	}
+
+	data := struct{ Msg string }{""}
+
+	if r.Method == http.MethodPost {
+		username := strings.ToLower(r.FormValue("username"))
+		fullname := r.FormValue("fullname")
+		password := r.FormValue("password")
+		admin := r.FormValue("admin") == "true"
+
+		if username == "" {
+			data.Msg = "Username cannot be empty"
+		} else if SliceContains(reserved, username) {
+			data.Msg = "Username \"" + username + "\" is reserved"
+		} else if exists, err := g.UserExists(username); err != nil {
+			log.Println("[Admin:User:Create:Exists]", err.Error())
+			http.Error(w, "500 internal server error", http.StatusInternalServerError)
+			return
+		} else if exists {
+			data.Msg = "Username \"" + username + "\" is taken"
+		} else if salt, err := Salt(); err != nil {
+			log.Println("[Admin:User:Create:Salt]", err.Error())
+			http.Error(w, "500 internal server error", http.StatusInternalServerError)
+			return
+		} else if _, err := g.db.Exec(
+			"INSERT INTO users (name, name_full, pass, pass_algo, salt, is_admin) VALUES (?, ?, ?, ?, ?, ?)",
+			username, fullname, Hash(password, salt), "argon2", salt, admin,
+		); err != nil {
+			log.Println("[Admin:User:Create:INSERT]", err.Error())
+			http.Error(w, "500 internal server error", http.StatusInternalServerError)
+			return
+		} else {
+			data.Msg = "User \"" + username + "\" created successfully"
+		}
+	}
+
+	userCreate.Execute(w, data)
+}
diff --git a/src/auth.go b/src/auth.go
new file mode 100644
index 0000000..15ace19
--- /dev/null
+++ b/src/auth.go
@@ -0,0 +1,105 @@
+// auth.go
+// Copyright (C) 2023, Jakob Wakeling
+// All rights reserved.
+
+package goit
+
+import (
+	"crypto/rand"
+	"encoding/base64"
+	"fmt"
+	"log"
+	"math"
+	"net/http"
+	"time"
+
+	"golang.org/x/crypto/argon2"
+)
+
+type session struct {
+	id     uint64
+	expiry time.Time
+}
+
+var sessions = map[string]session{}
+
+/* Hash a password with a salt using Argon2. */
+func Hash(pass string, salt []byte) []byte {
+	return argon2.IDKey([]byte(pass), salt, 3, 64*1024, 4, 32)
+}
+
+/* Generate a random Base64 salt. */
+func Salt() ([]byte, error) {
+	b := make([]byte, 16)
+	if _, err := rand.Read(b); err != nil {
+		return nil, err
+	}
+
+	return b, nil
+}
+
+func NewSession(id uint64, expiry time.Time) (string, error) {
+	b := make([]byte, 24)
+	if _, err := rand.Read(b); err != nil {
+		return "", err
+	}
+
+	s := base64.StdEncoding.EncodeToString(b)
+	sessions[s] = session{id, expiry}
+	return s, nil
+}
+
+func EndSession(s string) {
+	delete(sessions, s)
+}
+
+func Auth(s string) (bool, uint64) {
+	if v, ok := sessions[s]; ok {
+		if v.expiry.After(time.Now()) {
+			return true, v.id
+		} else {
+			delete(sessions, s)
+		}
+	}
+
+	return false, math.MaxUint64
+}
+
+func AuthHttp(r *http.Request) (bool, uint64) {
+	if c := Cookie(r, "session"); c != nil {
+		return Auth(c.Value)
+	}
+
+	return false, math.MaxUint64
+}
+
+func SessionCookie(r *http.Request) string {
+	if c := Cookie(r, "session"); c != nil {
+		return c.Value
+	}
+
+	return ""
+}
+
+func GetSessions() (s string) {
+	for k, v := range sessions {
+		s += fmt.Sprint(k, v.id, v.expiry)
+	}
+
+	return s
+}
+
+func CleanupSessions() {
+	n := 0
+
+	for k, v := range sessions {
+		if v.expiry.Before(time.Now()) {
+			delete(sessions, k)
+			n += 1
+		}
+	}
+
+	if n > 0 {
+		log.Println("[Sessions] Cleaned up", n, "expired sessions")
+	}
+}
diff --git a/src/goit.go b/src/goit.go
new file mode 100644
index 0000000..17c535a
--- /dev/null
+++ b/src/goit.go
@@ -0,0 +1,77 @@
+// goit.go
+// Copyright (C) 2023, Jakob Wakeling
+// All rights reserved.
+
+package goit
+
+import (
+	"database/sql"
+	"fmt"
+	"log"
+
+	_ "github.com/mattn/go-sqlite3"
+)
+
+type Goit struct {
+	db *sql.DB
+}
+
+/* Initialise Goit. */
+func InitGoit() (g *Goit, err error) {
+	g = &Goit{}
+
+	if g.db, err = sql.Open("sqlite3", "./goit.db"); err != nil {
+		return nil, fmt.Errorf("[SQL:open] %w", err)
+	}
+
+	if _, err = g.db.Exec(
+		`CREATE TABLE IF NOT EXISTS users (
+			id INTEGER PRIMARY KEY AUTOINCREMENT,
+			name TEXT UNIQUE NOT NULL,
+			name_full TEXT UNIQUE NOT NULL,
+			pass BLOB NOT NULL,
+			pass_algo TEXT NOT NULL,
+			salt BLOB NOT NULL,
+			is_admin BOOLEAN NOT NULL
+		)`,
+	); err != nil {
+		return nil, fmt.Errorf("[CREATE:users] %w", err)
+	}
+
+	if _, err = g.db.Exec(
+		`CREATE TABLE IF NOT EXISTS repos (
+			id INTEGER PRIMARY KEY AUTOINCREMENT,
+			owner_id INTEGER NOT NULL,
+			name TEXT UNIQUE NOT NULL,
+			name_lower TEXT UNIQUE NOT NULL,
+			description TEXT NOT NULL,
+			default_branch TEXT NOT NULL,
+			is_private BOOLEAN NOT NULL
+		)`,
+	); err != nil {
+		return nil, fmt.Errorf("[CREATE:repos] %w", err)
+	}
+
+	/* Create an admin user if one does not exist */
+	if exists, err := g.UserExists("admin"); err != nil {
+		log.Println("[admin:Exists]", err.Error())
+		err = nil /* ignored */
+	} else if !exists {
+		if salt, err := Salt(); err != nil {
+			log.Println("[admin:Salt]", err.Error())
+			err = nil /* ignored */
+		} else if _, err = g.db.Exec(
+			"INSERT INTO users (id, name, name_full, pass, pass_algo, salt, is_admin) VALUES (?, ?, ?, ?, ?, ?, ?)",
+			0, "admin", "Administrator", Hash("admin", salt), "argon2", salt, true,
+		); err != nil {
+			log.Println("[admin:INSERT]", err.Error())
+			err = nil /* ignored */
+		}
+	}
+
+	return g, nil
+}
+
+func (g *Goit) Close() error {
+	return g.db.Close()
+}
diff --git a/src/repo.go b/src/repo.go
new file mode 100644
index 0000000..04db773
--- /dev/null
+++ b/src/repo.go
@@ -0,0 +1,118 @@
+// repo.go
+// Copyright (C) 2023, Jakob Wakeling
+// All rights reserved.
+
+package goit
+
+import (
+	"database/sql"
+	"errors"
+	"html/template"
+	"log"
+	"net/http"
+	"strings"
+
+	"github.com/Jamozed/Goit/res"
+)
+
+type Repo struct {
+	Id            uint64
+	OwnerId       uint64
+	Name          string
+	NameLower     string
+	Description   string
+	DefaultBranch string
+	IsPrivate     bool
+}
+
+var (
+	repoIndex  *template.Template
+	repoCreate *template.Template
+)
+
+func init() {
+	repoIndex = template.Must(template.New("repo_index").Parse(res.RepoIndex))
+	repoCreate = template.Must(template.New("repo_create").Parse(res.RepoCreate))
+}
+
+func (g *Goit) HandleIndex(w http.ResponseWriter, r *http.Request) {
+	authOk, uid := AuthHttp(r)
+
+	if rows, err := g.db.Query("SELECT id, owner_id, name, description, is_private FROM repos"); err != nil {
+		log.Println("[Index:SELECT]", err.Error())
+		http.Error(w, "500 internal server error", http.StatusInternalServerError)
+	} else {
+		defer rows.Close()
+
+		type row struct{ Name, Description, Owner, Visibility, LastCommit string }
+		repos := []row{}
+
+		for rows.Next() {
+			r := Repo{}
+
+			if err := rows.Scan(&r.Id, &r.OwnerId, &r.Name, &r.Description, &r.IsPrivate); err != nil {
+				log.Println("[Index:SELECT:Scan]", err.Error())
+			} else if !r.IsPrivate || (authOk && uid == r.OwnerId) {
+				owner, err := g.GetUser(r.OwnerId)
+				if err != nil {
+					log.Println("[Index:SELECT:UserName]", err.Error())
+				}
+
+				repos = append(repos, row{r.Name, "", owner.Name, If(r.IsPrivate, "private", "public"), ""})
+			}
+		}
+
+		if err := rows.Err(); err != nil {
+			log.Println("[Index:SELECT:Err]", err.Error())
+			http.Error(w, "500 internal server error", http.StatusInternalServerError)
+		} else {
+			repoIndex.Execute(w, struct{ Repos []row }{repos})
+		}
+	}
+}
+
+func (g *Goit) HandleRepoCreate(w http.ResponseWriter, r *http.Request) {
+	if ok, uid := AuthHttp(r); !ok {
+		http.Error(w, "401 unauthorized", http.StatusUnauthorized)
+	} else if r.Method == http.MethodPost {
+		name := r.FormValue("reponame")
+		private := r.FormValue("visibility") == "private"
+
+		if taken, err := RepoExists(g.db, name); err != nil {
+			log.Println("[RepoCreate:RepoExists]", err.Error())
+			http.Error(w, "500 internal server error", http.StatusInternalServerError)
+		} else if taken {
+			repoCreate.Execute(w, struct{ Msg string }{"Reponame is taken"})
+		} else if SliceContains[string](reserved, name) {
+			repoCreate.Execute(w, struct{ Msg string }{"Reponame is reserved"})
+		} else {
+			if _, err := g.db.Exec(
+				`INSERT INTO repos (
+					owner_id, name, name_lower, description, default_branch, is_private
+				) VALUES (?, ?, ?, ?, ?, ?)`,
+				uid, name, strings.ToLower(name), "", "master", private,
+			); err != nil {
+				log.Println("[RepoCreate:INSERT]", err.Error())
+				http.Error(w, "500 internal server error", http.StatusInternalServerError)
+			} else {
+				http.Redirect(w, r, "/"+name+"/", http.StatusFound)
+			}
+		}
+	} else /* GET */ {
+		repoCreate.Execute(w, nil)
+	}
+}
+
+func RepoExists(db *sql.DB, name string) (bool, error) {
+	if err := db.QueryRow(
+		"SELECT name FROM repos WHERE name_lower = ?", strings.ToLower(name),
+	).Scan(&name); err != nil {
+		if !errors.Is(err, sql.ErrNoRows) {
+			return false, err
+		} else {
+			return false, nil
+		}
+	} else {
+		return true, nil
+	}
+}
diff --git a/src/user.go b/src/user.go
new file mode 100644
index 0000000..53e7653
--- /dev/null
+++ b/src/user.go
@@ -0,0 +1,121 @@
+// user.go
+// Copyright (C) 2023, Jakob Wakeling
+// All rights reserved.
+
+package goit
+
+import (
+	"bytes"
+	"database/sql"
+	"errors"
+	"fmt"
+	"html/template"
+	"log"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/Jamozed/Goit/res"
+)
+
+type User struct {
+	Id       uint64
+	Name     string
+	NameFull string
+	Pass     []byte
+	PassAlgo string
+	Salt     []byte
+	IsAdmin  bool
+}
+
+var (
+	reserved []string = []string{"admin", "repo", "static", "user"}
+
+	userLogin  *template.Template
+	userCreate *template.Template
+)
+
+func init() {
+	userLogin = template.Must(template.New("user_login").Parse(res.UserLogin))
+	userCreate = template.Must(template.New("user_create").Parse(res.UserCreate))
+}
+
+func (g *Goit) HandleUserLogin(w http.ResponseWriter, r *http.Request) {
+	if ok, _ := AuthHttp(r); ok {
+		http.Redirect(w, r, "/", http.StatusFound)
+		return
+	}
+
+	data := struct{ Msg string }{""}
+
+	if r.Method == http.MethodPost {
+		u := User{}
+		username := strings.ToLower(r.FormValue("username"))
+		password := r.FormValue("password")
+
+		if username == "" {
+			data.Msg = "Username cannot be empty"
+		} else if exists, err := g.UserExists(username); err != nil {
+			log.Println("[User:Login:Exists]", err.Error())
+			http.Error(w, "500 internal server error", http.StatusInternalServerError)
+			return
+		} else if !exists {
+			data.Msg = "Invalid credentials"
+		} else if err := g.db.QueryRow(
+			"SELECT id, name, pass, pass_algo, salt FROM users WHERE name = ?", username,
+		).Scan(&u.Id, &u.Name, &u.Pass, &u.PassAlgo, &u.Salt); err != nil {
+			log.Println("[User:Login:SELECT]", err.Error())
+			http.Error(w, "500 internal server error", http.StatusInternalServerError)
+			return
+		} else if !bytes.Equal(Hash(password, u.Salt), u.Pass) {
+			data.Msg = "Invalid credentials"
+		} else {
+			expiry := time.Now().Add(15 * time.Minute)
+			if s, err := NewSession(u.Id, expiry); err != nil {
+				log.Println("[User:Login:Session]", err.Error())
+				http.Error(w, "500 internal server error", http.StatusInternalServerError)
+				return
+			} else {
+				http.SetCookie(w, &http.Cookie{Name: "session", Value: s, Path: "/", Expires: expiry})
+				http.Redirect(w, r, "/", http.StatusFound)
+				return
+			}
+		}
+	}
+
+	userLogin.Execute(w, data)
+}
+
+func (g *Goit) HandleUserLogout(w http.ResponseWriter, r *http.Request) {
+	EndSession(SessionCookie(r))
+	http.SetCookie(w, &http.Cookie{Name: "session", Path: "/", MaxAge: -1})
+	http.Redirect(w, r, "/", http.StatusFound)
+}
+
+func (g *Goit) GetUser(id uint64) (*User, error) {
+	u := User{}
+
+	if err := g.db.QueryRow(
+		"SELECT id, name, name_full, is_admin FROM users WHERE id = ?", id,
+	).Scan(&u.Id, &u.Name, &u.NameFull, &u.IsAdmin); err != nil {
+		if !errors.Is(err, sql.ErrNoRows) {
+			return nil, fmt.Errorf("[SELECT:user] %w", err)
+		} else {
+			return nil, nil
+		}
+	} else {
+		return &u, nil
+	}
+}
+
+func (g *Goit) UserExists(name string) (bool, error) {
+	if err := g.db.QueryRow("SELECT name FROM users WHERE name = ?", strings.ToLower(name)).Scan(&name); err != nil {
+		if !errors.Is(err, sql.ErrNoRows) {
+			return false, err
+		} else {
+			return false, nil
+		}
+	} else {
+		return true, nil
+	}
+}
diff --git a/src/util.go b/src/util.go
new file mode 100644
index 0000000..40255f1
--- /dev/null
+++ b/src/util.go
@@ -0,0 +1,34 @@
+// util.go
+// Copyright (C) 2023, Jakob Wakeling
+// All rights reserved.
+
+package goit
+
+import "net/http"
+
+func If[T any](cond bool, a, b T) T {
+	if cond {
+		return a
+	} else {
+		return b
+	}
+}
+
+func SliceContains[T comparable](s []T, e T) bool {
+	for _, v := range s {
+		if v == e {
+			return true
+		}
+	}
+
+	return false
+}
+
+/* Return the named cookie or nil if not found. */
+func Cookie(r *http.Request, name string) *http.Cookie {
+	if c, err := r.Cookie(name); err != nil {
+		return nil
+	} else {
+		return c
+	}
+}