diff --git a/internal/notification/ntfy.go b/internal/notification/ntfy.go index a8315cf..a7692a1 100644 --- a/internal/notification/ntfy.go +++ b/internal/notification/ntfy.go @@ -3,6 +3,7 @@ package notification import ( "bytes" "context" + "encoding/base64" "fmt" "io" "net/http" @@ -11,11 +12,15 @@ import ( ) // NtfyConfig is the per-channel JSON shape stored AEAD-encrypted in -// notification_channels.config. +// notification_channels.config. AccessToken takes precedence over +// (Username, Password) when both are set; supply one or the other for +// self-hosted ntfy that requires auth. type NtfyConfig struct { ServerURL string `json:"server_url"` Topic string `json:"topic"` AccessToken string `json:"access_token,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` } // NtfyChannel delivers alerts to an ntfy server using POST with @@ -65,8 +70,12 @@ func (c *NtfyChannel) Send(ctx context.Context, p Payload) (int, time.Duration, if p.Link != "" { req.Header.Set("Click", p.Link) } - if c.cfg.AccessToken != "" { + switch { + case c.cfg.AccessToken != "": req.Header.Set("Authorization", "Bearer "+c.cfg.AccessToken) + case c.cfg.Username != "": + creds := c.cfg.Username + ":" + c.cfg.Password + req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(creds))) } t0 := time.Now() diff --git a/internal/server/http/ui_notifications.go b/internal/server/http/ui_notifications.go index cf6a548..5ba0bca 100644 --- a/internal/server/http/ui_notifications.go +++ b/internal/server/http/ui_notifications.go @@ -79,6 +79,8 @@ type notificationForm struct { NtfyServerURL string NtfyTopic string NtfyAccessToken string + NtfyUsername string + NtfyPassword string // SMTP sub-fields. SMTPHost string @@ -188,6 +190,8 @@ func formFromRequest(r *stdhttp.Request) *notificationForm { NtfyServerURL: strings.TrimSpace(r.PostForm.Get("ntfy_server_url")), NtfyTopic: strings.TrimSpace(r.PostForm.Get("ntfy_topic")), NtfyAccessToken: r.PostForm.Get("ntfy_access_token"), + NtfyUsername: strings.TrimSpace(r.PostForm.Get("ntfy_username")), + NtfyPassword: r.PostForm.Get("ntfy_password"), SMTPHost: strings.TrimSpace(r.PostForm.Get("smtp_host")), SMTPPort: strings.TrimSpace(r.PostForm.Get("smtp_port")), @@ -288,11 +292,19 @@ func buildConfig(f *notificationForm, existing any) (any, error) { ServerURL: f.NtfyServerURL, Topic: f.NtfyTopic, AccessToken: f.NtfyAccessToken, + Username: f.NtfyUsername, + Password: f.NtfyPassword, } if existing != nil { - ex, ok := existing.(*notification.NtfyConfig) - if ok && cfg.AccessToken == "" { - cfg.AccessToken = ex.AccessToken + if ex, ok := existing.(*notification.NtfyConfig); ok { + // Blank password on edit means "keep stored value" + // — same write-only treatment as smtp_password. + if cfg.AccessToken == "" { + cfg.AccessToken = ex.AccessToken + } + if cfg.Password == "" { + cfg.Password = ex.Password + } } } return cfg, nil @@ -479,7 +491,8 @@ func (s *Server) handleUINotificationEditGet(w stdhttp.ResponseWriter, r *stdhtt if err := s.decryptChannelConfig(*ch, &cfg); err == nil { f.NtfyServerURL = cfg.ServerURL f.NtfyTopic = cfg.Topic - // AccessToken is write-only. + f.NtfyUsername = cfg.Username + // AccessToken and Password are write-only. } case "smtp": var cfg notification.SMTPConfig diff --git a/web/templates/pages/settings.html b/web/templates/pages/settings.html index 26b38fb..529bbff 100644 --- a/web/templates/pages/settings.html +++ b/web/templates/pages/settings.html @@ -271,7 +271,20 @@ -
Required for protected topics on self-hosted ntfy.
+
Use this OR username+password below. Token wins when both are set.
+ +
+
+ + +
+
+ + +
Sent as HTTP Basic auth when no token is set.
+