From a415178d0521f7135be8bf7f0082b67be8110a0b Mon Sep 17 00:00:00 2001
From: Kevin Lyda <kevin@lyda.ie>
Date: Sat, 17 May 2025 13:33:37 +0100
Subject: [PATCH] Implement the SSH and USER commands

---
 NOTES.md                                 |   4 +-
 ask/ask.go                               |  23 ++
 key/key.go                               | 155 +++++++++++++-
 repl/accounts.go                         | 257 ++++++++++++++++++++---
 repl/command.go                          |  35 ++-
 storage/display.go                       |   2 +-
 storage/migrations/1_create_table.up.sql |   4 +-
 storage/queries/seed.sql                 |   4 +-
 storage/queries/standard.sql             |   2 +-
 storage/queries/users.sql                |  18 ++
 storage/seed.sql.go                      |   4 +-
 storage/standard.sql.go                  |   2 +-
 storage/users.sql.go                     |  79 +++++++
 users/users.go                           |  17 +-
 14 files changed, 550 insertions(+), 56 deletions(-)

diff --git a/NOTES.md b/NOTES.md
index 7651e96..4d60cce 100644
--- a/NOTES.md
+++ b/NOTES.md
@@ -35,11 +35,11 @@ Switch between MAIL and BULLETIN modes? MAIL commands are documented
   * Database
     * trigger to limit values for 'visibility'?
   * Add commands:
-    * A way to add / delete ssh keys.
+    * ~~A way to add / delete ssh keys.~~
     * A way to manage files?
     * Commands for a local mail system?
     * Commands to connect to Mattermost or mastodon?
-    * Commands to manage users.
+    * ~~Commands to manage users.~~
   * Handle MARK for SELECT and DIRECTORY.
 
 Done:
diff --git a/ask/ask.go b/ask/ask.go
index e8d6a9c..bdde8a3 100644
--- a/ask/ask.go
+++ b/ask/ask.go
@@ -5,8 +5,10 @@ getting a line of text, getting a choice from a liat and other things.
 package ask
 
 import (
+	"errors"
 	"fmt"
 	"os"
+	"strconv"
 
 	"github.com/chzyer/readline"
 )
@@ -29,3 +31,24 @@ func GetLine(prompt string) (string, error) {
 	line, err := rl.Readline()
 	return line, err
 }
+
+// Choose presents a list and asks a user to choose one.
+func Choose(prompt string, choices []string) (int, error) {
+	fmt.Println(prompt)
+	for i := range choices {
+		fmt.Printf("  %d. %s\n", i+1, choices[i])
+	}
+	response, err := GetLine("Choose a number or enter q to abort: ")
+	if err != nil {
+		return -1, err
+	}
+	choice, err := strconv.Atoi(response)
+	if err != nil {
+		return -1, nil
+	}
+	choice--
+	if choice < 0 || choice >= len(choices) {
+		return -1, errors.New("Choice out of bounds")
+	}
+	return choice, nil
+}
diff --git a/key/key.go b/key/key.go
index 2e716d9..634b254 100644
--- a/key/key.go
+++ b/key/key.go
@@ -2,24 +2,38 @@
 package key
 
 import (
+	"bufio"
+	"bytes"
+	"errors"
 	"fmt"
+	"io"
 	"os"
 	"path"
+	"strings"
 
 	"github.com/adrg/xdg"
+	"golang.org/x/crypto/ssh"
+	"golang.org/x/sys/unix"
 )
 
-var keytemplate = `command="%s -u %s",no-port-forwarding,no-X11-forwarding,no-agent-forwarding %s\n`
+var keytemplate = `command="%s -u %s",no-port-forwarding,no-X11-forwarding,no-agent-forwarding %s`
 
 // Add adds an ssh key to the `authorized_keys` file.
 func Add(login, public string) error {
-	bulletin, err := os.Executable()
+	// Parse and verify the key.
+	public = strings.TrimSpace(public)
+	theKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(public))
 	if err != nil {
 		return err
 	}
 
-	keyline := fmt.Sprintf(keytemplate, bulletin, login, public)
+	// Find the bulletin binary.
+	bulletin, err := os.Executable()
+	if err != nil {
+		return err
+	}
 
+	// File system management.
 	sshdir := path.Join(xdg.Home, ".ssh")
 	err = os.MkdirAll(sshdir, 0700)
 	if err != nil {
@@ -27,11 +41,28 @@ func Add(login, public string) error {
 	}
 	keyfile := path.Join(sshdir, "authorized_keys")
 
-	f, err := os.OpenFile(keyfile, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600)
+	// Open and lock the authorized_keys file.
+	f, err := os.OpenFile(keyfile, os.O_RDWR|os.O_CREATE, 0600)
 	if err != nil {
 		return err
 	}
 	defer f.Close()
+	if err := unix.Flock(int(f.Fd()), unix.LOCK_EX); err != nil {
+		return err
+	}
+	defer unix.Flock(int(f.Fd()), unix.LOCK_UN) // unlock after we're done
+
+	// Check for duplicates.
+	keycontent, err := io.ReadAll(f)
+	if err != nil {
+		return err
+	}
+	if bytes.Contains(keycontent, ssh.MarshalAuthorizedKey(theKey)) {
+		return errors.New("key already exists")
+	}
+
+	// Generate and write the key.
+	keyline := fmt.Sprintf(keytemplate, bulletin, login, string(ssh.MarshalAuthorizedKey(theKey)))
 	n, err := f.WriteString(keyline)
 	if err != nil {
 		return err
@@ -43,3 +74,119 @@ func Add(login, public string) error {
 
 	return nil
 }
+
+// List returns a list of ssh keys for this user.
+func List(login string) ([]string, error) {
+	keys := []string{}
+
+	// Find the bulletin binary.
+	bulletin, err := os.Executable()
+	if err != nil {
+		return keys, err
+	}
+
+	// File system management.
+	sshdir := path.Join(xdg.Home, ".ssh")
+	err = os.MkdirAll(sshdir, 0700)
+	if err != nil {
+		return keys, err
+	}
+	keyfile := path.Join(sshdir, "authorized_keys")
+
+	// Open the authorized_keys file.
+	f, err := os.OpenFile(keyfile, os.O_RDWR|os.O_CREATE, 0600)
+	if err != nil {
+		return keys, err
+	}
+	defer f.Close()
+
+	// look for lines.
+	scanner := bufio.NewScanner(f)
+	for scanner.Scan() {
+		keyline := bytes.TrimSpace(scanner.Bytes())
+		if len(keyline) == 0 {
+			continue
+		}
+		public, _, options, _, err := ssh.ParseAuthorizedKey([]byte(keyline))
+		if err != nil {
+			return keys, err
+		}
+		for i := range options {
+			opts := strings.SplitN(options[i], "=", 2)
+			if len(opts) != 2 || opts[0] != "command" {
+				continue
+			}
+			cmd := strings.Split(strings.Trim(opts[1], "\" "), " ")
+			if len(cmd) != 3 {
+				return keys, fmt.Errorf("Unexpected command in authorized keys file (%s)", opts[1])
+			}
+			if cmd[0] != bulletin {
+				return keys, fmt.Errorf("Unexpected bulletin in authorized keys file (%s)", opts[1])
+			}
+			if cmd[1] != "-u" {
+				return keys, fmt.Errorf("Unexpected flag in authorized keys file (%s)", opts[1])
+			}
+			if cmd[2] == login {
+				keys = append(keys, strings.Trim(string(ssh.MarshalAuthorizedKey(public)), "\n"))
+			}
+			break
+		}
+	}
+
+	return keys, nil
+}
+
+// Delete removes the key.
+func Delete(public string) error {
+	keys := []string{}
+
+	// Parse and verify the key.
+	public = strings.TrimSpace(public)
+	doomedRaw, _, _, _, err := ssh.ParseAuthorizedKey([]byte(public))
+	if err != nil {
+		return err
+	}
+
+	// File system management.
+	sshdir := path.Join(xdg.Home, ".ssh")
+	err = os.MkdirAll(sshdir, 0700)
+	if err != nil {
+		return err
+	}
+	keyfile := path.Join(sshdir, "authorized_keys")
+
+	// Open the authorized_keys file.
+	f, err := os.OpenFile(keyfile, os.O_RDWR|os.O_CREATE, 0600)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+	if err := unix.Flock(int(f.Fd()), unix.LOCK_EX); err != nil {
+		return err
+	}
+	defer unix.Flock(int(f.Fd()), unix.LOCK_UN) // unlock after we're done
+
+	// look for lines.
+	doomed := doomedRaw.Marshal()
+	scanner := bufio.NewScanner(f)
+	for scanner.Scan() {
+		keyline := bytes.TrimSpace(scanner.Bytes())
+		if len(keyline) == 0 {
+			continue
+		}
+		potential, _, _, _, err := ssh.ParseAuthorizedKey(keyline)
+		if err != nil {
+			return err
+		}
+		if bytes.Compare(potential.Marshal(), doomed) != 0 {
+			keys = append(keys, string(keyline))
+		}
+	}
+	if _, err := f.Seek(0, os.SEEK_SET); err != nil {
+		return fmt.Errorf("seek: %w", err)
+	}
+	f.Truncate(0)
+	f.WriteString(strings.Join(keys, "\n") + "\n")
+
+	return nil
+}
diff --git a/repl/accounts.go b/repl/accounts.go
index 0047b87..ad85656 100644
--- a/repl/accounts.go
+++ b/repl/accounts.go
@@ -2,8 +2,14 @@ package repl
 
 import (
 	"fmt"
+	"strings"
 
+	"git.lyda.ie/kevin/bulletin/ask"
 	"git.lyda.ie/kevin/bulletin/dclish"
+	"git.lyda.ie/kevin/bulletin/key"
+	"git.lyda.ie/kevin/bulletin/storage"
+	"git.lyda.ie/kevin/bulletin/this"
+	"git.lyda.ie/kevin/bulletin/users"
 )
 
 // ActionUser handles the `USER` command.
@@ -12,83 +18,272 @@ func ActionUser(cmd *dclish.Command) error {
 	fmt.Println(`
 The following commands are available:
 
-  ADD        ADMIN      DELETE     DISABLE    ENABLE     MOD
-  NOADMIN    NOMOD`)
+  ADD        ADMIN      DELETE     DISABLE    ENABLE     LIST
+  MOD        NOADMIN    NOMOD`)
 	fmt.Println()
 	return nil
 }
 
 // ActionUserAdd handles the `USER ADD` command.
-func ActionUserAdd(_ *dclish.Command) error {
-	// TODO: implement
+func ActionUserAdd(cmd *dclish.Command) error {
+	login := strings.ToUpper(cmd.Args[0])
+	err := users.ValidLogin(login)
+	if err != nil {
+		fmt.Printf("ERROR: %s.\n", err)
+		return nil
+	}
+	return nil
+}
+
+// ActionUserList handles the `USER LIST` command.
+func ActionUserList(_ *dclish.Command) error {
+	if this.User.Admin == 0 {
+		fmt.Println("ERROR: You are not an admin.")
+		return nil
+	}
+	ctx := storage.Context()
+	userlist, err := this.Q.ListUsers(ctx)
+	if err != nil {
+		fmt.Printf("ERROR: Failed to list users (%s).\n", err)
+		return nil
+	}
+	// TODO: nicer output for user.
+	for _, u := range userlist {
+		fmt.Printf("%s\n", u)
+	}
 	return nil
 }
 
 // ActionUserDelete handles the `USER DELETE` command.
-func ActionUserDelete(_ *dclish.Command) error {
-	// TODO: implement
+func ActionUserDelete(cmd *dclish.Command) error {
+	if this.User.Admin == 0 {
+		fmt.Println("ERROR: You are not an admin.")
+		return nil
+	}
+	u, err := users.ValidExistingLogin(this.Q, cmd.Args[0])
+	if err != nil || u.Login == "" {
+		fmt.Println("ERROR: User not found.")
+		return nil
+	}
+	ctx := storage.Context()
+	err = this.Q.DeleteUser(ctx, u.Login)
+	if err != nil {
+		fmt.Printf("ERROR: Failed to delete user (%s).\n", err)
+		return nil
+	}
+	fmt.Println("User deleted.")
 	return nil
 }
 
-// ActionUserEnable handles the `USER ENABLE` command.
-func ActionUserEnable(_ *dclish.Command) error {
-	// TODO: implement
+func actionUserEnable(cmd *dclish.Command, disabled int64, doing string) error {
+	if this.User.Admin == 0 {
+		fmt.Println("ERROR: You are not an admin.")
+		return nil
+	}
+	u, err := users.ValidExistingLogin(this.Q, cmd.Args[0])
+	if err != nil || u.Login == "" {
+		fmt.Println("ERROR: User not found.")
+		return nil
+	}
+	if u.Disabled == disabled {
+		fmt.Printf("User already %sd.\n", doing)
+		return nil
+	}
+	ctx := storage.Context()
+	err = this.Q.UpdateUserDisabled(ctx, storage.UpdateUserDisabledParams{
+		Login:    u.Login,
+		Disabled: disabled,
+	})
+	if err != nil {
+		fmt.Printf("ERROR: Failed to %s user (%s).\n", doing, err)
+		return nil
+	}
+	fmt.Printf("User %sd.\n", doing)
 	return nil
 }
 
+// ActionUserEnable handles the `USER ENABLE` command.
+func ActionUserEnable(cmd *dclish.Command) error {
+	return actionUserEnable(cmd, 0, "enable")
+}
+
 // ActionUserDisable handles the `USER DISABLE` command.
-func ActionUserDisable(_ *dclish.Command) error {
-	// TODO: implement
+func ActionUserDisable(cmd *dclish.Command) error {
+	return actionUserEnable(cmd, 1, "disable")
+}
+
+func actionUserAdmin(cmd *dclish.Command, admin int64, doing string) error {
+	if this.User.Admin == 0 {
+		fmt.Println("ERROR: You are not an admin.")
+		return nil
+	}
+	u, err := users.ValidExistingLogin(this.Q, cmd.Args[0])
+	if err != nil || u.Login == "" {
+		fmt.Println("ERROR: User not found.")
+		return nil
+	}
+	if u.Admin == admin {
+		fmt.Printf("User is already %s.\n", doing)
+		return nil
+	}
+	ctx := storage.Context()
+	err = this.Q.UpdateUserAdmin(ctx, storage.UpdateUserAdminParams{
+		Login: u.Login,
+		Admin: admin,
+	})
+	if err != nil {
+		fmt.Printf("ERROR: Failed to make user %s (%s).\n", doing, err)
+		return nil
+	}
+	fmt.Printf("User is now %s.\n", doing)
 	return nil
 }
 
 // ActionUserAdmin handles the `USER ADMIN` command.
-func ActionUserAdmin(_ *dclish.Command) error {
-	// TODO: implement
-	return nil
+func ActionUserAdmin(cmd *dclish.Command) error {
+	return actionUserAdmin(cmd, 1, "an admin")
 }
 
 // ActionUserNoadmin handles the `USER NOADMIN` command.
-func ActionUserNoadmin(_ *dclish.Command) error {
-	// TODO: implement
+func ActionUserNoadmin(cmd *dclish.Command) error {
+	return actionUserAdmin(cmd, 0, "not an admin")
+}
+
+func actionUserMod(cmd *dclish.Command, mod int64, doing string) error {
+	if this.User.Admin == 0 {
+		fmt.Println("ERROR: You are not an admin.")
+		return nil
+	}
+	u, err := users.ValidExistingLogin(this.Q, cmd.Args[0])
+	if err != nil || u.Login == "" {
+		fmt.Println("ERROR: User not found.")
+		return nil
+	}
+	if u.Moderator == mod {
+		fmt.Printf("User is already %s.\n", doing)
+		return nil
+	}
+	ctx := storage.Context()
+	err = this.Q.UpdateUserMod(ctx, storage.UpdateUserModParams{
+		Login:     u.Login,
+		Moderator: mod,
+	})
+	if err != nil {
+		fmt.Printf("ERROR: Failed to make user %s (%s).\n", doing, err)
+		return nil
+	}
+	fmt.Printf("User is now %s.\n", doing)
 	return nil
 }
 
 // ActionUserMod handles the `USER MOD` command.
-func ActionUserMod(_ *dclish.Command) error {
-	// TODO: implement
-	return nil
+func ActionUserMod(cmd *dclish.Command) error {
+	return actionUserMod(cmd, 1, "a moderator")
 }
 
 // ActionUserNomod handles the `USER NOMOD` command.
-func ActionUserNomod(_ *dclish.Command) error {
-	// TODO: implement
-	return nil
+func ActionUserNomod(cmd *dclish.Command) error {
+	return actionUserMod(cmd, 0, "not a moderator")
 }
 
 // ActionSSH handles the `SSH` command.
 func ActionSSH(cmd *dclish.Command) error {
 	fmt.Println(cmd.Description)
-	fmt.Println(`\nThe following commands are available:
+	fmt.Println(`
+The following commands are available:
 
   ADD        DELETE     LIST`)
+	fmt.Println()
 	return nil
 }
 
 // ActionSSHAdd handles the `SSH ADD` command.
-func ActionSSHAdd(_ *dclish.Command) error {
-	// TODO: implement
+func ActionSSHAdd(cmd *dclish.Command) error {
+	if this.User.Admin == 0 && len(cmd.Args) == 1 {
+		fmt.Println("ERROR: You are not an admin.")
+		return nil
+	}
+	login := this.User.Login
+	if len(cmd.Args) == 1 {
+		login = cmd.Args[0]
+	}
+	u, err := users.ValidExistingLogin(this.Q, login)
+	if err != nil || u.Login == "" {
+		fmt.Println("ERROR: User not found.")
+		return nil
+	}
+	sshkey, err := ask.GetLine("Enter ssh public key: ")
+	if err != nil {
+		fmt.Printf("ERROR: Failed to read ssh key (%s).\n", err)
+		return nil
+	}
+	key.Add(login, sshkey)
+	fmt.Println("Key is added.")
 	return nil
 }
 
-// ActionSSHDelete handles the `SSH DELETE` command.
-func ActionSSHDelete(_ *dclish.Command) error {
-	// TODO: implement
+// ActionSSHList handles the `SSH LIST` command.
+func ActionSSHList(cmd *dclish.Command) error {
+	if this.User.Admin == 0 && len(cmd.Args) == 1 {
+		fmt.Println("ERROR: You are not an admin.")
+		return nil
+	}
+	login := this.User.Login
+	if len(cmd.Args) == 1 {
+		login = cmd.Args[0]
+	}
+	u, err := users.ValidExistingLogin(this.Q, login)
+	if err != nil || u.Login == "" {
+		fmt.Println("ERROR: User not found.")
+		return nil
+	}
+	keys, err := key.List(login)
+	if err != nil {
+		fmt.Printf("ERROR: Problem listing keys (%s).\n", err)
+		return nil
+	}
+	fmt.Printf("The %d keys:\n  %s\n", len(keys), strings.Join(keys, "\n  "))
 	return nil
 }
 
-// ActionSSHList handles the `SSH LIST` command.
-func ActionSSHList(_ *dclish.Command) error {
-	// TODO: implement
+// ActionSSHDelete handles the `SSH DELETE` command.
+func ActionSSHDelete(cmd *dclish.Command) error {
+	if this.User.Admin == 0 && len(cmd.Args) == 1 {
+		fmt.Println("ERROR: You are not an admin.")
+		return nil
+	}
+	login := this.User.Login
+	if len(cmd.Args) == 1 {
+		login = cmd.Args[0]
+	}
+	u, err := users.ValidExistingLogin(this.Q, login)
+	if err != nil || u.Login == "" {
+		fmt.Println("ERROR: User not found.")
+		return nil
+	}
+	keys, err := key.List(login)
+	if err != nil {
+		fmt.Printf("ERROR: Problem listing keys (%s).\n", err)
+		return nil
+	}
+	if len(keys) == 0 {
+		fmt.Println("No keys to delete.")
+		return nil
+	}
+	choice, err := ask.Choose("Choose a key to delete:", keys)
+	if err != nil {
+		fmt.Printf("ERROR: Problem choosing key (%s).\n", err)
+		return nil
+	}
+	if choice < 0 {
+		fmt.Println("Aborted.")
+		return nil
+	}
+	err = key.Delete(keys[choice])
+	if err != nil {
+		fmt.Printf("ERROR: Problem deleting key (%s).\n", err)
+		return nil
+	}
+	fmt.Println("Key deleted.")
 	return nil
 }
diff --git a/repl/command.go b/repl/command.go
index 6b87031..493a12b 100644
--- a/repl/command.go
+++ b/repl/command.go
@@ -1141,6 +1141,10 @@ message.`,
 				MaxArgs:     1,
 				Action:      ActionUserNoadmin,
 			},
+			"LIST": {
+				Description: `  Lists all the users.  Must be an admin.`,
+				Action:      ActionUserList,
+			},
 			"MOD": {
 				Description: `  Makes a user an mod.`,
 				MinArgs:     1,
@@ -1160,21 +1164,36 @@ message.`,
 		Action:      ActionSSH,
 		Commands: dclish.Commands{
 			"ADD": {
-				Description: `  Adds an ssh key for a user.
+				Description: `  Adds an  ssh key  for a user.  Only an  admin can add  an ssh  key for
+  another user.
 
-  Prompts the user for an ssh key and then adds it.`,
-				Action: ActionSSHAdd,
+  Prompts the user for an ssh key and then adds it.
+
+    Format:
+      SSH ADD [login]`,
+				Action:  ActionSSHAdd,
+				MaxArgs: 1,
 			},
 			"DELETE": {
-				Description: `  Removes an ssh key for a user.
+				Description: `  Removes an ssh key for a user. Only an admin can remove an ssh key for
+  another user.
 
   The user  is given a list  of current ssh  keys and is asked  which to
-  remove.`,
-				Action: ActionSSHDelete,
+  remove.
+
+    Format:
+      SSH DELETE [login]`,
+				Action:  ActionSSHDelete,
+				MaxArgs: 1,
 			},
 			"LIST": {
-				Description: `  Prints a list of ssh keys for the user.`,
-				Action:      ActionSSHList,
+				Description: `  Prints a list of ssh keys for a  user. Only an admin can list ssh keys
+  for another user.
+
+    Format:
+      SSH LIST [login]`,
+				Action:  ActionSSHList,
+				MaxArgs: 1,
 			},
 		},
 	},
diff --git a/storage/display.go b/storage/display.go
index cf23041..1ac380f 100644
--- a/storage/display.go
+++ b/storage/display.go
@@ -36,7 +36,7 @@ func (m *Message) OneLine(expire bool) string {
 
 // String displays a user (mainly used for debugging).
 func (u User) String() string {
-	return fmt.Sprintf("User %s (%s) [a%d, m%d, !%d, d%d] [%s]",
+	return fmt.Sprintf("%-12s %-25.25s [a%d, m%d, !%d, d%d] [%s]",
 		u.Login,
 		u.Name,
 		u.Admin,
diff --git a/storage/migrations/1_create_table.up.sql b/storage/migrations/1_create_table.up.sql
index b2de88b..46dfee3 100644
--- a/storage/migrations/1_create_table.up.sql
+++ b/storage/migrations/1_create_table.up.sql
@@ -106,9 +106,7 @@ CREATE TABLE messages (
   folder      VARCHAR(25)  REFERENCES folders(name)
                            ON DELETE CASCADE ON UPDATE CASCADE
                            NOT NULL,
-  author      VARCHAR(25)  REFERENCES users(login)
-                           ON UPDATE CASCADE
-                           NOT NULL,
+  author      VARCHAR(25)  NOT NULL,
   subject     VARCHAR(53)  NOT NULL,
   message     TEXT         NOT NULL,
   permanent   INT          DEFAULT 0 NOT NULL,
diff --git a/storage/queries/seed.sql b/storage/queries/seed.sql
index 27e3f00..5772eb0 100644
--- a/storage/queries/seed.sql
+++ b/storage/queries/seed.sql
@@ -1,6 +1,6 @@
 -- name: SeedUserSystem :exec
-  INSERT INTO users (login, name, admin)
-         VALUES ('SYSTEM', 'System User', 1);
+  INSERT INTO users (login, name, admin, moderator)
+         VALUES ('SYSTEM', 'System User', 1, 1);
 
 -- name: SeedFolderGeneral :exec
   INSERT INTO folders (name, description, system, shownew)
diff --git a/storage/queries/standard.sql b/storage/queries/standard.sql
index 3af1772..ca717ed 100644
--- a/storage/queries/standard.sql
+++ b/storage/queries/standard.sql
@@ -2,7 +2,7 @@
 SELECT * FROM users;
 
 -- name: DeleteUser :exec
-DELETE FROM users WHERE login = ?;
+DELETE FROM users WHERE login = ? AND login != 'SYSTEM';
 
 -- name: AddFolder :exec
 INSERT INTO folders (name) VALUES (?);
diff --git a/storage/queries/users.sql b/storage/queries/users.sql
index 1ee85d5..880ddcb 100644
--- a/storage/queries/users.sql
+++ b/storage/queries/users.sql
@@ -7,3 +7,21 @@ RETURNING *;
 
 -- name: IsUserAdmin :one
 SELECT admin FROM users WHERE login = ?;
+
+-- name: UpdateUserDisabled :exec
+UPDATE users SET disabled = ? WHERE login = ? AND login != 'SYSTEM';
+
+-- name: UpdateUserAdmin :exec
+UPDATE users SET admin = ? WHERE login = ? AND login != 'SYSTEM';
+
+-- name: UpdateUserAlert :exec
+UPDATE users SET alert = ? WHERE login = ? AND login != 'SYSTEM';
+
+-- name: UpdateUserName :exec
+UPDATE users SET name = ? WHERE login = ? AND login != 'SYSTEM';
+
+-- name: UpdateUserMod :exec
+UPDATE users SET moderator = ? WHERE login = ? AND login != 'SYSTEM';
+
+-- name: UpdateUserLastLogin :exec
+UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE login = ? AND login != 'SYSTEM';
diff --git a/storage/seed.sql.go b/storage/seed.sql.go
index c8fcbfd..0f21d68 100644
--- a/storage/seed.sql.go
+++ b/storage/seed.sql.go
@@ -60,8 +60,8 @@ func (q *Queries) SeedGeneralOwner(ctx context.Context) error {
 }
 
 const seedUserSystem = `-- name: SeedUserSystem :exec
-  INSERT INTO users (login, name, admin)
-         VALUES ('SYSTEM', 'System User', 1)
+  INSERT INTO users (login, name, admin, moderator)
+         VALUES ('SYSTEM', 'System User', 1, 1)
 `
 
 func (q *Queries) SeedUserSystem(ctx context.Context) error {
diff --git a/storage/standard.sql.go b/storage/standard.sql.go
index 8ddd2c8..edf7270 100644
--- a/storage/standard.sql.go
+++ b/storage/standard.sql.go
@@ -209,7 +209,7 @@ func (q *Queries) DeleteSeen(ctx context.Context, arg DeleteSeenParams) error {
 }
 
 const deleteUser = `-- name: DeleteUser :exec
-DELETE FROM users WHERE login = ?
+DELETE FROM users WHERE login = ? AND login != 'SYSTEM'
 `
 
 func (q *Queries) DeleteUser(ctx context.Context, login string) error {
diff --git a/storage/users.sql.go b/storage/users.sql.go
index ae1cad7..79a138a 100644
--- a/storage/users.sql.go
+++ b/storage/users.sql.go
@@ -68,3 +68,82 @@ func (q *Queries) IsUserAdmin(ctx context.Context, login string) (int64, error)
 	err := row.Scan(&admin)
 	return admin, err
 }
+
+const updateUserAdmin = `-- name: UpdateUserAdmin :exec
+UPDATE users SET admin = ? WHERE login = ? AND login != 'SYSTEM'
+`
+
+type UpdateUserAdminParams struct {
+	Admin int64
+	Login string
+}
+
+func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams) error {
+	_, err := q.db.ExecContext(ctx, updateUserAdmin, arg.Admin, arg.Login)
+	return err
+}
+
+const updateUserAlert = `-- name: UpdateUserAlert :exec
+UPDATE users SET alert = ? WHERE login = ? AND login != 'SYSTEM'
+`
+
+type UpdateUserAlertParams struct {
+	Alert int64
+	Login string
+}
+
+func (q *Queries) UpdateUserAlert(ctx context.Context, arg UpdateUserAlertParams) error {
+	_, err := q.db.ExecContext(ctx, updateUserAlert, arg.Alert, arg.Login)
+	return err
+}
+
+const updateUserDisabled = `-- name: UpdateUserDisabled :exec
+UPDATE users SET disabled = ? WHERE login = ? AND login != 'SYSTEM'
+`
+
+type UpdateUserDisabledParams struct {
+	Disabled int64
+	Login    string
+}
+
+func (q *Queries) UpdateUserDisabled(ctx context.Context, arg UpdateUserDisabledParams) error {
+	_, err := q.db.ExecContext(ctx, updateUserDisabled, arg.Disabled, arg.Login)
+	return err
+}
+
+const updateUserLastLogin = `-- name: UpdateUserLastLogin :exec
+UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE login = ? AND login != 'SYSTEM'
+`
+
+func (q *Queries) UpdateUserLastLogin(ctx context.Context, login string) error {
+	_, err := q.db.ExecContext(ctx, updateUserLastLogin, login)
+	return err
+}
+
+const updateUserMod = `-- name: UpdateUserMod :exec
+UPDATE users SET moderator = ? WHERE login = ? AND login != 'SYSTEM'
+`
+
+type UpdateUserModParams struct {
+	Moderator int64
+	Login     string
+}
+
+func (q *Queries) UpdateUserMod(ctx context.Context, arg UpdateUserModParams) error {
+	_, err := q.db.ExecContext(ctx, updateUserMod, arg.Moderator, arg.Login)
+	return err
+}
+
+const updateUserName = `-- name: UpdateUserName :exec
+UPDATE users SET name = ? WHERE login = ? AND login != 'SYSTEM'
+`
+
+type UpdateUserNameParams struct {
+	Name  string
+	Login string
+}
+
+func (q *Queries) UpdateUserName(ctx context.Context, arg UpdateUserNameParams) error {
+	_, err := q.db.ExecContext(ctx, updateUserName, arg.Name, arg.Login)
+	return err
+}
diff --git a/users/users.go b/users/users.go
index ecbf8ef..bf3da42 100644
--- a/users/users.go
+++ b/users/users.go
@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"strings"
 
+	"git.lyda.ie/kevin/bulletin/storage"
 	_ "modernc.org/sqlite" // Loads sqlite driver.
 )
 
@@ -14,8 +15,22 @@ func ValidLogin(login string) error {
 	if login == "" {
 		return errors.New("empty account is invalid")
 	}
-	if strings.ContainsAny(login, "./") {
+	if strings.ContainsAny(login, "./%:") {
 		return fmt.Errorf("account name '%s' is invalid", login)
 	}
 	return nil
 }
+
+// ValidExistingLogin makes sure that an account name is a valid name.
+func ValidExistingLogin(q *storage.Queries, login string) (storage.User, error) {
+	u := storage.User{}
+	if login == "" {
+		return u, errors.New("empty account is invalid")
+	}
+	login = strings.ToUpper(login)
+	if strings.ContainsAny(login, "./%:") {
+		return u, fmt.Errorf("account name '%s' is invalid", login)
+	}
+	ctx := storage.Context()
+	return q.GetUser(ctx, login)
+}
-- 
GitLab