176 lines
4.8 KiB
Go
176 lines
4.8 KiB
Go
package expiry
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/noderunners/nip05api/internal/audit"
|
|
"github.com/noderunners/nip05api/internal/dm"
|
|
"github.com/noderunners/nip05api/internal/nostr"
|
|
"github.com/noderunners/nip05api/internal/user"
|
|
"github.com/noderunners/nip05api/internal/webhook"
|
|
)
|
|
|
|
type Worker struct {
|
|
users *user.Service
|
|
dms *dm.Service
|
|
hooks *webhook.Service
|
|
audit *audit.Logger
|
|
domain string
|
|
frontend string
|
|
graceDays int
|
|
reminderDays []int
|
|
cronHourUTC int
|
|
clock func() time.Time
|
|
}
|
|
|
|
func NewWorker(users *user.Service, dms *dm.Service, hooks *webhook.Service, aud *audit.Logger,
|
|
domain, frontend string, graceDays int, reminderDays []int, cronHourUTC int) *Worker {
|
|
if len(reminderDays) == 0 {
|
|
reminderDays = []int{7}
|
|
}
|
|
return &Worker{
|
|
users: users,
|
|
dms: dms,
|
|
hooks: hooks,
|
|
audit: aud,
|
|
domain: domain,
|
|
frontend: frontend,
|
|
graceDays: graceDays,
|
|
reminderDays: reminderDays,
|
|
cronHourUTC: cronHourUTC,
|
|
clock: func() time.Time { return time.Now().UTC() },
|
|
}
|
|
}
|
|
|
|
func (w *Worker) WithClock(c func() time.Time) *Worker { w.clock = c; return w }
|
|
|
|
func (w *Worker) Run(ctx context.Context) {
|
|
w.RunOnce(ctx)
|
|
for {
|
|
next := w.nextRun()
|
|
timer := time.NewTimer(time.Until(next))
|
|
select {
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return
|
|
case <-timer.C:
|
|
w.RunOnce(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (w *Worker) nextRun() time.Time {
|
|
now := w.clock()
|
|
target := time.Date(now.Year(), now.Month(), now.Day(), w.cronHourUTC, 0, 0, 0, time.UTC)
|
|
if !target.After(now) {
|
|
target = target.Add(24 * time.Hour)
|
|
}
|
|
return target
|
|
}
|
|
|
|
func (w *Worker) RunOnce(ctx context.Context) {
|
|
now := w.clock()
|
|
w.processReminders(ctx, now)
|
|
w.processExpirations(ctx, now)
|
|
w.processGraceCleanup(ctx, now)
|
|
}
|
|
|
|
func (w *Worker) processReminders(ctx context.Context, now time.Time) {
|
|
for _, days := range w.reminderDays {
|
|
users, err := w.users.Repo().ListPendingReminders(ctx, days, now)
|
|
if err != nil {
|
|
slog.Error("expiry reminders list", "err", err)
|
|
continue
|
|
}
|
|
for _, u := range users {
|
|
vars := buildVars(u, w.domain, w.frontend, days)
|
|
if err := w.dms.Send(ctx, dm.EventExpiringSoon, u.Pubkey, vars); err != nil {
|
|
slog.Error("expiry reminder dm", "pubkey", u.Pubkey, "err", err)
|
|
continue
|
|
}
|
|
ts := now
|
|
u.ExpiringReminderSentAt = &ts
|
|
if err := w.users.Repo().Update(ctx, u); err != nil {
|
|
slog.Error("expiry reminder update", "pubkey", u.Pubkey, "err", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (w *Worker) processExpirations(ctx context.Context, now time.Time) {
|
|
users, err := w.users.Repo().ListExpired(ctx, now)
|
|
if err != nil {
|
|
slog.Error("expiry list", "err", err)
|
|
return
|
|
}
|
|
for _, u := range users {
|
|
ts := now
|
|
u.IsActive = false
|
|
u.DeactivatedAt = &ts
|
|
if err := w.users.Repo().Update(ctx, u); err != nil {
|
|
slog.Error("expiry deactivate", "pubkey", u.Pubkey, "err", err)
|
|
continue
|
|
}
|
|
vars := buildVars(u, w.domain, w.frontend, 0)
|
|
vars["grace_days"] = strconv.Itoa(w.graceDays)
|
|
_ = w.dms.Send(ctx, dm.EventExpired, u.Pubkey, vars)
|
|
_ = w.hooks.Enqueue(ctx, webhook.EventUserRemoved, hookData(u, "expired"))
|
|
w.audit.Log(ctx, audit.ActionUserExpired, audit.ActorSystem, u.Pubkey, map[string]any{
|
|
"username": u.Username,
|
|
})
|
|
}
|
|
}
|
|
|
|
func (w *Worker) processGraceCleanup(ctx context.Context, now time.Time) {
|
|
cutoff := now.Add(-time.Duration(w.graceDays) * 24 * time.Hour)
|
|
users, err := w.users.Repo().ListGraceExpired(ctx, cutoff)
|
|
if err != nil {
|
|
slog.Error("grace list", "err", err)
|
|
return
|
|
}
|
|
for _, u := range users {
|
|
if err := w.users.Repo().Delete(ctx, u.Pubkey); err != nil {
|
|
slog.Error("grace delete", "pubkey", u.Pubkey, "err", err)
|
|
continue
|
|
}
|
|
_ = w.hooks.Enqueue(ctx, webhook.EventUserRemoved, hookData(u, "grace_cleanup"))
|
|
w.audit.Log(ctx, audit.ActionUserGracePurged, audit.ActorSystem, u.Pubkey, map[string]any{
|
|
"username": u.Username,
|
|
})
|
|
}
|
|
}
|
|
|
|
func buildVars(u *user.User, domain, frontend string, daysRemaining int) map[string]string {
|
|
expires := "lifetime"
|
|
if u.ExpiresAt != nil {
|
|
expires = u.ExpiresAt.Format("2006-01-02")
|
|
}
|
|
return map[string]string{
|
|
"username": u.Username,
|
|
"npub": nostr.HexToNpub(u.Pubkey),
|
|
"pubkey": u.Pubkey,
|
|
"domain": domain,
|
|
"expires_at": expires,
|
|
"days_remaining": strconv.Itoa(daysRemaining),
|
|
"frontend_url": frontend,
|
|
"subscription_type": string(u.SubscriptionType),
|
|
}
|
|
}
|
|
|
|
func hookData(u *user.User, reason string) map[string]any {
|
|
d := map[string]any{
|
|
"pubkey": u.Pubkey,
|
|
"npub": nostr.HexToNpub(u.Pubkey),
|
|
"username": u.Username,
|
|
"subscription_type": string(u.SubscriptionType),
|
|
"reason": reason,
|
|
}
|
|
if u.ExpiresAt != nil {
|
|
d["expires_at"] = u.ExpiresAt.UTC().Format(time.RFC3339)
|
|
}
|
|
return d
|
|
}
|