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-20 11:13:39
Commitd631c5e22bf3af99f9be0e9cd8d8ea9ca0c14505
Parent6727af8703164d18b368d911ac94e4b7a7df9d7a

Implement admin users, repos, and user edit pages

Also make use of XDG directories for config and data.

Diffstat

M README.md | 15 +++++++++++++++
M go.mod | 1 +
M go.sum | 8 ++++++--
M main.go | 10 +++++-----
A res/admin/repos.html | 38 ++++++++++++++++++++++++++++++++++++++
A res/admin/user_edit.html | 19 +++++++++++++++++++
A res/admin/users.html | 36 ++++++++++++++++++++++++++++++++++++
D res/admin_user_index.html | 32 --------------------------------
R res/base/head.html.tmpl -> res/base/head.html | 0
R res/base/repo_header.html.tmpl -> res/repo/header.html | 4 ++--
R res/error.html.tmpl -> res/error.html | 0
M res/res.go | 26 ++++++++++++++++----------
A res/user/login.html | 14 ++++++++++++++
R res/user_create.html -> res/admin/user_create.html | 12 +++---------
D res/user_login.html | 19 -------------------
M src/admin.go | 242 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
M src/auth.go | 5 +++--
M src/git.go | 1 -
M src/goit.go | 32 ++++++++++++++++++++++----------
M src/http.go | 9 +++++++--
M src/repo.go | 13 +++++++------
M src/user.go | 26 +++++++++-----------------
R src/util.go -> src/util/util.go | 10 ++--------

23 files changed, 389 insertions, 183 deletions

diff --git a/README.md b/README.md
index f334d0d..6f5616e 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,21 @@
 
 A simple and lightweight Git web server.
 
+## Features
+
+- Git Smart HTTP protocol (v2 only)
+- Git SSH protocol (planned)
+- Repository log, tree, refs, and commit viewers
+- File viewer with syntax highlighting and markdown support (planned)
+- File raw, blame, and history views (planned)
+- Public and private repositories
+- Read and write permissions for non owners (planned)
+- Repository mirroring (pull and/or push) (planned)
+
+## Usage
+
+To build **Goit**, from the project root, run `go build`.
+
 ## Meta
 
 Copyright (C) 2023, Jakob Wakeling
diff --git a/go.mod b/go.mod
index 7974e86..e1748a3 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module github.com/Jamozed/Goit
 go 1.20
 
 require (
+	github.com/adrg/xdg v0.4.0
 	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
diff --git a/go.sum b/go.sum
index 2b14a0d..6036ded 100644
--- a/go.sum
+++ b/go.sum
@@ -4,6 +4,8 @@ github.com/ProtonMail/go-crypto v0.0.0-20230518184743-7afd39499903 h1:ZK3C5DtzV2
 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/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls=
+github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E=
 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=
@@ -61,8 +63,9 @@ github.com/skeema/knownhosts v1.1.1 h1:MTk78x9FPgDFVFkDLTrsnnfCJl7g1C/nnKvePgrIn
 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/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 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=
@@ -92,6 +95,7 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w
 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-20211025201205-69cdffdb9359/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=
@@ -124,7 +128,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
 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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 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
index 28a77e2..36a08f7 100644
--- a/main.go
+++ b/main.go
@@ -16,8 +16,8 @@ import (
 )
 
 func main() {
-	if err := goit.InitGoit("./goit.json"); err != nil {
-		log.Fatalln("[InitGoit]", err.Error())
+	if err := goit.Goit(goit.GetConfPath()); err != nil {
+		log.Fatalln(err.Error())
 	}
 
 	h := mux.NewRouter()
@@ -30,10 +30,10 @@ func main() {
 	h.Path("/repo/create").Methods("GET", "POST").HandlerFunc(goit.HandleRepoCreate)
 	// h.Path("/repo/delete").Methods("POST").HandlerFunc()
 	// h.Path("/admin/settings").Methods("GET").HandlerFunc()
-	h.Path("/admin/user").Methods("GET").HandlerFunc(goit.HandleAdminUserIndex)
-	// h.Path("/admin/repos").Methods("GET").HandlerFunc()
+	h.Path("/admin/users").Methods("GET").HandlerFunc(goit.HandleAdminUsers)
 	h.Path("/admin/user/create").Methods("GET", "POST").HandlerFunc(goit.HandleAdminUserCreate)
-	// h.Path("/admin/user/edit").Methods("GET", "POST").HandlerFunc()
+	h.Path("/admin/user/edit").Methods("GET", "POST").HandlerFunc(goit.HandleAdminUserEdit)
+	h.Path("/admin/repos").Methods("GET").HandlerFunc(goit.HandleAdminRepos)
 
 	h.Path("/{repo:.+(?:\\.git)$}").Methods(http.MethodGet).HandlerFunc(redirectDotGit)
 	h.Path("/{repo}").Methods(http.MethodGet).HandlerFunc(goit.HandleRepoLog)
diff --git a/res/admin/repos.html b/res/admin/repos.html
new file mode 100644
index 0000000..db8b797
--- /dev/null
+++ b/res/admin/repos.html
@@ -0,0 +1,38 @@
+<!DOCTYPE html>
+<head>{{template "base/head" .}}</head>
+<body>
+	<header>
+		<table>
+			<tr>
+				<td><img src = "/static/favicon.png" style="max-height: 24px"></td>
+				<td><h1>{{.Title}}</h1></td>
+			</tr>
+		</table>
+	</header>
+	<main>
+		<table>
+			<thead>
+				<tr>
+					<td><b>ID</b></td>
+					<td><b>Owner</b></td>
+					<td><b>Name</b></td>
+					<td><b>Visibility</b></td>
+					<td><b>Size</b></td>
+					<td></td>
+				</tr>
+			</thead>
+			<tbody>
+			{{range .Repos}}
+				<tr>
+					<td>{{.Id}}</td>
+					<td><a href="/?user={{.Owner}}">{{.Owner}}</a></td>
+					<td><a href="/{{.Name}}">{{.Name}}</a></td>
+					<td>{{.Visibility}}</td>
+					<td>{{.Size}}</td>
+					<td><a href="/admin/repo/edit?repo={{.Id}}">edit</a></td>
+				</tr>
+			{{end}}
+			</tbody>
+		</table>
+	</main>
+</body>
diff --git a/res/user_create.html b/res/admin/user_create.html
similarity index 60%
rename from res/user_create.html
rename to res/admin/user_create.html
index c4cfbd7..c19b879 100644
--- a/res/user_create.html
+++ b/res/admin/user_create.html
@@ -1,13 +1,7 @@
 <!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">
-	<link rel="icon" type="image/png" href="/static/favicon.png">
-</head>
+<head>{{template "base/head" .}}</head>
 <body>
-	<h1>Create User</h1>
+	<h1>{{.Title}}</h1>
 	<form action="/admin/user/create" method="post">
 		<label for="username">Username:</label>
 		<input type="text" name="username"><br>
@@ -19,5 +13,5 @@
 		<input type="checkbox" name="admin" value="true"><br>
 		<input type="submit" value="Create">
 	</form>
-	<p>{{.Msg}}</p>
+	<span>{{.Message}}</span>
 </body>
diff --git a/res/admin/user_edit.html b/res/admin/user_edit.html
new file mode 100644
index 0000000..877a7b7
--- /dev/null
+++ b/res/admin/user_edit.html
@@ -0,0 +1,19 @@
+<!DOCTYPE html>
+<head>{{template "base/head" .}}</head>
+<body>
+	<h1>{{.Title}}</h1>
+	<form action="/admin/user/edit?user={{.Id}}" method="post">
+		<label for="id">ID:</label>
+		<input type="text" name="id" value="{{.Id}}" disabled><br>
+		<label for="username">Username:</label>
+		<input type="text" name="username" value="{{.Name}}"><br>
+		<label for="fullname">Full Name:</label>
+		<input type="text" name="fullname" value="{{.FullName}}"><br>
+		<label for="password">Password:</label>
+		<input type="password" name="password" placeholder="Unchanged"><br>
+		<label for="admin">Admin:</label>
+		<input type="checkbox" name="admin" value="true" {{if .IsAdmin}}checked{{end}}><br>
+		<input type="submit" value="Update">
+	</form>
+	<span>{{.Message}}</span>
+</body>
diff --git a/res/admin/users.html b/res/admin/users.html
new file mode 100644
index 0000000..b1d88bf
--- /dev/null
+++ b/res/admin/users.html
@@ -0,0 +1,36 @@
+<!DOCTYPE html>
+<head>{{template "base/head" .}}</head>
+<body>
+	<header>
+		<table>
+			<tr>
+				<td><img src = "/static/favicon.png" style="max-height: 24px"></td>
+				<td><h1>{{.Title}}</h1></td>
+			</tr>
+		</table>
+	</header>
+	<main>
+		<table>
+			<thead>
+				<tr>
+					<td><b>ID</b></td>
+					<td><b>Name</b></td>
+					<td><b>Full Name</b></td>
+					<td><b>Admin</b></td>
+					<td></td>
+				</tr>
+			</thead>
+			<tbody>
+			{{range .Users}}
+				<tr>
+					<td>{{.Id}}</td>
+					<td><a href="/?user={{.Name}}">{{.Name}}</a></td>
+					<td>{{.FullName}}</td>
+					<td>{{.IsAdmin}}</td>
+					<td><a href="/admin/user/edit?user={{.Id}}">edit</a></td>
+				</tr>
+			{{end}}
+			</tbody>
+		</table>
+	</main>
+</body>
diff --git a/res/admin_user_index.html b/res/admin_user_index.html
deleted file mode 100644
index 08bc819..0000000
--- a/res/admin_user_index.html
+++ /dev/null
@@ -1,32 +0,0 @@
-<!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">
-	<link rel="icon" type="image/png" href="/static/favicon.png">
-</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/base/head.html.tmpl b/res/base/head.html
similarity index 100%
rename from res/base/head.html.tmpl
rename to res/base/head.html
diff --git a/res/error.html.tmpl b/res/error.html
similarity index 100%
rename from res/error.html.tmpl
rename to res/error.html
diff --git a/res/base/repo_header.html.tmpl b/res/repo/header.html
similarity index 92%
rename from res/base/repo_header.html.tmpl
rename to res/repo/header.html
index b25b18e..3a97944 100644
--- a/res/base/repo_header.html.tmpl
+++ b/res/repo/header.html
@@ -18,10 +18,10 @@
 			<a href="/{{.Name}}/log">Log</a>
 			| <a href="/{{.Name}}/tree">Tree</a>
 			| <a href="/{{.Name}}/refs">Refs</a>
-			{{if .HasReadme}}
+			{{if .Readme}}
 				| <a href="">README</a>
 			{{end}}
-			{{if .HasLicence}}
+			{{if .Licence}}
 				| <a href="">LICENCE</a>
 			{{end}}
 		</td>
diff --git a/res/res.go b/res/res.go
index 0f1cd3a..dd66d0b 100644
--- a/res/res.go
+++ b/res/res.go
@@ -2,19 +2,31 @@ package res
 
 import _ "embed"
 
-//go:embed error.html.tmpl
+//go:embed error.html
 var Error string
 
-//go:embed base/head.html.tmpl
+//go:embed base/head.html
 var BaseHead string
 
-//go:embed base/repo_header.html.tmpl
+//go:embed admin/users.html
+var AdminUsers string
+
+//go:embed admin/user_create.html
+var AdminUserCreate string
+
+//go:embed admin/user_edit.html
+var AdminUserEdit string
+
+//go:embed admin/repos.html
+var AdminRepos string
+
+//go:embed repo/header.html
 var RepoHeader string
 
 //go:embed repo_index.html
 var RepoIndex string
 
-//go:embed user_login.html
+//go:embed user/login.html
 var UserLogin string
 
 //go:embed repo_create.html
@@ -29,11 +41,5 @@ var RepoTree string
 //go:embed repo_refs.html
 var RepoRefs 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/user_login.html b/res/user/login.html
similarity index 52%
rename from res/user_login.html
rename to res/user/login.html
index 0da5c64..24c080d 100644
--- a/res/user_login.html
+++ b/res/user/login.html
@@ -1,11 +1,6 @@
 <!DOCTYPE html>
-<head>
-	<meta charset="UTF-8">
-	<title>Login</title>
-	<meta name="viewport" content="width=device-width, initial-scale=1.0">
-	<link rel="stylesheet" type="text/css" href="/static/style.css">
-	<link rel="icon" type="image/png" href="/static/favicon.png">
-</head>
+<head>{{template "base/head" .}}</head>
+<body>
 <body>
 	<h1>Login</h1>
 	<form action="/user/login" method="post">
@@ -15,5 +10,5 @@
 		<input type="password" name="password"><br>
 		<input type="submit" value="Login">
 	</form>
-	<p>{{.Msg}}</p>
+	<p>{{.Message}}</p>
 </body>
diff --git a/src/admin.go b/src/admin.go
index 7d134cf..45006c1 100644
--- a/src/admin.go
+++ b/src/admin.go
@@ -6,109 +6,235 @@ package goit
 
 import (
 	"fmt"
-	"html/template"
 	"log"
 	"net/http"
+	"strconv"
 	"strings"
 
-	"github.com/Jamozed/Goit/res"
+	"github.com/Jamozed/Goit/src/util"
 )
 
-var (
-	adminUserIndex *template.Template
-)
-
-func init() {
-	adminUserIndex = template.Must(template.New("admin_user_index").Parse(res.AdminUserIndex))
-}
-
-func HandleAdminUserIndex(w http.ResponseWriter, r *http.Request) {
-	if ok, uid := AuthHttp(r); !ok {
-		HttpError(w, http.StatusNotFound)
-		return
-	} else if user, err := GetUser(uid); err != nil {
-		log.Println("[Admin:User:Create:Auth]", err.Error())
-		HttpError(w, http.StatusNotFound)
-		return
-	} else if !user.IsAdmin {
+func HandleAdminUsers(w http.ResponseWriter, r *http.Request) {
+	if !authHttpAdmin(r) {
 		HttpError(w, http.StatusNotFound)
 		return
 	}
 
-	if rows, err := db.Query("SELECT id, name, name_full, is_admin FROM users"); err != nil {
-		log.Println("[Admin:User:Index:SELECT]", err.Error())
+	rows, err := db.Query("SELECT id, name, name_full, is_admin FROM users")
+	if err != nil {
+		log.Println("[/admin/users]", err.Error())
 		HttpError(w, http.StatusInternalServerError)
-	} else {
-		defer rows.Close()
+		return
+	}
 
-		type row struct{ Id, Name, FullName, IsAdmin string }
-		users := []row{}
+	defer rows.Close()
 
-		for rows.Next() {
-			u := User{}
+	type row struct{ Id, Name, FullName, IsAdmin string }
+	data := struct {
+		Title string
+		Users []row
+	}{Title: "Users"}
 
-			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())
+	for rows.Next() {
+		d := User{}
+		if err := rows.Scan(&d.Id, &d.Name, &d.FullName, &d.IsAdmin); err != nil {
+			log.Println("[/admin/users]", err.Error())
 			HttpError(w, http.StatusInternalServerError)
-		} else {
-			adminUserIndex.Execute(w, struct{ Users []row }{users})
+			return
 		}
+
+		data.Users = append(data.Users, row{
+			fmt.Sprint(d.Id), d.Name, d.FullName, util.If(d.IsAdmin, "true", "false"),
+		})
+	}
+
+	if err := rows.Err(); err != nil {
+		log.Println("[/admin/users]", err.Error())
+		HttpError(w, http.StatusInternalServerError)
+		return
+	}
+
+	if err := tmpl.ExecuteTemplate(w, "admin/users", data); err != nil {
+		log.Println("[/admin/users]", err.Error())
 	}
 }
 
 func HandleAdminUserCreate(w http.ResponseWriter, r *http.Request) {
-	if ok, uid := AuthHttp(r); !ok {
-		HttpError(w, http.StatusNotFound)
-		return
-	} else if user, err := GetUser(uid); err != nil {
-		log.Println("[Admin:User:Create:Auth]", err.Error())
-		HttpError(w, http.StatusNotFound)
-		return
-	} else if !user.IsAdmin {
+	if !authHttpAdmin(r) {
 		HttpError(w, http.StatusNotFound)
 		return
 	}
 
-	data := struct{ Msg string }{""}
+	data := struct{ Title, Message string }{"Create User", ""}
 
 	if r.Method == http.MethodPost {
 		username := strings.ToLower(r.FormValue("username"))
 		fullname := r.FormValue("fullname")
 		password := r.FormValue("password")
-		admin := r.FormValue("admin") == "true"
+		isAdmin := r.FormValue("admin") == "true"
 
 		if username == "" {
-			data.Msg = "Username cannot be empty"
-		} else if SliceContains(reserved, username) {
-			data.Msg = "Username \"" + username + "\" is reserved"
+			data.Message = "Username cannot be empty"
+		} else if util.SliceContains(reserved, username) {
+			data.Message = "Username \"" + username + "\" is reserved"
 		} else if exists, err := UserExists(username); err != nil {
-			log.Println("[Admin:User:Create:Exists]", err.Error())
+			log.Println("[/admin/user/create]", err.Error())
 			HttpError(w, http.StatusInternalServerError)
 			return
 		} else if exists {
-			data.Msg = "Username \"" + username + "\" is taken"
+			data.Message = "Username \"" + username + "\" is taken"
 		} else if salt, err := Salt(); err != nil {
-			log.Println("[Admin:User:Create:Salt]", err.Error())
+			log.Println("[/admin/user/create]", err.Error())
 			HttpError(w, http.StatusInternalServerError)
 			return
 		} else if _, err := db.Exec(
 			"INSERT INTO users (name, name_full, pass, pass_algo, salt, is_admin) VALUES (?, ?, ?, ?, ?, ?)",
-			username, fullname, Hash(password, salt), "argon2", salt, admin,
+			username, fullname, Hash(password, salt), "argon2", salt, isAdmin,
 		); err != nil {
-			log.Println("[Admin:User:Create:INSERT]", err.Error())
+			log.Println("[/admin/user/create]", err.Error())
+			HttpError(w, http.StatusInternalServerError)
+			return
+		} else {
+			data.Message = "User \"" + username + "\" created successfully"
+		}
+	}
+
+	if err := tmpl.ExecuteTemplate(w, "admin/user_create", data); err != nil {
+		log.Println("[/admin/user/create]", err.Error())
+	}
+}
+
+func HandleAdminUserEdit(w http.ResponseWriter, r *http.Request) {
+	if !authHttpAdmin(r) {
+		HttpError(w, http.StatusNotFound)
+		return
+	}
+
+	id, err := strconv.ParseUint(r.URL.Query().Get("user"), 10, 64)
+	if err != nil {
+		HttpError(w, http.StatusNotFound)
+		return
+	}
+
+	user, err := GetUser(id)
+	if err != nil {
+		log.Println("[/admin/user/edit]", err.Error())
+		HttpError(w, http.StatusInternalServerError)
+		return
+	} else if user == nil {
+		HttpError(w, http.StatusNotFound)
+		return
+	}
+
+	data := struct {
+		Title, Id, Name, FullName, Message string
+		IsAdmin                            bool
+	}{Title: "Edit User ", Id: fmt.Sprint(user.Id), Name: user.Name, FullName: user.FullName, IsAdmin: user.IsAdmin}
+
+	if r.Method == http.MethodPost {
+		data.Name = strings.ToLower(r.FormValue("username"))
+		data.FullName = r.FormValue("fullname")
+		password := r.FormValue("password")
+		data.IsAdmin = r.FormValue("admin") == "true"
+
+		if data.Name == "" {
+			data.Message = "Username cannot be empty"
+		} else if util.SliceContains(reserved, data.Name) {
+			data.Message = "Username \"" + data.Name + "\" is reserved"
+		} else if exists, err := UserExists(data.Name); err != nil {
+			log.Println("[/admin/user/edit]", err.Error())
+			HttpError(w, http.StatusInternalServerError)
+			return
+		} else if exists && data.Name != user.Name {
+			data.Message = "Username \"" + data.Name + "\" is taken"
+		} else if salt, err := Salt(); err != nil {
+			log.Println("[/admin/user/edit]", err.Error())
 			HttpError(w, http.StatusInternalServerError)
 			return
 		} else {
-			data.Msg = "User \"" + username + "\" created successfully"
+			if password == "" {
+				_, err = db.Exec(
+					"UPDATE users SET name = ?, name_full = ?, is_admin = ? WHERE id = ?",
+					data.Name, data.FullName, data.IsAdmin, user.Id,
+				)
+			} else {
+				_, err = db.Exec(
+					"UPDATE users SET name = ?, name_full = ?, pass = ?, salt = ?, is_admin = ? WHERE id = ?",
+					data.Name, data.FullName, Hash(password, salt), salt, data.IsAdmin, user.Id,
+				)
+			}
+
+			if err != nil {
+				log.Println("[/admin/user/edit]", err.Error())
+				HttpError(w, http.StatusInternalServerError)
+				return
+			} else {
+				data.Message = "User \"" + user.Name + "\" updated successfully"
+			}
+		}
+	}
+
+	if err := tmpl.ExecuteTemplate(w, "admin/user_edit", data); err != nil {
+		log.Println("[/admin/user/edit]", err.Error())
+	}
+}
+
+func HandleAdminRepos(w http.ResponseWriter, r *http.Request) {
+	if !authHttpAdmin(r) {
+		HttpError(w, http.StatusNotFound)
+		return
+	}
+
+	rows, err := db.Query("SELECT id, owner_id, name, is_private FROM repos")
+	if err != nil {
+		log.Println("[/admin/repos]", err.Error())
+		HttpError(w, http.StatusInternalServerError)
+		return
+	}
+
+	defer rows.Close()
+
+	type row struct{ Id, Owner, Name, Visibility, Size string }
+	data := struct {
+		Title string
+		Repos []row
+	}{Title: "Repos"}
+
+	for rows.Next() {
+		d := Repo{}
+		if err := rows.Scan(&d.Id, &d.OwnerId, &d.Name, &d.IsPrivate); err != nil {
+			log.Println("[/admin/repos]", err.Error())
+			HttpError(w, http.StatusInternalServerError)
+			return
+		}
+
+		user, err := GetUser(d.OwnerId)
+		if err != nil {
+			log.Println("[/admin/repos]", err.Error())
+		}
+
+		data.Repos = append(data.Repos, row{
+			fmt.Sprint(d.Id), user.Name, d.Name, util.If(d.IsPrivate, "private", "public"), "",
+		})
+	}
+
+	if err := rows.Err(); err != nil {
+		log.Println("[/admin/repos]", err.Error())
+		HttpError(w, http.StatusInternalServerError)
+		return
+	}
+
+	if err := tmpl.ExecuteTemplate(w, "admin/repos", data); err != nil {
+		log.Println("[/admin/repos]", err.Error())
+	}
+}
+
+func authHttpAdmin(r *http.Request) bool {
+	if ok, uid := AuthHttp(r); ok {
+		if user, err := GetUser(uid); err == nil && user.IsAdmin {
+			return true
 		}
 	}
 
-	userCreate.Execute(w, data)
+	return false
 }
diff --git a/src/auth.go b/src/auth.go
index 15ace19..d2fb2c0 100644
--- a/src/auth.go
+++ b/src/auth.go
@@ -13,6 +13,7 @@ import (
 	"net/http"
 	"time"
 
+	"github.com/Jamozed/Goit/src/util"
 	"golang.org/x/crypto/argon2"
 )
 
@@ -66,7 +67,7 @@ func Auth(s string) (bool, uint64) {
 }
 
 func AuthHttp(r *http.Request) (bool, uint64) {
-	if c := Cookie(r, "session"); c != nil {
+	if c := util.Cookie(r, "session"); c != nil {
 		return Auth(c.Value)
 	}
 
@@ -74,7 +75,7 @@ func AuthHttp(r *http.Request) (bool, uint64) {
 }
 
 func SessionCookie(r *http.Request) string {
-	if c := Cookie(r, "session"); c != nil {
+	if c := util.Cookie(r, "session"); c != nil {
 		return c.Value
 	}
 
diff --git a/src/git.go b/src/git.go
index df2d8c8..bef8f2d 100644
--- a/src/git.go
+++ b/src/git.go
@@ -180,7 +180,6 @@ func gitHttpRpc(w http.ResponseWriter, r *http.Request, service string, repo *Re
 }
 
 func pktLine(str string) []byte {
-	PanicIf(len(str) > 65516, "pktLine: Payload exceeds maximum length")
 	s := strconv.FormatUint(uint64(len(str)+4), 16)
 	s = strings.Repeat("0", 4-len(s)%4) + s
 	return []byte(s + str)
diff --git a/src/goit.go b/src/goit.go
index d14a58e..b9b21e3 100644
--- a/src/goit.go
+++ b/src/goit.go
@@ -13,6 +13,7 @@ import (
 	"os"
 	"path"
 
+	"github.com/adrg/xdg"
 	_ "github.com/mattn/go-sqlite3"
 )
 
@@ -24,7 +25,7 @@ type Config struct {
 }
 
 var Conf = Config{
-	DataPath: ".",
+	DataPath: path.Join(xdg.DataHome, "goit"),
 	HttpAddr: "",
 	HttpPort: "8080",
 	GitPath:  "git",
@@ -33,8 +34,7 @@ var Conf = Config{
 var db *sql.DB
 var Favicon []byte
 
-/* Initialise Goit. */
-func InitGoit(conf string) (err error) {
+func Goit(conf string) (err error) {
 	if dat, err := os.ReadFile(conf); err != nil {
 		if !errors.Is(err, os.ErrNotExist) {
 			return fmt.Errorf("[Config] %w", err)
@@ -45,10 +45,13 @@ func InitGoit(conf string) (err error) {
 		}
 	}
 
+	log.Println("[Config] using data path:", Conf.DataPath)
+	if err := os.MkdirAll(Conf.DataPath, 0o777); err != nil {
+		return fmt.Errorf("[Config] %w", err)
+	}
+
 	if dat, err := os.ReadFile(path.Join(Conf.DataPath, "favicon.png")); err != nil {
-		if !errors.Is(err, os.ErrNotExist) {
-			return fmt.Errorf("[Config] %w", err)
-		}
+		log.Println("[Favicon]", err.Error())
 	} else {
 		Favicon = dat
 	}
@@ -82,22 +85,22 @@ func InitGoit(conf string) (err error) {
 			is_private BOOLEAN NOT NULL
 		)`,
 	); err != nil {
-		return fmt.Errorf("[CREATE:repos] %w", err)
+		return fmt.Errorf("[CREATE repos] %w", err)
 	}
 
 	/* Create an admin user if one does not exist */
 	if exists, err := UserExists("admin"); err != nil {
-		log.Println("[admin:Exists]", err.Error())
+		log.Println("[admin Exists]", err.Error())
 		err = nil /* ignored */
 	} else if !exists {
 		if salt, err := Salt(); err != nil {
-			log.Println("[admin:Salt]", err.Error())
+			log.Println("[admin Salt]", err.Error())
 			err = nil /* ignored */
 		} else if _, err = 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())
+			log.Println("[admin INSERT]", err.Error())
 			err = nil /* ignored */
 		}
 	}
@@ -105,6 +108,15 @@ func InitGoit(conf string) (err error) {
 	return nil
 }
 
+func GetConfPath() string {
+	if p, err := xdg.SearchConfigFile(path.Join("goit", "goit.json")); err != nil {
+		log.Println("[Config]", err.Error())
+		return ""
+	} else {
+		return p
+	}
+}
+
 func GetRepoPath(name string) string {
 	return path.Join(Conf.DataPath, "repos", name+".git")
 }
diff --git a/src/http.go b/src/http.go
index b3c293c..60f5be0 100644
--- a/src/http.go
+++ b/src/http.go
@@ -15,10 +15,15 @@ import (
 var tmpl = template.Must(template.New("error").Parse(res.Error))
 
 func init() {
-	tmpl.Option("missingkey=zero")
-
 	template.Must(tmpl.New("base/head").Parse(res.BaseHead))
+
+	template.Must(tmpl.New("admin/users").Parse(res.AdminUsers))
+	template.Must(tmpl.New("admin/user_create").Parse(res.AdminUserCreate))
+	template.Must(tmpl.New("admin/user_edit").Parse(res.AdminUserEdit))
+	template.Must(tmpl.New("admin/repos").Parse(res.AdminRepos))
+
 	template.Must(tmpl.New("base/repo_header").Parse(res.RepoHeader))
+	template.Must(tmpl.New("user_login").Parse(res.UserLogin))
 
 	template.Must(tmpl.New("repo_index").Parse(res.RepoIndex))
 	template.Must(tmpl.New("repo_create").Parse(res.RepoCreate))
diff --git a/src/repo.go b/src/repo.go
index 4220c8b..8ed70f0 100644
--- a/src/repo.go
+++ b/src/repo.go
@@ -12,6 +12,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/Jamozed/Goit/src/util"
 	"github.com/go-git/go-git/v5"
 	"github.com/go-git/go-git/v5/plumbing"
 	"github.com/go-git/go-git/v5/plumbing/object"
@@ -51,7 +52,7 @@ func HandleIndex(w http.ResponseWriter, r *http.Request) {
 					log.Println("[Index:SELECT:UserName]", err.Error())
 				}
 
-				repos = append(repos, row{r.Name, "", owner.Name, If(r.IsPrivate, "private", "public"), ""})
+				repos = append(repos, row{r.Name, "", owner.Name, util.If(r.IsPrivate, "private", "public"), ""})
 			}
 		}
 
@@ -79,7 +80,7 @@ func HandleRepoCreate(w http.ResponseWriter, r *http.Request) {
 			HttpError(w, http.StatusInternalServerError)
 		} else if taken {
 			tmpl.ExecuteTemplate(w, "repo_create", struct{ Msg string }{"Reponame is taken"})
-		} else if SliceContains[string](reserved, name) {
+		} else if util.SliceContains[string](reserved, name) {
 			tmpl.ExecuteTemplate(w, "repo_create", struct{ Msg string }{"Reponame is reserved"})
 		} else {
 			if _, err := db.Exec(
@@ -139,10 +140,10 @@ func HandleRepoLog(w http.ResponseWriter, r *http.Request) {
 
 	if err := tmpl.ExecuteTemplate(w, "repo_log", struct {
 		Title, Name, Description, Url string
-		HasReadme, HasLicence         bool
+		Readme, Licence               string
 		Commits                       []row
 	}{
-		"Log", reponame, repo.Description, r.URL.Host + "/" + repo.Name + ".git", false, false, commits,
+		"Log", reponame, repo.Description, r.URL.Host + "/" + repo.Name + ".git", "", "", commits,
 	}); err != nil {
 		log.Println("[Repo:Log]", err.Error())
 	}
@@ -199,11 +200,11 @@ func HandleRepoRefs(w http.ResponseWriter, r *http.Request) {
 
 	if err := tmpl.ExecuteTemplate(w, "repo_refs", struct {
 		Title, Name, Description, Url string
-		HasReadme, HasLicence         bool
+		Readme, Licence               string
 		Branches                      []bra
 		Tags                          []tag
 	}{
-		"Refs", reponame, repo.Description, r.URL.Host + "/" + repo.Name + ".git", false, false, bras, tags,
+		"Refs", reponame, repo.Description, r.URL.Host + "/" + repo.Name + ".git", "", "", bras, tags,
 	}); err != nil {
 		log.Println("[Repo:Refs]", err.Error())
 	}
diff --git a/src/user.go b/src/user.go
index f60fe7f..77c11a9 100644
--- a/src/user.go
+++ b/src/user.go
@@ -9,31 +9,23 @@ import (
 	"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
+	FullName string
 	Pass     []byte
 	PassAlgo string
 	Salt     []byte
 	IsAdmin  bool
 }
 
-var (
-	reserved []string = []string{"admin", "repo", "static", "user"}
-
-	userLogin  *template.Template = template.Must(template.New("user_login").Parse(res.UserLogin))
-	userCreate *template.Template = template.Must(template.New("user_create").Parse(res.UserCreate))
-)
+var reserved []string = []string{"admin", "repo", "static", "user"}
 
 func HandleUserLogin(w http.ResponseWriter, r *http.Request) {
 	if ok, _ := AuthHttp(r); ok {
@@ -41,7 +33,7 @@ func HandleUserLogin(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	data := struct{ Msg string }{""}
+	data := struct{ Title, Message string }{"Login", ""}
 
 	if r.Method == http.MethodPost {
 		u := User{}
@@ -49,13 +41,13 @@ func HandleUserLogin(w http.ResponseWriter, r *http.Request) {
 		password := r.FormValue("password")
 
 		if username == "" {
-			data.Msg = "Username cannot be empty"
+			data.Message = "Username cannot be empty"
 		} else if exists, err := UserExists(username); err != nil {
 			log.Println("[User:Login:Exists]", err.Error())
 			HttpError(w, http.StatusInternalServerError)
 			return
 		} else if !exists {
-			data.Msg = "Invalid credentials"
+			data.Message = "Invalid credentials"
 		} else if err := 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 {
@@ -63,7 +55,7 @@ func HandleUserLogin(w http.ResponseWriter, r *http.Request) {
 			HttpError(w, http.StatusInternalServerError)
 			return
 		} else if !bytes.Equal(Hash(password, u.Salt), u.Pass) {
-			data.Msg = "Invalid credentials"
+			data.Message = "Invalid credentials"
 		} else {
 			expiry := time.Now().Add(15 * time.Minute)
 			if s, err := NewSession(u.Id, expiry); err != nil {
@@ -78,7 +70,7 @@ func HandleUserLogin(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 
-	userLogin.Execute(w, data)
+	tmpl.ExecuteTemplate(w, "user_login", data)
 }
 
 func HandleUserLogout(w http.ResponseWriter, r *http.Request) {
@@ -92,7 +84,7 @@ func GetUser(id uint64) (*User, error) {
 
 	if err := 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 {
+	).Scan(&u.Id, &u.Name, &u.FullName, &u.IsAdmin); err != nil {
 		if !errors.Is(err, sql.ErrNoRows) {
 			return nil, fmt.Errorf("[SELECT:user] %w", err)
 		} else {
@@ -108,7 +100,7 @@ func GetUserByName(name string) (*User, error) {
 
 	err := db.QueryRow(
 		"SELECT id, name, name_full, pass, pass_algo, salt, is_admin FROM users WHERE name = ?", strings.ToLower(name),
-	).Scan(&u.Id, &u.Name, &u.NameFull, &u.Pass, &u.PassAlgo, &u.Salt, &u.IsAdmin)
+	).Scan(&u.Id, &u.Name, &u.FullName, &u.Pass, &u.PassAlgo, &u.Salt, &u.IsAdmin)
 	if errors.Is(err, sql.ErrNoRows) {
 		return nil, nil
 	} else if err != nil {
diff --git a/src/util.go b/src/util/util.go
similarity index 85%
rename from src/util.go
rename to src/util/util.go
index 3cd9efc..065fd85 100644
--- a/src/util.go
+++ b/src/util/util.go
@@ -1,8 +1,8 @@
-// util.go
+// util/util.go
 // Copyright (C) 2023, Jakob Wakeling
 // All rights reserved.
 
-package goit
+package util
 
 import (
 	"net/http"
@@ -26,12 +26,6 @@ func SliceContains[T comparable](s []T, e T) bool {
 	return false
 }
 
-func PanicIf(cond bool, v any) {
-	if cond {
-		panic(v)
-	}
-}
-
 /* 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 {