Add Authentik SSO, budget visibility, Resend email, 12h item default

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.
This commit is contained in:
prosolis
2026-06-20 11:12:11 -07:00
parent b6fadf6504
commit 7c95e4fd4e
23 changed files with 1883 additions and 261 deletions

View File

@@ -36,6 +36,9 @@ type Manager struct {
Sessions *scs.SessionManager
Store *db.Store
hmacKey []byte
// forward, when non-nil, enables trust of reverse-proxy identity headers.
// See forward.go.
forward *ForwardAuthConfig
}
func NewManager(sqlDB *sql.DB, store *db.Store, sessionSecret string, secureCookies bool) (*Manager, error) {

156
internal/auth/forward.go Normal file
View File

@@ -0,0 +1,156 @@
package auth
import (
"context"
"log/slog"
"net"
"net/http"
"strings"
"veola/internal/models"
)
// ForwardAuthConfig configures trust of reverse-proxy identity headers
// (Traefik forwardAuth in front of Authentik). It is nil unless forward-auth
// is enabled in config.
type ForwardAuthConfig struct {
HeaderUser string
HeaderEmail string
HeaderName string
HeaderGroups string
AdminGroup string
trustedNets []*net.IPNet
}
// NewForwardAuthConfig parses the trusted-proxy CIDRs and returns a ready
// config. An entry that is a bare IP (no "/") is treated as a /32 or /128.
func NewForwardAuthConfig(headerUser, headerEmail, headerName, headerGroups, adminGroup string, trustedProxies []string) (*ForwardAuthConfig, error) {
fa := &ForwardAuthConfig{
HeaderUser: headerUser,
HeaderEmail: headerEmail,
HeaderName: headerName,
HeaderGroups: headerGroups,
AdminGroup: adminGroup,
}
for _, raw := range trustedProxies {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
if !strings.Contains(raw, "/") {
if strings.Contains(raw, ":") {
raw += "/128"
} else {
raw += "/32"
}
}
_, n, err := net.ParseCIDR(raw)
if err != nil {
return nil, err
}
fa.trustedNets = append(fa.trustedNets, n)
}
return fa, nil
}
// trusts reports whether a direct-peer IP string is within a trusted CIDR.
func (fa *ForwardAuthConfig) trusts(ipStr string) bool {
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
for _, n := range fa.trustedNets {
if n.Contains(ip) {
return true
}
}
return false
}
// SetForwardAuth enables forward-auth header trust on the manager.
func (m *Manager) SetForwardAuth(fa *ForwardAuthConfig) { m.forward = fa }
type directIPCtxKey struct{}
// CaptureDirectIP records the direct connection's remote IP into the request
// context BEFORE middleware.RealIP rewrites RemoteAddr from X-Forwarded-For.
// Forward-auth trust is checked against this captured value, not the
// proxy-supplied one, so a client cannot claim to be the proxy.
func CaptureDirectIP(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
ctx := context.WithValue(r.Context(), directIPCtxKey{}, host)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func directIP(ctx context.Context) string {
s, _ := ctx.Value(directIPCtxKey{}).(string)
return s
}
// ForwardAuth, when enabled, trusts identity headers from a configured proxy.
// It runs after LoadUser: if the direct peer is a trusted proxy and an email
// header is present, it find-or-creates the local user (keyed by email),
// re-syncs role from the IdP group, writes the user into the session + context,
// and so supersedes any stale local session. When forward-auth is disabled, or
// the peer is untrusted, or no headers are present, it is a no-op and local
// session auth applies (break-glass login still works).
func (m *Manager) ForwardAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fa := m.forward
if fa == nil {
next.ServeHTTP(w, r)
return
}
if !fa.trusts(directIP(r.Context())) {
next.ServeHTTP(w, r)
return
}
email := strings.TrimSpace(r.Header.Get(fa.HeaderEmail))
if email == "" {
next.ServeHTTP(w, r)
return
}
username := strings.TrimSpace(r.Header.Get(fa.HeaderUser))
if username == "" {
username = strings.TrimSpace(r.Header.Get(fa.HeaderName))
}
role := models.RoleUser
if fa.hasAdminGroup(r.Header.Get(fa.HeaderGroups)) {
role = models.RoleAdmin
}
u, err := m.Store.UpsertForwardUser(r.Context(), email, username, role)
if err != nil || u == nil {
slog.Error("forward-auth upsert failed", "email", email, "err", err)
http.Error(w, "forward-auth provisioning failed", http.StatusInternalServerError)
return
}
// Keep the session in sync so UserID()/CSRFToken() and a later
// non-proxied request behave; rotate only when the bound user changes.
if m.UserID(r.Context()) != u.ID {
if err := m.LogIn(r.Context(), u.ID); err != nil {
slog.Error("forward-auth session login failed", "err", err)
}
}
ctx := context.WithValue(r.Context(), ctxKeyUser, u)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// hasAdminGroup reports whether the IdP groups header contains the admin group.
// Authentik joins groups with "|"; commas are also accepted.
func (fa *ForwardAuthConfig) hasAdminGroup(header string) bool {
if fa.AdminGroup == "" || header == "" {
return false
}
for _, g := range strings.FieldsFunc(header, func(r rune) bool { return r == '|' || r == ',' }) {
if strings.EqualFold(strings.TrimSpace(g), fa.AdminGroup) {
return true
}
}
return false
}

View File

@@ -0,0 +1,53 @@
package auth
import "testing"
func TestForwardAuthTrusts(t *testing.T) {
fa, err := NewForwardAuthConfig("u", "e", "n", "g", "admins", []string{"127.0.0.1/32", "10.0.0.0/8", "::1"})
if err != nil {
t.Fatalf("config: %v", err)
}
cases := map[string]bool{
"127.0.0.1": true,
"10.5.6.7": true,
"::1": true,
"192.168.1.1": false,
"8.8.8.8": false,
"": false,
"not-an-ip": false,
}
for ip, want := range cases {
if got := fa.trusts(ip); got != want {
t.Errorf("trusts(%q) = %v, want %v", ip, got, want)
}
}
}
func TestForwardAuthBadCIDR(t *testing.T) {
if _, err := NewForwardAuthConfig("u", "e", "n", "g", "a", []string{"not-a-cidr/99"}); err == nil {
t.Fatal("expected error for malformed CIDR")
}
}
func TestHasAdminGroup(t *testing.T) {
fa := &ForwardAuthConfig{AdminGroup: "veola-admins"}
cases := map[string]bool{
"veola-admins": true,
"users|veola-admins|staff": true, // Authentik pipe-delimited
"users,veola-admins": true, // comma-delimited
"VEOLA-ADMINS": true, // case-insensitive
"users|staff": false,
"": false,
"veola-admins-extra": false,
}
for header, want := range cases {
if got := fa.hasAdminGroup(header); got != want {
t.Errorf("hasAdminGroup(%q) = %v, want %v", header, got, want)
}
}
// Empty admin group never matches.
empty := &ForwardAuthConfig{AdminGroup: ""}
if empty.hasAdminGroup("anything") {
t.Error("empty admin group should not match")
}
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/BurntSushi/toml"
)
@@ -12,12 +13,53 @@ import (
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
@@ -144,6 +186,26 @@ func (c *Config) validate() error {
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
}

View File

@@ -53,6 +53,27 @@ func Open(path string) (*sql.DB, error) {
conn.Close()
return nil, err
}
// Multi-user / email columns (Authentik forward-auth + Resend mail).
for _, c := range []struct{ col, typ string }{
{"email", "TEXT"},
{"auth_source", "TEXT NOT NULL DEFAULT 'local'"},
{"email_deal_alerts", "INTEGER NOT NULL DEFAULT 0"},
{"email_weekly_digest", "INTEGER NOT NULL DEFAULT 0"},
} {
if err := addColumnIfMissing(conn, "users", c.col, c.typ); err != nil {
conn.Close()
return nil, err
}
}
// Email is the identity key for forward-auth (Authentik) and the Resend
// destination. Partial unique index: many local users may have no email
// (NULL/'') while any populated address stays unique. Created here, after
// the email column exists, so it works on both fresh and migrated DBs.
if _, err := conn.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email
ON users(email) WHERE email IS NOT NULL AND email != ''`); err != nil {
conn.Close()
return nil, fmt.Errorf("create users email index: %w", err)
}
return conn, nil
}

View File

@@ -94,6 +94,28 @@ func (s *Store) UserCount(ctx context.Context) (int, error) {
return n, err
}
const userSelect = `SELECT id, username, password_hash, role, email, auth_source,
email_deal_alerts, email_weekly_digest, created_at FROM users`
func scanUser(r rowScanner) (*models.User, error) {
var (
u models.User
role, authSource string
email sql.NullString
dealAlerts, digest int
)
if err := r.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &email, &authSource,
&dealAlerts, &digest, &u.CreatedAt); err != nil {
return nil, err
}
u.Role = models.Role(role)
u.Email = email.String
u.AuthSource = authSource
u.EmailDealAlerts = dealAlerts != 0
u.EmailWeeklyDigest = digest != 0
return &u, nil
}
func (s *Store) CreateUser(ctx context.Context, username, hash string, role models.Role) (int64, error) {
res, err := s.DB.ExecContext(ctx,
`INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)`,
@@ -105,61 +127,136 @@ func (s *Store) CreateUser(ctx context.Context, username, hash string, role mode
}
func (s *Store) GetUserByUsername(ctx context.Context, username string) (*models.User, error) {
row := s.DB.QueryRowContext(ctx,
`SELECT id, username, password_hash, role, created_at FROM users WHERE username = ?`,
username)
var u models.User
var role string
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &u.CreatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
u, err := scanUser(s.DB.QueryRowContext(ctx, userSelect+` WHERE username = ?`, username))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
u.Role = models.Role(role)
return &u, nil
return u, err
}
func (s *Store) GetUserByID(ctx context.Context, id int64) (*models.User, error) {
row := s.DB.QueryRowContext(ctx,
`SELECT id, username, password_hash, role, created_at FROM users WHERE id = ?`, id)
var u models.User
var role string
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &u.CreatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
u, err := scanUser(s.DB.QueryRowContext(ctx, userSelect+` WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
u.Role = models.Role(role)
return &u, nil
return u, err
}
func (s *Store) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
if email == "" {
return nil, nil
}
u, err := scanUser(s.DB.QueryRowContext(ctx, userSelect+` WHERE email = ?`, email))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return u, err
}
func (s *Store) ListUsers(ctx context.Context) ([]models.User, error) {
rows, err := s.DB.QueryContext(ctx,
`SELECT id, username, password_hash, role, created_at FROM users ORDER BY id`)
rows, err := s.DB.QueryContext(ctx, userSelect+` ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.User
for rows.Next() {
var u models.User
var role string
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &u.CreatedAt); err != nil {
u, err := scanUser(rows)
if err != nil {
return nil, err
}
u.Role = models.Role(role)
out = append(out, u)
out = append(out, *u)
}
return out, rows.Err()
}
// UpsertForwardUser find-or-creates a user keyed by email for forward-auth
// (Authentik). On an existing row it re-syncs role + username so the IdP stays
// the source of truth. Returns the resolved user. New rows are marked
// auth_source='forward' and carry an unusable password hash (forward-auth
// users never log in with a local password).
func (s *Store) UpsertForwardUser(ctx context.Context, email, username string, role models.Role) (*models.User, error) {
if email == "" {
return nil, errors.New("forward-auth requires an email")
}
existing, err := s.GetUserByEmail(ctx, email)
if err != nil {
return nil, err
}
if existing != nil {
if existing.Role != role || (username != "" && existing.Username != username) {
newName := existing.Username
if username != "" {
newName = username
}
if _, err := s.DB.ExecContext(ctx,
`UPDATE users SET role = ?, username = ? WHERE id = ?`,
string(role), newName, existing.ID); err != nil {
return nil, err
}
existing.Role = role
existing.Username = newName
}
return existing, nil
}
if username == "" {
username = email
}
res, err := s.DB.ExecContext(ctx,
`INSERT INTO users (username, password_hash, role, email, auth_source) VALUES (?, ?, ?, ?, 'forward')`,
username, "!forward-auth", string(role), email)
if err != nil {
return nil, err
}
id, err := res.LastInsertId()
if err != nil {
return nil, err
}
return s.GetUserByID(ctx, id)
}
func (s *Store) UpdateUserPassword(ctx context.Context, id int64, hash string) error {
_, err := s.DB.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, hash, id)
return err
}
// UpdateUserEmailPrefs sets a user's notification email and per-channel email
// opt-ins. Email "" clears the address (and silences email for that user).
func (s *Store) UpdateUserEmailPrefs(ctx context.Context, id int64, email string, dealAlerts, weeklyDigest bool) error {
_, err := s.DB.ExecContext(ctx,
`UPDATE users SET email = ?, email_deal_alerts = ?, email_weekly_digest = ? WHERE id = ?`,
nullStr(email), boolToInt(dealAlerts), boolToInt(weeklyDigest), id)
return err
}
// UsersWithDealAlerts returns users who opted into deal-alert email and have a
// destination address. UsersWithWeeklyDigest is the digest equivalent.
func (s *Store) UsersWithDealAlerts(ctx context.Context) ([]models.User, error) {
return s.usersWhereEmailPref(ctx, "email_deal_alerts")
}
func (s *Store) UsersWithWeeklyDigest(ctx context.Context) ([]models.User, error) {
return s.usersWhereEmailPref(ctx, "email_weekly_digest")
}
func (s *Store) usersWhereEmailPref(ctx context.Context, col string) ([]models.User, error) {
rows, err := s.DB.QueryContext(ctx,
userSelect+` WHERE `+col+` = 1 AND email IS NOT NULL AND email != '' ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.User
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, err
}
out = append(out, *u)
}
return out, rows.Err()
}
func (s *Store) DeleteUser(ctx context.Context, id int64) error {
_, err := s.DB.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
return err
@@ -267,6 +364,47 @@ func (s *Store) IncrementEbayUsage(ctx context.Context) (int, error) {
return n, nil
}
// ============ apify api usage ============
// apifyUsageDay buckets Apify runs by UTC calendar day. Unlike eBay (which has
// its own Pacific-time quota reset) Apify billing is a soft estimate here, so a
// plain UTC day keeps the month math simple.
func apifyUsageDay() string {
return time.Now().UTC().Format("2006-01-02")
}
// IncrementApifyUsage records one Apify actor run against the current UTC day.
func (s *Store) IncrementApifyUsage(ctx context.Context) error {
_, err := s.DB.ExecContext(ctx, `
INSERT INTO apify_api_usage (usage_date, call_count) VALUES (?, 1)
ON CONFLICT(usage_date) DO UPDATE SET call_count = call_count + 1
`, apifyUsageDay())
return err
}
// ApifyUsageToday returns Apify runs recorded for the current UTC day.
func (s *Store) ApifyUsageToday(ctx context.Context) (int, error) {
var n int
err := s.DB.QueryRowContext(ctx,
`SELECT call_count FROM apify_api_usage WHERE usage_date = ?`, apifyUsageDay()).Scan(&n)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
return n, err
}
// ApifyUsageMonth returns total Apify runs in the current UTC calendar month.
func (s *Store) ApifyUsageMonth(ctx context.Context) (int, error) {
prefix := time.Now().UTC().Format("2006-01") + "-%"
var n sql.NullInt64
err := s.DB.QueryRowContext(ctx,
`SELECT SUM(call_count) FROM apify_api_usage WHERE usage_date LIKE ?`, prefix).Scan(&n)
if err != nil {
return 0, err
}
return int(n.Int64), nil
}
// ============ items ============
func (s *Store) CreateItem(ctx context.Context, it *models.Item) (int64, error) {

View File

@@ -6,8 +6,18 @@ CREATE TABLE IF NOT EXISTS users (
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
email TEXT,
-- auth_source records how the row was provisioned: 'local' (password
-- login / setup) or 'forward' (Traefik forward-auth from Authentik). For
-- forward rows the role is re-synced from the IdP group on every request.
auth_source TEXT NOT NULL DEFAULT 'local',
email_deal_alerts INTEGER NOT NULL DEFAULT 0,
email_weekly_digest INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- The email column and its unique index are created in db.go AFTER the
-- addColumnIfMissing migrations, so existing databases (whose users table
-- predates the email column) gain the column before the index references it.
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -92,7 +102,20 @@ INSERT OR IGNORE INTO settings (key, value) VALUES
('ntfy_base_url', ''),
('ntfy_default_topic', 'veola'),
('global_poll_interval_minutes', '60'),
('match_confidence_threshold', '0.6');
('match_confidence_threshold', '0.6'),
('apify_cost_per_call', '0.00'),
('monthly_budget_usd', '0.00'),
('resend_api_key', ''),
('resend_from', '');
-- apify_api_usage tracks Apify actor runs per UTC day so the operator (and
-- every signed-in user) can see consumption and an estimated spend. Apify
-- bills per actor run / per result, which Veola cannot read back exactly, so
-- this is a call counter multiplied by a configurable per-call cost estimate.
CREATE TABLE IF NOT EXISTS apify_api_usage (
usage_date TEXT PRIMARY KEY,
call_count INTEGER NOT NULL DEFAULT 0
);
-- ebay_api_usage tracks Browse API calls per day so Veola can surface
-- consumption and halt polling before the developer keyset's daily call

97
internal/email/client.go Normal file
View File

@@ -0,0 +1,97 @@
// Package email sends transactional mail through Resend (https://resend.com).
// Veola uses it for opt-in deal alerts and the weekly digest. Credentials are
// resolved at call time (settings table overriding config.toml), mirroring how
// the ntfy and Apify clients are built per-send.
package email
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const apiURL = "https://api.resend.com/emails"
// Client is a thin wrapper around the Resend send endpoint.
type Client struct {
APIKey string
From string
HTTP *http.Client
}
// New returns a Resend client. apiKey and from are typically resolved from
// settings (falling back to config.toml) by the caller.
func New(apiKey, from string) *Client {
return &Client{
APIKey: strings.TrimSpace(apiKey),
From: strings.TrimSpace(from),
HTTP: &http.Client{Timeout: 15 * time.Second},
}
}
// Configured reports whether the client has enough to attempt a send.
func (c *Client) Configured() bool {
return c.APIKey != "" && c.From != ""
}
// Message is a single outbound email. HTML is the rendered body; Text is an
// optional plain-text alternative.
type Message struct {
To string
Subject string
HTML string
Text string
}
type sendRequest struct {
From string `json:"from"`
To []string `json:"to"`
Subject string `json:"subject"`
HTML string `json:"html,omitempty"`
Text string `json:"text,omitempty"`
}
// Send delivers one message. Returns an error if the client is unconfigured or
// Resend rejects the request.
func (c *Client) Send(ctx context.Context, m Message) error {
if c.APIKey == "" {
return fmt.Errorf("resend api key not configured")
}
if c.From == "" {
return fmt.Errorf("resend from address not configured")
}
if strings.TrimSpace(m.To) == "" {
return fmt.Errorf("recipient address required")
}
body, err := json.Marshal(sendRequest{
From: c.From,
To: []string{m.To},
Subject: m.Subject,
HTML: m.HTML,
Text: m.Text,
})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.APIKey)
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("resend POST: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("resend returned %d: %s", resp.StatusCode, string(b))
}
return nil
}

View File

@@ -63,11 +63,19 @@ func (a *App) dashboardData(r *http.Request) (templates.DashboardData, error) {
if err != nil {
return templates.DashboardData{}, err
}
apifyToday, apifyMonth, costPerCall, monthlyBudget := a.Scheduler.ApifyUsage(r.Context())
ebayUsed, ebayLimit := a.Scheduler.EbayUsage(r.Context())
return templates.DashboardData{
Page: a.page(r, "Dashboard", "dashboard"),
Stats: stats,
RecentResults: rrs,
RecentAlerts: alerts,
Page: a.page(r, "Dashboard", "dashboard"),
Stats: stats,
RecentResults: rrs,
RecentAlerts: alerts,
ApifyToday: apifyToday,
ApifyMonth: apifyMonth,
ApifyCostPerCall: costPerCall,
MonthlyBudget: monthlyBudget,
EbayToday: ebayUsed,
EbayLimit: ebayLimit,
}, nil
}

View File

@@ -49,6 +49,10 @@ func (a *App) Routes() http.Handler {
// RealIP rewrites RemoteAddr from X-Forwarded-For — safe only because
// Veola is expected to sit behind a trusted proxy (Traefik) that sets
// it; Traefik must be configured to strip client-supplied values.
// CaptureDirectIP must run before RealIP: forward-auth trusts identity
// headers only from the real connecting peer, which RealIP would otherwise
// overwrite with the proxy-supplied X-Forwarded-For.
r.Use(auth.CaptureDirectIP)
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
r.Use(securityHeaders)
@@ -70,6 +74,10 @@ func (a *App) Routes() http.Handler {
r.Group(func(r chi.Router) {
r.Use(a.Auth.Sessions.LoadAndSave)
r.Use(a.Auth.LoadUser)
// ForwardAuth runs after LoadUser so a trusted proxy's identity headers
// supersede any stale local session. No-op when forward-auth is off or
// the peer is untrusted.
r.Use(a.Auth.ForwardAuth)
r.Use(a.setupGate)
// Public auth pages.
@@ -106,6 +114,8 @@ func (a *App) Routes() http.Handler {
r.With(a.Auth.CSRFProtect).Post("/settings/test-ntfy", a.PostTestNtfy)
r.With(a.Auth.CSRFProtect).Post("/settings/test-apify", a.PostTestApify)
r.With(a.Auth.CSRFProtect).Post("/settings/test-ebay", a.PostTestEbay)
r.With(a.Auth.CSRFProtect).Post("/settings/test-resend", a.PostTestResend)
r.With(a.Auth.CSRFProtect).Post("/settings/email", a.PostEmailPrefs)
r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users", a.PostCreateUser)
r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users/{id}/delete", a.PostDeleteUser)
r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users/{id}/reset-password", a.PostResetPassword)

View File

@@ -16,6 +16,10 @@ import (
"veola/templates"
)
// defaultPollIntervalMinutes is the poll cadence pre-selected on the add-item
// form: 12 hours, chosen to keep Apify spend low by default.
const defaultPollIntervalMinutes = 720
func (a *App) GetItems(w http.ResponseWriter, r *http.Request) {
cat := r.URL.Query().Get("category")
all, err := a.Store.ListItems(r.Context())
@@ -56,8 +60,11 @@ func (a *App) GetNewItem(w http.ResponseWriter, r *http.Request) {
IsEdit: false,
Categories: cats,
Item: models.Item{
NtfyPriority: "default",
PollIntervalMinutes: a.Cfg.Scheduler.GlobalPollIntervalMinutes,
NtfyPriority: "default",
// Default new items to a 12-hour refresh. Apify runs cost credits
// and most watched items do not move hour-to-hour, so a slow
// default keeps spend down; the operator can dial it tighter.
PollIntervalMinutes: defaultPollIntervalMinutes,
Marketplaces: []string{"ebay.com"},
ListingType: "all",
},

View File

@@ -9,6 +9,7 @@ import (
"veola/internal/apify"
"veola/internal/auth"
"veola/internal/ebay"
"veola/internal/email"
"veola/internal/models"
"veola/internal/ntfy"
"veola/templates"
@@ -24,6 +25,10 @@ var settingsKeys = []string{
"ntfy_token",
"global_poll_interval_minutes",
"match_confidence_threshold",
"apify_cost_per_call",
"monthly_budget_usd",
"resend_api_key",
"resend_from",
}
// secretSettingsKeys are credential fields. Their values are never rendered
@@ -34,6 +39,7 @@ var secretSettingsKeys = map[string]bool{
"ebay_client_id": true,
"ebay_client_secret": true,
"ntfy_token": true,
"resend_api_key": true,
}
// credentialStatus reports, per secret key, whether a value is saved in the
@@ -45,6 +51,7 @@ func (a *App) credentialStatus(values map[string]string) map[string]string {
"ebay_client_id": a.Cfg.Ebay.ClientID,
"ebay_client_secret": a.Cfg.Ebay.ClientSecret,
"ntfy_token": "",
"resend_api_key": a.Cfg.Resend.APIKey,
}
status := make(map[string]string, len(secretSettingsKeys))
for k := range secretSettingsKeys {
@@ -71,6 +78,7 @@ 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())
apifyToday, apifyMonth, costPerCall, monthlyBudget := a.Scheduler.ApifyUsage(r.Context())
return templates.SettingsData{
Page: a.page(r, "Settings", "settings"),
Values: values,
@@ -79,9 +87,103 @@ func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
Users: users,
EbayUsedToday: ebayUsed,
EbayDailyLimit: ebayLimit,
ApifyToday: apifyToday,
ApifyMonth: apifyMonth,
ApifyCostPerCall: costPerCall,
MonthlyBudget: monthlyBudget,
}, nil
}
// PostEmailPrefs lets the signed-in user set their own notification email and
// opt into deal-alert / weekly-digest email. Available to every user, not just
// admins (each user owns their own delivery prefs).
func (a *App) PostEmailPrefs(w http.ResponseWriter, r *http.Request) {
cur := auth.CurrentUserFromRequest(r)
if cur == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
email := strings.TrimSpace(r.PostFormValue("email"))
dealAlerts := r.PostFormValue("email_deal_alerts") == "1"
weeklyDigest := r.PostFormValue("email_weekly_digest") == "1"
d, err := a.settingsData(r)
if err != nil {
a.serverError(w, r, err)
return
}
if email == "" && (dealAlerts || weeklyDigest) {
d.EmailError = "Set an email address to receive email notifications."
render(w, r, templates.Settings(d))
return
}
if err := a.Store.UpdateUserEmailPrefs(r.Context(), cur.ID, email, dealAlerts, weeklyDigest); err != nil {
a.serverError(w, r, err)
return
}
// Reflect the saved values immediately (CurrentUser in d predates the write).
d, err = a.settingsData(r)
if err != nil {
a.serverError(w, r, err)
return
}
if d.Page.CurrentUser != nil {
d.Page.CurrentUser.Email = email
d.Page.CurrentUser.EmailDealAlerts = dealAlerts
d.Page.CurrentUser.EmailWeeklyDigest = weeklyDigest
}
d.EmailMsg = "Email preferences saved."
render(w, r, templates.Settings(d))
}
// PostTestResend sends a test email to the admin's own address to verify the
// Resend configuration.
func (a *App) PostTestResend(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 {
a.serverError(w, r, err)
return
}
apiKey := strings.TrimSpace(d.Values["resend_api_key"])
if apiKey == "" {
apiKey = a.Cfg.Resend.APIKey
}
from := strings.TrimSpace(d.Values["resend_from"])
if from == "" {
from = a.Cfg.Resend.From
}
to := ""
if cur != nil {
to = cur.Email
}
if to == "" {
d.TestResendOK = "Set your notification email below first, then test."
render(w, r, templates.Settings(d))
return
}
client := email.New(apiKey, from)
if err := client.Send(r.Context(), email.Message{
To: to,
Subject: "Veola test email",
HTML: "<p>Test email from Veola settings. Resend is configured correctly.</p>",
Text: "Test email from Veola settings. Resend is configured correctly.",
}); err != nil {
d.TestResendOK = "Resend test failed: " + err.Error()
} else {
d.TestResendOK = "Sent a test email to " + to + "."
}
render(w, r, templates.Settings(d))
}
func (a *App) GetSettings(w http.ResponseWriter, r *http.Request) {
d, err := a.settingsData(r)
if err != nil {

View File

@@ -17,7 +17,15 @@ type User struct {
Username string
PasswordHash string
Role Role
CreatedAt time.Time
// Email is the address Resend mail is sent to and the identity key used
// to match a forward-auth (Authentik) identity to a local row. Optional
// for password-login users.
Email string
// AuthSource is "local" (password) or "forward" (Traefik forward-auth).
AuthSource string
EmailDealAlerts bool
EmailWeeklyDigest bool
CreatedAt time.Time
}
type Item struct {

171
internal/scheduler/email.go Normal file
View File

@@ -0,0 +1,171 @@
package scheduler
import (
"context"
"fmt"
"html"
"log/slog"
"strings"
"veola/internal/apify"
"veola/internal/db"
"veola/internal/email"
"veola/internal/models"
)
// emailClient builds a Resend client with credentials resolved from settings,
// falling back to config.toml. Returns a client even when unconfigured; callers
// check Configured() to skip silently.
func (s *Scheduler) emailClient(ctx context.Context) *email.Client {
apiKey := s.cfg.Resend.APIKey
if v, _ := s.store.GetSetting(ctx, "resend_api_key"); v != "" {
apiKey = v
}
from := s.cfg.Resend.From
if v, _ := s.store.GetSetting(ctx, "resend_from"); v != "" {
from = v
}
return email.New(apiKey, from)
}
// sendDealEmails mails a deal alert to every user who opted into deal-alert
// email. Entirely best-effort: an unconfigured Resend account or a per-user
// send failure is logged and swallowed.
func (s *Scheduler) sendDealEmails(ctx context.Context, it models.Item, r apify.UnifiedResult) {
client := s.emailClient(ctx)
if !client.Configured() {
return
}
recipients, err := s.store.UsersWithDealAlerts(ctx)
if err != nil {
slog.Error("load deal-alert recipients failed", "err", err)
return
}
if len(recipients) == 0 {
return
}
subject := fmt.Sprintf("Veola deal: %s", it.Name)
price := fmt.Sprintf("%s%.2f", currencyPrefix(r.Currency), r.Price)
target := ""
if it.TargetPrice != nil {
target = fmt.Sprintf("%s%.2f", currencyPrefix(r.Currency), *it.TargetPrice)
}
body := dealEmailHTML(it.Name, r.Title, r.Store, price, target, r.URL)
text := dealEmailText(it.Name, r.Title, r.Store, price, target, r.URL)
for _, u := range recipients {
if err := client.Send(ctx, email.Message{
To: u.Email, Subject: subject, HTML: body, Text: text,
}); err != nil {
slog.Error("deal email send failed", "to", u.Email, "err", err)
}
}
}
// RunWeeklyDigest builds and mails the weekly digest to every opted-in user.
// Public so it can be triggered manually as well as on the cron schedule.
func (s *Scheduler) RunWeeklyDigest(ctx context.Context) {
client := s.emailClient(ctx)
if !client.Configured() {
return
}
recipients, err := s.store.UsersWithWeeklyDigest(ctx)
if err != nil {
slog.Error("load digest recipients failed", "err", err)
return
}
if len(recipients) == 0 {
return
}
items, err := s.store.ListActiveItems(ctx)
if err != nil {
slog.Error("digest item load failed", "err", err)
return
}
stats, err := s.store.GetDashboardStats(ctx)
if err != nil {
slog.Error("digest stats load failed", "err", err)
return
}
subject := fmt.Sprintf("Veola weekly digest — %d items watched", len(items))
body := digestHTML(items, stats)
text := digestText(items, stats)
for _, u := range recipients {
if err := client.Send(ctx, email.Message{
To: u.Email, Subject: subject, HTML: body, Text: text,
}); err != nil {
slog.Error("digest email send failed", "to", u.Email, "err", err)
}
}
slog.Info("weekly digest sent", "recipients", len(recipients), "items", len(items))
}
func dealEmailHTML(itemName, title, store, price, target, url string) string {
var b strings.Builder
fmt.Fprintf(&b, `<div style="font-family:sans-serif">`)
fmt.Fprintf(&b, `<h2>Veola deal: %s</h2>`, html.EscapeString(itemName))
fmt.Fprintf(&b, `<p style="font-size:20px"><strong>%s</strong> at %s</p>`, html.EscapeString(price), html.EscapeString(store))
if target != "" {
fmt.Fprintf(&b, `<p>Your target: %s</p>`, html.EscapeString(target))
}
if title != "" {
fmt.Fprintf(&b, `<p>%s</p>`, html.EscapeString(title))
}
if url != "" {
fmt.Fprintf(&b, `<p><a href="%s">View listing</a></p>`, html.EscapeString(url))
}
b.WriteString(`</div>`)
return b.String()
}
func dealEmailText(itemName, title, store, price, target, url string) string {
var b strings.Builder
fmt.Fprintf(&b, "Veola deal: %s\n\n%s at %s\n", itemName, price, store)
if target != "" {
fmt.Fprintf(&b, "Your target: %s\n", target)
}
if title != "" {
fmt.Fprintf(&b, "%s\n", title)
}
if url != "" {
fmt.Fprintf(&b, "%s\n", url)
}
return b.String()
}
func digestHTML(items []models.Item, stats *db.DashboardStats) string {
var b strings.Builder
b.WriteString(`<div style="font-family:sans-serif">`)
b.WriteString(`<h2>Veola weekly digest</h2>`)
if stats != nil {
fmt.Fprintf(&b, `<p>%d active items. Estimated money saved so far: $%.2f.</p>`,
stats.ActiveItems, stats.MoneySaved)
}
b.WriteString(`<table style="border-collapse:collapse" cellpadding="6">`)
b.WriteString(`<tr><th align="left">Item</th><th align="left">Best price</th><th align="left">Where</th></tr>`)
for _, it := range items {
best := "—"
if it.BestPrice != nil {
best = fmt.Sprintf("%s%.2f", currencyPrefix(it.BestPriceCurrency), *it.BestPrice)
}
fmt.Fprintf(&b, `<tr><td>%s</td><td>%s</td><td>%s</td></tr>`,
html.EscapeString(it.Name), html.EscapeString(best), html.EscapeString(it.BestPriceStore))
}
b.WriteString(`</table></div>`)
return b.String()
}
func digestText(items []models.Item, stats *db.DashboardStats) string {
var b strings.Builder
b.WriteString("Veola weekly digest\n\n")
if stats != nil {
fmt.Fprintf(&b, "%d active items. Estimated money saved so far: $%.2f.\n\n", stats.ActiveItems, stats.MoneySaved)
}
for _, it := range items {
best := "no price yet"
if it.BestPrice != nil {
best = fmt.Sprintf("%s%.2f", currencyPrefix(it.BestPriceCurrency), *it.BestPrice)
}
fmt.Fprintf(&b, "- %s: %s %s\n", it.Name, best, it.BestPriceStore)
}
return b.String()
}

View File

@@ -2,6 +2,7 @@ package scheduler
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strconv"
@@ -64,6 +65,15 @@ func (s *Scheduler) Start(ctx context.Context) error {
for _, it := range items {
s.register(it)
}
// Weekly digest: Monday 09:00 server-local. Best-effort inside
// RunWeeklyDigest (no-op when Resend is unconfigured or nobody opted in).
if _, err := s.cron.AddFunc("0 9 * * 1", func() {
ctx, cancel := context.WithTimeout(s.rootCtx, 5*time.Minute)
defer cancel()
s.RunWeeklyDigest(ctx)
}); err != nil {
slog.Error("weekly digest schedule failed", "err", err)
}
s.cron.Start()
slog.Info("scheduler started", "items", len(items))
return nil
@@ -142,7 +152,6 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
s.recordError(ctx, it.ID, "no marketplaces configured for this item")
return
}
apifyClient := s.apifyClient(ctx)
var results []apify.UnifiedResult
var errs []string
successes := 0
@@ -176,7 +185,7 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
pcQueries = []string{""}
}
for _, q := range pcQueries {
pcRaw, err := apifyClient.Run(ctx, pcID, apify.PriceComparisonInput{
pcRaw, err := s.runApifyActor(ctx, pcID, apify.PriceComparisonInput{
Query: q, URL: it.URL,
ProxyConfiguration: s.proxyConfig(),
})
@@ -238,6 +247,9 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
alerted = true
alertsSent++
}
// Email opted-in users in addition to ntfy. Best-effort: a mail
// failure must not block result storage or the next alert.
s.sendDealEmails(ctx, it, r)
}
price := r.Price
_, err = s.store.InsertResult(ctx, &models.Result{
@@ -307,6 +319,37 @@ func (s *Scheduler) apifyClient(ctx context.Context) *apify.Client {
return apify.New(key)
}
// runApifyActor runs an Apify actor and records the call against the daily
// usage counter that feeds the budget view. Every Apify-billed run in Veola
// goes through here so the count stays honest.
func (s *Scheduler) runApifyActor(ctx context.Context, actorID string, input any) ([]json.RawMessage, error) {
if err := s.store.IncrementApifyUsage(ctx); err != nil {
slog.Error("apify usage increment failed", "err", err)
}
return s.apifyClient(ctx).Run(ctx, actorID, input)
}
// ApifyUsage reports Apify run counts and the cost model used by the budget
// view. costPerCall and monthlyBudget come from settings, falling back to
// config.toml.
func (s *Scheduler) ApifyUsage(ctx context.Context) (today, month int, costPerCall, monthlyBudget float64) {
today, _ = s.store.ApifyUsageToday(ctx)
month, _ = s.store.ApifyUsageMonth(ctx)
costPerCall = s.cfg.Budget.CostPerApifyCall
if v, _ := s.store.GetSetting(ctx, "apify_cost_per_call"); v != "" {
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
costPerCall = f
}
}
monthlyBudget = s.cfg.Budget.MonthlyBudgetUSD
if v, _ := s.store.GetSetting(ctx, "monthly_budget_usd"); v != "" {
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
monthlyBudget = f
}
}
return today, month, costPerCall, monthlyBudget
}
// 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
@@ -382,7 +425,7 @@ func (s *Scheduler) ExecutePlan(ctx context.Context, p actorPlan) ([]apify.Unifi
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)
raw, err := s.runApifyActor(ctx, p.actorID, p.input)
if err != nil {
return nil, err
}
@@ -677,7 +720,7 @@ func (s *Scheduler) seedSoldHistoryFor(ctx context.Context, it models.Item, quer
if actorID == "" {
return
}
raw, err := s.apifyClient(ctx).Run(ctx, actorID, apify.SoldListingInput{
raw, err := s.runApifyActor(ctx, actorID, apify.SoldListingInput{
Query: query, Marketplace: marketplace, MaxResults: 50, DaysBack: 30,
ProxyConfiguration: s.proxyConfig(),
})