413 lines
12 KiB
Go
413 lines
12 KiB
Go
// 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
|
||
}
|