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

@@ -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