From 72f4bdd88a3bab50f9ae810824545d58df277ad8 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:56:24 -0700 Subject: [PATCH] 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. --- config.toml.example | 6 +++ internal/config/config.go | 5 +++ internal/handlers/auth.go | 8 ++++ internal/handlers/settings.go | 69 +++++++++++++------------------- internal/ntfy/client.go | 38 +++++++++++++----- internal/scheduler/scheduler.go | 3 +- static/css/app.css | 17 ++++++++ templates/layout.templ | 10 +++-- templates/layout_templ.go | 46 +++++++++++++--------- templates/settings.templ | 12 +++--- templates/settings_templ.go | 70 ++++++++++++++++----------------- 11 files changed, 166 insertions(+), 118 deletions(-) diff --git a/config.toml.example b/config.toml.example index 803d70b..e7c7873 100644 --- a/config.toml.example +++ b/config.toml.example @@ -70,9 +70,15 @@ client_secret = "" environment = "production" daily_call_limit = 5000 +# ntfy push notifications. If the server has access control +# (auth-default-access: deny-all), set username/password to an ntfy user that +# has write access to the topics you publish to; Veola authenticates publishes +# with HTTP Basic auth. Leave both blank for an open server. [ntfy] base_url = "https://ntfy.yourdomain.com" default_topic = "veola" +username = "" +password = "" # Resend transactional email (https://resend.com) for opt-in deal alerts and # the weekly digest. api_key and from can also be set via /settings. The from diff --git a/internal/config/config.go b/internal/config/config.go index 2589c11..d6c55b2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 { diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 57004a7..8c92c37 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -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) } diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go index edbbde0..88601fd 100644 --- a/internal/handlers/settings.go +++ b/internal/handlers/settings.go @@ -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", diff --git a/internal/ntfy/client.go b/internal/ntfy/client.go index 3168351..5e5c7be 100644 --- a/internal/ntfy/client.go +++ b/internal/ntfy/client.go @@ -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 { diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index e4109b3..105e8b5 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -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), diff --git a/static/css/app.css b/static/css/app.css index c6d9134..8c76e1e 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -304,6 +304,23 @@ a.v-feed-name:hover { color: var(--accent); text-decoration: none; } } .v-side-nav a:hover { color: white; } +/* Sign-out button, styled to read like a nav link (it lives in a