first commit

This commit is contained in:
2026-10-03 10:44:29 +08:00
parent f373cb8d37
commit bcf3d3769c
58 changed files with 4313 additions and 487 deletions
+189
View File
@@ -0,0 +1,189 @@
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
}
const minPasswordLen = 8
// resolvePassword 回傳帳號密碼:有 -password 旗標時直接使用,
// 否則須於終端機以無回顯方式輸入兩次;非終端機環境不得省略旗標。
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) < minPasswordLen {
return "", fmt.Errorf("密碼長度至少 %d 字元", 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
}