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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user