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 [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 ", 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 ", 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 ", 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 ", Short: "Delete a user", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { deleteUser(args[0]) }, } var usersRemoveCmd = &cobra.Command{ Use: "remove ", 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 instead.") os.Exit(1) } pw = strings.TrimRight(p, "\r\n") } if pw != "" { return pw } fmt.Println("Password cannot be empty, please try again.") } }