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:
@@ -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
156
internal/auth/forward.go
Normal 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
|
||||
}
|
||||
53
internal/auth/forward_test.go
Normal file
53
internal/auth/forward_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
97
internal/email/client.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
171
internal/scheduler/email.go
Normal 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()
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user