Files
veola/internal/handlers/settings.go
prosolis 56fe8d7c88 Make Resend from-address config-only; drop credential status rows
The Settings page no longer renders the Resend From Address input or any
of the read-only "Set in config.toml" credential status rows (Apify key,
eBay id/secret, ntfy auth, Resend key). Those rows looked like editable
inputs but were dead text, and duplicated what the Test buttons verify
more authoritatively.

- resend_from is now sourced only from config.toml ([resend] from); it is
  removed from settingsKeys, the test-Resend handler, the scheduler email
  client, and the schema seed.
- Removed the credStatus templ component, the CredentialStatus field on
  SettingsData, and the credentialStatus() handler helper.
2026-06-20 15:42:14 -07:00

403 lines
13 KiB
Go

package handlers
import (
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"veola/internal/apify"
"veola/internal/auth"
"veola/internal/ebay"
"veola/internal/email"
"veola/internal/models"
"veola/internal/ntfy"
"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. The Test buttons verify them.
var settingsKeys = []string{
"ebay_daily_call_limit",
"ntfy_base_url",
"ntfy_default_topic",
"global_poll_interval_minutes",
"match_confidence_threshold",
"apify_cost_per_call",
"monthly_budget_usd",
}
func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
values, err := a.Store.GetAllSettings(r.Context())
if err != nil {
return templates.SettingsData{}, err
}
if values == nil {
values = map[string]string{}
}
users, _ := a.Store.ListUsers(r.Context())
cur := auth.CurrentUserFromRequest(r)
ebayUsed, ebayLimit := a.Scheduler.EbayUsage(r.Context())
apifyToday, apifyMonth, costPerCall, monthlyBudget := a.Scheduler.ApifyUsage(r.Context())
return templates.SettingsData{
Page: a.page(r, "Settings", "settings"),
Values: values,
IsAdmin: cur != nil && cur.Role == models.RoleAdmin,
Users: users,
EbayUsedToday: ebayUsed,
EbayDailyLimit: ebayLimit,
ApifyToday: apifyToday,
ApifyMonth: apifyMonth,
ApifyCostPerCall: costPerCall,
MonthlyBudget: monthlyBudget,
ForwardAuthMode: a.Cfg.Auth.ForwardAuthEnabled(),
}, nil
}
// PostEmailPrefs lets the signed-in user set their own notification email and
// opt into deal-alert / weekly-digest email. Available to every user, not just
// admins (each user owns their own delivery prefs).
func (a *App) PostEmailPrefs(w http.ResponseWriter, r *http.Request) {
cur := auth.CurrentUserFromRequest(r)
if cur == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
email := strings.TrimSpace(r.PostFormValue("email"))
// In forward-auth mode, Authentik is the single source of truth for email
// addresses — Veola never writes them. For a forward row this also preserves
// the IdP match key (changing it would orphan the row on next sign-in); for
// a break-glass local row it keeps email management centralized in Authentik.
// Only the opt-in toggles are editable here. When forward-auth is off (pure
// local deployment, no IdP), local users set their own Resend address.
if a.Cfg.Auth.ForwardAuthEnabled() || cur.AuthSource == "forward" {
email = cur.Email
}
dealAlerts := r.PostFormValue("email_deal_alerts") == "1"
weeklyDigest := r.PostFormValue("email_weekly_digest") == "1"
d, err := a.settingsData(r)
if err != nil {
a.serverError(w, r, err)
return
}
if email == "" && (dealAlerts || weeklyDigest) {
d.EmailError = "Set an email address to receive email notifications."
render(w, r, templates.Settings(d))
return
}
if err := a.Store.UpdateUserEmailPrefs(r.Context(), cur.ID, email, dealAlerts, weeklyDigest); err != nil {
a.serverError(w, r, err)
return
}
// Reflect the saved values immediately on the already-built data (CurrentUser
// in d predates the write); no need to reassemble the whole settings page.
if d.Page.CurrentUser != nil {
d.Page.CurrentUser.Email = email
d.Page.CurrentUser.EmailDealAlerts = dealAlerts
d.Page.CurrentUser.EmailWeeklyDigest = weeklyDigest
}
d.EmailMsg = "Email preferences saved."
render(w, r, templates.Settings(d))
}
// PostTestResend sends a test email to the admin's own address to verify the
// Resend configuration.
func (a *App) PostTestResend(w http.ResponseWriter, r *http.Request) {
cur := auth.CurrentUserFromRequest(r)
if cur == nil || cur.Role != models.RoleAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
d, err := a.settingsData(r)
if err != nil {
a.serverError(w, r, err)
return
}
apiKey := strings.TrimSpace(d.Values["resend_api_key"])
if apiKey == "" {
apiKey = a.Cfg.Resend.APIKey
}
from := a.Cfg.Resend.From
to := ""
if cur != nil {
to = cur.Email
}
if to == "" {
d.TestResendOK = "Set your notification email below first, then test."
render(w, r, templates.Settings(d))
return
}
client := email.New(apiKey, from)
if err := client.Send(r.Context(), email.Message{
To: to,
Subject: "Veola test email",
HTML: "<p>Test email from Veola settings. Resend is configured correctly.</p>",
Text: "Test email from Veola settings. Resend is configured correctly.",
}); err != nil {
d.TestResendOK = "Resend test failed: " + err.Error()
} else {
d.TestResendOK = "Sent a test email to " + to + "."
}
render(w, r, templates.Settings(d))
}
func (a *App) GetSettings(w http.ResponseWriter, r *http.Request) {
d, err := a.settingsData(r)
if err != nil {
a.serverError(w, r, err)
return
}
render(w, r, templates.Settings(d))
}
func (a *App) PostSettings(w http.ResponseWriter, r *http.Request) {
cur := auth.CurrentUserFromRequest(r)
if cur == nil || cur.Role != models.RoleAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
for _, k := range settingsKeys {
v := strings.TrimSpace(r.PostFormValue(k))
if err := a.Store.SetSetting(r.Context(), k, v); err != nil {
a.serverError(w, r, err)
return
}
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
func (a *App) PostPasswordChange(w http.ResponseWriter, r *http.Request) {
cur := auth.CurrentUserFromRequest(r)
if cur == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
current := r.PostFormValue("current_password")
next := r.PostFormValue("new_password")
confirm := r.PostFormValue("new_password_confirm")
d, err := a.settingsData(r)
if err != nil {
a.serverError(w, r, err)
return
}
switch {
case !auth.CheckPassword(cur.PasswordHash, current):
d.PasswordError = "Current password is incorrect"
case len(next) < auth.MinPasswordLen:
d.PasswordError = fmt.Sprintf("New password must be at least %d characters", auth.MinPasswordLen)
case next != confirm:
d.PasswordError = "New passwords do not match"
}
if d.PasswordError != "" {
render(w, r, templates.Settings(d))
return
}
hash, err := auth.HashPassword(next)
if err != nil {
http.Error(w, "hash error", http.StatusInternalServerError)
return
}
if err := a.Store.UpdateUserPassword(r.Context(), cur.ID, hash); err != nil {
a.serverError(w, r, err)
return
}
d.PasswordMsg = "Password updated"
render(w, r, templates.Settings(d))
}
func (a *App) PostTestNtfy(w http.ResponseWriter, r *http.Request) {
cur := auth.CurrentUserFromRequest(r)
if cur == nil || cur.Role != models.RoleAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
d, err := a.settingsData(r)
if err != nil {
a.serverError(w, r, err)
return
}
baseURL := strings.TrimSpace(d.Values["ntfy_base_url"])
if baseURL == "" {
baseURL = a.Cfg.Ntfy.BaseURL
}
topic := strings.TrimSpace(d.Values["ntfy_default_topic"])
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.NewWithBasicAuth(baseURL, a.Cfg.Ntfy.Username, a.Cfg.Ntfy.Password)
if err := client.Send(r.Context(), ntfy.Notification{
Topic: topic,
Title: "Veola test",
Message: "Test notification from Veola settings.",
Priority: "default",
Tags: []string{"white_check_mark"},
}); err != nil {
d.TestNtfyOK = "Ntfy test failed: " + err.Error()
} else {
d.TestNtfyOK = fmt.Sprintf("Sent test notification to %s/%s", baseURL, topic)
}
render(w, r, templates.Settings(d))
}
func (a *App) PostTestApify(w http.ResponseWriter, r *http.Request) {
cur := auth.CurrentUserFromRequest(r)
if cur == nil || cur.Role != models.RoleAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
d, err := a.settingsData(r)
if err != nil {
a.serverError(w, r, err)
return
}
apiKey := strings.TrimSpace(d.Values["apify_api_key"])
actorID := a.Cfg.Apify.Actors.ActiveListings
if apiKey == "" {
apiKey = a.Cfg.Apify.APIKey
}
if apiKey == "" || actorID == "" {
d.TestApifyOK = "Apify API key or active_listings actor is not configured."
render(w, r, templates.Settings(d))
return
}
client := apify.New(apiKey)
var proxy *apify.ProxyConfiguration
p := a.Cfg.Apify.Proxy
if p.UseApifyProxy {
proxy = &apify.ProxyConfiguration{
UseApifyProxy: true,
ApifyProxyGroups: p.Groups,
ApifyProxyCountry: p.Country,
}
}
raw, err := client.Run(r.Context(), actorID, apify.ActiveListingInput{
SearchQueries: []string{"test"},
MaxProductsPerSearch: 1,
MaxSearchPages: 1,
ListingType: "all",
ProxyConfiguration: proxy,
})
if err != nil {
d.TestApifyOK = "Apify test failed: " + err.Error()
} else {
// The test launches a real, billed actor run; count it toward the
// budget view like every other Apify run.
if incErr := a.Store.IncrementApifyUsage(r.Context()); incErr != nil {
slog.Error("apify usage increment failed", "err", incErr)
}
d.TestApifyOK = fmt.Sprintf("Apify returned %d item(s).", len(raw))
}
render(w, r, templates.Settings(d))
}
func (a *App) PostTestEbay(w http.ResponseWriter, r *http.Request) {
cur := auth.CurrentUserFromRequest(r)
if cur == nil || cur.Role != models.RoleAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
d, err := a.settingsData(r)
if err != nil {
a.serverError(w, r, err)
return
}
// Settings-table values win over config.toml. Both paths are trimmed:
// a stray newline in the TOML would otherwise reach eBay verbatim.
clientID := strings.TrimSpace(d.Values["ebay_client_id"])
if clientID == "" {
clientID = strings.TrimSpace(a.Cfg.Ebay.ClientID)
}
clientSecret := strings.TrimSpace(d.Values["ebay_client_secret"])
if clientSecret == "" {
clientSecret = strings.TrimSpace(a.Cfg.Ebay.ClientSecret)
}
if clientID == "" || clientSecret == "" {
d.TestEbayOK = "Set the eBay App ID and Cert ID first."
render(w, r, templates.Settings(d))
return
}
if d.EbayDailyLimit > 0 && d.EbayUsedToday >= d.EbayDailyLimit {
d.TestEbayOK = fmt.Sprintf("Daily eBay API call limit reached (%d/%d). Test skipped.", d.EbayUsedToday, d.EbayDailyLimit)
render(w, r, templates.Settings(d))
return
}
env := "production"
if strings.EqualFold(strings.TrimSpace(a.Cfg.Ebay.Environment), "sandbox") {
env = "sandbox"
}
// Echo back exactly what was sent (App ID masked, Cert ID length only) so
// a failure points at the inputs, not just "it failed".
inputs := fmt.Sprintf("%s, App ID %s, Cert ID %d chars", env, maskID(clientID), len(clientSecret))
client := ebay.New(clientID, clientSecret, a.Cfg.Ebay.Environment)
listings, err := client.Search(r.Context(), ebay.SearchParams{
MarketplaceID: "EBAY_US",
Query: "test",
Limit: 1,
})
// A real call was made; count it against the daily allowance.
if n, incErr := a.Store.IncrementEbayUsage(r.Context()); incErr == nil {
d.EbayUsedToday = n
}
if err != nil {
msg := fmt.Sprintf("eBay test failed (%s): %s", inputs, err.Error())
if ks := ebayKeysetEnv(clientID); ks != "" && ks != env {
msg += fmt.Sprintf(" — the App ID looks like a %s keyset, but environment is %q. Set environment = %q in the [ebay] config block (or use your %s keyset).", ks, env, ks, env)
}
d.TestEbayOK = msg
} else {
d.TestEbayOK = fmt.Sprintf("eBay Browse API reachable (%s). Returned %d item(s).", inputs, len(listings))
}
render(w, r, templates.Settings(d))
}
// maskID returns a fingerprint of a credential for display: enough of the head
// to recognize it, the rest elided. Used only for the App ID (the non-secret
// half of the OAuth pair) — never for the Cert ID.
func maskID(s string) string {
if len(s) <= 12 {
return strings.Repeat("•", len(s))
}
return s[:12] + "…(" + strconv.Itoa(len(s)) + " chars)"
}
// ebayKeysetEnv guesses which environment an eBay App ID belongs to from the
// SBX/PRD marker eBay embeds in it (e.g. "Name-app-PRD-1a2b..."). Returns ""
// when no marker is present.
func ebayKeysetEnv(clientID string) string {
up := strings.ToUpper(clientID)
switch {
case strings.Contains(up, "-SBX-"):
return "sandbox"
case strings.Contains(up, "-PRD-"):
return "production"
default:
return ""
}
}