- Service worker (src/service-worker.ts) handles push events and notification clicks, navigating to the book page on tap - Web app manifest (manifest.webmanifest) linked in app.html - Profile page: push notification toggle (subscribe/unsubscribe) using the browser Notification + PushManager API with VAPID - API route POST/DELETE /api/push-subscription proxies to backend - Go backend: push_subscriptions PocketBase collection storage methods (SavePushSubscription, DeletePushSubscription, ListPushSubscriptionsByBook) in storage/store.go - handlers_push.go: GET vapid-public-key, POST/DELETE subscription - webpush package: VAPID-signed sends via webpush-go, SendToBook fans out to all users who have the book in their library - Runner fires push to subscribers whenever ChaptersScraped > 0 after a successful book scrape - Config: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT env vars - domain.ScrapeResult gets a Slug field; orchestrator populates it
148 lines
3.6 KiB
Go
148 lines
3.6 KiB
Go
// 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] + "..."
|
|
}
|