package service import ( "context" "encoding/json" "io" "net/http" "net/url" "strconv" "strings" "time" webpush "github.com/SherClockHolmes/webpush-go" "github.com/prabhupadasays/spsays-bff/internal/repository" "go.uber.org/zap" ) const pushMaxAttempts = 3 // PushPayload is the JSON the service worker receives in its `push` event. type PushPayload struct { Title string `json:"title"` Body string `json:"body"` URL string `json:"url,omitempty"` Icon string `json:"icon,omitempty"` Tag string `json:"tag,omitempty"` // RequireInteraction keeps the notification on screen until the user acts // on it (used for sādhana reminders, which shouldn't auto-dismiss). RequireInteraction bool `json:"require_interaction,omitempty"` } // PushSender delivers Web Push messages via VAPID. Construct with // NewPushSender; it returns nil when VAPID isn't configured, and every method // is nil-safe so callers needn't branch. type PushSender struct { repo *repository.PushRepo pub string priv string subject string logger *zap.Logger } // NewPushSender returns nil if the VAPID keys are absent (push disabled). func NewPushSender(repo *repository.PushRepo, publicKey, privateKey, subject string, logger *zap.Logger) *PushSender { if publicKey == "" || privateKey == "" || repo == nil { return nil } // webpush-go prepends "mailto:" to any non-https subscriber, so a subject // already in "mailto:foo@bar" form would become "mailto:mailto:foo@bar" — // a malformed VAPID `sub` that Apple rejects with BadJwtToken (FCM is // lenient). Hand the library a bare address (or an https: URL) so it adds // exactly one prefix. subject = strings.TrimSpace(subject) if !strings.HasPrefix(subject, "https:") { subject = strings.TrimPrefix(subject, "mailto:") } return &PushSender{repo: repo, pub: publicKey, priv: privateKey, subject: subject, logger: logger} } // PublicKey is the VAPID application server key the browser subscribes with. func (p *PushSender) PublicKey() string { if p == nil { return "" } return p.pub } // Enabled reports whether push delivery is configured. func (p *PushSender) Enabled() bool { return p != nil } // SendToUser pushes a payload to every subscription the user has, pruning any // endpoint the push service reports as gone (404/410). Best-effort: failures // are logged, never returned. func (p *PushSender) SendToUser(ctx context.Context, userID string, payload PushPayload) { if p == nil { return } subs, err := p.repo.ListByUser(ctx, userID) if err != nil { p.logger.Warn("push: list subscriptions failed", zap.String("user", userID), zap.Error(err)) return } if len(subs) == 0 { return } body, err := json.Marshal(payload) if err != nil { return } var sent, pruned, failed int for _, s := range subs { sub := &webpush.Subscription{ Endpoint: s.Endpoint, Keys: webpush.Keys{P256dh: s.P256dh, Auth: s.Auth}, } status, respBody, err := p.sendWithRetry(ctx, body, sub) if err != nil { failed++ p.logger.Warn("push: send failed", zap.String("user", userID), zap.String("host", endpointHost(s.Endpoint)), zap.Error(err)) continue } switch { // Subscription expired/gone — drop it so we stop trying. case status == http.StatusNotFound || status == http.StatusGone: pruned++ if derr := p.repo.DeleteByEndpoint(ctx, s.Endpoint); derr != nil { p.logger.Warn("push: prune failed", zap.Error(derr)) } case status >= 400: failed++ // The response body carries the real reason (e.g. a VAPID // credential mismatch, malformed request) — log it. p.logger.Warn("push: non-2xx", zap.Int("status", status), zap.String("user", userID), zap.String("host", endpointHost(s.Endpoint)), zap.String("body", respBody)) default: sent++ } } p.logger.Debug("push: delivery summary", zap.String("user", userID), zap.Int("sent", sent), zap.Int("pruned", pruned), zap.Int("failed", failed)) } // sendWithRetry posts one notification, retrying on transient failures // (network error or 429/5xx) with a short backoff that honors Retry-After. // Returns the final HTTP status and, on a 4xx/5xx, a snippet of the response // body (the push service's error reason). Returns an error if every attempt // errored at the transport level. func (p *PushSender) sendWithRetry(ctx context.Context, body []byte, sub *webpush.Subscription) (int, string, error) { var lastErr error for attempt := 1; attempt <= pushMaxAttempts; attempt++ { resp, err := webpush.SendNotification(body, sub, &webpush.Options{ Subscriber: p.subject, VAPIDPublicKey: p.pub, VAPIDPrivateKey: p.priv, // Hold the message for up to 6h so a sleeping/offline device // still gets it on wake (60s would silently expire meanwhile). TTL: 6 * 60 * 60, Urgency: webpush.UrgencyNormal, }) if err != nil { lastErr = err if attempt < pushMaxAttempts { sleepCtx(ctx, backoff(attempt)) continue } return 0, "", err } status := resp.StatusCode retryAfter := resp.Header.Get("Retry-After") if isTransientStatus(status) && attempt < pushMaxAttempts { _ = resp.Body.Close() sleepCtx(ctx, retryDelay(retryAfter, attempt)) continue } // Final attempt — capture the body on errors for diagnostics. var snippet string if status >= 400 { b, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) snippet = strings.TrimSpace(string(b)) } _ = resp.Body.Close() return status, snippet, nil } return 0, "", lastErr } // endpointHost extracts the host of a push endpoint for log context. func endpointHost(endpoint string) string { if u, err := url.Parse(endpoint); err == nil { return u.Host } return "" } func isTransientStatus(s int) bool { switch s { case http.StatusTooManyRequests, // 429 http.StatusInternalServerError, // 500 http.StatusBadGateway, // 502 http.StatusServiceUnavailable, // 503 http.StatusGatewayTimeout: // 504 return true } return false } func backoff(attempt int) time.Duration { return time.Duration(attempt) * 500 * time.Millisecond } // retryDelay prefers the push service's Retry-After (seconds), capped, else a // plain backoff. func retryDelay(retryAfter string, attempt int) time.Duration { if retryAfter != "" { if secs, err := strconv.Atoi(retryAfter); err == nil && secs > 0 { d := time.Duration(secs) * time.Second if d > 30*time.Second { d = 30 * time.Second } return d } } return backoff(attempt) } // sleepCtx waits for d, returning early if the context is cancelled. func sleepCtx(ctx context.Context, d time.Duration) { t := time.NewTimer(d) defer t.Stop() select { case <-ctx.Done(): case <-t.C: } }