// config.go — teai 登入組態的讀取與行級編輯。 // // 組態由 teai 自行管理(README 設計原則 5),不依賴 tea。寫入採「行級編輯」: // 只修改目標登入項目的所屬行,其餘內容(未知欄位、註解、空行與縮排)逐字 // 保留。新增項目只寫 teai 使用的欄位:name/url/token/default/user。 // // token 值只會進出這個套件的資料結構與組態檔本身,永遠不出現在錯誤訊息。 package gitea import ( "fmt" "os" "path/filepath" "regexp" "slices" "strconv" "strings" ) // LoginEntry 是組態檔中的一筆登入資料(編輯用,含 user 欄位)。 type LoginEntry struct { Name string URL string Token string Default bool User string } // loginItemSpan 標記 logins 清單中一個項目在檔案中的行範圍 [start, end)。 type loginItemSpan struct { name string start int end int } // configDoc 是解析後的組態檔:原始行與 logins 項目的位置資訊。 type configDoc struct { lines []string items []loginItemSpan loginsHeader int // 「logins:」所在行;-1 表示沒有 itemIndent string // 項目行的縮排(取第一個項目);預設四格空白 } // parseConfigDoc 掃描組態內容,定位 logins 清單與各項目的行範圍。 // 只認識 teai 寫出的格式:頂層 `logins:` 加縮排的 `- key: value` 項目; // 其餘行原樣保留在 lines 中。 func parseConfigDoc(data []byte) configDoc { doc := configDoc{lines: splitLines(data), loginsHeader: -1, itemIndent: " "} inLogins := false cur := -1 for i, raw := range doc.lines { line := raw trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "#") { continue } indent := line[:len(line)-len(strings.TrimLeft(line, " "))] if indent == "" { if inLogins && doc.loginsHeader >= 0 { // 離開 logins 區段;listEnd 由最後一個項目的 end 決定,這裡不用記。 } inLogins = strings.HasPrefix(trimmed, "logins:") if inLogins && doc.loginsHeader < 0 { doc.loginsHeader = i } cur = -1 continue } if !inLogins { continue } if strings.HasPrefix(trimmed, "- ") || trimmed == "-" { if cur >= 0 { doc.items[cur].end = i } doc.items = append(doc.items, loginItemSpan{start: i, end: i + 1}) cur = len(doc.items) - 1 if len(doc.items) == 1 { doc.itemIndent = indent } if rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "-")); rest != "" { if key, value, ok := strings.Cut(rest, ":"); ok && strings.TrimSpace(key) == "name" { doc.items[cur].name = unquoteYAML(strings.TrimSpace(value)) } } continue } if cur >= 0 { doc.items[cur].end = i + 1 } } return doc } // findItem 依名稱找項目;找不到回 -1。 func (d configDoc) findItem(name string) int { for i, it := range d.items { if it.name == name { return i } } return -1 } // fieldIndent 回傳項目內屬性行的縮排(項目縮排 + 兩格)。 func (d configDoc) fieldIndent() string { return d.itemIndent + " " } // entryAt 取某個項目的完整欄位值。 func (d configDoc) entryAt(it loginItemSpan) LoginEntry { var e LoginEntry apply := func(kv string) { key, value, ok := strings.Cut(kv, ":") if !ok { return } key = strings.TrimSpace(key) value = unquoteYAML(strings.TrimSpace(value)) switch key { case "name": e.Name = value case "url": e.URL = value case "token": e.Token = value case "default": e.Default = value == "true" case "user": e.User = value } } first := strings.TrimSpace(d.lines[it.start]) if rest := strings.TrimSpace(strings.TrimPrefix(first, "-")); rest != "" { apply(rest) } for i := it.start + 1; i < it.end; i++ { if kv := strings.TrimSpace(d.lines[i]); kv != "" && !strings.HasPrefix(kv, "#") { apply(kv) } } return e } // ListLogins 回傳組態內容中的所有登入(依檔案順序)。 func ListLogins(data []byte) []LoginEntry { doc := parseConfigDoc(data) out := make([]LoginEntry, 0, len(doc.items)) for _, it := range doc.items { out = append(out, doc.entryAt(it)) } return out } // ListLoginsFile 讀取組態檔並列出登入;檔案不存在視為空清單(非錯誤)。 func ListLoginsFile(path string) ([]LoginEntry, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } return ListLogins(data), nil } // UpsertLogin 新增或更新名為 e.Name 的登入。 // // 已存在則就地更新 url/token/user/default(保留其他欄位與行);不存在則 // 依 teai 的欄位順序插入新項目。makeDefault 為 true 時將其設為預設並取消 // 其他登入的預設旗標;新增後若成為唯一登入也自動成為預設。 // 回傳新內容與最終生效的項目值。 func UpsertLogin(data []byte, e LoginEntry, makeDefault bool) ([]byte, LoginEntry, error) { if strings.TrimSpace(e.Name) == "" { return nil, LoginEntry{}, fmt.Errorf("gitea: login name must not be empty") } if strings.TrimSpace(e.URL) == "" { return nil, LoginEntry{}, fmt.Errorf("gitea: login url must not be empty") } if e.Token == "" { return nil, LoginEntry{}, fmt.Errorf("gitea: login token must not be empty") } doc := parseConfigDoc(data) idx := doc.findItem(e.Name) if idx >= 0 { existing := doc.entryAt(doc.items[idx]) wantDefault := existing.Default || makeDefault it := doc.items[idx] lines := doc.lines // 就地替換各欄位;缺少的欄位補在項目結尾。 for _, kv := range []struct{ key, value string }{ {"name", yamlScalar(e.Name)}, {"url", yamlScalar(e.URL)}, {"token", yamlScalar(e.Token)}, {"user", yamlScalar(e.User)}, {"default", strconv.FormatBool(wantDefault)}, } { lines = setFieldInItem(lines, it, kv.key, kv.value, doc.fieldIndent()) } out := joinLines(lines) if wantDefault { var err error out, err = SetDefaultLogin(out, e.Name) if err != nil { return nil, LoginEntry{}, err } } final := existing final.URL, final.Token, final.User = e.URL, e.Token, e.User final.Default = wantDefault return out, final, nil } // 新增項目。 wantDefault := makeDefault || len(doc.items) == 0 block := newLoginBlock(doc.itemIndent, e.Name, e.URL, e.Token, wantDefault, e.User) var lines []string var insertAt int if doc.loginsHeader < 0 { if len(doc.lines) == 0 { lines = []string{"logins:"} } else { lines = append(slices.Clone(doc.lines), "", "logins:") } insertAt = len(lines) } else { lines = slices.Clone(doc.lines) // 「logins: []」是空清單的串流寫法,直接接項目會變無效 YAML;改回區段寫法。 if len(doc.items) == 0 && strings.TrimSpace(lines[doc.loginsHeader]) == "logins: []" { lines[doc.loginsHeader] = "logins:" } if len(doc.items) > 0 { insertAt = doc.items[len(doc.items)-1].end } else { insertAt = doc.loginsHeader + 1 } } out := append(slices.Clone(lines[:insertAt]), block...) out = append(out, lines[insertAt:]...) data2 := joinLines(out) if wantDefault { var err error data2, err = SetDefaultLogin(data2, e.Name) if err != nil { return nil, LoginEntry{}, err } } return data2, LoginEntry{ Name: e.Name, URL: e.URL, Token: e.Token, Default: wantDefault, User: e.User, }, nil } // SetDefaultLogin 把名為 name 的登入設為預設,其餘一律取消預設。 func SetDefaultLogin(data []byte, name string) ([]byte, error) { doc := parseConfigDoc(data) idx := doc.findItem(name) if idx < 0 { return nil, fmt.Errorf("gitea: login %q not found", name) } lines := doc.lines // 反序處理:前面的插入不影響後面已算好的行範圍,這裡的反序則保證 // 後方項目的插入/修改不會推移前方項目的範圍。 for i := len(doc.items) - 1; i >= 0; i-- { want := "false" if i == idx { want = "true" } lines = setFieldInItem(lines, doc.items[i], "default", want, doc.fieldIndent()) } return joinLines(lines), nil } // RemoveLogin 移除名為 name 的登入。若被移除者是預設且還有其他登入, // 剩下的第一筆自動遞補為預設。回傳新內容與遞補後的預設名稱(無則空字串)。 func RemoveLogin(data []byte, name string) ([]byte, string, error) { doc := parseConfigDoc(data) idx := doc.findItem(name) if idx < 0 { return nil, "", fmt.Errorf("gitea: login %q not found", name) } removed := doc.entryAt(doc.items[idx]) it := doc.items[idx] lines := slices.Delete(slices.Clone(doc.lines), it.start, it.end) out := joinLines(lines) promoted := "" if removed.Default { rest := ListLogins(out) if len(rest) > 0 { promoted = rest[0].Name var err error out, err = SetDefaultLogin(out, promoted) if err != nil { return nil, "", err } } } return out, promoted, nil } // setFieldInItem 把項目內 key 的值換成 value,保留該行的原始縮排; // key 不存在時以 fieldIndent 插在項目結尾。回傳新的行切片。 func setFieldInItem(lines []string, it loginItemSpan, key, value, fieldIndent string) []string { first := lines[it.start] trimmed := strings.TrimSpace(first) if rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "-")); rest != "" { if k, _, ok := strings.Cut(rest, ":"); ok && strings.TrimSpace(k) == key { prefix := first[:strings.Index(first, "-")] + "- " lines[it.start] = prefix + key + ": " + value return lines } } for i := it.start + 1; i < it.end; i++ { t := strings.TrimSpace(lines[i]) if k, _, ok := strings.Cut(t, ":"); ok && strings.TrimSpace(k) == key { indent := lines[i][:len(lines[i])-len(strings.TrimLeft(lines[i], " "))] lines[i] = indent + key + ": " + value return lines } } out := append(slices.Clone(lines[:it.end]), fieldIndent+key+": "+value) return append(out, lines[it.end:]...) } // newLoginBlock 產生 teai 格式的新登入項目(name/url/token/default/user)。 func newLoginBlock(itemIndent, name, url, token string, isDefault bool, user string) []string { f := itemIndent + " " return []string{ itemIndent + "- name: " + yamlScalar(name), f + "url: " + yamlScalar(url), f + "token: " + yamlScalar(token), f + "default: " + strconv.FormatBool(isDefault), f + "user: " + yamlScalar(user), } } // plainScalar 是可以不加引號直接寫出的 YAML 純量。 var plainScalar = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/~+=@-]*$`) // yamlScalar 把值寫成 YAML 純量:安全字元集直接輸出,其餘加雙引號。 func yamlScalar(s string) string { if plainScalar.MatchString(s) { return s } return strconv.Quote(s) } // HostOf 取 URL 的主機部分(連埠,小寫);解析失敗回空字串。 // 供比對「同一站點」的登入時使用。 func HostOf(rawURL string) string { return hostOf(rawURL) } // splitLines 把內容拆成行(去掉行尾換行與 CR)。 func splitLines(data []byte) []string { if len(data) == 0 { return nil } s := strings.TrimSuffix(string(data), "\n") if s == "" { return []string{""} } return strings.Split(s, "\n") } // joinLines 把行組回內容,保證以單一換行結尾。 func joinLines(lines []string) []byte { if len(lines) == 0 { return nil } s := strings.Join(lines, "\n") if !strings.HasSuffix(s, "\n") { s += "\n" } return []byte(s) } // SaveConfigFile 把組態內容原子寫入 path:先寫同目錄暫存檔(0600), // 再 rename 取代,避免半寫入狀態。必要時建立上層目錄(0700)。 func SaveConfigFile(path string, data []byte) error { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o700); err != nil { return fmt.Errorf("gitea: create config dir: %w", err) } tmp, err := os.CreateTemp(dir, ".config-*.yml") if err != nil { return fmt.Errorf("gitea: create temp file: %w", err) } tmpName := tmp.Name() defer func() { if tmpName != "" { _ = os.Remove(tmpName) } }() if _, err := tmp.Write(data); err != nil { tmp.Close() return fmt.Errorf("gitea: write temp file: %w", err) } if err := tmp.Chmod(0o600); err != nil { tmp.Close() return fmt.Errorf("gitea: chmod temp file: %w", err) } if err := tmp.Close(); err != nil { return fmt.Errorf("gitea: close temp file: %w", err) } if err := os.Rename(tmpName, path); err != nil { return fmt.Errorf("gitea: rename config: %w", err) } tmpName = "" // 已改名成功,不必清理。 return nil }