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
}
+104
View File
@@ -0,0 +1,104 @@
package cli
import (
"errors"
"strings"
"testing"
"alterminal/internal/auth"
)
func TestAccountInputValidate(t *testing.T) {
tests := []struct {
name string
in accountInput
wantErr string // 空字串表示應通過
}{
{"最小欄位", accountInput{Username: "alice", Email: "alice@example.com"}, ""},
{"含顯示名稱", accountInput{Username: "alice", Email: "alice@example.com", Name: "Alice"}, ""},
{"role 為 admin", accountInput{Username: "alice", Email: "alice@example.com", Role: auth.RoleAdmin}, ""},
{"role 為 user", accountInput{Username: "alice", Email: "alice@example.com", Role: auth.RoleUser}, ""},
{"role 為空", accountInput{Username: "alice", Email: "alice@example.com"}, ""},
{"role 不允許的值", accountInput{Username: "alice", Email: "alice@example.com", Role: "superuser"}, "role"},
{"role 為 Admin(大寫)", accountInput{Username: "alice", Email: "alice@example.com", Role: "Admin"}, "role"},
{"username 允許的符號", accountInput{Username: "a_li-ce.01", Email: "alice@example.com"}, ""},
{"缺 username", accountInput{Email: "alice@example.com"}, "username"},
{"username 過長", accountInput{Username: strings.Repeat("a", 65), Email: "alice@example.com"}, "64"},
{"username 含空白", accountInput{Username: "alice wang", Email: "alice@example.com"}, "username"},
{"username 含 @", accountInput{Username: "alice@example.com", Email: "alice@example.com"}, "username"},
{"缺 email", accountInput{Username: "alice"}, "email"},
{"email 過長", accountInput{Username: "alice", Email: strings.Repeat("a", 250) + "@example.com"}, "255"},
{"email 格式無效", accountInput{Username: "alice", Email: "example.com"}, "email"},
{"email 帶顯示名稱", accountInput{Username: "alice", Email: "Alice <alice@example.com>"}, "email"},
{"name 過長", accountInput{Username: "alice", Email: "alice@example.com", Name: strings.Repeat("名", 256)}, "255"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.in.validate()
if tt.wantErr == "" {
if err != nil {
t.Fatalf("validate() = %v, want nil", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("validate() = %v, want error containing %q", err, tt.wantErr)
}
})
}
}
func TestResolvePasswordFromFlag(t *testing.T) {
got, err := resolvePassword("sup3r-secret")
if err != nil {
t.Fatalf("resolvePassword() = %v, want nil", err)
}
if got != "sup3r-secret" {
t.Fatalf("resolvePassword() = %q, want %q", got, "sup3r-secret")
}
}
func TestResolvePasswordTooShort(t *testing.T) {
_, err := resolvePassword("1234567")
if err == nil || !strings.Contains(err.Error(), "8") {
t.Fatalf("resolvePassword(\"1234567\") = %v, want 長度錯誤", err)
}
}
// go test 執行時 stdin 不是終端機,省略 -password 應直接報錯而非等待輸入。
func TestResolvePasswordRequiresFlagWithoutTerminal(t *testing.T) {
_, err := resolvePassword("")
if err == nil || !strings.Contains(err.Error(), "-password") {
t.Fatalf("resolvePassword(\"\") = %v, want 提示改用 -password 的錯誤", err)
}
}
func TestPromptPasswordTwice(t *testing.T) {
t.Run("兩次一致", func(t *testing.T) {
got, err := promptPasswordTwice(func(string) ([]byte, error) { return []byte("sup3r-secret"), nil })
if err != nil {
t.Fatalf("promptPasswordTwice() = %v, want nil", err)
}
if got != "sup3r-secret" {
t.Fatalf("promptPasswordTwice() = %q, want %q", got, "sup3r-secret")
}
})
t.Run("兩次不一致", func(t *testing.T) {
inputs := []string{"sup3r-secret", "sup3r-secret2"}
calls := 0
_, err := promptPasswordTwice(func(string) ([]byte, error) {
b := []byte(inputs[calls])
calls++
return b, nil
})
if err == nil || !strings.Contains(err.Error(), "不一致") {
t.Fatalf("promptPasswordTwice() = %v, want 不一致錯誤", err)
}
})
t.Run("讀取失敗", func(t *testing.T) {
_, err := promptPasswordTwice(func(string) ([]byte, error) { return nil, errors.New("boom") })
if err == nil {
t.Fatal("promptPasswordTwice() = nil, want error")
}
})
}
+82
View File
@@ -0,0 +1,82 @@
package cli
import (
"errors"
"flag"
"fmt"
"strings"
"gorm.io/gorm"
"alterminal/internal/auth"
"alterminal/internal/store"
)
// runUpdatePassword 解析旗標並重設指定帳號的密碼。此為管理用指令,
// 不需驗證舊密碼;密碼更新成功後一併撤銷該使用者所有 Session,
// 避免既有登入在密碼重設後續存。
func runUpdatePassword(args []string) error {
fs := flag.NewFlagSet("update-password", flag.ExitOnError)
username := fs.String("username", "", "登入帳號(必填)")
password := fs.String("password", "", "新密碼(選填;省略時於終端機輸入)")
fs.Parse(args)
name := strings.TrimSpace(*username)
if name == "" {
return errors.New("username 不可為空")
}
pw, err := resolvePassword(*password)
if err != nil {
return err
}
db, err := store.Open()
if err != nil {
return fmt.Errorf("database: %w", err)
}
u, err := findUserByUsername(db, name)
if err != nil {
return err
}
if err := u.SetPassword(pw); err != nil {
return fmt.Errorf("hash password: %w", err)
}
revoked, err := updateUserPassword(db, u)
if err != nil {
return err
}
fmt.Printf("密碼更新成功:id=%d username=%s(已撤銷 %d 個 Session)\n", u.ID, u.Username, revoked)
return nil
}
// findUserByUsername 以帳號查詢使用者,查無時回傳可讀的錯誤。
func findUserByUsername(db *gorm.DB, username string) (*auth.User, error) {
var u auth.User
err := db.Where("username = ?", username).First(&u).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("username %q 不存在", username)
}
if err != nil {
return nil, fmt.Errorf("query user: %w", err)
}
return &u, nil
}
// updateUserPassword 於單一交易內寫入新密碼雜湊並刪除該使用者所有
// Session,回傳撤銷的 Session 數;交易確保密碼與 Session 不會只更新一半。
func updateUserPassword(db *gorm.DB, u *auth.User) (int64, error) {
var revoked int64
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(u).Update("password_hash", u.PasswordHash).Error; err != nil {
return fmt.Errorf("update password: %w", err)
}
res := tx.Where("user_id = ?", u.ID).Delete(&auth.Session{})
if res.Error != nil {
return fmt.Errorf("delete sessions: %w", res.Error)
}
revoked = res.RowsAffected
return nil
})
return revoked, err
}
+28
View File
@@ -0,0 +1,28 @@
package cli
import (
"strings"
"testing"
)
// go test 執行時 stdin 不是終端機,輸入驗證應在連線資料庫前就失敗。
func TestRunUpdatePasswordValidatesInput(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{"缺 username", nil, "username"},
{"username 僅空白", []string{"-username", " "}, "username"},
{"省略 -password 且非終端機", []string{"-username", "alice"}, "-password"},
{"新密碼過短", []string{"-username", "alice", "-password", "1234567"}, "8"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := runUpdatePassword(tt.args)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("runUpdatePassword(%v) = %v, want error containing %q", tt.args, err, tt.wantErr)
}
})
}
}