diff --git a/internal/handler/push.go b/internal/handler/push.go index 650293a..13bbd02 100644 --- a/internal/handler/push.go +++ b/internal/handler/push.go @@ -3,6 +3,7 @@ package handler import ( "encoding/json" "net/http" + "net/url" "strings" "github.com/prabhupadasays/spsays-bff/internal/middleware" @@ -11,6 +12,40 @@ import ( "github.com/prabhupadasays/spsays-bff/pkg/response" ) +// Subscription limits. The endpoint length cap + host allow-list guard against +// junk/abusive rows and SSRF (the BFF POSTs to the endpoint when sending), and +// the per-user device cap bounds fan-out. +const ( + maxEndpointLen = 2048 + maxKeyLen = 256 + maxDevicesPerUser = 10 +) + +// validatePushEndpoint requires an https URL belonging to a known browser push +// service — preventing the endpoint from pointing at internal/arbitrary hosts +// (SSRF) and keeping the table free of junk. +func validatePushEndpoint(endpoint string) bool { + if l := len(endpoint); l == 0 || l > maxEndpointLen { + return false + } + u, err := url.Parse(endpoint) + if err != nil || u.Scheme != "https" || u.Host == "" { + return false + } + host := strings.ToLower(u.Hostname()) + switch host { + case "fcm.googleapis.com", "updates.push.services.mozilla.com": + return true // Chrome/Brave/Edge (FCM), Firefox + } + // Safari/iOS (*.push.apple.com) and Windows/Edge WNS (*.notify.windows.com). + for _, suffix := range []string{".push.apple.com", ".notify.windows.com", ".wns.windows.com"} { + if strings.HasSuffix(host, suffix) { + return true + } + } + return false +} + // PushHandler serves Web Push subscription management. The VAPID public key // is handed to the browser so it can subscribe; subscriptions are stored per // user. All routes require auth. @@ -59,10 +94,20 @@ func (h *PushHandler) Subscribe(w http.ResponseWriter, r *http.Request) { response.Error(w, http.StatusBadRequest, "missing subscription fields") return } + if !validatePushEndpoint(req.Endpoint) { + response.Error(w, http.StatusBadRequest, "invalid push endpoint") + return + } + if len(req.Keys.P256dh) > maxKeyLen || len(req.Keys.Auth) > maxKeyLen { + response.Error(w, http.StatusBadRequest, "subscription keys too long") + return + } if err := h.repo.Upsert(r.Context(), uid, req.Endpoint, req.Keys.P256dh, req.Keys.Auth, r.UserAgent()); err != nil { response.Error(w, http.StatusInternalServerError, "could not save subscription") return } + // Keep only the most-recent N devices per user (best-effort). + _ = h.repo.PruneToMostRecent(r.Context(), uid, maxDevicesPerUser) response.NoContent(w) } diff --git a/internal/repository/push_repo.go b/internal/repository/push_repo.go index 1100ad0..0c983dd 100644 --- a/internal/repository/push_repo.go +++ b/internal/repository/push_repo.go @@ -52,6 +52,28 @@ func (r *PushRepo) ListByUser(ctx context.Context, userID string) ([]model.PushS return out, rows.Err() } +// PruneToMostRecent keeps only the `keep` most-recently-used subscriptions for +// a user, deleting older ones — a soft per-user device cap. +func (r *PushRepo) PruneToMostRecent(ctx context.Context, userID string, keep int) error { + if keep < 1 { + keep = 1 + } + _, err := r.pg.Pool.Exec(ctx, ` + DELETE FROM push_subscriptions + WHERE user_id = $1 AND id NOT IN ( + SELECT id FROM push_subscriptions + WHERE user_id = $1 + ORDER BY last_used_at DESC NULLS LAST + LIMIT $2 + )`, + userID, keep, + ) + if err != nil { + return fmt.Errorf("prune push subscriptions: %w", err) + } + return nil +} + // DeleteByEndpoint removes a subscription (on unsubscribe or when the push // service reports it gone). func (r *PushRepo) DeleteByEndpoint(ctx context.Context, endpoint string) error { diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go index e804195..c6518cc 100644 --- a/internal/service/notification_service.go +++ b/internal/service/notification_service.go @@ -185,7 +185,7 @@ func (s *NotificationService) SendReminder(ctx context.Context, userID, kind, ti // Web Push (reaches closed apps). if s.push != nil { - s.push.SendToUser(ctx, userID, PushPayload{Title: title, Body: body, URL: url, Tag: kind}) + s.push.SendToUser(ctx, userID, PushPayload{Title: title, Body: body, URL: url, Tag: kind, RequireInteraction: true}) } // Email (only if there's an address on file). diff --git a/internal/service/push_sender.go b/internal/service/push_sender.go index 59ac9a1..ef7f506 100644 --- a/internal/service/push_sender.go +++ b/internal/service/push_sender.go @@ -4,12 +4,16 @@ import ( "context" "encoding/json" "net/http" + "strconv" + "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"` @@ -17,6 +21,9 @@ type PushPayload struct { 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 @@ -68,11 +75,43 @@ func (p *PushSender) SendToUser(ctx context.Context, userID string, payload Push 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, err := p.sendWithRetry(ctx, body, sub) + if err != nil { + failed++ + p.logger.Warn("push: send failed", zap.String("user", userID), 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++ + p.logger.Warn("push: non-2xx", zap.Int("status", status), zap.String("user", userID)) + 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, or an error if every attempt errored. +func (p *PushSender) sendWithRetry(ctx context.Context, body []byte, sub *webpush.Subscription) (int, error) { + var lastErr error + for attempt := 1; attempt <= pushMaxAttempts; attempt++ { resp, err := webpush.SendNotification(body, sub, &webpush.Options{ Subscriber: p.subject, VAPIDPublicKey: p.pub, @@ -83,18 +122,62 @@ func (p *PushSender) SendToUser(ctx context.Context, userID string, payload Push Urgency: webpush.UrgencyNormal, }) if err != nil { - p.logger.Warn("push: send failed", zap.String("user", userID), zap.Error(err)) - continue + lastErr = err + if attempt < pushMaxAttempts { + sleepCtx(ctx, backoff(attempt)) + continue + } + return 0, err } status := resp.StatusCode + retryAfter := resp.Header.Get("Retry-After") _ = resp.Body.Close() - // Subscription expired/gone — drop it so we stop trying. - if status == http.StatusNotFound || status == http.StatusGone { - if derr := p.repo.DeleteByEndpoint(ctx, s.Endpoint); derr != nil { - p.logger.Warn("push: prune failed", zap.Error(derr)) + if isTransientStatus(status) && attempt < pushMaxAttempts { + sleepCtx(ctx, retryDelay(retryAfter, attempt)) + continue + } + return status, nil + } + return 0, lastErr +} + +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 } - } else if status >= 400 { - p.logger.Warn("push: non-2xx", zap.Int("status", status), zap.String("user", userID)) + 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: + } }