From a456d0096abdb93fc8d5784789050c49fa496201 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Tue, 7 Apr 2026 13:37:39 +0200 Subject: [PATCH] feat: add CLI commands for user management Adds subcommands to the binary for admin tasks without needing the web UI: list users, set passwords, promote/demote roles, and lock/unlock accounts. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 15 ++- cmd/favoritter/cli_user.go | 246 +++++++++++++++++++++++++++++++++++++ cmd/favoritter/main.go | 23 ++-- go.mod | 1 + go.sum | 2 + 5 files changed, 275 insertions(+), 12 deletions(-) create mode 100644 cmd/favoritter/cli_user.go diff --git a/CLAUDE.md b/CLAUDE.md index 9519306..494a620 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,13 +14,24 @@ make test # Run tests make container # Build container image ``` +### CLI Admin Commands + +```bash +favoritter user list # List all users +favoritter user set-password # Change a user's password +favoritter user promote # Promote user to admin +favoritter user demote # Remove admin role +favoritter user lock # Disable user account +favoritter user unlock # Enable user account +``` + ## Architecture Single Go binary serving HTML (server-rendered templates + HTMX) and a JSON API. SQLite for storage, filesystem for uploaded images. All templates and static assets are embedded via `go:embed`. ### Directory Layout -- `cmd/favoritter/` — Entry point, wiring, graceful shutdown +- `cmd/favoritter/` — Entry point, wiring, graceful shutdown, CLI admin commands (`cli_*.go`) - `internal/config/` — Environment variable configuration - `internal/database/` — SQLite connection, PRAGMAs, migration runner - `internal/model/` — Domain types (no logic, no DB) @@ -37,7 +48,7 @@ Single Go binary serving HTML (server-rendered templates + HTMX) and a JSON API. ### Key Design Decisions - **Go 1.22+ stdlib router** — no framework, `http.ServeMux` with method routing -- **3 external dependencies** — modernc.org/sqlite (pure Go), golang.org/x/crypto (Argon2id), gorilla/feeds +- **Minimal dependencies** — modernc.org/sqlite (pure Go), golang.org/x/crypto (Argon2id), gorilla/feeds, google/uuid, golang.org/x/term - **`SetMaxOpenConns(1)`** — SQLite works best with a single writer; PRAGMAs are set once on the single connection - **Templates embedded in binary** — `//go:embed` for single-binary deployment; dev mode reads from disk for live reload - **Middleware chain order matters** — Recovery → SecurityHeaders → BasePath → RealIP → Logger → SessionLoader → CSRF → MustResetGuard diff --git a/cmd/favoritter/cli_user.go b/cmd/favoritter/cli_user.go new file mode 100644 index 0000000..ed48237 --- /dev/null +++ b/cmd/favoritter/cli_user.go @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + + "golang.org/x/term" + + "kode.naiv.no/olemd/favoritter/internal/config" + "kode.naiv.no/olemd/favoritter/internal/database" + "kode.naiv.no/olemd/favoritter/internal/store" +) + +// runUserCommand dispatches user management subcommands. +func runUserCommand(args []string) { + if len(args) == 0 { + printUserUsage() + os.Exit(1) + } + + switch args[0] { + case "list": + runUserList() + case "set-password": + if len(args) < 2 { + fmt.Fprintf(os.Stderr, "Bruk: favoritter user set-password \n") + os.Exit(1) + } + runUserSetPassword(args[1]) + case "promote": + if len(args) < 2 { + fmt.Fprintf(os.Stderr, "Bruk: favoritter user promote \n") + os.Exit(1) + } + runUserSetRole(args[1], "admin") + case "demote": + if len(args) < 2 { + fmt.Fprintf(os.Stderr, "Bruk: favoritter user demote \n") + os.Exit(1) + } + runUserSetRole(args[1], "user") + case "lock": + if len(args) < 2 { + fmt.Fprintf(os.Stderr, "Bruk: favoritter user lock \n") + os.Exit(1) + } + runUserSetDisabled(args[1], true) + case "unlock": + if len(args) < 2 { + fmt.Fprintf(os.Stderr, "Bruk: favoritter user unlock \n") + os.Exit(1) + } + runUserSetDisabled(args[1], false) + default: + fmt.Fprintf(os.Stderr, "Ukjent underkommando: %s\n\n", args[0]) + printUserUsage() + os.Exit(1) + } +} + +// printUserUsage prints help text for the user subcommand. +func printUserUsage() { + fmt.Fprintf(os.Stderr, "Bruk: favoritter user \n\n") + fmt.Fprintf(os.Stderr, "Kommandoer:\n") + fmt.Fprintf(os.Stderr, " list Vis alle brukere\n") + fmt.Fprintf(os.Stderr, " set-password Endre passord for en bruker\n") + fmt.Fprintf(os.Stderr, " promote Gjør bruker til administrator\n") + fmt.Fprintf(os.Stderr, " demote Fjern administratorrettigheter\n") + fmt.Fprintf(os.Stderr, " lock Deaktiver brukerkonto\n") + fmt.Fprintf(os.Stderr, " unlock Aktiver brukerkonto\n") +} + +// openDB loads config, opens the database, and runs migrations. +// It exits the process on failure. +func openDB() (*store.UserStore, func()) { + cfg := config.Load() + db, err := database.Open(cfg.DBPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Feil: kunne ikke åpne databasen: %v\n", err) + os.Exit(1) + } + + if err := database.Migrate(db); err != nil { + db.Close() + fmt.Fprintf(os.Stderr, "Feil: migrering feilet: %v\n", err) + os.Exit(1) + } + + // Configure Argon2 parameters from config. + store.Argon2Memory = cfg.Argon2Memory + store.Argon2Time = cfg.Argon2Time + store.Argon2Parallelism = cfg.Argon2Parallelism + + users := store.NewUserStore(db) + return users, func() { db.Close() } +} + +// runUserList prints all users in a table. +func runUserList() { + users, cleanup := openDB() + defer cleanup() + + all, err := users.ListAll() + if err != nil { + fmt.Fprintf(os.Stderr, "Feil: kunne ikke hente brukere: %v\n", err) + os.Exit(1) + } + + if len(all) == 0 { + fmt.Println("Ingen brukere funnet.") + return + } + + w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(w, "ID\tBRUKERNAVN\tVISNINGSNAVN\tROLLE\tSTATUS\tOPPRETTET") + for _, u := range all { + status := "aktiv" + if u.Disabled { + status = "deaktivert" + } + if u.MustResetPassword { + status += ", må bytte passord" + } + fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", + u.ID, + u.Username, + u.DisplayName, + u.Role, + status, + u.CreatedAt.Format("2006-01-02"), + ) + } + w.Flush() +} + +// runUserSetPassword prompts for a new password and updates it. +func runUserSetPassword(username string) { + users, cleanup := openDB() + defer cleanup() + + user, err := users.GetByUsername(username) + if err != nil { + fmt.Fprintf(os.Stderr, "Feil: bruker %q ikke funnet\n", username) + os.Exit(1) + } + + // Read new password from terminal. + fmt.Fprintf(os.Stderr, "Nytt passord for %s: ", username) + pw1, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + if err != nil { + fmt.Fprintf(os.Stderr, "Feil: kunne ikke lese passord: %v\n", err) + os.Exit(1) + } + + fmt.Fprintf(os.Stderr, "Gjenta passord: ") + pw2, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + if err != nil { + fmt.Fprintf(os.Stderr, "Feil: kunne ikke lese passord: %v\n", err) + os.Exit(1) + } + + password := strings.TrimSpace(string(pw1)) + if password != strings.TrimSpace(string(pw2)) { + fmt.Fprintf(os.Stderr, "Feil: passordene stemmer ikke overens\n") + os.Exit(1) + } + + if len(password) < 8 { + fmt.Fprintf(os.Stderr, "Feil: passordet må være minst 8 tegn\n") + os.Exit(1) + } + + if err := users.UpdatePassword(user.ID, password); err != nil { + fmt.Fprintf(os.Stderr, "Feil: kunne ikke oppdatere passord: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Passord oppdatert for %s.\n", username) +} + +// runUserSetRole promotes or demotes a user. +func runUserSetRole(username, role string) { + users, cleanup := openDB() + defer cleanup() + + user, err := users.GetByUsername(username) + if err != nil { + fmt.Fprintf(os.Stderr, "Feil: bruker %q ikke funnet\n", username) + os.Exit(1) + } + + if user.Role == role { + fmt.Fprintf(os.Stderr, "%s har allerede rollen %q\n", username, role) + return + } + + if err := users.SetRole(user.ID, role); err != nil { + fmt.Fprintf(os.Stderr, "Feil: kunne ikke endre rolle: %v\n", err) + os.Exit(1) + } + + switch role { + case "admin": + fmt.Printf("%s er nå administrator.\n", username) + default: + fmt.Printf("%s er ikke lenger administrator.\n", username) + } +} + +// runUserSetDisabled locks or unlocks a user account. +func runUserSetDisabled(username string, disabled bool) { + users, cleanup := openDB() + defer cleanup() + + user, err := users.GetByUsername(username) + if err != nil { + fmt.Fprintf(os.Stderr, "Feil: bruker %q ikke funnet\n", username) + os.Exit(1) + } + + if user.Disabled == disabled { + if disabled { + fmt.Fprintf(os.Stderr, "%s er allerede deaktivert\n", username) + } else { + fmt.Fprintf(os.Stderr, "%s er allerede aktiv\n", username) + } + return + } + + if err := users.SetDisabled(user.ID, disabled); err != nil { + fmt.Fprintf(os.Stderr, "Feil: kunne ikke endre kontostatus: %v\n", err) + os.Exit(1) + } + + if disabled { + fmt.Printf("%s er nå deaktivert.\n", username) + } else { + fmt.Printf("%s er nå aktivert.\n", username) + } +} diff --git a/cmd/favoritter/main.go b/cmd/favoritter/main.go index d194a18..5bb264b 100644 --- a/cmd/favoritter/main.go +++ b/cmd/favoritter/main.go @@ -28,16 +28,19 @@ var ( ) func main() { - // Handle -healthcheck flag for container health checks. - if len(os.Args) > 1 && os.Args[1] == "-healthcheck" { - runHealthCheck() - return - } - - // Handle -version flag. - if len(os.Args) > 1 && os.Args[1] == "-version" { - fmt.Printf("favoritter %s (built %s)\n", version, buildDate) - return + // Handle flags and subcommands before starting the server. + if len(os.Args) > 1 { + switch os.Args[1] { + case "-healthcheck": + runHealthCheck() + return + case "-version": + fmt.Printf("favoritter %s (built %s)\n", version, buildDate) + return + case "user": + runUserCommand(os.Args[2:]) + return + } } cfg := config.Load() diff --git a/go.mod b/go.mod index 336ed5f..1867857 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/feeds v1.2.0 golang.org/x/crypto v0.49.0 + golang.org/x/term v0.41.0 modernc.org/sqlite v1.48.0 ) diff --git a/go.sum b/go.sum index b1e95be..3793a55 100644 --- a/go.sum +++ b/go.sum @@ -29,6 +29,8 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=