feat(ntfy): support HTTP Basic auth alongside access tokens

Self-hosted ntfy that doesn't expose a token-mint endpoint can still
authenticate over HTTP Basic. Add Username + Password fields to
NtfyConfig; the channel sends 'Authorization: Basic …' when token is
empty and username is set. Token wins when both are configured.

Form-side: two new optional fields next to the access token, with
the same write-only placeholder treatment as smtp_password (blank
on edit means 'keep stored value'). Username is round-tripped on
edit; password is masked.
This commit is contained in:
2026-05-04 22:25:42 +01:00
parent d830635a2e
commit 38683b4e64
3 changed files with 42 additions and 7 deletions
+11 -2
View File
@@ -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()
+16 -3
View File
@@ -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,12 +292,20 @@ 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 == "" {
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
+14 -1
View File
@@ -271,7 +271,20 @@
<label class="field-label" for="nt-token">Access token <span class="text-ink-fade font-normal">· optional</span></label>
<input id="nt-token" name="ntfy_access_token" type="password" class="field mono"
placeholder="{{if and $isEdit (eq $f.Kind "ntfy")}}tk_ · stored, leave blank to keep{{else}}tk_ · required for protected topics{{end}}" />
<div class="field-help">Required for protected topics on self-hosted ntfy.</div>
<div class="field-help">Use this OR username+password below. Token wins when both are set.</div>
</div>
<div class="grid grid-cols-2 gap-3.5">
<div>
<label class="field-label" for="nt-user">Username <span class="text-ink-fade font-normal">· optional</span></label>
<input id="nt-user" name="ntfy_username" type="text" class="field mono"
value="{{$f.NtfyUsername}}" placeholder="ntfy basic-auth user" />
</div>
<div>
<label class="field-label" for="nt-pass">Password <span class="text-ink-fade font-normal">· optional</span></label>
<input id="nt-pass" name="ntfy_password" type="password" class="field mono"
placeholder="{{if and $isEdit (eq $f.Kind "ntfy")}}stored, leave blank to keep{{else}}ntfy basic-auth password{{end}}" />
<div class="field-help">Sent as HTTP Basic auth when no token is set.</div>
</div>
</div>
<div>
<label class="field-label" for="nt-priority">Default priority</label>