package config import ( "errors" "fmt" "os" "path/filepath" "strings" "github.com/BurntSushi/toml" ) type Config struct { Server ServerConfig `toml:"server"` Security SecurityConfig `toml:"security"` Auth AuthConfig `toml:"auth"` Apify ApifyConfig `toml:"apify"` Ebay EbayConfig `toml:"ebay"` Ntfy NtfyConfig `toml:"ntfy"` Resend ResendConfig `toml:"resend"` Budget BudgetConfig `toml:"budget"` Scheduler SchedulerConfig `toml:"scheduler"` } // AuthConfig controls how identities are established. Mode "local" (default) // uses Veola's own password login. Mode "forward" trusts identity headers set // by a reverse proxy doing forward-auth (Traefik in front of Authentik); local // password login stays available as a break-glass fallback. The forward-auth // headers are honored ONLY when the direct connection comes from a CIDR in // TrustedProxies — without that gate anyone reaching the port could spoof them. type AuthConfig struct { Mode string `toml:"mode"` ForwardHeaderUser string `toml:"forward_header_user"` ForwardHeaderEmail string `toml:"forward_header_email"` ForwardHeaderName string `toml:"forward_header_name"` ForwardHeaderGroups string `toml:"forward_header_groups"` AdminGroup string `toml:"admin_group"` TrustedProxies []string `toml:"trusted_proxies"` } // ForwardAuthEnabled reports whether forward-auth header trust is active. func (c AuthConfig) ForwardAuthEnabled() bool { return strings.EqualFold(strings.TrimSpace(c.Mode), "forward") } // ResendConfig holds Resend transactional-email credentials, set only in // config.toml (never via the web UI). From is an RFC-5322 address, e.g. // "Veola ". type ResendConfig struct { APIKey string `toml:"api_key"` From string `toml:"from"` } // BudgetConfig drives the cost estimate shown to every user. CostPerApifyCall // is a USD estimate multiplied by the recorded Apify run count (Apify does not // report exact spend back to Veola). MonthlyBudgetUSD, when > 0, draws a // budget-consumed bar on the dashboard. Both are overridable via /settings. type BudgetConfig struct { CostPerApifyCall float64 `toml:"apify_cost_per_call"` MonthlyBudgetUSD float64 `toml:"monthly_budget_usd"` } // 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"` // Username/Password authenticate publishes via HTTP Basic auth, for ntfy // servers with access control (auth-default-access: deny-all). Leave both // empty for an open server. Username string `toml:"username"` Password string `toml:"password"` } 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 } // Default the forward-auth header names to Authentik's defaults so a // minimal [auth] block (just mode + trusted_proxies) works out of the box. if c.Auth.ForwardHeaderUser == "" { c.Auth.ForwardHeaderUser = "X-Authentik-Username" } if c.Auth.ForwardHeaderEmail == "" { c.Auth.ForwardHeaderEmail = "X-Authentik-Email" } if c.Auth.ForwardHeaderName == "" { c.Auth.ForwardHeaderName = "X-Authentik-Name" } if c.Auth.ForwardHeaderGroups == "" { c.Auth.ForwardHeaderGroups = "X-Authentik-Groups" } if c.Auth.AdminGroup == "" { c.Auth.AdminGroup = "veola-admins" } if c.Auth.ForwardAuthEnabled() && len(c.Auth.TrustedProxies) == 0 { return errors.New("auth.mode is \"forward\" but auth.trusted_proxies is empty: forward-auth headers would be spoofable. Set the CIDR(s) of your reverse proxy (e.g. [\"127.0.0.1/32\"])") } 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) }