// Package webpush sends Web Push notifications using the VAPID protocol. package webpush import ( "context" "encoding/json" "fmt" "log/slog" "sync" webpushgo "github.com/SherClockHolmes/webpush-go" "github.com/libnovel/backend/internal/storage" ) // Payload is the JSON body delivered to the browser service worker. type Payload struct { Title string `json:"title"` Body string `json:"body"` URL string `json:"url,omitempty"` Icon string `json:"icon,omitempty"` } // Sender sends Web Push notifications to subscribed browsers. type Sender struct { vapidPublic string vapidPrivate string subject string log *slog.Logger } // New returns a Sender configured with the given VAPID key pair. // subject should be a mailto: or https: contact URL per the VAPID spec. func New(vapidPublic, vapidPrivate, subject string, log *slog.Logger) *Sender { if log == nil { log = slog.Default() } return &Sender{ vapidPublic: vapidPublic, vapidPrivate: vapidPrivate, subject: subject, log: log, } } // Enabled returns true when VAPID keys are configured. func (s *Sender) Enabled() bool { return s.vapidPublic != "" && s.vapidPrivate != "" } // Send delivers payload to all provided subscriptions concurrently. // Errors for individual subscriptions are logged but do not abort other sends. // Returns the number of successful sends. func (s *Sender) Send(ctx context.Context, subs []storage.PushSubscription, p Payload) int { if !s.Enabled() || len(subs) == 0 { return 0 } body, err := json.Marshal(p) if err != nil { s.log.Error("webpush: marshal payload", "err", err) return 0 } var ( wg sync.WaitGroup mu sync.Mutex success int ) for _, sub := range subs { sub := sub wg.Add(1) go func() { defer wg.Done() resp, err := webpushgo.SendNotificationWithContext(ctx, body, &webpushgo.Subscription{ Endpoint: sub.Endpoint, Keys: webpushgo.Keys{ P256dh: sub.P256DH, Auth: sub.Auth, }, }, &webpushgo.Options{ VAPIDPublicKey: s.vapidPublic, VAPIDPrivateKey: s.vapidPrivate, Subscriber: s.subject, TTL: 86400, }) if err != nil { s.log.Warn("webpush: send failed", "endpoint", truncate(sub.Endpoint, 60), "err", err) return } defer resp.Body.Close() //nolint:errcheck if resp.StatusCode >= 400 { s.log.Warn("webpush: push service returned error", "endpoint", truncate(sub.Endpoint, 60), "status", resp.StatusCode) return } mu.Lock() success++ mu.Unlock() }() } wg.Wait() return success } // SendToBook sends a push notification to all subscribers of the given book. // store is used to list subscriptions for the book's library followers. func (s *Sender) SendToBook(ctx context.Context, store *storage.Store, slug string, p Payload) { if !s.Enabled() { return } subs, err := store.ListPushSubscriptionsByBook(ctx, slug) if err != nil { s.log.Warn("webpush: list push subscriptions", "slug", slug, "err", err) return } if len(subs) == 0 { return } n := s.Send(ctx, subs, p) s.log.Info("webpush: sent chapter notification", "slug", slug, "recipients", n, "total_subs", len(subs), ) } // GenerateVAPIDKeys generates a new VAPID key pair and prints them. // Useful for one-off key generation during setup. func GenerateVAPIDKeys() (public, private string, err error) { private, public, err = webpushgo.GenerateVAPIDKeys() if err != nil { return "", "", fmt.Errorf("generate VAPID keys: %w", err) } return public, private, nil } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "..." }