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.
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user