Files
veola/internal/config/config.go
prosolis 1ae2c50b9a 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>
2026-05-14 12:10:39 -07:00

159 lines
4.8 KiB
Go

package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/BurntSushi/toml"
)
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 {
SessionSecret string `toml:"session_secret"`
EncryptionKey string `toml:"encryption_key"`
}
type ApifyConfig struct {
APIKey string `toml:"api_key"`
Actors ActorConfig `toml:"actors"`
Proxy ProxyConfig `toml:"proxy"`
}
// ProxyConfig controls the proxyConfiguration block passed to apify actors
// that scrape sites which block datacenter IPs (e.g. eBay returns 403 without
// a residential proxy).
type ProxyConfig struct {
UseApifyProxy bool `toml:"use_apify_proxy"`
Groups []string `toml:"groups"`
Country string `toml:"country"`
}
type ActorConfig struct {
ActiveListings string `toml:"active_listings"`
SoldListings string `toml:"sold_listings"`
PriceComparison string `toml:"price_comparison"`
YahooAuctionsJP string `toml:"yahoo_auctions_jp"`
YahooAuctionsJPSold string `toml:"yahoo_auctions_jp_sold"`
MercariJP string `toml:"mercari_jp"`
}
type NtfyConfig struct {
BaseURL string `toml:"base_url"`
DefaultTopic string `toml:"default_topic"`
}
type SchedulerConfig struct {
GlobalPollIntervalMinutes int `toml:"global_poll_interval_minutes"`
MatchConfidenceThreshold float64 `toml:"match_confidence_threshold"`
}
func Load(path string) (*Config, error) {
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("config file not found at %s. Copy config.toml.example to that path and fill it in", path)
} else if err != nil {
return nil, fmt.Errorf("stat config: %w", err)
}
var c Config
if _, err := toml.DecodeFile(path, &c); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
if err := c.validate(); err != nil {
return nil, err
}
return &c, nil
}
func (c *Config) validate() error {
if len(c.Security.SessionSecret) < 32 {
return errors.New("security.session_secret must be at least 32 bytes")
}
if len(c.Security.EncryptionKey) < 32 {
return errors.New("security.encryption_key must be at least 32 bytes")
}
if c.Security.SessionSecret == c.Security.EncryptionKey {
return errors.New("security.session_secret and security.encryption_key must not be equal")
}
if c.Server.DBPath == "" {
return errors.New("server.db_path must be set")
}
dir := filepath.Dir(c.Server.DBPath)
if dir == "" {
dir = "."
}
if err := checkWritable(dir); err != nil {
return fmt.Errorf("server.db_path directory %s not writable: %w", dir, err)
}
if c.Server.Port == 0 {
c.Server.Port = 8080
}
if c.Scheduler.GlobalPollIntervalMinutes == 0 {
c.Scheduler.GlobalPollIntervalMinutes = 60
}
if c.Scheduler.MatchConfidenceThreshold == 0 {
c.Scheduler.MatchConfidenceThreshold = 0.6
}
if c.Ntfy.DefaultTopic == "" {
c.Ntfy.DefaultTopic = "veola"
}
if c.Ebay.DailyCallLimit == 0 {
c.Ebay.DailyCallLimit = 5000
}
return nil
}
func checkWritable(dir string) error {
f, err := os.CreateTemp(dir, ".veola-write-test-*")
if err != nil {
return err
}
name := f.Name()
f.Close()
return os.Remove(name)
}