Harden forward-auth, per-user isolation, and budget accuracy
Code-review fixes on the Authentik/budget/Resend work: - UpsertForwardUser: avoid 500 lockout on username UNIQUE collision and on the concurrent first-login race; normalize emails (lower+trim) so case variants don't create duplicate accounts. - Centralize email management in Authentik: when forward-auth mode is on, email is read-only in Veola for every user (not just forward rows); pure-local deployments keep editable email for Resend. - Fail closed on per-user isolation: ownedItem rejects uid==0/orphaned items; dashboard and global results error on a userless request. - Budget accuracy: count Apify usage only after a successful run, and count the billed Test Apify run. - Efficiency: resolve the deal-email recipient once per poll; build the settings page once in PostEmailPrefs.
This commit is contained in:
@@ -28,26 +28,31 @@ func (s *Scheduler) emailClient(ctx context.Context) *email.Client {
|
||||
return email.New(apiKey, from)
|
||||
}
|
||||
|
||||
// sendDealEmails mails a deal alert to the item's owner, if that user opted
|
||||
// into deal-alert email and has a destination address. Items are private, so a
|
||||
// deal only ever notifies the person who is watching it. Entirely best-effort:
|
||||
// an unconfigured Resend account or a send failure is logged and swallowed.
|
||||
func (s *Scheduler) sendDealEmails(ctx context.Context, it models.Item, r apify.UnifiedResult) {
|
||||
// dealMailer resolves the item owner and Resend client for deal-alert email.
|
||||
// It returns (nil, nil) when email should be skipped (Resend unconfigured, no
|
||||
// owner, owner opted out, or no address) so the caller can resolve once per
|
||||
// poll instead of once per result. Best-effort: errors are logged and swallowed.
|
||||
func (s *Scheduler) dealMailer(ctx context.Context, it models.Item) (*email.Client, *models.User) {
|
||||
client := s.emailClient(ctx)
|
||||
if !client.Configured() {
|
||||
return
|
||||
}
|
||||
if it.UserID == 0 {
|
||||
return
|
||||
if !client.Configured() || it.UserID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
owner, err := s.store.GetUserByID(ctx, it.UserID)
|
||||
if err != nil {
|
||||
slog.Error("load item owner failed", "item_id", it.ID, "err", err)
|
||||
return
|
||||
return nil, nil
|
||||
}
|
||||
if owner == nil || !owner.EmailDealAlerts || owner.Email == "" {
|
||||
return
|
||||
return nil, nil
|
||||
}
|
||||
return client, owner
|
||||
}
|
||||
|
||||
// sendDealEmail mails a single deal alert to the resolved owner. The client and
|
||||
// owner come from dealMailer (resolved once per poll), so this does no DB or
|
||||
// settings I/O per result. Items are private, so a deal only ever notifies the
|
||||
// person watching it.
|
||||
func (s *Scheduler) sendDealEmail(ctx context.Context, client *email.Client, owner *models.User, it models.Item, r apify.UnifiedResult) {
|
||||
subject := fmt.Sprintf("Veola deal: %s", it.Name)
|
||||
price := fmt.Sprintf("%s%.2f", currencyPrefix(r.Currency), r.Price)
|
||||
target := ""
|
||||
|
||||
@@ -221,6 +221,10 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
|
||||
)
|
||||
|
||||
bestIdx := PickBest(results)
|
||||
// Resolve the deal-email recipient once: owner and Resend client are
|
||||
// constant for the whole poll, so per-result sends reuse them instead of
|
||||
// re-reading settings and the owner row for every alerting result.
|
||||
dealClient, dealOwner := s.dealMailer(ctx, it)
|
||||
alertsSent := 0
|
||||
for _, r := range results {
|
||||
exists, err := s.store.ResultExists(ctx, it.ID, r.URL)
|
||||
@@ -247,9 +251,11 @@ 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)
|
||||
// Email the owner in addition to ntfy. Best-effort: a mail failure
|
||||
// must not block result storage or the next alert.
|
||||
if dealOwner != nil {
|
||||
s.sendDealEmail(ctx, dealClient, dealOwner, it, r)
|
||||
}
|
||||
}
|
||||
price := r.Price
|
||||
_, err = s.store.InsertResult(ctx, &models.Result{
|
||||
@@ -323,10 +329,17 @@ func (s *Scheduler) apifyClient(ctx context.Context) *apify.Client {
|
||||
// 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) {
|
||||
raw, err := s.apifyClient(ctx).Run(ctx, actorID, input)
|
||||
if err != nil {
|
||||
// Count only runs that actually reached Apify. Counting before the call
|
||||
// would inflate the budget estimate with config errors and failed runs
|
||||
// that Apify never billed.
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.IncrementApifyUsage(ctx); err != nil {
|
||||
slog.Error("apify usage increment failed", "err", err)
|
||||
}
|
||||
return s.apifyClient(ctx).Run(ctx, actorID, input)
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// ApifyUsage reports Apify run counts and the cost model used by the budget
|
||||
|
||||
Reference in New Issue
Block a user