fix: push notification
This commit is contained in:
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||||
@@ -11,6 +12,40 @@ import (
|
|||||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
"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
|
// PushHandler serves Web Push subscription management. The VAPID public key
|
||||||
// is handed to the browser so it can subscribe; subscriptions are stored per
|
// is handed to the browser so it can subscribe; subscriptions are stored per
|
||||||
// user. All routes require auth.
|
// 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")
|
response.Error(w, http.StatusBadRequest, "missing subscription fields")
|
||||||
return
|
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 {
|
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")
|
response.Error(w, http.StatusInternalServerError, "could not save subscription")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Keep only the most-recent N devices per user (best-effort).
|
||||||
|
_ = h.repo.PruneToMostRecent(r.Context(), uid, maxDevicesPerUser)
|
||||||
response.NoContent(w)
|
response.NoContent(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,28 @@ func (r *PushRepo) ListByUser(ctx context.Context, userID string) ([]model.PushS
|
|||||||
return out, rows.Err()
|
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
|
// DeleteByEndpoint removes a subscription (on unsubscribe or when the push
|
||||||
// service reports it gone).
|
// service reports it gone).
|
||||||
func (r *PushRepo) DeleteByEndpoint(ctx context.Context, endpoint string) error {
|
func (r *PushRepo) DeleteByEndpoint(ctx context.Context, endpoint string) error {
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ func (s *NotificationService) SendReminder(ctx context.Context, userID, kind, ti
|
|||||||
|
|
||||||
// Web Push (reaches closed apps).
|
// Web Push (reaches closed apps).
|
||||||
if s.push != nil {
|
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).
|
// Email (only if there's an address on file).
|
||||||
|
|||||||
@@ -4,12 +4,16 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
webpush "github.com/SherClockHolmes/webpush-go"
|
webpush "github.com/SherClockHolmes/webpush-go"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const pushMaxAttempts = 3
|
||||||
|
|
||||||
// PushPayload is the JSON the service worker receives in its `push` event.
|
// PushPayload is the JSON the service worker receives in its `push` event.
|
||||||
type PushPayload struct {
|
type PushPayload struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@@ -17,6 +21,9 @@ type PushPayload struct {
|
|||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
Icon string `json:"icon,omitempty"`
|
Icon string `json:"icon,omitempty"`
|
||||||
Tag string `json:"tag,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
|
// 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 {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
var sent, pruned, failed int
|
||||||
for _, s := range subs {
|
for _, s := range subs {
|
||||||
sub := &webpush.Subscription{
|
sub := &webpush.Subscription{
|
||||||
Endpoint: s.Endpoint,
|
Endpoint: s.Endpoint,
|
||||||
Keys: webpush.Keys{P256dh: s.P256dh, Auth: s.Auth},
|
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{
|
resp, err := webpush.SendNotification(body, sub, &webpush.Options{
|
||||||
Subscriber: p.subject,
|
Subscriber: p.subject,
|
||||||
VAPIDPublicKey: p.pub,
|
VAPIDPublicKey: p.pub,
|
||||||
@@ -83,18 +122,62 @@ func (p *PushSender) SendToUser(ctx context.Context, userID string, payload Push
|
|||||||
Urgency: webpush.UrgencyNormal,
|
Urgency: webpush.UrgencyNormal,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.logger.Warn("push: send failed", zap.String("user", userID), zap.Error(err))
|
lastErr = err
|
||||||
continue
|
if attempt < pushMaxAttempts {
|
||||||
|
sleepCtx(ctx, backoff(attempt))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
}
|
}
|
||||||
status := resp.StatusCode
|
status := resp.StatusCode
|
||||||
|
retryAfter := resp.Header.Get("Retry-After")
|
||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
// Subscription expired/gone — drop it so we stop trying.
|
if isTransientStatus(status) && attempt < pushMaxAttempts {
|
||||||
if status == http.StatusNotFound || status == http.StatusGone {
|
sleepCtx(ctx, retryDelay(retryAfter, attempt))
|
||||||
if derr := p.repo.DeleteByEndpoint(ctx, s.Endpoint); derr != nil {
|
continue
|
||||||
p.logger.Warn("push: prune failed", zap.Error(derr))
|
}
|
||||||
|
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 {
|
return d
|
||||||
p.logger.Warn("push: non-2xx", zap.Int("status", status), zap.String("user", userID))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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:
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user