forked from alterminal/alterminal
189 lines
5.8 KiB
Go
189 lines
5.8 KiB
Go
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 <alice@example.com>" 這類含顯示名稱的寫法。
|
|
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
|
|
}
|