Opens Veola up to more users (Parodia's Authentik) and makes Apify spend visible to everyone. - Auth: Traefik forward-auth (trust X-Authentik-* headers only from trusted_proxies CIDRs, keyed by email, role synced from admin_group), keeping local password login as break-glass. New [auth] config, CaptureDirectIP + ForwardAuth middleware, deploy/authentik-forward-auth.md. - Budget: count every Apify run (apify_api_usage table) and show calls + estimated cost to all users on the dashboard, with an optional monthly-budget bar. New [budget] config + settings. - Email: Resend client for opt-in deal alerts and a weekly digest (Mon 09:00). Per-user email + toggles on Settings. New [resend] config. - Defaults: new items default to a 12-hour poll interval to cut spend. users table gains email/auth_source/email-pref columns (migrated in place). go build/vet/test green; boots and migrates cleanly.
221 lines
7.6 KiB
Go
221 lines
7.6 KiB
Go
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. APIKey and From
|
|
// can both be overridden at runtime via /settings (resend_api_key,
|
|
// resend_from). From is an RFC-5322 address, e.g. "Veola <veola@example.com>".
|
|
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"`
|
|
}
|
|
|
|
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)
|
|
}
|