internal/gitea:API 用戶端、認證與輸出基礎(#5)
- internal/gitea:僅標準庫的 HTTP 用戶端;ListAll 以 limit=50 逐頁抓滿 - 認證選序 --token → TEAI_TOKEN → tea 組態(--config/TEA_CONFIG/預設路徑); token 不入輸出、日誌與錯誤訊息 - 共用輸出 --output json|table(預設 json)、--timeout(預設 30s) - CLI 結束碼 0/2/3:用法錯誤 → 2、API 錯誤(*gitea.ErrAPI)→ 3 - 單元測試:認證選序、tea 組態解析、分頁、逾時/401/5xx 錯誤映射、輸出 實作 issue #5
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
// auth.go — 認證來源的解析與選序。
|
||||
//
|
||||
// 選序(README「全域介面」):--token 旗標 → TEAI_TOKEN 環境變數 →
|
||||
// tea 登入組態(TEA_CONFIG 指定的路徑,或 ~/.config/tea/config.yml)。
|
||||
// token 一律只在記憶體傳遞,不寫入輸出、日誌或錯誤訊息。
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TokenSource 標記 token 的來源,供測試與診斷(不含 token 本身)。
|
||||
type TokenSource int
|
||||
|
||||
const (
|
||||
// SourceNone 表示沒有任何可用 token(匿名存取)。
|
||||
SourceNone TokenSource = iota
|
||||
// SourceFlag 是 --token 旗標。
|
||||
SourceFlag
|
||||
// SourceEnv 是 TEAI_TOKEN 環境變數。
|
||||
SourceEnv
|
||||
// SourceTeaConfig 是 tea 登入組態。
|
||||
SourceTeaConfig
|
||||
)
|
||||
|
||||
// String 回傳來源名稱(診斷用)。
|
||||
func (s TokenSource) String() string {
|
||||
switch s {
|
||||
case SourceFlag:
|
||||
return "flag"
|
||||
case SourceEnv:
|
||||
return "env"
|
||||
case SourceTeaConfig:
|
||||
return "tea-config"
|
||||
default:
|
||||
return "none"
|
||||
}
|
||||
}
|
||||
|
||||
// EnvLookup 是環境變數查詢介面(測試可替換)。
|
||||
type EnvLookup func(key string) (string, bool)
|
||||
|
||||
// ReadFileFunc 是檔案讀取介面(測試可替換)。
|
||||
type ReadFileFunc func(path string) ([]byte, error)
|
||||
|
||||
// osEnv / osReadFile 是正式環境的預設實作。
|
||||
func osEnv(key string) (string, bool) {
|
||||
v, ok := os.LookupEnv(key)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func osReadFile(path string) ([]byte, error) {
|
||||
// 假設路徑已被 ExpandConfigPath 展開為絕對路徑。
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
// AuthOptions 聚合認證解析所需的輸入。
|
||||
type AuthOptions struct {
|
||||
// FlagToken 是 --token 旗標值;空字串表示未提供。
|
||||
FlagToken string
|
||||
// SiteURL 是目標站點(用來比對 tea 組態中的 login url)。
|
||||
SiteURL string
|
||||
// Env 是環境變數查詢;nil 時用 os.LookupEnv。
|
||||
Env EnvLookup
|
||||
// ConfigPath 是 tea 組態的明確路徑(TEA_CONFIG 或 --config)。
|
||||
// 空字串表示用預設 ~/.config/tea/config.yml。
|
||||
ConfigPath string
|
||||
// Home 是使用者家目錄(展開 ~ 用);空字串表示不展開。
|
||||
Home string
|
||||
// ReadFile 是檔案讀取函式;nil 時用 os.ReadFile。
|
||||
ReadFile ReadFileFunc
|
||||
}
|
||||
|
||||
// ResolveToken 依選序解析 token:flag → env(TEAI_TOKEN) → tea 組態。
|
||||
//
|
||||
// 找不到任何 token 時回傳 ("", SourceNone, nil)——查詢公開端點不需要
|
||||
// token,是否為錯由呼叫端依情境決定。組態檔存在但解析失敗(格式錯誤)
|
||||
// 時回傳錯誤,因為這通常代表組態被改壞了,靜默降級為匿名會更難查。
|
||||
func ResolveToken(opts AuthOptions) (string, TokenSource, error) {
|
||||
if t := strings.TrimSpace(opts.FlagToken); t != "" {
|
||||
return t, SourceFlag, nil
|
||||
}
|
||||
env := opts.Env
|
||||
if env == nil {
|
||||
env = osEnv
|
||||
}
|
||||
if t, ok := env("TEAI_TOKEN"); ok && strings.TrimSpace(t) != "" {
|
||||
return strings.TrimSpace(t), SourceEnv, nil
|
||||
}
|
||||
path := opts.ConfigPath
|
||||
if path == "" {
|
||||
// --config 未給時,先看 TEA_CONFIG 環境變數。
|
||||
if p, ok := env("TEA_CONFIG"); ok && strings.TrimSpace(p) != "" {
|
||||
path = strings.TrimSpace(p)
|
||||
}
|
||||
}
|
||||
if path == "" {
|
||||
home := opts.Home
|
||||
if home == "" {
|
||||
h, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", SourceNone, nil
|
||||
}
|
||||
home = h
|
||||
}
|
||||
if home == "" {
|
||||
return "", SourceNone, nil
|
||||
}
|
||||
path = filepath.Join(home, ".config", "tea", "config.yml")
|
||||
}
|
||||
if path == "" {
|
||||
return "", SourceNone, nil
|
||||
}
|
||||
readFile := opts.ReadFile
|
||||
if readFile == nil {
|
||||
readFile = osReadFile
|
||||
}
|
||||
data, err := readFile(path)
|
||||
if err != nil {
|
||||
// 組態不存在或讀不到:視為沒有這個來源,不是錯誤。
|
||||
return "", SourceNone, nil
|
||||
}
|
||||
login, err := parseTeaConfig(data, opts.SiteURL)
|
||||
if err != nil {
|
||||
return "", SourceNone, fmt.Errorf("gitea: parse tea config %s: %w", path, err)
|
||||
}
|
||||
if login.Token == "" {
|
||||
return "", SourceNone, nil
|
||||
}
|
||||
return login.Token, SourceTeaConfig, nil
|
||||
}
|
||||
|
||||
// teaLogin 是 tea 組態中一筆登入資料(只取 teai 需要的欄位)。
|
||||
type teaLogin struct {
|
||||
Name string
|
||||
URL string
|
||||
Token string
|
||||
Default bool
|
||||
}
|
||||
|
||||
// parseTeaConfig 從 tea 的 config.yml 內容挑出最匹配 siteURL 的登入:
|
||||
// url 主機相同者優先,其中 default: true 最優,否則取第一筆;
|
||||
// 都不相同時退而取 default: true,再退第一筆。
|
||||
//
|
||||
// 僅解析 tea 實際寫出的 YAML 子集:頂層 `logins:` 清單,項目為
|
||||
// `key: value` 的平面映射。不處理錨點、多文件、巢狀清單——tea 不會寫出
|
||||
// 這些,遇到也不該默默誤讀。
|
||||
func parseTeaConfig(data []byte, siteURL string) (teaLogin, error) {
|
||||
var logins []teaLogin
|
||||
inLogins := false
|
||||
cur := -1 // 目前項目在 logins 的索引;-1 表示尚未開始任何項目
|
||||
for lineNo, raw := range strings.Split(string(data), "\n") {
|
||||
line := strings.TrimRight(raw, "\r")
|
||||
// 忽略註解行(行的第一個非空白字元是 #)。
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
indent := len(line) - len(strings.TrimLeft(line, " "))
|
||||
if indent == 0 {
|
||||
inLogins = strings.HasPrefix(trimmed, "logins:")
|
||||
cur = -1
|
||||
continue
|
||||
}
|
||||
if !inLogins {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
|
||||
// 新的清單項目;`- name: x` 形式同行帶鍵值。
|
||||
logins = append(logins, teaLogin{})
|
||||
cur = len(logins) - 1
|
||||
rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
|
||||
if rest != "" {
|
||||
if err := applyLoginField(&logins[cur], rest, lineNo+1); err != nil {
|
||||
return teaLogin{}, err
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if cur < 0 {
|
||||
// logins: 底下卻不是清單項目——不是預期的結構。
|
||||
return teaLogin{}, fmt.Errorf("line %d: expected list item under logins:", lineNo+1)
|
||||
}
|
||||
if err := applyLoginField(&logins[cur], trimmed, lineNo+1); err != nil {
|
||||
return teaLogin{}, err
|
||||
}
|
||||
}
|
||||
if len(logins) == 0 {
|
||||
return teaLogin{}, nil
|
||||
}
|
||||
wantHost := hostOf(siteURL)
|
||||
// 第一輪:主機相符者,default 優先,否則第一筆。
|
||||
var fallback = -1 // 主機相符的非 default
|
||||
var anyDefault = -1 // 全域 default
|
||||
for i, l := range logins {
|
||||
if wantHost != "" && hostOf(l.URL) == wantHost {
|
||||
if l.Default {
|
||||
return l, nil
|
||||
}
|
||||
if fallback < 0 {
|
||||
fallback = i
|
||||
}
|
||||
}
|
||||
if l.Default && anyDefault < 0 {
|
||||
anyDefault = i
|
||||
}
|
||||
}
|
||||
if fallback >= 0 {
|
||||
return logins[fallback], nil
|
||||
}
|
||||
if anyDefault >= 0 {
|
||||
return logins[anyDefault], nil
|
||||
}
|
||||
return logins[0], nil
|
||||
}
|
||||
|
||||
// applyLoginField 把 `key: value` 套用到 login;value 的引號會剝除。
|
||||
func applyLoginField(l *teaLogin, kv string, lineNo int) error {
|
||||
key, value, ok := strings.Cut(kv, ":")
|
||||
if !ok {
|
||||
return fmt.Errorf("line %d: expected key: value, got %q", lineNo, kv)
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.TrimSpace(value)
|
||||
value = unquoteYAML(value)
|
||||
switch key {
|
||||
case "name":
|
||||
l.Name = value
|
||||
case "url":
|
||||
l.URL = value
|
||||
case "token":
|
||||
l.Token = value
|
||||
case "default":
|
||||
l.Default = value == "true"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// unquoteYAML 剝除 YAML 的單/雙引號。
|
||||
func unquoteYAML(s string) string {
|
||||
if len(s) >= 2 {
|
||||
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// hostOf 取 URL 的主機部分(連埠);解析失敗回空字串。
|
||||
func hostOf(rawURL string) string {
|
||||
s := strings.TrimSpace(rawURL)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
// 補配置讓 url.Parse 能取 Host。
|
||||
if !strings.Contains(s, "://") {
|
||||
s = "https://" + s
|
||||
}
|
||||
u, err := url.Parse(s)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(u.Host)
|
||||
}
|
||||
Reference in New Issue
Block a user