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: "

Test email from Veola settings. Resend is configured correctly.

", 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 "" } }