From: Thomas Karpiniec <tom.karpiniec@outlook.com>
Date: Wed, 23 Oct 2024 10:55:42 +0000 (+1100)
Subject: User management
X-Git-Tag: v1.0.0~5
X-Git-Url: https://code.octet-stream.net/broadcaster/commitdiff_plain/6f8503e889dc1f45eddd987ee44e6338712de4fe?hp=04483e823a42650816c2edbb276709145b9068db

User management
---

diff --git a/server/database.go b/server/database.go
index 1312208..06e5968 100644
--- a/server/database.go
+++ b/server/database.go
@@ -79,6 +79,15 @@ func (d *Database) GetUser(username string) (User, error) {
 	return user, nil
 }
 
+func (d *Database) GetUserById(id int) (User, error) {
+	var user User
+	err := d.sqldb.QueryRow("SELECT id, username, password_hash, is_admin FROM users WHERE id = ?", id).Scan(&user.Id, &user.Username, &user.PasswordHash, &user.IsAdmin)
+	if err != nil {
+		return User{}, errors.New("no user with that id")
+	}
+	return user, nil
+}
+
 func (d *Database) GetUsers() []User {
 	ret := make([]User, 0)
 	rows, err := d.sqldb.Query("SELECT id, username, password_hash, is_admin FROM users ORDER BY username ASC")
diff --git a/server/main.go b/server/main.go
index 5f0a748..fe09ea2 100644
--- a/server/main.go
+++ b/server/main.go
@@ -5,6 +5,7 @@ import (
 	"embed"
 	"flag"
 	"fmt"
+	"golang.org/x/crypto/bcrypt"
 	"golang.org/x/net/websocket"
 	"html/template"
 	"io"
@@ -92,7 +93,7 @@ func main() {
 
 	// Admin routes
 
-	// TODO: user management
+	http.Handle("/users/", requireAdmin(userSection))
 
 	// Websocket routes, which perform their own auth
 
@@ -271,6 +272,123 @@ func radioSection(w http.ResponseWriter, r *http.Request, user User) {
 	}
 }
 
+func userSection(w http.ResponseWriter, r *http.Request, user User) {
+	path := strings.Split(r.URL.Path, "/")
+	if len(path) != 3 {
+		http.NotFound(w, r)
+		return
+	}
+	if path[2] == "new" {
+		editUserPage(w, r, 0)
+	} else if path[2] == "submit" && r.Method == "POST" {
+		submitUser(w, r)
+	} else if path[2] == "delete" && r.Method == "POST" {
+		deleteUser(w, r)
+	} else if path[2] == "reset-password" && r.Method == "POST" {
+		resetUserPassword(w, r)
+	} else if path[2] == "" {
+		usersPage(w, r)
+	} else {
+		id, err := strconv.Atoi(path[2])
+		if err != nil {
+			http.NotFound(w, r)
+			return
+		}
+		editUserPage(w, r, id)
+	}
+}
+
+type EditUserPageData struct {
+	User User
+}
+
+func editUserPage(w http.ResponseWriter, r *http.Request, id int) {
+	var data EditUserPageData
+	if id != 0 {
+		user, err := db.GetUserById(id)
+		if err != nil {
+			http.NotFound(w, r)
+			return
+		}
+		data.User = user
+	}
+	renderHeader(w, "users")
+	tmpl := template.Must(template.ParseFS(content, "templates/user.html"))
+	tmpl.Execute(w, data)
+	renderFooter(w)
+}
+
+func submitUser(w http.ResponseWriter, r *http.Request) {
+	err := r.ParseForm()
+	if err == nil {
+		id, err := strconv.Atoi(r.Form.Get("userId"))
+		if err != nil {
+			return
+		}
+		if id == 0 {
+			newPassword := r.Form.Get("password")
+			hashed, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
+			if err != nil {
+				return
+			}
+			user := User{
+				Id:           0,
+				Username:     r.Form.Get("username"),
+				IsAdmin:      r.Form.Get("isAdmin") == "1",
+				PasswordHash: string(hashed),
+			}
+			db.CreateUser(user)
+		} else {
+			user, err := db.GetUserById(id)
+			if err != nil {
+				http.NotFound(w, r)
+				return
+			}
+			db.SetUserIsAdmin(user.Username, r.Form.Get("isAdmin") == "1")
+		}
+	}
+	http.Redirect(w, r, "/users/", http.StatusFound)
+}
+
+func deleteUser(w http.ResponseWriter, r *http.Request) {
+	err := r.ParseForm()
+	if err == nil {
+		id, err := strconv.Atoi(r.Form.Get("userId"))
+		if err != nil {
+			return
+		}
+		user, err := db.GetUserById(id)
+		if err != nil {
+			http.NotFound(w, r)
+			return
+		}
+		db.DeleteUser(user.Username)
+	}
+	http.Redirect(w, r, "/users/", http.StatusFound)
+}
+
+func resetUserPassword(w http.ResponseWriter, r *http.Request) {
+	err := r.ParseForm()
+	if err == nil {
+		id, err := strconv.Atoi(r.Form.Get("userId"))
+		if err != nil {
+			return
+		}
+		user, err := db.GetUserById(id)
+		if err != nil {
+			http.NotFound(w, r)
+			return
+		}
+		newPassword := r.Form.Get("newPassword")
+		hashed, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
+		if err != nil {
+			return
+		}
+		db.SetUserPassword(user.Username, string(hashed))
+	}
+	http.Redirect(w, r, "/users/", http.StatusFound)
+}
+
 type ChangePasswordPageData struct {
 	Message  string
 	ShowForm bool
@@ -312,6 +430,23 @@ func changePasswordPage(w http.ResponseWriter, r *http.Request, user User) {
 	renderFooter(w)
 }
 
+type UsersPageData struct {
+	Users []User
+}
+
+func usersPage(w http.ResponseWriter, _ *http.Request) {
+	renderHeader(w, "users")
+	data := UsersPageData{
+		Users: db.GetUsers(),
+	}
+	tmpl := template.Must(template.ParseFS(content, "templates/users.html"))
+	err := tmpl.Execute(w, data)
+	if err != nil {
+		log.Fatal(err)
+	}
+	renderFooter(w)
+}
+
 type PlaylistsPageData struct {
 	Playlists []Playlist
 }
diff --git a/server/templates/user.html b/server/templates/user.html
new file mode 100644
index 0000000..1118bb6
--- /dev/null
+++ b/server/templates/user.html
@@ -0,0 +1,48 @@
+
+      <h2>
+      {{if .User.Id}}
+      Edit User
+      {{else}}
+      Add New User
+      {{end}}
+      </h2>
+      <form action="/users/submit" method="POST">
+        <input type="hidden" name="userId" value="{{.User.Id}}">
+        <p>
+        <label for="username">Username:</label>
+        <input type="text" id="username" name="username" value="{{.User.Username}}" {{if .User.Id}} disabled {{end}}>
+        </p>
+        <p>
+        <input type="checkbox" id="isAdmin" name="isAdmin" value="1" {{if .User.IsAdmin}} checked {{end}}>
+        <label for="isAdmin">Is an administrator - can manage system users</label><br>
+        </p>
+        {{if not .User.Id}}
+        <p>
+        <label for="password">Password:</label>
+        <input type="password" id="password" name="password">
+        </p>
+        {{end}}
+        <p>
+        <input type="submit" value="Save User">
+        </p>
+      </form>
+      {{if .User.Id}}
+      <h3>Reset Password</h3>
+      <form action="/users/reset-password" method="POST">
+        <input type="hidden" name="userId" value="{{.User.Id}}">
+        <p>
+        <label for="newPassword">New Password:</label>
+        <input type="password" id="newPassword" name="newPassword">
+        </p>
+        <p>
+        <input type="submit" value="Reset Password">
+        </p>
+      </form>
+      <h3>Delete</h3>
+      <form action="/users/delete" method="POST">
+        <input type="hidden" name="userId" value="{{.User.Id}}">
+        <p>
+        <input type="submit" value="Delete User">
+        </p>
+      </form>
+      {{end}}
diff --git a/server/templates/users.html b/server/templates/users.html
new file mode 100644
index 0000000..b95b354
--- /dev/null
+++ b/server/templates/users.html
@@ -0,0 +1,8 @@
+
+      <h1>User Management</h1>
+      <ul>
+      {{range .Users}}
+        <li><b>{{.Username}}</b> <a href="/users/{{.Id}}">(Edit)</a></li>
+      {{end}}
+      </ul>
+      <p><a href="/users/new">Add New User</a></p>