Add ntfy Basic auth, sign-out button, config-only credentials
- ntfy client supports HTTP Basic auth (username/password) for servers with access control; [ntfy] config gains username/password. Scheduler + Test Ntfy use it instead of the old settings-only bearer token. - Settings page no longer has credential inputs (Apify/eBay/ntfy/Resend); they are managed in config.toml. Read-only status + Test buttons remain. - Sidebar gains a Sign out button; in forward-auth mode it routes through the Authentik outpost sign_out so the IdP session is cleared.
This commit is contained in:
@@ -126,6 +126,11 @@ type ActorConfig struct {
|
||||
type NtfyConfig struct {
|
||||
BaseURL string `toml:"base_url"`
|
||||
DefaultTopic string `toml:"default_topic"`
|
||||
// Username/Password authenticate publishes via HTTP Basic auth, for ntfy
|
||||
// servers with access control (auth-default-access: deny-all). Leave both
|
||||
// empty for an open server.
|
||||
Username string `toml:"username"`
|
||||
Password string `toml:"password"`
|
||||
}
|
||||
|
||||
type SchedulerConfig struct {
|
||||
|
||||
@@ -49,6 +49,14 @@ func (a *App) PostLogin(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) PostLogout(w http.ResponseWriter, r *http.Request) {
|
||||
_ = a.Auth.LogOut(r.Context())
|
||||
// In forward-auth mode, destroying only the local session is pointless: the
|
||||
// next request still carries a valid Authentik proxy session and would be
|
||||
// re-provisioned immediately. Send the user through the Authentik outpost
|
||||
// sign-out so the IdP session is cleared too.
|
||||
if a.Cfg.Auth.ForwardAuthEnabled() {
|
||||
http.Redirect(w, r, "/outpost.goauthentik.io/sign_out", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,52 +16,38 @@ import (
|
||||
"veola/templates"
|
||||
)
|
||||
|
||||
// settingsKeys are the non-secret operational settings editable from the
|
||||
// Settings page. API credentials (Apify, eBay, ntfy, Resend) are deliberately
|
||||
// NOT here — they are managed only in config.toml on the server, so the UI
|
||||
// never reads or writes them. credentialStatus surfaces whether each is
|
||||
// configured, and the Test buttons verify them.
|
||||
var settingsKeys = []string{
|
||||
"apify_api_key",
|
||||
"ebay_client_id",
|
||||
"ebay_client_secret",
|
||||
"ebay_daily_call_limit",
|
||||
"ntfy_base_url",
|
||||
"ntfy_default_topic",
|
||||
"ntfy_token",
|
||||
"global_poll_interval_minutes",
|
||||
"match_confidence_threshold",
|
||||
"apify_cost_per_call",
|
||||
"monthly_budget_usd",
|
||||
"resend_api_key",
|
||||
"resend_from",
|
||||
}
|
||||
|
||||
// secretSettingsKeys are credential fields. Their values are never rendered
|
||||
// back into the form, so a blank submission means "leave unchanged" rather
|
||||
// than "clear" — see PostSettings.
|
||||
var secretSettingsKeys = map[string]bool{
|
||||
"apify_api_key": true,
|
||||
"ebay_client_id": true,
|
||||
"ebay_client_secret": true,
|
||||
"ntfy_token": true,
|
||||
"resend_api_key": true,
|
||||
}
|
||||
|
||||
// credentialStatus reports, per secret key, whether a value is saved in the
|
||||
// settings table, inherited from config.toml, or absent — without exposing
|
||||
// the secret itself.
|
||||
func (a *App) credentialStatus(values map[string]string) map[string]string {
|
||||
configVals := map[string]string{
|
||||
"apify_api_key": a.Cfg.Apify.APIKey,
|
||||
"ebay_client_id": a.Cfg.Ebay.ClientID,
|
||||
"ebay_client_secret": a.Cfg.Ebay.ClientSecret,
|
||||
"ntfy_token": "",
|
||||
"resend_api_key": a.Cfg.Resend.APIKey,
|
||||
// credentialStatus reports, per credential, whether it is configured in
|
||||
// config.toml — without exposing the secret itself. ntfy_auth reflects the
|
||||
// Basic-auth user/password pair.
|
||||
func (a *App) credentialStatus() map[string]string {
|
||||
configured := map[string]bool{
|
||||
"apify_api_key": strings.TrimSpace(a.Cfg.Apify.APIKey) != "",
|
||||
"ebay_client_id": strings.TrimSpace(a.Cfg.Ebay.ClientID) != "",
|
||||
"ebay_client_secret": strings.TrimSpace(a.Cfg.Ebay.ClientSecret) != "",
|
||||
"ntfy_auth": strings.TrimSpace(a.Cfg.Ntfy.Username) != "" && a.Cfg.Ntfy.Password != "",
|
||||
"resend_api_key": strings.TrimSpace(a.Cfg.Resend.APIKey) != "",
|
||||
}
|
||||
status := make(map[string]string, len(secretSettingsKeys))
|
||||
for k := range secretSettingsKeys {
|
||||
switch {
|
||||
case strings.TrimSpace(values[k]) != "":
|
||||
status[k] = "Saved in settings"
|
||||
case strings.TrimSpace(configVals[k]) != "":
|
||||
status := make(map[string]string, len(configured))
|
||||
for k, ok := range configured {
|
||||
if ok {
|
||||
status[k] = "Set in config.toml"
|
||||
default:
|
||||
} else {
|
||||
status[k] = "Not set"
|
||||
}
|
||||
}
|
||||
@@ -83,7 +69,7 @@ func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
|
||||
return templates.SettingsData{
|
||||
Page: a.page(r, "Settings", "settings"),
|
||||
Values: values,
|
||||
CredentialStatus: a.credentialStatus(values),
|
||||
CredentialStatus: a.credentialStatus(),
|
||||
IsAdmin: cur != nil && cur.Role == models.RoleAdmin,
|
||||
Users: users,
|
||||
EbayUsedToday: ebayUsed,
|
||||
@@ -212,12 +198,6 @@ func (a *App) PostSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
for _, k := range settingsKeys {
|
||||
v := strings.TrimSpace(r.PostFormValue(k))
|
||||
// Secret fields are never rendered back into the form, so a blank
|
||||
// submission is the normal state and means "leave unchanged" — not
|
||||
// "clear". (To clear a stored credential, edit the settings table.)
|
||||
if v == "" && secretSettingsKeys[k] {
|
||||
continue
|
||||
}
|
||||
if err := a.Store.SetSetting(r.Context(), k, v); err != nil {
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
@@ -284,14 +264,19 @@ func (a *App) PostTestNtfy(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
baseURL := strings.TrimSpace(d.Values["ntfy_base_url"])
|
||||
if baseURL == "" {
|
||||
baseURL = a.Cfg.Ntfy.BaseURL
|
||||
}
|
||||
topic := strings.TrimSpace(d.Values["ntfy_default_topic"])
|
||||
token := strings.TrimSpace(d.Values["ntfy_token"])
|
||||
if topic == "" {
|
||||
topic = a.Cfg.Ntfy.DefaultTopic
|
||||
}
|
||||
if baseURL == "" || topic == "" {
|
||||
d.TestNtfyOK = "Set ntfy base URL and default topic first."
|
||||
render(w, r, templates.Settings(d))
|
||||
return
|
||||
}
|
||||
client := ntfy.NewWithToken(baseURL, token)
|
||||
client := ntfy.NewWithBasicAuth(baseURL, a.Cfg.Ntfy.Username, a.Cfg.Ntfy.Password)
|
||||
if err := client.Send(r.Context(), ntfy.Notification{
|
||||
Topic: topic,
|
||||
Title: "Veola test",
|
||||
|
||||
@@ -13,7 +13,12 @@ import (
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
HTTP *http.Client
|
||||
// Username/Password take precedence over Token when set, using HTTP Basic
|
||||
// auth (for ntfy servers that authenticate with user accounts rather than
|
||||
// access tokens).
|
||||
Username string
|
||||
Password string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func New(baseURL string) *Client {
|
||||
@@ -24,13 +29,23 @@ func New(baseURL string) *Client {
|
||||
}
|
||||
|
||||
// NewWithToken returns a ntfy Client with bearer-token auth set. Use this
|
||||
// when the ntfy server requires authentication.
|
||||
// when the ntfy server requires token authentication.
|
||||
func NewWithToken(baseURL, token string) *Client {
|
||||
c := New(baseURL)
|
||||
c.Token = strings.TrimSpace(token)
|
||||
return c
|
||||
}
|
||||
|
||||
// NewWithBasicAuth returns a ntfy Client that authenticates publishes with an
|
||||
// ntfy user account (HTTP Basic auth). Use this for servers configured with
|
||||
// auth-default-access: deny-all and per-user topic ACLs.
|
||||
func NewWithBasicAuth(baseURL, username, password string) *Client {
|
||||
c := New(baseURL)
|
||||
c.Username = strings.TrimSpace(username)
|
||||
c.Password = password
|
||||
return c
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
Topic string
|
||||
Title string
|
||||
@@ -58,7 +73,9 @@ func (c *Client) Send(ctx context.Context, n Notification) error {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
if c.Token != "" {
|
||||
if c.Username != "" {
|
||||
req.SetBasicAuth(c.Username, c.Password)
|
||||
} else if c.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
if n.Title != "" {
|
||||
@@ -73,17 +90,18 @@ func (c *Client) Send(ctx context.Context, n Notification) error {
|
||||
if n.Click != "" {
|
||||
req.Header.Set("Click", n.Click)
|
||||
}
|
||||
tokenLen := len(c.Token)
|
||||
tokenPrefix := ""
|
||||
if tokenLen >= 4 {
|
||||
tokenPrefix = c.Token[:4]
|
||||
authMode := "none"
|
||||
switch {
|
||||
case c.Username != "":
|
||||
authMode = "basic"
|
||||
case c.Token != "":
|
||||
authMode = "bearer"
|
||||
}
|
||||
slog.Info("ntfy publish",
|
||||
"url", url,
|
||||
"topic", n.Topic,
|
||||
"auth_header_set", c.Token != "",
|
||||
"token_prefix", tokenPrefix,
|
||||
"token_len", tokenLen,
|
||||
"auth", authMode,
|
||||
"user", c.Username,
|
||||
)
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -489,8 +489,7 @@ func (s *Scheduler) sendNotification(ctx context.Context, it models.Item, r apif
|
||||
if v, _ := s.store.GetSetting(ctx, "ntfy_base_url"); v != "" {
|
||||
baseURL = v
|
||||
}
|
||||
token, _ := s.store.GetSetting(ctx, "ntfy_token")
|
||||
client := ntfy.NewWithToken(baseURL, token)
|
||||
client := ntfy.NewWithBasicAuth(baseURL, s.cfg.Ntfy.Username, s.cfg.Ntfy.Password)
|
||||
return client.Send(ctx, ntfy.Notification{
|
||||
Topic: topic,
|
||||
Title: fmt.Sprintf("Veola Alert: %s", it.Name),
|
||||
|
||||
Reference in New Issue
Block a user