// 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) }