Files
teai/internal/gitea/auth.go
T
max acf8cce68c internal/gitea:API 用戶端、認證與輸出基礎(#5)
- internal/gitea:僅標準庫的 HTTP 用戶端;ListAll 以 limit=50 逐頁抓滿
- 認證選序 --token → TEAI_TOKEN → tea 組態(--config/TEA_CONFIG/預設路徑);
  token 不入輸出、日誌與錯err誤訊息
- 共用輸出 --output json|table(預設 json)、--timeout(預設 30s)
- CLI 結束碼 0/2/3:用法錯誤 → 2、API 錯誤(*gitea.ErrAPI)→ 3
- 單元測試:認證選序、tea 組態解析、分颁頁、逾時/401/5xx 錯誤映射、輸出

實作 issue #5
2026-09-10 08:20:02 +08:00

269 lines
7.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}