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
|
||||
}
|
||||
Reference in New Issue
Block a user