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) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-04-07 13:37:39 +02:00
commit a456d0096a
5 changed files with 275 additions and 12 deletions

View file

@ -14,13 +14,24 @@ make test # Run tests
make container # Build container image make container # Build container image
``` ```
### CLI Admin Commands
```bash
favoritter user list # List all users
favoritter user set-password <user> # Change a user's password
favoritter user promote <user> # Promote user to admin
favoritter user demote <user> # Remove admin role
favoritter user lock <user> # Disable user account
favoritter user unlock <user> # Enable user account
```
## Architecture ## 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`. 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 ### 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/config/` — Environment variable configuration
- `internal/database/` — SQLite connection, PRAGMAs, migration runner - `internal/database/` — SQLite connection, PRAGMAs, migration runner
- `internal/model/` — Domain types (no logic, no DB) - `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 ### Key Design Decisions
- **Go 1.22+ stdlib router** — no framework, `http.ServeMux` with method routing - **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 - **`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 - **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 - **Middleware chain order matters** — Recovery → SecurityHeaders → BasePath → RealIP → Logger → SessionLoader → CSRF → MustResetGuard

246
cmd/favoritter/cli_user.go Normal file
View file

@ -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 <brukernavn>\n")
os.Exit(1)
}
runUserSetPassword(args[1])
case "promote":
if len(args) < 2 {
fmt.Fprintf(os.Stderr, "Bruk: favoritter user promote <brukernavn>\n")
os.Exit(1)
}
runUserSetRole(args[1], "admin")
case "demote":
if len(args) < 2 {
fmt.Fprintf(os.Stderr, "Bruk: favoritter user demote <brukernavn>\n")
os.Exit(1)
}
runUserSetRole(args[1], "user")
case "lock":
if len(args) < 2 {
fmt.Fprintf(os.Stderr, "Bruk: favoritter user lock <brukernavn>\n")
os.Exit(1)
}
runUserSetDisabled(args[1], true)
case "unlock":
if len(args) < 2 {
fmt.Fprintf(os.Stderr, "Bruk: favoritter user unlock <brukernavn>\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 <kommando>\n\n")
fmt.Fprintf(os.Stderr, "Kommandoer:\n")
fmt.Fprintf(os.Stderr, " list Vis alle brukere\n")
fmt.Fprintf(os.Stderr, " set-password <bruker> Endre passord for en bruker\n")
fmt.Fprintf(os.Stderr, " promote <bruker> Gjør bruker til administrator\n")
fmt.Fprintf(os.Stderr, " demote <bruker> Fjern administratorrettigheter\n")
fmt.Fprintf(os.Stderr, " lock <bruker> Deaktiver brukerkonto\n")
fmt.Fprintf(os.Stderr, " unlock <bruker> 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)
}
}

View file

@ -28,16 +28,19 @@ var (
) )
func main() { func main() {
// Handle -healthcheck flag for container health checks. // Handle flags and subcommands before starting the server.
if len(os.Args) > 1 && os.Args[1] == "-healthcheck" { if len(os.Args) > 1 {
runHealthCheck() switch os.Args[1] {
return case "-healthcheck":
} runHealthCheck()
return
// Handle -version flag. case "-version":
if len(os.Args) > 1 && os.Args[1] == "-version" { fmt.Printf("favoritter %s (built %s)\n", version, buildDate)
fmt.Printf("favoritter %s (built %s)\n", version, buildDate) return
return case "user":
runUserCommand(os.Args[2:])
return
}
} }
cfg := config.Load() cfg := config.Load()

1
go.mod
View file

@ -6,6 +6,7 @@ require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/feeds v1.2.0 github.com/gorilla/feeds v1.2.0
golang.org/x/crypto v0.49.0 golang.org/x/crypto v0.49.0
golang.org/x/term v0.41.0
modernc.org/sqlite v1.48.0 modernc.org/sqlite v1.48.0
) )

2
go.sum
View file

@ -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.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 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=