From ae37b98ef871127b864628bc37ef29c7dff4081c Mon Sep 17 00:00:00 2001 From: Amir Husayn Panahifar Date: Sun, 11 Oct 2026 12:04:41 +0330 Subject: [PATCH] feat(config): load ntfy client.yml and resolve topic origins Parse the official ntfy client configuration, apply command-line overrides, and turn short names, host/topic pairs, and absolute URLs into an origin and topic name. --- internal/config/config.go | 215 +++++++++++++++++++++++++++++++++ internal/config/config_test.go | 82 +++++++++++++ internal/config/topic.go | 72 +++++++++++ internal/config/topic_test.go | 27 +++++ 4 files changed, 396 insertions(+) create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/topic.go create mode 100644 internal/config/topic_test.go diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..55a39ea --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,215 @@ +// 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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..f1037fa --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,82 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadSubscribeAndTokenOverride(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "client.yml") + body := []byte(` +default-host: https://ntfy.example +default-token: tk_default +subscribe: + - topic: alerts + command: notify-send + - topic: public + token: "" + - topic: other.example/ops + user: ada + password: secret +`) + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatal(err) + } + app, err := Load(Options{Path: path, PathSet: true, Since: "1h"}) + if err != nil { + t.Fatal(err) + } + if app.Since != "1h" || len(app.Subs) != 3 { + t.Fatalf("app: %+v", app.Subs) + } + if app.Subs[0].Name != "alerts" || app.Subs[0].Origin != "https://ntfy.example" || app.Subs[0].Auth.Token != "tk_default" { + t.Fatalf("alerts: %+v", app.Subs[0]) + } + if app.Subs[1].Auth.Token != "" || app.Subs[1].Auth.User != "" { + t.Fatalf("empty token should clear auth: %+v", app.Subs[1].Auth) + } + if app.Subs[2].Origin != "https://other.example" || app.Subs[2].Name != "ops" || app.Subs[2].Auth.User != "ada" || app.Subs[2].Auth.Password != "secret" { + t.Fatalf("per-topic user: %+v", app.Subs[2]) + } +} + +func TestPositionalTopicsReplaceSubscribe(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "client.yml") + body := []byte("default-host: https://ntfy.example\ndefault-token: tk_default\nsubscribe:\n - topic: ignored\n") + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatal(err) + } + app, err := Load(Options{Path: path, PathSet: true, Topics: []string{"alerts"}, User: "ada:pw"}) + if err != nil { + t.Fatal(err) + } + if len(app.Subs) != 1 || app.Subs[0].Name != "alerts" || app.Subs[0].Auth.Token != "" || app.Subs[0].Auth.User != "ada" { + t.Fatalf("%+v", app.Subs[0]) + } +} + +func TestHostWithoutTopicsStartsEmpty(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "client.yml") + body := []byte("default-host: https://ntfy.example\ndefault-token: tk_default\n") + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatal(err) + } + app, err := Load(Options{Path: path, PathSet: true}) + if err != nil { + t.Fatal(err) + } + if app.DefaultHost != "https://ntfy.example" || len(app.Subs) != 0 || app.Auth.Token != "tk_default" { + t.Fatalf("%+v", app) + } +} + +func TestMissingDefaultFileNeedsTopic(t *testing.T) { + _, err := Load(Options{Path: filepath.Join(t.TempDir(), "missing.yml")}) + if err == nil { + t.Fatal("expected error") + } +} diff --git a/internal/config/topic.go b/internal/config/topic.go new file mode 100644 index 0000000..ba3cf8d --- /dev/null +++ b/internal/config/topic.go @@ -0,0 +1,72 @@ +package config + +import ( + "fmt" + "net/url" + "strings" +) + +// Resolve turns a short topic, host/topic, or absolute URL into an origin and +// a single topic name. defaultHost is used only for short names. +func Resolve(topic, defaultHost string) (origin, name string, err error) { + topic = strings.TrimSpace(topic) + if topic == "" { + return "", "", fmt.Errorf("empty topic") + } + if !strings.Contains(topic, "://") && strings.Contains(topic, "/") { + return Resolve("https://"+topic, "") + } + if strings.Contains(topic, "://") { + u, err := url.Parse(topic) + if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") { + return "", "", fmt.Errorf("invalid topic URL %q", topic) + } + path := strings.Trim(u.Path, "/") + if path == "" { + return "", "", fmt.Errorf("topic URL %q has no topic", topic) + } + base := u.Scheme + "://" + u.Host + if i := strings.LastIndex(path, "/"); i >= 0 { + base += "/" + path[:i] + path = path[i+1:] + } + if err := checkName(path); err != nil { + return "", "", err + } + return base, path, nil + } + if err := checkName(topic); err != nil { + return "", "", err + } + origin, err = normalizeOrigin(defaultHost) + if err != nil { + return "", "", fmt.Errorf("topic %q: %w", topic, err) + } + return origin, topic, nil +} + +func checkName(name string) error { + if name == "" || strings.Contains(name, ",") || strings.ContainsAny(name, " \t") { + return fmt.Errorf("invalid topic name %q", name) + } + return nil +} + +func normalizeOrigin(host string) (string, error) { + host = strings.TrimSpace(host) + if host == "" { + return "", fmt.Errorf("no default-host") + } + if !strings.Contains(host, "://") { + host = "https://" + host + } + u, err := url.Parse(strings.TrimRight(host, "/")) + if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") { + return "", fmt.Errorf("invalid host %q", host) + } + origin := u.Scheme + "://" + u.Host + if path := strings.Trim(u.Path, "/"); path != "" { + origin += "/" + path + } + return origin, nil +} diff --git a/internal/config/topic_test.go b/internal/config/topic_test.go new file mode 100644 index 0000000..e388eb2 --- /dev/null +++ b/internal/config/topic_test.go @@ -0,0 +1,27 @@ +package config + +import "testing" + +func TestResolve(t *testing.T) { + cases := []struct { + topic, host, origin, name string + }{ + {"alerts", "https://ntfy.example", "https://ntfy.example", "alerts"}, + {"alerts", "https://ntfy.example/", "https://ntfy.example", "alerts"}, + {"https://ntfy.example/alerts", "", "https://ntfy.example", "alerts"}, + {"ntfy.example/alerts", "", "https://ntfy.example", "alerts"}, + {"https://ntfy.example/base/alerts", "", "https://ntfy.example/base", "alerts"}, + } + for _, tc := range cases { + origin, name, err := Resolve(tc.topic, tc.host) + if err != nil { + t.Fatalf("%s: %v", tc.topic, err) + } + if origin != tc.origin || name != tc.name { + t.Fatalf("%s: got %s %s", tc.topic, origin, name) + } + } + if _, _, err := Resolve("alerts", ""); err == nil { + t.Fatal("expected missing host error") + } +}