diff --git a/config.toml.example b/config.toml.example index e877f5b..329cae9 100644 --- a/config.toml.example +++ b/config.toml.example @@ -1,6 +1,11 @@ [server] port = 8080 db_path = "./veola.db" +# Sets the Secure attribute on the session cookie. Leave true for any +# HTTPS-reachable deployment, including behind a TLS-terminating proxy such as +# Traefik. Defaults to true if omitted; set false only for local plain-HTTP +# development on a non-localhost address. +secure_cookies = true [security] # Both must be at least 32 bytes and different from each other. @@ -31,6 +36,21 @@ yahoo_auctions_jp = "meron1122/zenmarket-scraper" yahoo_auctions_jp_sold = "" # no known verified sold-listings actor for Yahoo JP mercari_jp = "cloud9_ai/mercari-scraper" +# eBay's official Buy > Browse API. When client_id and client_secret are set, +# eBay marketplaces (ebay.com, ebay.co.uk, ...) are polled through this API +# instead of an Apify scraper actor; Apify still handles Yahoo JP and Mercari. +# client_id is the App ID and client_secret is the Cert ID from your eBay +# developer keyset. Both can also be set/overridden at runtime via /settings. +# environment is "production" (default) or "sandbox". +# daily_call_limit caps Browse API calls per day on eBay's own quota clock +# (midnight US Pacific); once hit, eBay polling halts until the next reset. +# 5000 is the standard Browse API allowance; set a negative value to disable. +[ebay] +client_id = "" +client_secret = "" +environment = "production" +daily_call_limit = 5000 + [ntfy] base_url = "https://ntfy.yourdomain.com" default_topic = "veola" diff --git a/internal/config/config.go b/internal/config/config.go index e282929..7c2a6c9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,13 +13,43 @@ type Config struct { Server ServerConfig `toml:"server"` Security SecurityConfig `toml:"security"` Apify ApifyConfig `toml:"apify"` + Ebay EbayConfig `toml:"ebay"` Ntfy NtfyConfig `toml:"ntfy"` Scheduler SchedulerConfig `toml:"scheduler"` } +// EbayConfig holds credentials for eBay's official Buy > Browse API. When set, +// eBay marketplaces are polled through the Browse API instead of an Apify +// scraper actor. ClientID is the App ID and ClientSecret is the Cert ID from +// the eBay developer keyset. Environment is "production" (default) or +// "sandbox". Like the Apify key, both credentials can be overridden at +// runtime via the Settings page. +type EbayConfig struct { + ClientID string `toml:"client_id"` + ClientSecret string `toml:"client_secret"` + Environment string `toml:"environment"` + // DailyCallLimit caps Browse API calls per day, on eBay's own quota + // clock (midnight US Pacific). Once reached, eBay polling halts until + // the next reset. Defaults to 5000 (the standard Browse API allowance). + // Set to a negative value to disable the cap. + DailyCallLimit int `toml:"daily_call_limit"` +} + type ServerConfig struct { Port int `toml:"port"` DBPath string `toml:"db_path"` + // SecureCookies sets the Secure attribute on the session cookie. It must + // be true in any deployment reachable over HTTPS — including behind a + // TLS-terminating proxy like Traefik, where the browser-facing leg is + // HTTPS even though Veola itself speaks plain HTTP. Defaults to true; + // set false only for local non-TLS development. + SecureCookies *bool `toml:"secure_cookies"` +} + +// UseSecureCookies resolves the SecureCookies setting, defaulting to true when +// the key is absent from config. +func (c ServerConfig) UseSecureCookies() bool { + return c.SecureCookies == nil || *c.SecureCookies } type SecurityConfig struct { @@ -111,6 +141,9 @@ func (c *Config) validate() error { if c.Ntfy.DefaultTopic == "" { c.Ntfy.DefaultTopic = "veola" } + if c.Ebay.DailyCallLimit == 0 { + c.Ebay.DailyCallLimit = 5000 + } return nil } diff --git a/internal/db/queries.go b/internal/db/queries.go index ffb7c04..94c9fb4 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -213,6 +213,60 @@ func (s *Store) SetSetting(ctx context.Context, key, value string) error { return err } +// ============ ebay api usage ============ + +// ebayResetLoc is the timezone eBay's API rate limits reset in: midnight +// Pacific (it observes US DST). If the zone database is somehow unavailable +// we fall back to UTC rather than failing — main.go embeds time/tzdata so in +// practice the lookup always succeeds. +var ebayResetLoc = func() *time.Location { + loc, err := time.LoadLocation("America/Los_Angeles") + if err != nil { + return time.UTC + } + return loc +}() + +// ebayUsageDay returns the date key used to bucket eBay API calls, aligned to +// eBay's own quota reset (midnight US Pacific). +func ebayUsageDay() string { + return time.Now().In(ebayResetLoc).Format("2006-01-02") +} + +// EbayUsageToday returns the number of eBay Browse API calls recorded for the +// current UTC day. A missing row counts as zero. +func (s *Store) EbayUsageToday(ctx context.Context) (int, error) { + var n int + err := s.DB.QueryRowContext(ctx, + `SELECT call_count FROM ebay_api_usage WHERE usage_date = ?`, ebayUsageDay()).Scan(&n) + if errors.Is(err, sql.ErrNoRows) { + return 0, nil + } + if err != nil { + return 0, err + } + return n, nil +} + +// IncrementEbayUsage records one eBay Browse API call against the current UTC +// day and returns the new running total. +func (s *Store) IncrementEbayUsage(ctx context.Context) (int, error) { + day := ebayUsageDay() + _, err := s.DB.ExecContext(ctx, ` + INSERT INTO ebay_api_usage (usage_date, call_count) VALUES (?, 1) + ON CONFLICT(usage_date) DO UPDATE SET call_count = call_count + 1 + `, day) + if err != nil { + return 0, err + } + var n int + if err := s.DB.QueryRowContext(ctx, + `SELECT call_count FROM ebay_api_usage WHERE usage_date = ?`, day).Scan(&n); err != nil { + return 0, err + } + return n, nil +} + // ============ items ============ func (s *Store) CreateItem(ctx context.Context, it *models.Item) (int64, error) { diff --git a/internal/db/schema.sql b/internal/db/schema.sql index ad7f692..fb9aac5 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -90,6 +90,15 @@ INSERT OR IGNORE INTO settings (key, value) VALUES ('global_poll_interval_minutes', '60'), ('match_confidence_threshold', '0.6'); +-- ebay_api_usage tracks Browse API calls per day so Veola can surface +-- consumption and halt polling before the developer keyset's daily call +-- limit is exceeded. usage_date is YYYY-MM-DD in US Pacific time, matching +-- eBay's own quota reset. +CREATE TABLE IF NOT EXISTS ebay_api_usage ( + usage_date TEXT PRIMARY KEY, + call_count INTEGER NOT NULL DEFAULT 0 +); + CREATE TABLE IF NOT EXISTS sessions ( token TEXT PRIMARY KEY, data BLOB NOT NULL, diff --git a/internal/ebay/client.go b/internal/ebay/client.go new file mode 100644 index 0000000..8046f74 --- /dev/null +++ b/internal/ebay/client.go @@ -0,0 +1,227 @@ +// Package ebay is a thin client for eBay's official Buy > Browse API. It +// handles client-credentials OAuth2 token caching and active-listing search +// (item_summary/search). It deliberately covers only what Veola needs: a +// keyword search returning normalized listings. Sold/completed data (the +// Marketplace Insights API) is not implemented here. +package ebay + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +// endpoints bundles the production/sandbox base URLs for the two APIs used. +type endpoints struct { + oauth string + browse string +} + +func endpointsFor(environment string) endpoints { + if strings.EqualFold(strings.TrimSpace(environment), "sandbox") { + return endpoints{ + oauth: "https://api.sandbox.ebay.com/identity/v1/oauth2/token", + browse: "https://api.sandbox.ebay.com/buy/browse/v1", + } + } + return endpoints{ + oauth: "https://api.ebay.com/identity/v1/oauth2/token", + browse: "https://api.ebay.com/buy/browse/v1", + } +} + +// Client is safe for concurrent use. The application access token is cached +// in memory and refreshed shortly before it expires. +type Client struct { + HTTP *http.Client + + mu sync.Mutex + clientID string + clientSecret string + ends endpoints + token string + tokenExpiry time.Time +} + +// New builds a client for the given keyset. environment is "production" +// (default) or "sandbox". Credentials may be empty; calls then fail fast +// with a "not configured" error. +func New(clientID, clientSecret, environment string) *Client { + return &Client{ + HTTP: &http.Client{Timeout: 30 * time.Second}, + clientID: clientID, + clientSecret: clientSecret, + ends: endpointsFor(environment), + } +} + +// EnsureCredentials updates the keyset if it changed, discarding any cached +// token so the next call re-authenticates. The environment is fixed at +// construction time and is not changed here. Safe to call on every poll. +func (c *Client) EnsureCredentials(clientID, clientSecret string) { + c.mu.Lock() + defer c.mu.Unlock() + if clientID == c.clientID && clientSecret == c.clientSecret { + return + } + c.clientID = clientID + c.clientSecret = clientSecret + c.token = "" + c.tokenExpiry = time.Time{} +} + +func (c *Client) accessToken(ctx context.Context) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.clientID == "" || c.clientSecret == "" { + return "", errors.New("ebay credentials not configured") + } + if c.token != "" && time.Now().Before(c.tokenExpiry) { + return c.token, nil + } + + form := url.Values{} + form.Set("grant_type", "client_credentials") + form.Set("scope", "https://api.ebay.com/oauth/api_scope") + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.ends.oauth, strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + basic := base64.StdEncoding.EncodeToString([]byte(c.clientID + ":" + c.clientSecret)) + req.Header.Set("Authorization", "Basic "+basic) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.HTTP.Do(req) + if err != nil { + return "", fmt.Errorf("ebay oauth: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode >= 300 { + return "", fmt.Errorf("ebay oauth: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + var tr struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` + } + if err := json.Unmarshal(body, &tr); err != nil { + return "", fmt.Errorf("ebay oauth: decode: %w", err) + } + if tr.AccessToken == "" { + return "", errors.New("ebay oauth: empty access token") + } + c.token = tr.AccessToken + // Refresh a minute early to avoid racing the expiry. + ttl := time.Duration(tr.ExpiresIn) * time.Second + if ttl <= time.Minute { + ttl = time.Minute + } + c.tokenExpiry = time.Now().Add(ttl - time.Minute) + return c.token, nil +} + +// browseItemSummary mirrors the subset of the item_summary/search response +// Veola consumes. +type browseItemSummary struct { + ItemID string `json:"itemId"` + Title string `json:"title"` + Price struct { + Value string `json:"value"` + Currency string `json:"currency"` + } `json:"price"` + ItemWebURL string `json:"itemWebUrl"` + Image struct { + ImageURL string `json:"imageUrl"` + } `json:"image"` + ThumbnailImages []struct { + ImageURL string `json:"imageUrl"` + } `json:"thumbnailImages"` + Seller struct { + Username string `json:"username"` + } `json:"seller"` +} + +// Search runs one item_summary/search call and returns normalized listings. +// An empty query is rejected: the Browse API requires a non-empty q. +func (c *Client) Search(ctx context.Context, p SearchParams) ([]Listing, error) { + query := strings.TrimSpace(p.Query) + if query == "" { + return nil, errors.New("ebay search requires a non-empty query") + } + token, err := c.accessToken(ctx) + if err != nil { + return nil, err + } + marketplace := p.MarketplaceID + if marketplace == "" { + marketplace = "EBAY_US" + } + limit := p.Limit + if limit <= 0 || limit > 200 { + limit = 50 + } + + q := url.Values{} + q.Set("q", query) + q.Set("limit", strconv.Itoa(limit)) + if f := buyingOptionsFilter(p.ListingType); f != "" { + q.Set("filter", f) + } + reqURL := c.ends.browse + "/item_summary/search?" + q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("X-EBAY-C-MARKETPLACE-ID", marketplace) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("ebay browse: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("ebay browse: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + var sr struct { + ItemSummaries []browseItemSummary `json:"itemSummaries"` + } + if err := json.Unmarshal(body, &sr); err != nil { + return nil, fmt.Errorf("ebay browse: decode: %w", err) + } + + out := make([]Listing, 0, len(sr.ItemSummaries)) + for _, s := range sr.ItemSummaries { + price, _ := strconv.ParseFloat(strings.TrimSpace(s.Price.Value), 64) + img := s.Image.ImageURL + if img == "" && len(s.ThumbnailImages) > 0 { + img = s.ThumbnailImages[0].ImageURL + } + store := "ebay" + if s.Seller.Username != "" { + store = "ebay (" + s.Seller.Username + ")" + } + out = append(out, Listing{ + Title: s.Title, + Price: price, + Currency: s.Price.Currency, + URL: s.ItemWebURL, + Store: store, + ImageURL: img, + }) + } + return out, nil +} diff --git a/internal/ebay/ebay_test.go b/internal/ebay/ebay_test.go new file mode 100644 index 0000000..9d7a7d0 --- /dev/null +++ b/internal/ebay/ebay_test.go @@ -0,0 +1,45 @@ +package ebay + +import "testing" + +func TestMarketplaceID(t *testing.T) { + cases := map[string]string{ + "ebay.com": "EBAY_US", + "ebay.co.uk": "EBAY_GB", + "ebay.de": "EBAY_DE", + "ebay.com.au": "EBAY_AU", + "EBAY.CA": "EBAY_CA", + "ebay": "EBAY_US", + "weird-market": "EBAY_US", + " ebay.it ": "EBAY_IT", + } + for in, want := range cases { + if got := MarketplaceID(in); got != want { + t.Errorf("MarketplaceID(%q) = %q, want %q", in, got, want) + } + } +} + +func TestIsEbayMarketplace(t *testing.T) { + if !IsEbayMarketplace("ebay.co.uk") { + t.Error("ebay.co.uk should be an eBay marketplace") + } + if IsEbayMarketplace("yahoo-auctions-jp") { + t.Error("yahoo should not be an eBay marketplace") + } +} + +func TestBuyingOptionsFilter(t *testing.T) { + cases := map[string]string{ + "": "", + "all": "", + "bin": "buyingOptions:{FIXED_PRICE}", + "buy_it_now": "buyingOptions:{FIXED_PRICE}", + "auction": "buyingOptions:{AUCTION}", + } + for in, want := range cases { + if got := buyingOptionsFilter(in); got != want { + t.Errorf("buyingOptionsFilter(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/ebay/types.go b/internal/ebay/types.go new file mode 100644 index 0000000..07d18bf --- /dev/null +++ b/internal/ebay/types.go @@ -0,0 +1,94 @@ +package ebay + +import "strings" + +// SearchParams is the input to a single Browse API item_summary/search call. +// It is provider-specific and is carried as the opaque input payload of a +// scheduler plan, mirroring how Apify actor inputs are carried. +type SearchParams struct { + // MarketplaceID is an eBay marketplace identifier such as EBAY_US. + MarketplaceID string + // Query is the keyword search string. Required; the Browse API rejects + // an empty q. + Query string + // ListingType is Veola's vocabulary ("all", "bin"/"buy_it_now", + // "auction"); it is mapped to a buyingOptions filter. + ListingType string + // Limit caps the number of results requested (Browse API max is 200). + Limit int +} + +// Listing is one normalized active eBay listing. The scheduler converts these +// into the shared apify.UnifiedResult shape so the rest of the pipeline +// (dedup, filter, alert) is provider-agnostic. +type Listing struct { + Title string + Price float64 + Currency string + URL string + Store string + ImageURL string +} + +// MarketplaceID maps a Veola marketplace string (e.g. "ebay.com", +// "ebay.co.uk") to an eBay Browse API marketplace identifier. Unknown or +// bare "ebay" values fall back to EBAY_US. +func MarketplaceID(marketplace string) string { + m := strings.ToLower(strings.TrimSpace(marketplace)) + switch { + case strings.Contains(m, "ebay.co.uk"): + return "EBAY_GB" + case strings.Contains(m, "ebay.de"): + return "EBAY_DE" + case strings.Contains(m, "ebay.com.au"): + return "EBAY_AU" + case strings.Contains(m, "ebay.ca"): + return "EBAY_CA" + case strings.Contains(m, "ebay.fr"): + return "EBAY_FR" + case strings.Contains(m, "ebay.it"): + return "EBAY_IT" + case strings.Contains(m, "ebay.es"): + return "EBAY_ES" + case strings.Contains(m, "ebay.at"): + return "EBAY_AT" + case strings.Contains(m, "ebay.ch"): + return "EBAY_CH" + case strings.Contains(m, "ebay.ie"): + return "EBAY_IE" + case strings.Contains(m, "ebay.nl"): + return "EBAY_NL" + case strings.Contains(m, "ebay.com.hk"): + return "EBAY_HK" + case strings.Contains(m, "ebay.com.sg"): + return "EBAY_SG" + case strings.Contains(m, "ebay.com.my"): + return "EBAY_MY" + case strings.Contains(m, "ebay.ph"): + return "EBAY_PH" + case strings.Contains(m, "ebay.pl"): + return "EBAY_PL" + default: + // "ebay.com" and any bare/unknown eBay marketplace. + return "EBAY_US" + } +} + +// IsEbayMarketplace reports whether a Veola marketplace string should be +// polled through the official eBay Browse API. +func IsEbayMarketplace(marketplace string) bool { + return strings.Contains(strings.ToLower(marketplace), "ebay") +} + +// buyingOptionsFilter maps Veola's listing-type vocabulary to the Browse API +// "filter" query parameter. An empty string means no filter ("all"). +func buyingOptionsFilter(listingType string) string { + switch strings.ToLower(strings.TrimSpace(listingType)) { + case "bin", "buy_it_now", "fixed_price": + return "buyingOptions:{FIXED_PRICE}" + case "auction": + return "buyingOptions:{AUCTION}" + default: + return "" + } +} diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go index 6f36008..64b7168 100644 --- a/internal/handlers/settings.go +++ b/internal/handlers/settings.go @@ -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 "" + } +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 03c83b2..d205bf5 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "strconv" "strings" "sync" "time" @@ -13,14 +14,23 @@ import ( "veola/internal/apify" "veola/internal/config" "veola/internal/db" + "veola/internal/ebay" "veola/internal/models" "veola/internal/ntfy" ) +// Provider labels distinguish how a plan is executed: through an Apify actor +// run, or through eBay's official Browse API. +const ( + providerApify = "apify" + providerEbay = "ebay" +) + type Scheduler struct { cfg *config.Config store *db.Store apify *apify.Client + ebay *ebay.Client ntfy *ntfy.Client cron *cron.Cron @@ -37,6 +47,7 @@ func New(cfg *config.Config, store *db.Store, ap *apify.Client, nt *ntfy.Client) cfg: cfg, store: store, apify: ap, + ebay: ebay.New(cfg.Ebay.ClientID, cfg.Ebay.ClientSecret, cfg.Ebay.Environment), ntfy: nt, cron: cron.New(), entries: make(map[int64]cron.EntryID), @@ -136,52 +147,16 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) { var errs []string successes := 0 for _, p := range plans { - if p.actorID == "" { - errs = append(errs, fmt.Sprintf("%s: no actor configured", p.marketplace)) - continue - } - raw, err := apifyClient.Run(ctx, p.actorID, p.input) + decoded, err := s.ExecutePlan(ctx, p) if err != nil { label := p.marketplace if p.query != "" { label = fmt.Sprintf("query %q on %s", p.query, p.marketplace) } errs = append(errs, fmt.Sprintf("%s: %s", label, err.Error())) - slog.Error("apify run failed", "item_id", it.ID, "marketplace", p.marketplace, "query", p.query, "err", err) + slog.Error("plan failed", "item_id", it.ID, "provider", p.provider, "marketplace", p.marketplace, "query", p.query, "err", err) continue } - decoded, _ := apify.Decode(raw, p.source) - usable := 0 - for i := range decoded { - decoded[i].MatchedQuery = p.query - if decoded[i].URL != "" && decoded[i].Price > 0 { - usable++ - } - } - slog.Info("apify run decoded", - "item_id", it.ID, - "marketplace", p.marketplace, - "query", p.query, - "actor", p.actorID, - "raw", len(raw), - "decoded", len(decoded), - "usable", usable, - ) - if usable == 0 && len(raw) > 0 { - var sample map[string]any - if err := jsonUnmarshal(raw[0], &sample); err == nil { - keys := make([]string, 0, len(sample)) - for k := range sample { - keys = append(keys, k) - } - slog.Warn("decoded zero usable rows; raw item keys", - "item_id", it.ID, - "marketplace", p.marketplace, - "actor", p.actorID, - "keys", keys, - ) - } - } results = append(results, decoded...) successes++ } @@ -322,6 +297,103 @@ func (s *Scheduler) apifyClient(ctx context.Context) *apify.Client { return apify.New(key) } +// ebayClient returns the shared eBay client with credentials refreshed from +// settings (falling back to config.toml). The client caches its OAuth token +// in memory, so the same instance is reused across polls; credentials are +// only re-applied when they actually change. +func (s *Scheduler) ebayClient(ctx context.Context) *ebay.Client { + id := s.cfg.Ebay.ClientID + secret := s.cfg.Ebay.ClientSecret + if v, _ := s.store.GetSetting(ctx, "ebay_client_id"); v != "" { + id = v + } + if v, _ := s.store.GetSetting(ctx, "ebay_client_secret"); v != "" { + secret = v + } + s.ebay.EnsureCredentials(id, secret) + return s.ebay +} + +// EbayUsage returns the number of eBay Browse API calls made so far today and +// the configured daily limit. A limit <= 0 means uncapped. Settings override +// config.toml for the limit, mirroring how credentials are resolved. +func (s *Scheduler) EbayUsage(ctx context.Context) (used, limit int) { + used, _ = s.store.EbayUsageToday(ctx) + limit = s.cfg.Ebay.DailyCallLimit + if v, _ := s.store.GetSetting(ctx, "ebay_daily_call_limit"); v != "" { + if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil { + limit = n + } + } + return used, limit +} + +// ExecutePlan runs one plan and returns decoded, provider-agnostic results +// with MatchedQuery already stamped. eBay plans go through the official +// Browse API; all other plans run an Apify actor. Callers handle per-plan +// errors without poisoning sibling plans. +func (s *Scheduler) ExecutePlan(ctx context.Context, p actorPlan) ([]apify.UnifiedResult, error) { + var decoded []apify.UnifiedResult + switch p.provider { + case providerEbay: + sp, ok := p.input.(ebay.SearchParams) + if !ok { + return nil, fmt.Errorf("ebay plan has wrong input type %T", p.input) + } + used, limit := s.EbayUsage(ctx) + if limit > 0 && used >= limit { + return nil, fmt.Errorf("ebay daily API call limit reached (%d/%d); polling halted until the next reset (midnight US Pacific)", used, limit) + } + listings, err := s.ebayClient(ctx).Search(ctx, sp) + // The call hit eBay (or at least was attempted against it) whether + // or not it succeeded, so it counts against the daily allowance. + if n, incErr := s.store.IncrementEbayUsage(ctx); incErr != nil { + slog.Error("ebay usage increment failed", "err", incErr) + } else if limit > 0 && n >= limit { + slog.Warn("ebay daily API call limit reached", "used", n, "limit", limit) + } + if err != nil { + return nil, err + } + decoded = make([]apify.UnifiedResult, 0, len(listings)) + for _, l := range listings { + decoded = append(decoded, apify.UnifiedResult{ + Title: l.Title, + Price: l.Price, + Currency: l.Currency, + URL: l.URL, + Store: l.Store, + ImageURL: l.ImageURL, + Source: apify.SourceActiveEbay, + }) + } + default: + if p.actorID == "" { + return nil, fmt.Errorf("no actor configured for %s", p.marketplace) + } + raw, err := s.apifyClient(ctx).Run(ctx, p.actorID, p.input) + if err != nil { + return nil, err + } + decoded, _ = apify.Decode(raw, p.source) + } + usable := 0 + for i := range decoded { + decoded[i].MatchedQuery = p.query + if decoded[i].URL != "" && decoded[i].Price > 0 { + usable++ + } + } + slog.Info("plan executed", + "provider", p.provider, + "marketplace", p.marketplace, + "query", p.query, + "decoded", len(decoded), + "usable", usable, + ) + return decoded, nil +} + func (s *Scheduler) sendNotification(ctx context.Context, it models.Item, r apify.UnifiedResult) error { tags := []string{"mag"} if it.TargetPrice != nil && r.Price <= *it.TargetPrice { @@ -398,6 +470,7 @@ func (s *Scheduler) BuildPreviewInputs(it models.Item) []actorPlan { type actorPlan struct { marketplace string source string + provider string actorID string query string input any @@ -418,6 +491,9 @@ func (p actorPlan) Query() string { return p.query } // Input returns the actor input payload as expected by apify.Client.Run. func (p actorPlan) Input() any { return p.input } +// Provider returns "apify" or "ebay" — how this plan is executed. +func (p actorPlan) Provider() string { return p.provider } + // buildAllInputs returns one actor plan per (alias × marketplace) for the item. // For URL-only items (no aliases), produces one plan per marketplace with an // empty query string. @@ -447,27 +523,51 @@ func (s *Scheduler) buildInputsForQuery(it models.Item, query string, markets [] switch { case strings.Contains(mk, "yahoo") || strings.Contains(url, "yahoo.co.jp"): actorID := firstNonEmpty(it.ActorActive, s.cfg.Apify.Actors.YahooAuctionsJP) - plans = append(plans, actorPlan{m, apify.SourceYahooJP, actorID, query, apify.YahooAuctionsJPInput{ - SearchTerm: query, - MaxPages: 1, - }}) + plans = append(plans, actorPlan{ + marketplace: m, source: apify.SourceYahooJP, provider: providerApify, + actorID: actorID, query: query, + input: apify.YahooAuctionsJPInput{SearchTerm: query, MaxPages: 1}, + }) case strings.Contains(mk, "mercari") || strings.Contains(url, "mercari"): actorID := firstNonEmpty(it.ActorActive, s.cfg.Apify.Actors.MercariJP) - plans = append(plans, actorPlan{m, apify.SourceMercariJP, actorID, query, apify.MercariJPInput{ - SearchKeywords: []string{query}, - Status: "on_sale", - MaxResults: 30, - }}) + plans = append(plans, actorPlan{ + marketplace: m, source: apify.SourceMercariJP, provider: providerApify, + actorID: actorID, query: query, + input: apify.MercariJPInput{ + SearchKeywords: []string{query}, + Status: "on_sale", + MaxResults: 30, + }, + }) + case ebay.IsEbayMarketplace(mk): + // eBay marketplaces are polled through eBay's official Browse + // API, not an Apify scraper actor. + plans = append(plans, actorPlan{ + marketplace: m, source: apify.SourceActiveEbay, provider: providerEbay, + query: query, + input: ebay.SearchParams{ + MarketplaceID: ebay.MarketplaceID(mk), + Query: query, + ListingType: it.ListingType, + Limit: 30, + }, + }) default: + // Non-eBay custom marketplaces still fall back to the Apify + // active-listings actor. actorID := firstNonEmpty(it.ActorActive, s.cfg.Apify.Actors.ActiveListings) - plans = append(plans, actorPlan{m, apify.SourceActiveEbay, actorID, query, apify.ActiveListingInput{ - SearchQueries: []string{query}, - MaxProductsPerSearch: 30, - MaxSearchPages: 1, - Sort: "best_match", - ListingType: mapListingType(it.ListingType), - ProxyConfiguration: s.proxyConfig(), - }}) + plans = append(plans, actorPlan{ + marketplace: m, source: apify.SourceActiveEbay, provider: providerApify, + actorID: actorID, query: query, + input: apify.ActiveListingInput{ + SearchQueries: []string{query}, + MaxProductsPerSearch: 30, + MaxSearchPages: 1, + Sort: "best_match", + ListingType: mapListingType(it.ListingType), + ProxyConfiguration: s.proxyConfig(), + }, + }) } } return plans diff --git a/main.go b/main.go index 961907d..3347e13 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,9 @@ import ( "os/signal" "syscall" "time" + // Embed the timezone database so eBay's Pacific-time quota reset resolves + // correctly even on minimal hosts without system zoneinfo. + _ "time/tzdata" "veola/internal/apify" "veola/internal/auth" @@ -50,7 +53,7 @@ func run(configPath string) error { defer sqlDB.Close() store := db.NewStore(sqlDB, key) - authMgr, err := auth.NewManager(sqlDB, store, cfg.Security.SessionSecret) + authMgr, err := auth.NewManager(sqlDB, store, cfg.Security.SessionSecret, cfg.Server.UseSecureCookies()) if err != nil { return fmt.Errorf("auth manager: %w", err) } diff --git a/templates/settings.templ b/templates/settings.templ index 7bbc8e2..85321c3 100644 --- a/templates/settings.templ +++ b/templates/settings.templ @@ -8,15 +8,24 @@ import ( type SettingsData struct { Page - Values map[string]string - IsAdmin bool - Users []models.User - TestNtfyOK string - TestApifyOK string - PasswordMsg string - PasswordError string - UserMsg string - UserError string + Values map[string]string + IsAdmin bool + Users []models.User + TestNtfyOK string + TestApifyOK string + TestEbayOK string + EbayUsedToday int + EbayDailyLimit int + PasswordMsg string + PasswordError string + UserMsg string + UserError string +} + +// EbayLimitReached reports whether eBay polling is currently halted because +// the daily call limit has been hit. +func (d SettingsData) EbayLimitReached() bool { + return d.EbayDailyLimit > 0 && d.EbayUsedToday >= d.EbayDailyLimit } templ settingsBody(d SettingsData) { @@ -24,14 +33,37 @@ templ settingsBody(d SettingsData) {

Settings

-

Apify and Ntfy

+

Apify, eBay and Ntfy

@CSRFInput(d.CSRFToken)
+
+ + +
+ + +
+
+ + +
+
+ eBay API calls today: + if d.EbayDailyLimit > 0 { + { fmt.Sprintf("%d / %d", d.EbayUsedToday, d.EbayDailyLimit) } + } else { + { fmt.Sprintf("%d (uncapped)", d.EbayUsedToday) } + } + if d.EbayLimitReached() { + Limit reached. eBay polling halted until the next reset (midnight US Pacific). + } +
+
@@ -58,6 +90,7 @@ templ settingsBody(d SettingsData) { + }
@@ -67,6 +100,9 @@ templ settingsBody(d SettingsData) { if d.TestApifyOK != "" {
{ d.TestApifyOK }
} + if d.TestEbayOK != "" { +
{ d.TestEbayOK }
+ }
diff --git a/templates/settings_templ.go b/templates/settings_templ.go index 8627720..b451ba8 100644 --- a/templates/settings_templ.go +++ b/templates/settings_templ.go @@ -16,15 +16,24 @@ import ( type SettingsData struct { Page - Values map[string]string - IsAdmin bool - Users []models.User - TestNtfyOK string - TestApifyOK string - PasswordMsg string - PasswordError string - UserMsg string - UserError string + Values map[string]string + IsAdmin bool + Users []models.User + TestNtfyOK string + TestApifyOK string + TestEbayOK string + EbayUsedToday int + EbayDailyLimit int + PasswordMsg string + PasswordError string + UserMsg string + UserError string +} + +// EbayLimitReached reports whether eBay polling is currently halted because +// the daily call limit has been hit. +func (d SettingsData) EbayLimitReached() bool { + return d.EbayDailyLimit > 0 && d.EbayUsedToday >= d.EbayDailyLimit } func settingsBody(d SettingsData) templ.Component { @@ -48,7 +57,7 @@ func settingsBody(d SettingsData) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Settings

Apify and Ntfy

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Settings

Apify, eBay and Ntfy

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -63,177 +72,282 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["apify_api_key"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 32, Col: 108} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 41, Col: 108} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">
eBay API calls today: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var6 string - templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["global_poll_interval_minutes"]) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 48, Col: 136} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\">
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if !d.IsAdmin { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
Read-only for non-admin users.
") + if d.EbayDailyLimit > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", d.EbayUsedToday, d.EbayDailyLimit)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 58, Col: 89} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d (uncapped)", d.EbayUsedToday)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 60, Col: 77} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + if d.EbayLimitReached() { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "Limit reached. eBay polling halted until the next reset (midnight US Pacific).") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !d.IsAdmin { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
Read-only for non-admin users.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if d.TestNtfyOK != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestNtfyOK) + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestNtfyOK) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 65, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 98, Col: 44} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if d.TestApifyOK != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestApifyOK) + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestApifyOK) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 68, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 101, Col: 45} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "

Change Password

") + if d.TestEbayOK != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestEbayOK) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 104, Col: 44} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "

Change Password

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if d.PasswordError != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordError) + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordError) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 75, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 111, Col: 48} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if d.PasswordMsg != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordMsg) + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordMsg) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 78, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 114, Col: 40} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -241,173 +355,173 @@ func settingsBody(d SettingsData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if d.IsAdmin { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "

Users

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "

Users

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if d.UserError != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserError) + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserError) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 102, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 138, Col: 45} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if d.UserMsg != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserMsg) + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserMsg) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 105, Col: 37} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - for _, u := range d.Users { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
UsernameRoleCreated
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var14 string - templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 112, Col: 24} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var15 string - templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(string(u.Role)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 113, Col: 44} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var16 string - templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(u.CreatedAt.Format("2006-01-02")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 114, Col: 70} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, u := range d.Users { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
UsernameRoleCreated
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var20 string - templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken) + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 122, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 148, Col: 24} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(string(u.Role)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 149, Col: 44} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(u.CreatedAt.Format("2006-01-02")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 150, Col: 70} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -431,9 +545,9 @@ func Settings(d SettingsData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var22 := templ.GetChildren(ctx) - if templ_7745c5c3_Var22 == nil { - templ_7745c5c3_Var22 = templ.NopComponent + templ_7745c5c3_Var28 := templ.GetChildren(ctx) + if templ_7745c5c3_Var28 == nil { + templ_7745c5c3_Var28 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = Layout(d.Page, settingsBody(d)).Render(ctx, templ_7745c5c3_Buffer)