package handlers import ( "fmt" "net/http" "strconv" "strings" "veola/internal/apify" "veola/internal/auth" "veola/internal/ebay" "veola/internal/models" "veola/internal/ntfy" "veola/templates" ) 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", } // 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, } // 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": "", } 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[k] = "Set in config.toml" default: status[k] = "Not set" } } return status } 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()) return templates.SettingsData{ Page: a.page(r, "Settings", "settings"), Values: values, CredentialStatus: a.credentialStatus(values), IsAdmin: cur != nil && cur.Role == models.RoleAdmin, Users: users, EbayUsedToday: ebayUsed, EbayDailyLimit: ebayLimit, }, nil } 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)) // 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 } } 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"]) topic := strings.TrimSpace(d.Values["ntfy_default_topic"]) token := strings.TrimSpace(d.Values["ntfy_token"]) 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) 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 { 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 "" } }