- add success notification on successful login - show error notification with detailed message on login failure - normalize API paths to prevent double slashes and trailing slashes - redirect to login page only if not on login request or page
498 lines
13 KiB
Go
498 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"git.warky.dev/wdevs/whatshooked/pkg/config"
|
|
"git.warky.dev/wdevs/whatshooked/pkg/models"
|
|
"git.warky.dev/wdevs/whatshooked/pkg/storage"
|
|
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
|
"github.com/google/uuid"
|
|
"github.com/spf13/cobra"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"golang.org/x/term"
|
|
)
|
|
|
|
var serverConfigPath string
|
|
|
|
// flags for add
|
|
var (
|
|
addUsername string
|
|
addEmail string
|
|
addFullName string
|
|
addPassword string
|
|
addRole string
|
|
)
|
|
|
|
// flags for update
|
|
var (
|
|
updateUsername string
|
|
updateEmail string
|
|
updateFullName string
|
|
updateRole string
|
|
)
|
|
|
|
var usersCmd = &cobra.Command{
|
|
Use: "users",
|
|
Short: "Manage users (direct DB access)",
|
|
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
|
initUserDB()
|
|
},
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
listUsers()
|
|
},
|
|
}
|
|
|
|
var usersListCmd = &cobra.Command{
|
|
Use: "list",
|
|
Short: "List all users",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
listUsers()
|
|
},
|
|
}
|
|
|
|
var usersAddCmd = &cobra.Command{
|
|
Use: "add",
|
|
Short: "Add a new user",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
addUser()
|
|
},
|
|
}
|
|
|
|
var usersSetPasswordCmd = &cobra.Command{
|
|
Use: "set-password <username> [password]",
|
|
Short: "Change a user's password",
|
|
Args: cobra.RangeArgs(1, 2),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
if len(args) == 2 {
|
|
setPasswordDirect(args[0], args[1])
|
|
} else {
|
|
setPassword(args[0])
|
|
}
|
|
},
|
|
}
|
|
|
|
var usersDisableCmd = &cobra.Command{
|
|
Use: "disable <username>",
|
|
Short: "Disable a user account",
|
|
Args: cobra.ExactArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
setUserActive(args[0], false)
|
|
},
|
|
}
|
|
|
|
var usersEnableCmd = &cobra.Command{
|
|
Use: "enable <username>",
|
|
Short: "Enable a user account",
|
|
Args: cobra.ExactArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
setUserActive(args[0], true)
|
|
},
|
|
}
|
|
|
|
var usersUpdateCmd = &cobra.Command{
|
|
Use: "update <username>",
|
|
Short: "Update a user's details",
|
|
Args: cobra.ExactArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
updateUser(args[0])
|
|
},
|
|
}
|
|
|
|
var usersDeleteCmd = &cobra.Command{
|
|
Use: "delete <username>",
|
|
Short: "Delete a user",
|
|
Args: cobra.ExactArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
deleteUser(args[0])
|
|
},
|
|
}
|
|
|
|
var usersRemoveCmd = &cobra.Command{
|
|
Use: "remove <username>",
|
|
Short: "Remove a user (alias for delete)",
|
|
Args: cobra.ExactArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
deleteUser(args[0])
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
usersCmd.PersistentFlags().StringVar(&serverConfigPath, "server-config", "", "server config file (default: config.json, ../config.json, or ~/.whatshooked/config.json)")
|
|
|
|
usersAddCmd.Flags().StringVarP(&addUsername, "username", "u", "", "Username")
|
|
usersAddCmd.Flags().StringVarP(&addEmail, "email", "e", "", "Email address")
|
|
usersAddCmd.Flags().StringVarP(&addFullName, "full-name", "n", "", "Full name")
|
|
usersAddCmd.Flags().StringVarP(&addPassword, "password", "p", "", "Password")
|
|
usersAddCmd.Flags().StringVarP(&addRole, "role", "r", "", "Role (admin/user)")
|
|
|
|
usersUpdateCmd.Flags().StringVarP(&updateUsername, "username", "u", "", "New username")
|
|
usersUpdateCmd.Flags().StringVarP(&updateEmail, "email", "e", "", "New email address")
|
|
usersUpdateCmd.Flags().StringVarP(&updateFullName, "full-name", "n", "", "New full name")
|
|
usersUpdateCmd.Flags().StringVarP(&updateRole, "role", "r", "", "New role (admin/user)")
|
|
|
|
usersCmd.AddCommand(usersListCmd)
|
|
usersCmd.AddCommand(usersAddCmd)
|
|
usersCmd.AddCommand(usersUpdateCmd)
|
|
usersCmd.AddCommand(usersSetPasswordCmd)
|
|
usersCmd.AddCommand(usersDisableCmd)
|
|
usersCmd.AddCommand(usersEnableCmd)
|
|
usersCmd.AddCommand(usersDeleteCmd)
|
|
usersCmd.AddCommand(usersRemoveCmd)
|
|
}
|
|
|
|
func resolveServerConfigPath() string {
|
|
if serverConfigPath != "" {
|
|
return serverConfigPath
|
|
}
|
|
if _, err := os.Stat("config.json"); err == nil {
|
|
return "config.json"
|
|
}
|
|
if _, err := os.Stat("../config.json"); err == nil {
|
|
return "../config.json"
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err == nil {
|
|
p := filepath.Join(home, ".whatshooked", "config.json")
|
|
if _, err := os.Stat(p); err == nil {
|
|
return p
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// tryInitDB attempts to initialize the DB, returning false if it cannot.
|
|
func tryInitDB() bool {
|
|
if storage.DB != nil {
|
|
return true
|
|
}
|
|
cfgPath := resolveServerConfigPath()
|
|
if cfgPath == "" {
|
|
return false
|
|
}
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return storage.Initialize(&cfg.Database) == nil
|
|
}
|
|
|
|
// dbOwnerUserID returns the ID of the first admin user (or first user) for use
|
|
// when creating records via DB that require a user_id.
|
|
func dbOwnerUserID(username string) string {
|
|
var user models.ModelPublicUsers
|
|
q := storage.DB.NewSelect().Model(&user)
|
|
if username != "" {
|
|
q = q.Where("username = ?", username)
|
|
} else {
|
|
q = q.Where("role = ?", "admin")
|
|
}
|
|
if err := q.Limit(1).Scan(context.Background()); err != nil {
|
|
// fall back to any user
|
|
if err2 := storage.DB.NewSelect().Model(&user).Limit(1).Scan(context.Background()); err2 != nil {
|
|
return ""
|
|
}
|
|
}
|
|
return user.ID.String()
|
|
}
|
|
|
|
func initUserDB() {
|
|
cfgPath := resolveServerConfigPath()
|
|
if cfgPath == "" {
|
|
fmt.Fprintln(os.Stderr, "Error: server config not found. Use --server-config to specify path.")
|
|
os.Exit(1)
|
|
}
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error loading server config %s: %v\n", cfgPath, err)
|
|
os.Exit(1)
|
|
}
|
|
if err := storage.Initialize(&cfg.Database); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error connecting to database: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func listUsers() {
|
|
var users []models.ModelPublicUsers
|
|
err := storage.DB.NewSelect().Model(&users).OrderExpr("created_at ASC").Scan(context.Background())
|
|
checkError(err)
|
|
|
|
if len(users) == 0 {
|
|
fmt.Println("No users found")
|
|
return
|
|
}
|
|
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(w, "USERNAME\tEMAIL\tFULL NAME\tROLE\tACTIVE\tCREATED")
|
|
for _, u := range users {
|
|
active := "yes"
|
|
if !u.Active {
|
|
active = "no"
|
|
}
|
|
created := fmt.Sprint(u.CreatedAt)
|
|
if len(created) > 10 {
|
|
created = created[:10]
|
|
}
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
|
|
u.Username.String(),
|
|
u.Email.String(),
|
|
u.FullName.String(),
|
|
u.Role.String(),
|
|
active,
|
|
created,
|
|
)
|
|
}
|
|
w.Flush()
|
|
}
|
|
|
|
func addUser() {
|
|
username := addUsername
|
|
if username == "" {
|
|
username = promptRequired("Username")
|
|
}
|
|
|
|
email := addEmail
|
|
if email == "" {
|
|
email = promptRequired("Email")
|
|
}
|
|
|
|
fullName := addFullName
|
|
if fullName == "" {
|
|
fullName = promptLine("Full Name (optional)", "")
|
|
}
|
|
|
|
role := addRole
|
|
if role == "" {
|
|
role = promptLine("Role (admin/user)", "user")
|
|
}
|
|
|
|
password := addPassword
|
|
if password == "" {
|
|
password = readPassword("Password: ")
|
|
}
|
|
|
|
hashedPw, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
checkError(err)
|
|
|
|
now := time.Now()
|
|
user := &models.ModelPublicUsers{
|
|
ID: resolvespec_common.NewSqlString(uuid.New().String()),
|
|
Username: resolvespec_common.NewSqlString(username),
|
|
Email: resolvespec_common.NewSqlString(email),
|
|
FullName: resolvespec_common.NewSqlString(fullName),
|
|
Role: resolvespec_common.NewSqlString(role),
|
|
Password: resolvespec_common.NewSqlString(string(hashedPw)),
|
|
Active: true,
|
|
CreatedAt: resolvespec_common.NewSqlTimeStamp(now),
|
|
UpdatedAt: resolvespec_common.NewSqlTimeStamp(now),
|
|
}
|
|
|
|
_, err = storage.DB.NewInsert().Model(user).Exec(context.Background())
|
|
checkError(err)
|
|
|
|
fmt.Printf("User '%s' created\n", username)
|
|
}
|
|
|
|
func setPasswordDirect(username, password string) {
|
|
user := findUserByUsername(username)
|
|
hashedPw, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
checkError(err)
|
|
_, err = storage.DB.NewUpdate().Model(user).
|
|
Set("password = ?", string(hashedPw)).
|
|
Set("updated_at = ?", time.Now()).
|
|
Where("id = ?", user.ID.String()).
|
|
Exec(context.Background())
|
|
checkError(err)
|
|
fmt.Printf("Password updated for '%s'\n", username)
|
|
}
|
|
|
|
func setPassword(username string) {
|
|
findUserByUsername(username) // validate exists
|
|
|
|
password := readPassword("New password: ")
|
|
confirm := readPassword("Confirm password: ")
|
|
if password != confirm {
|
|
fmt.Fprintln(os.Stderr, "Error: passwords do not match")
|
|
os.Exit(1)
|
|
}
|
|
|
|
hashedPw, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
checkError(err)
|
|
|
|
var user models.ModelPublicUsers
|
|
err = storage.DB.NewSelect().Model(&user).Where("username = ?", username).Scan(context.Background())
|
|
checkError(err)
|
|
|
|
_, err = storage.DB.NewUpdate().Model(&user).
|
|
Set("password = ?", string(hashedPw)).
|
|
Set("updated_at = ?", time.Now()).
|
|
Where("id = ?", user.ID.String()).
|
|
Exec(context.Background())
|
|
checkError(err)
|
|
|
|
fmt.Printf("Password updated for '%s'\n", username)
|
|
}
|
|
|
|
func setUserActive(username string, active bool) {
|
|
user := findUserByUsername(username)
|
|
|
|
_, err := storage.DB.NewUpdate().Model(user).
|
|
Set("active = ?", active).
|
|
Set("updated_at = ?", time.Now()).
|
|
Where("id = ?", user.ID.String()).
|
|
Exec(context.Background())
|
|
checkError(err)
|
|
|
|
state := "enabled"
|
|
if !active {
|
|
state = "disabled"
|
|
}
|
|
fmt.Printf("User '%s' %s\n", username, state)
|
|
}
|
|
|
|
func updateUser(username string) {
|
|
user := findUserByUsername(username)
|
|
|
|
// If any flag was provided, apply flags only (non-interactive)
|
|
flagsProvided := updateUsername != "" || updateEmail != "" || updateFullName != "" || updateRole != ""
|
|
|
|
if flagsProvided {
|
|
if updateUsername != "" {
|
|
user.Username = resolvespec_common.NewSqlString(updateUsername)
|
|
}
|
|
if updateEmail != "" {
|
|
user.Email = resolvespec_common.NewSqlString(updateEmail)
|
|
}
|
|
if updateFullName != "" {
|
|
user.FullName = resolvespec_common.NewSqlString(updateFullName)
|
|
}
|
|
if updateRole != "" {
|
|
user.Role = resolvespec_common.NewSqlString(updateRole)
|
|
}
|
|
} else {
|
|
fmt.Printf("Updating user '%s' (press Enter to keep current value)\n", username)
|
|
|
|
if v := promptLine("Username", user.Username.String()); v != "" {
|
|
user.Username = resolvespec_common.NewSqlString(v)
|
|
}
|
|
if v := promptLine("Email", user.Email.String()); v != "" {
|
|
user.Email = resolvespec_common.NewSqlString(v)
|
|
}
|
|
if v := promptLine("Full Name", user.FullName.String()); v != "" {
|
|
user.FullName = resolvespec_common.NewSqlString(v)
|
|
}
|
|
if v := promptLine("Role (admin/user)", user.Role.String()); v != "" {
|
|
user.Role = resolvespec_common.NewSqlString(v)
|
|
}
|
|
}
|
|
|
|
_, err := storage.DB.NewUpdate().Model(user).
|
|
Set("username = ?", user.Username.String()).
|
|
Set("email = ?", user.Email.String()).
|
|
Set("full_name = ?", user.FullName.String()).
|
|
Set("role = ?", user.Role.String()).
|
|
Set("updated_at = ?", time.Now()).
|
|
Where("id = ?", user.ID.String()).
|
|
Exec(context.Background())
|
|
checkError(err)
|
|
|
|
fmt.Printf("User '%s' updated\n", user.Username.String())
|
|
}
|
|
|
|
func deleteUser(username string) {
|
|
user := findUserByUsername(username)
|
|
|
|
fmt.Printf("Delete user '%s'? This cannot be undone. [y/N]: ", username)
|
|
reader := bufio.NewReader(os.Stdin)
|
|
line, _ := reader.ReadString('\n')
|
|
if strings.ToLower(strings.TrimSpace(line)) != "y" {
|
|
fmt.Println("Cancelled")
|
|
return
|
|
}
|
|
|
|
_, err := storage.DB.NewDelete().Model(user).Where("id = ?", user.ID.String()).Exec(context.Background())
|
|
checkError(err)
|
|
|
|
fmt.Printf("User '%s' deleted\n", username)
|
|
}
|
|
|
|
func findUserByUsername(username string) *models.ModelPublicUsers {
|
|
var user models.ModelPublicUsers
|
|
err := storage.DB.NewSelect().Model(&user).Where("username = ?", username).Scan(context.Background())
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: user '%s' not found\n", username)
|
|
os.Exit(1)
|
|
}
|
|
return &user
|
|
}
|
|
|
|
// promptLine prints a prompt and reads one line. Returns defaultVal if Enter pressed with no input.
|
|
func promptLine(label, defaultVal string) string {
|
|
if defaultVal != "" {
|
|
fmt.Printf("%s [%s]: ", label, defaultVal)
|
|
} else {
|
|
fmt.Printf("%s: ", label)
|
|
}
|
|
reader := bufio.NewReader(os.Stdin)
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil && err != io.EOF {
|
|
return defaultVal
|
|
}
|
|
v := strings.TrimRight(line, "\r\n")
|
|
if v == "" {
|
|
return defaultVal
|
|
}
|
|
return v
|
|
}
|
|
|
|
// promptRequired loops until a non-empty value is entered.
|
|
func promptRequired(label string) string {
|
|
for {
|
|
v := promptLine(label, "")
|
|
if v != "" {
|
|
return v
|
|
}
|
|
fmt.Printf("%s is required, please try again.\n", label)
|
|
}
|
|
}
|
|
|
|
// readPassword reads a password with hidden input when on a TTY, plain text otherwise.
|
|
// Loops until a non-empty value is entered.
|
|
func readPassword(prompt string) string {
|
|
fd := int(os.Stdin.Fd())
|
|
isTTY := term.IsTerminal(fd)
|
|
for {
|
|
fmt.Print(prompt)
|
|
var pw string
|
|
if isTTY {
|
|
p, err := term.ReadPassword(fd)
|
|
fmt.Println()
|
|
if err == nil {
|
|
pw = string(p)
|
|
}
|
|
} else {
|
|
reader := bufio.NewReader(os.Stdin)
|
|
p, err := reader.ReadString('\n')
|
|
fmt.Println()
|
|
if err != nil && err != io.EOF {
|
|
fmt.Fprintln(os.Stderr, "Error: cannot read password interactively. Use set-password <username> <password> instead.")
|
|
os.Exit(1)
|
|
}
|
|
pw = strings.TrimRight(p, "\r\n")
|
|
}
|
|
if pw != "" {
|
|
return pw
|
|
}
|
|
fmt.Println("Password cannot be empty, please try again.")
|
|
}
|
|
}
|