package cli import ( "errors" "flag" "fmt" "log" "net/mail" "os" "regexp" "strings" "golang.org/x/term" "gorm.io/gorm" "alterminal/internal/auth" "alterminal/internal/store" ) // Run 分派 CLI 子指令;不帶任何參數時 main 直接啟動 HTTP 伺服器。 func Run(args []string) { switch args[0] { case "create-account": if err := runCreateAccount(args[1:]); err != nil { log.Fatal("create-account: ", err) } case "update-password": if err := runUpdatePassword(args[1:]); err != nil { log.Fatal("update-password: ", err) } default: fmt.Fprintf(os.Stderr, "未知指令 %q\n\n用法:\n alterminal create-account [-username 帳號] [-email Email] [-name 顯示名稱] [-email-verified] [-role admin|user] [-password 密碼]\n alterminal update-password -username 帳號 [-password 新密碼]\n", args[0]) os.Exit(2) } } // runCreateAccount 解析旗標、驗證輸入並建立使用者帳號。 func runCreateAccount(args []string) error { fs := flag.NewFlagSet("create-account", flag.ExitOnError) username := fs.String("username", "", "登入帳號(必填)") email := fs.String("email", "", "Email(必填)") name := fs.String("name", "", "顯示名稱(選填)") emailVerified := fs.Bool("email-verified", false, "Email 已驗證(選填)") role := fs.String("role", "user", "角色:admin 或 user(選填,預設 user)") password := fs.String("password", "", "密碼(選填;省略時於終端機輸入)") fs.Parse(args) in := accountInput{ Username: strings.TrimSpace(*username), Email: strings.TrimSpace(*email), Name: strings.TrimSpace(*name), Role: auth.Role(strings.TrimSpace(*role)), } if err := in.validate(); err != nil { return err } pw, err := resolvePassword(*password) if err != nil { return err } db, err := store.Open() if err != nil { return fmt.Errorf("database: %w", err) } u := &auth.User{Username: in.Username, Email: in.Email, Name: in.Name, EmailVerified: *emailVerified, Role: in.Role} if err := u.SetPassword(pw); err != nil { return fmt.Errorf("hash password: %w", err) } if err := createUser(db, u); err != nil { return err } fmt.Printf("帳號建立成功:id=%d username=%s email=%s role=%s\n", u.ID, u.Username, u.Email, u.Role) return nil } // accountInput 為 create-account 的輸入欄位,長度限制對應 users 資料表欄位定義。 type accountInput struct { Username string Email string Name string Role auth.Role } var usernamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) func (in *accountInput) validate() error { if in.Username == "" { return errors.New("username 不可為空") } if len(in.Username) > 64 { return errors.New("username 長度不可超過 64") } if !usernamePattern.MatchString(in.Username) { return errors.New("username 僅接受英文字母、數字與 . _ -") } if in.Email == "" { return errors.New("email 不可為空") } if len(in.Email) > 255 { return errors.New("email 長度不可超過 255") } // 僅接受純位址,排除 "Alice " 這類含顯示名稱的寫法。 if addr, err := mail.ParseAddress(in.Email); err != nil || addr.Address != in.Email { return errors.New("email 格式無效") } if len(in.Name) > 255 { return errors.New("name 長度不可超過 255") } if in.Role == "" { in.Role = auth.RoleUser // 未指定時預設一般使用者 } if !in.Role.Valid() { return errors.New("role 僅接受 admin 或 user") } return nil } // resolvePassword 回傳帳號密碼:有 -password 旗標時直接使用, // 否則須於終端機以無回顯方式輸入兩次;非終端機環境不得省略旗標。 // 最小長度採 auth.MinPasswordLen(與網頁更新密碼流程一致)。 func resolvePassword(flagPassword string) (string, error) { password := flagPassword if password == "" { if !term.IsTerminal(int(os.Stdin.Fd())) { return "", errors.New("非互動環境無法提示輸入密碼,請以 -password 提供") } var err error password, err = promptPasswordTwice(readHiddenLine) if err != nil { return "", err } } if len(password) < auth.MinPasswordLen { return "", fmt.Errorf("密碼長度至少 %d 字元", auth.MinPasswordLen) } return password, nil } // promptPasswordTwice 以 read 提示讀取密碼兩次,一致時回傳。 func promptPasswordTwice(read func(string) ([]byte, error)) (string, error) { first, err := read("輸入密碼: ") if err != nil { return "", fmt.Errorf("讀取密碼: %w", err) } second, err := read("再次輸入密碼: ") if err != nil { return "", fmt.Errorf("讀取密碼: %w", err) } if string(first) != string(second) { return "", errors.New("兩次輸入的密碼不一致") } return string(first), nil } // readHiddenLine 在終端機顯示 prompt 並無回顯讀取一行輸入。 func readHiddenLine(prompt string) ([]byte, error) { fmt.Print(prompt) b, err := term.ReadPassword(int(os.Stdin.Fd())) fmt.Println() return b, err } // createUser 將帳號寫入資料庫;寫入前預查提供友善的重複錯誤, // 寫入時再以唯一索引(gorm.ErrDuplicatedRows)兜底並發情境。 func createUser(db *gorm.DB, u *auth.User) error { var count int64 if err := db.Model(&auth.User{}).Where("username = ?", u.Username).Count(&count).Error; err != nil { return fmt.Errorf("query username: %w", err) } if count > 0 { return fmt.Errorf("username %q 已被使用", u.Username) } if err := db.Model(&auth.User{}).Where("email = ?", u.Email).Count(&count).Error; err != nil { return fmt.Errorf("query email: %w", err) } if count > 0 { return fmt.Errorf("email %q 已被使用", u.Email) } if err := db.Create(u).Error; err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return fmt.Errorf("username %q 或 email %q 已被使用", u.Username, u.Email) } return fmt.Errorf("create user: %w", err) } return nil }