Add eBay Browse API integration with daily call quota
eBay marketplaces are now polled through eBay's official Buy > Browse API (client-credentials OAuth2) instead of an Apify scraper actor; Apify still handles Yahoo JP and Mercari. Browse API calls are tracked per day in a new ebay_api_usage table and capped (default 5000, configurable) on eBay's Pacific-time reset clock, so polling halts before the limit is hit. Credentials live in config.toml [ebay] and are overridable via /settings, which also surfaces the day's running call count. Also carries the server.secure_cookies config plumbing (field, accessor, example) consumed by the following commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,10 +3,12 @@ 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"
|
||||
@@ -14,6 +16,9 @@ import (
|
||||
|
||||
var settingsKeys = []string{
|
||||
"apify_api_key",
|
||||
"ebay_client_id",
|
||||
"ebay_client_secret",
|
||||
"ebay_daily_call_limit",
|
||||
"ntfy_base_url",
|
||||
"ntfy_default_topic",
|
||||
"ntfy_token",
|
||||
@@ -31,11 +36,14 @@ func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
|
||||
}
|
||||
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,
|
||||
IsAdmin: cur != nil && cur.Role == models.RoleAdmin,
|
||||
Users: users,
|
||||
Page: a.page(r, "Settings", "settings"),
|
||||
Values: values,
|
||||
IsAdmin: cur != nil && cur.Role == models.RoleAdmin,
|
||||
Users: users,
|
||||
EbayUsedToday: ebayUsed,
|
||||
EbayDailyLimit: ebayLimit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -193,3 +201,90 @@ func (a *App) PostTestApify(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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 {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
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 ""
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user