// Package config loads an ntfy client.yml plus command-line overrides. package config import ( "fmt" "net/http" "os" "path/filepath" "strings" "gopkg.in/yaml.v3" ) // DefaultPath is the per-user ntfy client config used by the official CLI. func DefaultPath() string { home, err := os.UserHomeDir() if err != nil { return "" } return filepath.Join(home, ".config", "ntfy", "client.yml") } // Auth is sent as a Bearer token or HTTP basic credentials. // A token wins when both are set. Values are never formatted into errors. type Auth struct { Token string User string Password string } // Equal reports whether two credential sets would produce the same header. func (a Auth) Equal(b Auth) bool { return a.Token == b.Token && a.User == b.User && a.Password == b.Password } // Apply sets Authorization. A token is sent as Bearer; otherwise basic auth is used. func (a Auth) Apply(req *http.Request) { if a.Token != "" { req.Header.Set("Authorization", "Bearer "+a.Token) return } if a.User != "" || a.Password != "" { req.SetBasicAuth(a.User, a.Password) } } // Sub is one topic the TUI should subscribe to. type Sub struct { Origin string Name string Display string Auth Auth } // Label is the sidebar text for this topic. func (s Sub) Label() string { if strings.TrimSpace(s.Display) != "" { return s.Display } return s.Name } // App is the resolved runtime configuration. type App struct { Since string DefaultHost string Auth Auth Subs []Sub StartupStatus string } // Options are CLI inputs. PathSet reports whether --config was passed. type Options struct { Path string PathSet bool Host string Token string User string Since string Topics []string } type fileConfig struct { DefaultHost string `yaml:"default-host"` DefaultToken string `yaml:"default-token"` DefaultUser string `yaml:"default-user"` DefaultPassword string `yaml:"default-password"` Subscribe []fileSub `yaml:"subscribe"` } type fileSub struct { Topic string `yaml:"topic"` Token *string `yaml:"token"` User *string `yaml:"user"` Password *string `yaml:"password"` } // Load reads client.yml when it exists and applies CLI overrides. // Positional topics replace the file's subscribe list. func Load(opt Options) (App, error) { path := opt.Path if path == "" { path = DefaultPath() } var file fileConfig data, err := os.ReadFile(path) switch { case err == nil: if err := yaml.Unmarshal(data, &file); err != nil { return App{}, fmt.Errorf("parse %s: %w", path, err) } case os.IsNotExist(err) && !opt.PathSet: // A missing default file is fine when topics are passed explicitly. case err != nil: return App{}, fmt.Errorf("read config: %w", err) } host := first(opt.Host, file.DefaultHost) auth := Auth{ Token: file.DefaultToken, User: file.DefaultUser, Password: file.DefaultPassword, } if opt.Token != "" { auth.Token = opt.Token } if opt.User != "" { auth.User, auth.Password = splitUser(opt.User) auth.Token = "" } if auth.Token != "" { auth.User, auth.Password = "", "" } since := opt.Since if since == "" { since = "all" } specs := file.Subscribe if len(opt.Topics) > 0 { specs = make([]fileSub, len(opt.Topics)) for i, topic := range opt.Topics { specs[i].Topic = topic } } if len(specs) == 0 { if strings.TrimSpace(host) == "" { return App{}, fmt.Errorf("no topic given and no default-host in %s", path) } return App{Since: since, DefaultHost: host, Auth: auth}, nil } subs := make([]Sub, 0, len(specs)) for _, spec := range specs { origin, name, err := Resolve(spec.Topic, host) if err != nil { return App{}, err } subAuth := auth if spec.Token != nil { subAuth = Auth{Token: *spec.Token} } if spec.User != nil { pass := "" if spec.Password != nil { pass = *spec.Password } subAuth = Auth{User: *spec.User, Password: pass} } subs = append(subs, Sub{Origin: origin, Name: name, Auth: subAuth}) } return App{ Since: since, DefaultHost: host, Auth: auth, Subs: subs, }, nil } // MergeSubs appends topics that are not already in base, compared by origin and name. func MergeSubs(base, extra []Sub) []Sub { out := append([]Sub(nil), base...) seen := map[string]bool{} for _, sub := range out { seen[sub.Origin+"\n"+sub.Name] = true } for _, sub := range extra { key := sub.Origin + "\n" + sub.Name if seen[key] { continue } seen[key] = true out = append(out, sub) } return out } func first(vals ...string) string { for _, v := range vals { if strings.TrimSpace(v) != "" { return strings.TrimSpace(v) } } return "" } func splitUser(s string) (string, string) { user, pass, ok := strings.Cut(s, ":") if !ok { return user, "" } return user, pass }