Goit

Simple and lightweight Git web server
git clone https://git.omkov.net/Goit
git clone [email protected]:Goit
Log | Tree | Refs | README | Download

AuthorJakob Wakeling <[email protected]>
Date2025-01-03 02:33:07
Commit44175afe9872652f92cb0700b3bc59affb2393bb
Parent6c2749bbdf5760923827884196ad10a2a0b3388a

Implement user SSH key management

Diffstat

M res/res.go | 6 ++++++
M res/user/header.html | 2 ++
A res/user/keys.html | 35 +++++++++++++++++++++++++++++++++++
A res/user/keys_add.html | 22 ++++++++++++++++++++++
M src/goit/auth.go | 4 ++--
M src/goit/auth_test.go | 2 +-
M src/goit/config.go | 28 +++++++++++++++-------------
M src/goit/db.go | 49 +++++++++++++++++++++++++++++++++++++++++--------
M src/goit/git.go | 5 -----
M src/goit/goit.go | 16 ++++++++++++++--
M src/goit/http.go | 4 +++-
A src/goit/keys.go | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A src/goit/ssh.go | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
M src/main.go | 12 ++++++++----
M src/repo/commit.go | 1 +
M src/repo/create.go | 1 +
M src/repo/file.go | 1 +
M src/repo/refs.go | 1 +
M src/repo/repo.go | 2 +-
M src/repo/tree.go | 1 +
M src/user/edit.go | 1 +
A src/user/keys.go | 137 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
M src/user/login.go | 2 ++
M src/user/sessions.go | 1 +

24 files changed, 445 insertions, 37 deletions

diff --git a/res/res.go b/res/res.go
index 772f4e6..23d3ace 100644
--- a/res/res.go
+++ b/res/res.go
@@ -49,6 +49,12 @@ var UserSessions string
 //go:embed user/edit.html
 var UserEdit string
 
+//go:embed user/keys.html
+var UserKeys string
+
+//go:embed user/keys_add.html
+var UserKeysAdd string
+
 //go:embed repo/header.html
 var RepoHeader string
 
diff --git a/res/user/header.html b/res/user/header.html
index a984290..27e2c8d 100644
--- a/res/user/header.html
+++ b/res/user/header.html
@@ -10,6 +10,8 @@
 		<td>
 			<a href="/user/sessions">Sessions</a>
 			| <a href="/user/edit">Edit</a>
+			| <a href="/user/keys">Keys</a>
+			| <a href="/user/keys/add">Add Key</a>
 		</td>
 	</tr>
 </table>
diff --git a/res/user/keys.html b/res/user/keys.html
new file mode 100644
index 0000000..528d785
--- /dev/null
+++ b/res/user/keys.html
@@ -0,0 +1,35 @@
+<!DOCTYPE html>
+<html lang="en">
+	<head>{{template "base/head" .}}</head>
+	<body>
+		<header>{{template "user/header" .}}</header><hr>
+		<main>
+			<table>
+				<thead>
+					<tr>
+						<td><b>Type</b></td>
+						<td><b>Description</b></td>
+						<td><b>Key</b></td>
+						<td></td>
+					</tr>
+				</thead>
+				<tbody>
+				{{range $i, $key := .Keys}}
+					<tr>
+						<td>{{.Type}}</td>
+						<td>{{.Description}}</td>
+						<td>{{index $.KeyLines $i}}</td>
+						<td>
+							<form action="/user/keys" method="post">
+								{{$.CSRFField}}
+								<input type="hidden" name="kid" value="{{.ID}}">
+								<input type="submit" name="submit" value="Delete">
+							</form>
+						</td>
+					</tr>
+				{{end}}
+				</tbody>
+			</table>
+		</main>
+	</body>
+</html>
diff --git a/res/user/keys_add.html b/res/user/keys_add.html
new file mode 100644
index 0000000..3be1308
--- /dev/null
+++ b/res/user/keys_add.html
@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<html lang="en">
+	<head>{{template "base/head" .}}</head>
+	<body>
+		<header>{{template "user/header" .}}</header><hr>
+		<main>
+			<form action="/user/keys/add" method="post">
+				{{.CSRFField}}
+				<table>
+					<tr><td><label for="key">SSH Public Key</label></td></tr>
+					<tr><td><textarea name="key" spellcheck="false">{{.Form.Key}}</textarea></td></tr>
+					<tr>
+						<td>
+							<input type="submit" name="submit" value="Add">
+							<span style="color: #AA0000">{{.Message}}</span>
+						</td>
+					</tr>
+				</table>
+			</form>
+		</main>
+	</body>
+</html>
diff --git a/src/goit/auth.go b/src/goit/auth.go
index 41832d4..ceece02 100644
--- a/src/goit/auth.go
+++ b/src/goit/auth.go
@@ -34,7 +34,7 @@ func NewSession(uid int64, ip string, expiry time.Time) (Session, error) {
 	}
 
 	var t = base64.StdEncoding.EncodeToString(b)
-	var s = Session{Token: t, Ip: util.If(Conf.IpSessions, ip, ""), Seen: time.Now(), Expiry: expiry}
+	var s = Session{Token: t, Ip: util.If(Conf.IPSessions, ip, ""), Seen: time.Now(), Expiry: expiry}
 
 	SessionsMutex.Lock()
 	util.Debugln("[goit.NewSession] SessionsMutex lock")
@@ -111,7 +111,7 @@ func CleanupSessions() {
 func SetSessionCookie(w http.ResponseWriter, uid int64, s Session) {
 	c := &http.Cookie{
 		Name: "session", Value: fmt.Sprint(uid) + "." + s.Token, Path: "/", Expires: s.Expiry,
-		Secure: util.If(Conf.UsesHttps, true, false), HttpOnly: true, SameSite: http.SameSiteLaxMode,
+		Secure: util.If(Conf.UsesHTTPS, true, false), HttpOnly: true, SameSite: http.SameSiteLaxMode,
 	}
 
 	if err := c.Valid(); err != nil {
diff --git a/src/goit/auth_test.go b/src/goit/auth_test.go
index 3b39c71..3a091ca 100644
--- a/src/goit/auth_test.go
+++ b/src/goit/auth_test.go
@@ -20,7 +20,7 @@ func TestNewSession(t *testing.T) {
 	var uid int64 = 1
 	var session = goit.Session{Ip: "127.0.0.1", Expiry: time.Unix(0, 0)}
 
-	goit.Conf.IpSessions = true
+	goit.Conf.IPSessions = true
 	s, err := goit.NewSession(uid, session.Ip, session.Expiry)
 	if err != nil {
 		t.Fatal(err.Error())
diff --git a/src/goit/config.go b/src/goit/config.go
index df8d4a8..9e29c64 100644
--- a/src/goit/config.go
+++ b/src/goit/config.go
@@ -14,27 +14,29 @@ type config struct {
 	DataPath    string `json:"data_path"`
 	LogsPath    string `json:"logs_path"`
 	RuntimePath string `json:"runtime_path"`
-	HttpAddr    string `json:"http_addr"`
-	HttpPort    string `json:"http_port"`
+	HTTPAddr    string `json:"http_addr"`
+	HTTPPort    string `json:"http_port"`
 	GitPath     string `json:"git_path"`
-	IpSessions  bool   `json:"ip_sessions"`
-	UsesHttps   bool   `json:"uses_https"`
-	IpForwarded bool   `json:"ip_forwarded"`
-	CsrfSecret  string `json:"csrf_secret"`
+	IPSessions  bool   `json:"ip_sessions"`
+	UsesHTTPS   bool   `json:"uses_https"`
+	IPForwarded bool   `json:"ip_forwarded"`
+	EnableSSH   bool   `json:"enable_ssh"`
+	CSRFSecret  string `json:"csrf_secret"`
 }
 
-func loadConfig() (config, error) {
+func LoadConfig() (config, error) {
 	conf := config{
 		DataPath:    dataPath(),
 		LogsPath:    logsPath(),
 		RuntimePath: runtimePath(),
-		HttpAddr:    "",
-		HttpPort:    "8080",
+		HTTPAddr:    "",
+		HTTPPort:    "8080",
 		GitPath:     "git",
-		IpSessions:  true,
-		UsesHttps:   false,
-		IpForwarded: false,
-		CsrfSecret:  "1234567890abcdef1234567890abcdef",
+		IPSessions:  true,
+		UsesHTTPS:   false,
+		IPForwarded: false,
+		EnableSSH:   false,
+		CSRFSecret:  "1234567890abcdef1234567890abcdef",
 	}
 
 	/* Load config file(s) */
diff --git a/src/goit/db.go b/src/goit/db.go
index cdc24e4..f3a0516 100644
--- a/src/goit/db.go
+++ b/src/goit/db.go
@@ -18,6 +18,7 @@ users:
 	pass_algo TEXT NOT NULL
 	salt BLOB NOT NULL
 	is_admin BOOLEAN NOT NULL
+	default_visibility INTEGER NOT NULL DEFAULT 0
 
 repos:
 	id INTEGER PRIMARY KEY AUTOINCREMENT
@@ -29,11 +30,18 @@ repos:
 	upstream TEXT NOT NULL
 	visibility INTEGER NOT NULL DEFAULT 0
 	is_mirror BOOLEAN NOT NULL
+
+keys:
+	id INTEGER PRIMARY KEY AUTOINCREMENT
+	owner_id INTEGER NOT NULL
+	type INTEGER NOT NULL
+	description TEXT NOT NULL
+	key BLOB NOT NULL
 */
 
-func updateDatabase(db *sql.DB) error {
-	const LATEST_VERSION = 3
+const LATEST_VERSION = 4
 
+func updateDatabase(db *sql.DB) error {
 	tx, err := db.Begin()
 	if err != nil {
 		return err
@@ -51,8 +59,8 @@ func updateDatabase(db *sql.DB) error {
 
 	logMigration := true
 
-	if version <= 0 {
-		/* Database is empty or new, initialise from scratch */
+	if version < 1 {
+		/* Database is empty or new, initialise from scratch. */
 		log.Println("Initialising database at version", LATEST_VERSION)
 		logMigration = false
 
@@ -87,7 +95,7 @@ func updateDatabase(db *sql.DB) error {
 
 		version = 1
 	}
-	if version <= 1 {
+	if version < 2 {
 		if logMigration {
 			log.Println("Migrating database from version 1 to 2")
 		}
@@ -98,7 +106,7 @@ func updateDatabase(db *sql.DB) error {
 
 		version = 2
 	}
-	if version <= 2 {
+	if version < 3 {
 		if logMigration {
 			log.Println("Migrating database from version 2 to 3")
 		}
@@ -107,7 +115,7 @@ func updateDatabase(db *sql.DB) error {
 			return err
 		}
 
-		/* Set values for each repo according to is_private */
+		/* Set values for each repo according to is_private. */
 		var visibilities = map[int64]Visibility{}
 
 		if rows, err := tx.Query("SELECT id, is_private FROM repos"); err != nil {
@@ -133,13 +141,38 @@ func updateDatabase(db *sql.DB) error {
 			}
 		}
 
-		/* Remove is_private column */
+		/* Remove is_private column. */
 		if _, err := tx.Exec("ALTER TABLE repos DROP COLUMN is_private"); err != nil {
 			return err
 		}
 
 		version = 3
 	}
+	if version < 4 {
+		if logMigration {
+			log.Println("Migrating database from version 3 to 4")
+		}
+
+		/* Add the default_visibility column to users. */
+		if _, err := tx.Exec("ALTER TABLE users ADD COLUMN default_visibility INTEGER NOT NULL DEFAULT 0"); err != nil {
+			return err
+		}
+
+		/* Create the keys table. */
+		if _, err := tx.Exec(
+			`CREATE TABLE IF NOT EXISTS keys (
+				id INTEGER PRIMARY KEY AUTOINCREMENT,
+				owner_id INTEGER NOT NULL,
+				type INTEGER NOT NULL,
+				description TEXT NOT NULL,
+				key BLOB NOT NULL
+			)`,
+		); err != nil {
+			return err
+		}
+
+		version = 4
+	}
 
 	if _, err := tx.Exec(fmt.Sprint("PRAGMA user_version = ", version)); err != nil {
 		return err
diff --git a/src/goit/git.go b/src/goit/git.go
index 101eb8a..0c93c50 100644
--- a/src/goit/git.go
+++ b/src/goit/git.go
@@ -132,11 +132,6 @@ func gitHttpBase(w http.ResponseWriter, r *http.Request, service string) *Repo {
 		}
 	}
 
-	if repo == nil {
-		w.WriteHeader(http.StatusNotFound)
-		return nil
-	}
-
 	return repo
 }
 
diff --git a/src/goit/goit.go b/src/goit/goit.go
index 050bb54..98e3d08 100644
--- a/src/goit/goit.go
+++ b/src/goit/goit.go
@@ -36,7 +36,7 @@ var Reserved []string = []string{"admin", "repo", "static", "user"}
 var StartTime = time.Now()
 
 func Goit() error {
-	if conf, err := loadConfig(); err != nil {
+	if conf, err := LoadConfig(); err != nil {
 		return err
 	} else {
 		Conf = conf
@@ -54,6 +54,10 @@ func Goit() error {
 	log.SetOutput(io.MultiWriter(os.Stderr, logFile))
 	log.Println("Starting Goit", res.Version)
 
+	if Conf.CSRFSecret == "1234567890abcdef1234567890abcdef" {
+		log.Println("[config] WARNING: CSRF secret is insecure")
+	}
+
 	log.Println("[Config] using data path:", Conf.DataPath)
 	if err := os.MkdirAll(Conf.DataPath, 0o777); err != nil {
 		return fmt.Errorf("[config] %w", err)
@@ -91,6 +95,11 @@ func Goit() error {
 		}
 	}
 
+	/* Trigger an SSH authorized keys file update. */
+	if err := UpdateAuthorizedKeys(); err != nil {
+		return err
+	}
+
 	/* Initialise and start the cron service */
 	Cron = cron.New()
 	Cron.Start()
@@ -123,6 +132,9 @@ func Goit() error {
 	return nil
 }
 
+/* Set the internal db variable, ONLY used by goit-shell. */
+func ShellSetDB(d *sql.DB) { db = d }
+
 func RepoPath(name string, abs bool) string {
 	return util.If(abs, filepath.Join(Conf.DataPath, "repos", name+".git"), filepath.Join(name+".git"))
 }
@@ -138,7 +150,7 @@ func IsLegal(s string) bool {
 }
 
 func Backup() error {
-	if conf, err := loadConfig(); err != nil {
+	if conf, err := LoadConfig(); err != nil {
 		return err
 	} else {
 		Conf = conf
diff --git a/src/goit/http.go b/src/goit/http.go
index 62f81ed..2fbddc8 100644
--- a/src/goit/http.go
+++ b/src/goit/http.go
@@ -31,6 +31,8 @@ func init() {
 	template.Must(Tmpl.New("user/login").Parse(res.UserLogin))
 	template.Must(Tmpl.New("user/sessions").Parse(res.UserSessions))
 	template.Must(Tmpl.New("user/edit").Parse(res.UserEdit))
+	template.Must(Tmpl.New("user/keys").Parse(res.UserKeys))
+	template.Must(Tmpl.New("user/keys/add").Parse(res.UserKeysAdd))
 
 	template.Must(Tmpl.New("repo/header").Parse(res.RepoHeader))
 	template.Must(Tmpl.New("repo/create").Parse(res.RepoCreate))
@@ -55,7 +57,7 @@ func HttpNotFound(w http.ResponseWriter, r *http.Request) {
 }
 
 func Ip(r *http.Request) string {
-	if fip := r.Header.Get("X-Forwarded-For"); Conf.IpForwarded && fip != "" {
+	if fip := r.Header.Get("X-Forwarded-For"); Conf.IPForwarded && fip != "" {
 		return fip
 	}
 
diff --git a/src/goit/keys.go b/src/goit/keys.go
new file mode 100644
index 0000000..bb7c37c
--- /dev/null
+++ b/src/goit/keys.go
@@ -0,0 +1,84 @@
+// Copyright (C) 2025, Jakob Wakeling
+// All rights reserved.
+
+package goit
+
+type Key struct {
+	ID          int64   `json:"id"`
+	OwnerID     int64   `json:"owner_id"`
+	Type        KeyType `json:"type"`
+	Description string  `json:"description"`
+	Key         []byte  `json:"key"`
+}
+
+type KeyType int32
+
+const (
+	SSH_Auth KeyType = 0
+)
+
+func KeyTypeFromString(s string) KeyType {
+	switch s {
+	case "ssh-auth":
+		return SSH_Auth
+	default:
+		return -1
+	}
+}
+
+func (t KeyType) String() string {
+	return [...]string{"ssh-auth"}[t]
+}
+
+func GetKeys(uid int64) ([]Key, error) {
+	keys := []Key{}
+
+	rows, err := db.Query("SELECT id, owner_id, type, description, key FROM keys WHERE owner_id = ?", uid)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		k := Key{}
+		if err := rows.Scan(&k.ID, &k.OwnerID, &k.Type, &k.Description, &k.Key); err != nil {
+			return nil, err
+		}
+
+		keys = append(keys, k)
+	}
+
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+
+	return keys, nil
+}
+
+/* Input key ID is ignored. */
+func AddKey(key Key) error {
+	if _, err := db.Exec(
+		"INSERT INTO keys (owner_id, type, description, key) VALUES (?, ?, ?, ?)",
+		key.OwnerID, key.Type, key.Description, key.Key,
+	); err != nil {
+		return err
+	}
+
+	if err := UpdateAuthorizedKeys(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func DelKey(kid int64) error {
+	if _, err := db.Exec("DELETE FROM keys WHERE id = ?", kid); err != nil {
+		return err
+	}
+
+	if err := UpdateAuthorizedKeys(); err != nil {
+		return err
+	}
+
+	return nil
+}
diff --git a/src/goit/ssh.go b/src/goit/ssh.go
new file mode 100644
index 0000000..5de11d0
--- /dev/null
+++ b/src/goit/ssh.go
@@ -0,0 +1,65 @@
+// Copyright (C) 2025, Jakob Wakeling
+// All rights reserved.
+
+package goit
+
+import (
+	"fmt"
+	"log"
+	"os"
+	"path/filepath"
+
+	"github.com/Jamozed/Goit/src/util"
+	"golang.org/x/crypto/ssh"
+)
+
+func UpdateAuthorizedKeys() error {
+	if !Conf.EnableSSH {
+		return nil
+	}
+
+	log.Println("Updating SSH authorized keys file")
+
+	f, err := os.Create(filepath.Join(os.Getenv("HOME"), ".ssh", "authorized_keys"))
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+
+	f.WriteString("# This file is managed by Goit; edits will be overwritten.\n")
+
+	/* Write each users SSH keys to the SSH authorized keys file. */
+	users, err := GetUsers()
+	if err != nil {
+		return err
+	}
+
+	for _, u := range users {
+		keys, err := GetKeys(u.Id)
+		if err != nil {
+			util.PrintFuncError(err)
+			continue
+		}
+
+		for _, k := range keys {
+			if k.Type != SSH_Auth {
+				continue
+			}
+
+			ks, err := ssh.ParsePublicKey(k.Key)
+			if err != nil {
+				util.PrintFuncError(err)
+				continue
+			}
+
+			if _, err := f.WriteString(
+				fmt.Sprintf("command=\"goit-shell %s\" %s", u.Name, string(ssh.MarshalAuthorizedKey(ks))),
+			); err != nil {
+				util.PrintFuncError(err)
+				continue
+			}
+		}
+	}
+
+	return nil
+}
diff --git a/src/main.go b/src/main.go
index 6af8707..e47d18d 100644
--- a/src/main.go
+++ b/src/main.go
@@ -72,8 +72,8 @@ func main() {
 	})
 
 	protect = csrf.Protect(
-		[]byte(goit.Conf.CsrfSecret), csrf.FieldName("csrf.Token"), csrf.CookieName("csrf"),
-		csrf.Secure(util.If(goit.Conf.UsesHttps, true, false)),
+		[]byte(goit.Conf.CSRFSecret), csrf.FieldName("csrf.Token"), csrf.CookieName("csrf"),
+		csrf.Secure(util.If(goit.Conf.UsesHTTPS, true, false)),
 	)
 
 	h.Group(func(r chi.Router) {
@@ -86,6 +86,10 @@ func main() {
 		r.Post("/user/logout", goit.HandleUserLogout)
 		r.Get("/user/sessions", user.HandleSessions)
 		r.Post("/user/sessions", user.HandleSessions)
+		r.Get("/user/keys", user.HandleKeys)
+		r.Post("/user/keys", user.HandleKeys)
+		r.Get("/user/keys/add", user.HandleKeysAdd)
+		r.Post("/user/keys/add", user.HandleKeysAdd)
 		r.Get("/user/edit", user.HandleEdit)
 		r.Post("/user/edit", user.HandleEdit)
 		r.Get("/repo/create", repo.HandleCreate)
@@ -131,7 +135,7 @@ func main() {
 	// h.Post("/{repo}/git-receive-pack", goit.HandleReceivePack)
 
 	/* Listen for HTTP on the specified port */
-	if err := http.ListenAndServe(goit.Conf.HttpAddr+":"+goit.Conf.HttpPort, h); err != nil {
+	if err := http.ListenAndServe(goit.Conf.HTTPAddr+":"+goit.Conf.HTTPPort, h); err != nil {
 		log.Fatalln("[http]", err.Error())
 	}
 }
@@ -142,7 +146,7 @@ func logHttp(next http.Handler) http.Handler {
 		next.ServeHTTP(w, r)
 
 		ip := r.RemoteAddr
-		if fip := r.Header.Get("X-Forwarded-For"); goit.Conf.IpForwarded && fip != "" {
+		if fip := r.Header.Get("X-Forwarded-For"); goit.Conf.IPForwarded && fip != "" {
 			ip = fip
 		}
 
diff --git a/src/repo/commit.go b/src/repo/commit.go
index 4dd0e79..423791e 100644
--- a/src/repo/commit.go
+++ b/src/repo/commit.go
@@ -26,6 +26,7 @@ func HandleCommit(w http.ResponseWriter, r *http.Request) {
 	if err != nil {
 		log.Println("[/repo/commit]", err.Error())
 		goit.HttpError(w, http.StatusInternalServerError)
+		return
 	}
 
 	repo, err := goit.GetRepoByName(chi.URLParam(r, "repo"))
diff --git a/src/repo/create.go b/src/repo/create.go
index 9774fe3..96e2456 100644
--- a/src/repo/create.go
+++ b/src/repo/create.go
@@ -21,6 +21,7 @@ func HandleCreate(w http.ResponseWriter, r *http.Request) {
 	if err != nil {
 		log.Println("[admin]", err.Error())
 		goit.HttpError(w, http.StatusInternalServerError)
+		return
 	}
 
 	if !auth {
diff --git a/src/repo/file.go b/src/repo/file.go
index 161573e..e0935fb 100644
--- a/src/repo/file.go
+++ b/src/repo/file.go
@@ -25,6 +25,7 @@ func HandleFile(w http.ResponseWriter, r *http.Request) {
 	if err != nil {
 		util.PrintFuncError(err)
 		goit.HttpError(w, http.StatusInternalServerError)
+		return
 	}
 
 	tpath := chi.URLParam(r, "*")
diff --git a/src/repo/refs.go b/src/repo/refs.go
index 024c40a..5f87bc5 100644
--- a/src/repo/refs.go
+++ b/src/repo/refs.go
@@ -24,6 +24,7 @@ func HandleRefs(w http.ResponseWriter, r *http.Request) {
 	if err != nil {
 		log.Println("[admin]", err.Error())
 		goit.HttpError(w, http.StatusInternalServerError)
+		return
 	}
 
 	repo, err := goit.GetRepoByName(chi.URLParam(r, "repo"))
diff --git a/src/repo/repo.go b/src/repo/repo.go
index fa38468..a35398a 100644
--- a/src/repo/repo.go
+++ b/src/repo/repo.go
@@ -21,7 +21,7 @@ type HeaderFields struct {
 func GetHeaderFields(auth bool, user *goit.User, repo *goit.Repo, host string) HeaderFields {
 	return HeaderFields{
 		Name: repo.Name, Description: repo.Description,
-		Url:      util.If(goit.Conf.UsesHttps, "https://", "http://") + host + "/" + repo.Name,
+		Url:      util.If(goit.Conf.UsesHTTPS, "https://", "http://") + host + "/" + repo.Name,
 		Editable: (auth && repo.OwnerId == user.Id),
 		Mirror:   util.If(repo.IsMirror, repo.Upstream, ""),
 	}
diff --git a/src/repo/tree.go b/src/repo/tree.go
index 3a14e26..a3febfa 100644
--- a/src/repo/tree.go
+++ b/src/repo/tree.go
@@ -26,6 +26,7 @@ func HandleTree(w http.ResponseWriter, r *http.Request) {
 	if err != nil {
 		log.Println("[admin]", err.Error())
 		goit.HttpError(w, http.StatusInternalServerError)
+		return
 	}
 
 	tpath := chi.URLParam(r, "*")
diff --git a/src/user/edit.go b/src/user/edit.go
index 8468b95..6d2f347 100644
--- a/src/user/edit.go
+++ b/src/user/edit.go
@@ -20,6 +20,7 @@ func HandleEdit(w http.ResponseWriter, r *http.Request) {
 	if err != nil {
 		log.Println("[admin]", err.Error())
 		goit.HttpError(w, http.StatusInternalServerError)
+		return
 	}
 
 	if !auth {
diff --git a/src/user/keys.go b/src/user/keys.go
new file mode 100644
index 0000000..91845ee
--- /dev/null
+++ b/src/user/keys.go
@@ -0,0 +1,137 @@
+// Copyright (C) 2025, Jakob Wakeling
+// All rights reserved.
+
+package user
+
+import (
+	"fmt"
+	"html/template"
+	"net/http"
+	"strconv"
+	"strings"
+
+	"github.com/Jamozed/Goit/src/goit"
+	"github.com/Jamozed/Goit/src/util"
+	"github.com/gorilla/csrf"
+	"golang.org/x/crypto/ssh"
+)
+
+func HandleKeys(w http.ResponseWriter, r *http.Request) {
+	auth, user, err := goit.Auth(w, r, true)
+	if err != nil {
+		util.PrintFuncError(err)
+		goit.HttpError(w, http.StatusInternalServerError)
+		return
+	}
+
+	if !auth {
+		goit.HttpError(w, http.StatusUnauthorized)
+		return
+	}
+
+	data := struct {
+		Title     string
+		Keys      []goit.Key
+		KeyLines  []string
+		CSRFField template.HTML
+	}{
+		Title:     "User - Keys",
+		CSRFField: csrf.TemplateField(r),
+	}
+
+	if r.Method == http.MethodPost {
+		fmt.Println(r.FormValue("submit"))
+		if r.FormValue("submit") == "Delete" {
+			kid, err := strconv.ParseInt(r.FormValue("kid"), 10, 64)
+			if err != nil {
+				util.PrintFuncError(err)
+				goit.HttpError(w, http.StatusInternalServerError)
+				return
+			}
+
+			if err := goit.DelKey(kid); err != nil {
+				util.PrintFuncError(err)
+				goit.HttpError(w, http.StatusInternalServerError)
+				return
+			}
+
+			/* Redirect to user keys page on success. */
+			http.Redirect(w, r, "/user/keys", http.StatusFound)
+			return
+		}
+	}
+
+	if keys, err := goit.GetKeys(user.Id); err != nil {
+		util.PrintFuncError(err)
+		goit.HttpError(w, http.StatusInternalServerError)
+		return
+	} else {
+		data.Keys = keys
+	}
+
+	for _, key := range data.Keys {
+		k, err := ssh.ParsePublicKey(key.Key)
+		if err != nil {
+			util.PrintFuncError(err)
+			goit.HttpError(w, http.StatusInternalServerError)
+			return
+		}
+		data.KeyLines = append(data.KeyLines, strings.TrimSuffix(string(ssh.MarshalAuthorizedKey(k)), "\n"))
+	}
+
+	if err := goit.Tmpl.ExecuteTemplate(w, "user/keys", data); err != nil {
+		util.PrintFuncError(err)
+	}
+}
+
+func HandleKeysAdd(w http.ResponseWriter, r *http.Request) {
+	auth, user, err := goit.Auth(w, r, true)
+	if err != nil {
+		util.PrintFuncError(err)
+		goit.HttpError(w, http.StatusInternalServerError)
+		return
+	}
+
+	if !auth {
+		goit.HttpError(w, http.StatusUnauthorized)
+		return
+	}
+
+	data := struct {
+		Title, Message string
+
+		Form      struct{ Key string }
+		CSRFField template.HTML
+	}{
+		Title:     "User - Add Key",
+		CSRFField: csrf.TemplateField(r),
+	}
+
+	if r.Method == http.MethodPost {
+		data.Form.Key = r.FormValue("key")
+
+		if data.Form.Key == "" {
+			data.Message = "Key cannot be empty"
+		} else if key, comment, options, _, err := ssh.ParseAuthorizedKey([]byte(data.Form.Key)); err != nil {
+			data.Message = "Invalid SSH public key"
+		} else if len(options) != 0 {
+			data.Message = "Key options are not permitted"
+		} else if comment == "" {
+			data.Message = "Key comment is required"
+		} else if err := goit.AddKey(goit.Key{
+			OwnerID: user.Id, Description: comment, Key: key.Marshal(), Type: goit.SSH_Auth,
+		}); err != nil {
+			util.PrintFuncError(err)
+			goit.HttpError(w, http.StatusInternalServerError)
+			return
+		} else {
+			/* Redirect to user keys page on success. */
+			http.Redirect(w, r, "/user/keys", http.StatusFound)
+			return
+		}
+	}
+
+	if err := goit.Tmpl.ExecuteTemplate(w, "user/keys/add", data); err != nil {
+		util.PrintFuncError(err)
+	}
+}
diff --git a/src/user/login.go b/src/user/login.go
index aa81c61..ba727f3 100644
--- a/src/user/login.go
+++ b/src/user/login.go
@@ -19,10 +19,12 @@ func HandleLogin(w http.ResponseWriter, r *http.Request) {
 	if err != nil {
 		log.Println("[admin]", err.Error())
 		goit.HttpError(w, http.StatusInternalServerError)
+		return
 	}
 
 	if auth {
 		http.Redirect(w, r, "/", http.StatusFound)
+		return
 	}
 
 	data := struct {
diff --git a/src/user/sessions.go b/src/user/sessions.go
index 6526cc9..9d2896e 100644
--- a/src/user/sessions.go
+++ b/src/user/sessions.go
@@ -19,6 +19,7 @@ func HandleSessions(w http.ResponseWriter, r *http.Request) {
 	if err != nil {
 		log.Println("[admin]", err.Error())
 		goit.HttpError(w, http.StatusInternalServerError)
+		return
 	}
 
 	if !auth {