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:
@@ -3,6 +3,7 @@ package notification
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -11,11 +12,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// NtfyConfig is the per-channel JSON shape stored AEAD-encrypted in
|
// 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 {
|
type NtfyConfig struct {
|
||||||
ServerURL string `json:"server_url"`
|
ServerURL string `json:"server_url"`
|
||||||
Topic string `json:"topic"`
|
Topic string `json:"topic"`
|
||||||
AccessToken string `json:"access_token,omitempty"`
|
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
|
// 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 != "" {
|
if p.Link != "" {
|
||||||
req.Header.Set("Click", 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)
|
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()
|
t0 := time.Now()
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ type notificationForm struct {
|
|||||||
NtfyServerURL string
|
NtfyServerURL string
|
||||||
NtfyTopic string
|
NtfyTopic string
|
||||||
NtfyAccessToken string
|
NtfyAccessToken string
|
||||||
|
NtfyUsername string
|
||||||
|
NtfyPassword string
|
||||||
|
|
||||||
// SMTP sub-fields.
|
// SMTP sub-fields.
|
||||||
SMTPHost string
|
SMTPHost string
|
||||||
@@ -188,6 +190,8 @@ func formFromRequest(r *stdhttp.Request) *notificationForm {
|
|||||||
NtfyServerURL: strings.TrimSpace(r.PostForm.Get("ntfy_server_url")),
|
NtfyServerURL: strings.TrimSpace(r.PostForm.Get("ntfy_server_url")),
|
||||||
NtfyTopic: strings.TrimSpace(r.PostForm.Get("ntfy_topic")),
|
NtfyTopic: strings.TrimSpace(r.PostForm.Get("ntfy_topic")),
|
||||||
NtfyAccessToken: r.PostForm.Get("ntfy_access_token"),
|
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")),
|
SMTPHost: strings.TrimSpace(r.PostForm.Get("smtp_host")),
|
||||||
SMTPPort: strings.TrimSpace(r.PostForm.Get("smtp_port")),
|
SMTPPort: strings.TrimSpace(r.PostForm.Get("smtp_port")),
|
||||||
@@ -288,11 +292,19 @@ func buildConfig(f *notificationForm, existing any) (any, error) {
|
|||||||
ServerURL: f.NtfyServerURL,
|
ServerURL: f.NtfyServerURL,
|
||||||
Topic: f.NtfyTopic,
|
Topic: f.NtfyTopic,
|
||||||
AccessToken: f.NtfyAccessToken,
|
AccessToken: f.NtfyAccessToken,
|
||||||
|
Username: f.NtfyUsername,
|
||||||
|
Password: f.NtfyPassword,
|
||||||
}
|
}
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
ex, ok := existing.(*notification.NtfyConfig)
|
if ex, ok := existing.(*notification.NtfyConfig); ok {
|
||||||
if ok && cfg.AccessToken == "" {
|
// Blank password on edit means "keep stored value"
|
||||||
cfg.AccessToken = ex.AccessToken
|
// — same write-only treatment as smtp_password.
|
||||||
|
if cfg.AccessToken == "" {
|
||||||
|
cfg.AccessToken = ex.AccessToken
|
||||||
|
}
|
||||||
|
if cfg.Password == "" {
|
||||||
|
cfg.Password = ex.Password
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
@@ -479,7 +491,8 @@ func (s *Server) handleUINotificationEditGet(w stdhttp.ResponseWriter, r *stdhtt
|
|||||||
if err := s.decryptChannelConfig(*ch, &cfg); err == nil {
|
if err := s.decryptChannelConfig(*ch, &cfg); err == nil {
|
||||||
f.NtfyServerURL = cfg.ServerURL
|
f.NtfyServerURL = cfg.ServerURL
|
||||||
f.NtfyTopic = cfg.Topic
|
f.NtfyTopic = cfg.Topic
|
||||||
// AccessToken is write-only.
|
f.NtfyUsername = cfg.Username
|
||||||
|
// AccessToken and Password are write-only.
|
||||||
}
|
}
|
||||||
case "smtp":
|
case "smtp":
|
||||||
var cfg notification.SMTPConfig
|
var cfg notification.SMTPConfig
|
||||||
|
|||||||
@@ -271,7 +271,20 @@
|
|||||||
<label class="field-label" for="nt-token">Access token <span class="text-ink-fade font-normal">· optional</span></label>
|
<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"
|
<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}}" />
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label class="field-label" for="nt-priority">Default priority</label>
|
<label class="field-label" for="nt-priority">Default priority</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user