feat: web push notifications for new chapters
- 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
This commit is contained in:
87
backend/internal/backend/handlers_push.go
Normal file
87
backend/internal/backend/handlers_push.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/libnovel/backend/internal/storage"
|
||||
)
|
||||
|
||||
// handleGetVAPIDPublicKey handles GET /api/push-subscriptions/vapid-public-key.
|
||||
// Returns the VAPID public key so the SvelteKit frontend can subscribe browsers.
|
||||
func (s *Server) handleGetVAPIDPublicKey(w http.ResponseWriter, r *http.Request) {
|
||||
key := os.Getenv("VAPID_PUBLIC_KEY")
|
||||
if key == "" {
|
||||
jsonError(w, http.StatusServiceUnavailable, "push notifications not configured")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 0, map[string]string{"public_key": key})
|
||||
}
|
||||
|
||||
// handleSavePushSubscription handles POST /api/push-subscriptions.
|
||||
// Registers a new browser push subscription for the authenticated user.
|
||||
func (s *Server) handleSavePushSubscription(w http.ResponseWriter, r *http.Request) {
|
||||
store, ok := s.deps.Producer.(*storage.Store)
|
||||
if !ok {
|
||||
jsonError(w, http.StatusInternalServerError, "storage not available")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
UserID string `json:"user_id"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
P256DH string `json:"p256dh"`
|
||||
Auth string `json:"auth"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if body.UserID == "" || body.Endpoint == "" || body.P256DH == "" || body.Auth == "" {
|
||||
jsonError(w, http.StatusBadRequest, "user_id, endpoint, p256dh and auth are required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := store.SavePushSubscription(r.Context(), storage.PushSubscription{
|
||||
UserID: body.UserID,
|
||||
Endpoint: body.Endpoint,
|
||||
P256DH: body.P256DH,
|
||||
Auth: body.Auth,
|
||||
}); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "save push subscription: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, 0, map[string]any{"success": true})
|
||||
}
|
||||
|
||||
// handleDeletePushSubscription handles DELETE /api/push-subscriptions.
|
||||
// Removes a push subscription by endpoint for the given user.
|
||||
func (s *Server) handleDeletePushSubscription(w http.ResponseWriter, r *http.Request) {
|
||||
store, ok := s.deps.Producer.(*storage.Store)
|
||||
if !ok {
|
||||
jsonError(w, http.StatusInternalServerError, "storage not available")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
UserID string `json:"user_id"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if body.UserID == "" || body.Endpoint == "" {
|
||||
jsonError(w, http.StatusBadRequest, "user_id and endpoint are required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := store.DeletePushSubscription(r.Context(), body.UserID, body.Endpoint); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "delete push subscription: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, 0, map[string]any{"success": true})
|
||||
}
|
||||
@@ -270,6 +270,11 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux.HandleFunc("DELETE /api/notifications", s.handleClearAllNotifications)
|
||||
mux.HandleFunc("DELETE /api/notifications/{id}", s.handleDismissNotification)
|
||||
|
||||
// Web Push subscriptions
|
||||
mux.HandleFunc("GET /api/push-subscriptions/vapid-public-key", s.handleGetVAPIDPublicKey)
|
||||
mux.HandleFunc("POST /api/push-subscriptions", s.handleSavePushSubscription)
|
||||
mux.HandleFunc("DELETE /api/push-subscriptions", s.handleDeletePushSubscription)
|
||||
|
||||
// Voices list
|
||||
mux.HandleFunc("GET /api/voices", s.handleVoices)
|
||||
|
||||
|
||||
@@ -123,6 +123,19 @@ type Redis struct {
|
||||
Password string
|
||||
}
|
||||
|
||||
// VAPID holds Web Push VAPID key pair for browser push notifications.
|
||||
// Generate a pair once with: go run ./cmd/genkeys (or use the web-push CLI).
|
||||
// The public key is exposed via GET /api/push-subscriptions/vapid-public-key
|
||||
// and embedded in the SvelteKit app via PUBLIC_VAPID_PUBLIC_KEY.
|
||||
type VAPID struct {
|
||||
// PublicKey is the base64url-encoded VAPID public key (65 bytes, uncompressed EC P-256).
|
||||
PublicKey string
|
||||
// PrivateKey is the base64url-encoded VAPID private key (32 bytes).
|
||||
PrivateKey string
|
||||
// Subject is the mailto: or https: URL used as the VAPID subscriber contact.
|
||||
Subject string
|
||||
}
|
||||
|
||||
// Runner holds settings specific to the runner/worker binary.
|
||||
type Runner struct {
|
||||
// PollInterval is how often the runner checks PocketBase for pending tasks.
|
||||
@@ -172,6 +185,7 @@ type Config struct {
|
||||
Meilisearch Meilisearch
|
||||
Valkey Valkey
|
||||
Redis Redis
|
||||
VAPID VAPID
|
||||
// LogLevel is one of "debug", "info", "warn", "error".
|
||||
LogLevel string
|
||||
}
|
||||
@@ -258,6 +272,12 @@ func Load() Config {
|
||||
Addr: envOr("REDIS_ADDR", ""),
|
||||
Password: envOr("REDIS_PASSWORD", ""),
|
||||
},
|
||||
|
||||
VAPID: VAPID{
|
||||
PublicKey: envOr("VAPID_PUBLIC_KEY", ""),
|
||||
PrivateKey: envOr("VAPID_PRIVATE_KEY", ""),
|
||||
Subject: envOr("VAPID_SUBJECT", "mailto:admin@libnovel.cc"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,8 @@ type ScrapeTask struct {
|
||||
|
||||
// ScrapeResult is the outcome reported by the runner after finishing a ScrapeTask.
|
||||
type ScrapeResult struct {
|
||||
// Slug is the book slug that was scraped. Empty for catalogue tasks.
|
||||
Slug string `json:"slug,omitempty"`
|
||||
BooksFound int `json:"books_found"`
|
||||
ChaptersScraped int `json:"chapters_scraped"`
|
||||
ChaptersSkipped int `json:"chapters_skipped"`
|
||||
|
||||
@@ -90,6 +90,7 @@ func (o *Orchestrator) RunBook(ctx context.Context, task domain.ScrapeTask) doma
|
||||
result.Errors++
|
||||
return result
|
||||
}
|
||||
result.Slug = meta.Slug
|
||||
|
||||
if err := o.store.WriteMetadata(ctx, meta); err != nil {
|
||||
o.log.Error("metadata write failed", "slug", meta.Slug, "err", err)
|
||||
@@ -97,6 +98,7 @@ func (o *Orchestrator) RunBook(ctx context.Context, task domain.ScrapeTask) doma
|
||||
result.Errors++
|
||||
} else {
|
||||
result.BooksFound = 1
|
||||
result.Slug = meta.Slug
|
||||
// Fire optional post-metadata hook (e.g. Meilisearch indexing).
|
||||
if o.postMetadata != nil {
|
||||
o.postMetadata(ctx, meta)
|
||||
|
||||
@@ -36,7 +36,9 @@ import (
|
||||
"github.com/libnovel/backend/internal/orchestrator"
|
||||
"github.com/libnovel/backend/internal/pockettts"
|
||||
"github.com/libnovel/backend/internal/scraper"
|
||||
"github.com/libnovel/backend/internal/storage"
|
||||
"github.com/libnovel/backend/internal/taskqueue"
|
||||
"github.com/libnovel/backend/internal/webpush"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
@@ -130,6 +132,12 @@ type Dependencies struct {
|
||||
ChapterIngester ChapterIngester
|
||||
// Notifier creates notifications for users.
|
||||
Notifier Notifier
|
||||
// WebPush sends browser push notifications to subscribed users.
|
||||
// If nil, push notifications are disabled.
|
||||
WebPush *webpush.Sender
|
||||
// Store is the underlying *storage.Store; used for push subscription lookups.
|
||||
// Only needed when WebPush is non-nil.
|
||||
Store *storage.Store
|
||||
// SearchIndex indexes books in Meilisearch after scraping.
|
||||
// If nil a no-op is used.
|
||||
SearchIndex meili.Client
|
||||
@@ -531,6 +539,16 @@ func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) {
|
||||
fmt.Sprintf("Scraped %d chapters, skipped %d (%s)", result.ChaptersScraped, result.ChaptersSkipped, task.Kind),
|
||||
"/admin/tasks")
|
||||
}
|
||||
// Send push notifications to users who have this book in their library.
|
||||
if r.deps.WebPush != nil && r.deps.Store != nil &&
|
||||
result.ChaptersScraped > 0 && result.Slug != "" && task.Kind != "catalogue" {
|
||||
go r.deps.WebPush.SendToBook(context.Background(), r.deps.Store, result.Slug, webpush.Payload{
|
||||
Title: "New chapter available",
|
||||
Body: fmt.Sprintf("%d new chapter(s) added", result.ChaptersScraped),
|
||||
URL: "/books/" + result.Slug,
|
||||
Icon: "/icon-192.png",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("runner: scrape task finished",
|
||||
|
||||
@@ -1526,3 +1526,116 @@ func parseAIJob(raw json.RawMessage) (domain.AIJob, error) {
|
||||
HeartbeatAt: parseT(r.HeartbeatAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── Push subscriptions ────────────────────────────────────────────────────────
|
||||
|
||||
// PushSubscription holds the Web Push subscription data for a single browser.
|
||||
type PushSubscription struct {
|
||||
ID string
|
||||
UserID string
|
||||
Endpoint string
|
||||
P256DH string
|
||||
Auth string
|
||||
}
|
||||
|
||||
// SavePushSubscription upserts a Web Push subscription for a user.
|
||||
// If a record with the same endpoint already exists it is updated in place.
|
||||
func (s *Store) SavePushSubscription(ctx context.Context, sub PushSubscription) error {
|
||||
filter := fmt.Sprintf("endpoint=%q", sub.Endpoint)
|
||||
existing, err := s.pb.listAll(ctx, "push_subscriptions", filter, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("SavePushSubscription list: %w", err)
|
||||
}
|
||||
payload := map[string]any{
|
||||
"user_id": sub.UserID,
|
||||
"endpoint": sub.Endpoint,
|
||||
"p256dh": sub.P256DH,
|
||||
"auth": sub.Auth,
|
||||
}
|
||||
if len(existing) > 0 {
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if json.Unmarshal(existing[0], &rec) == nil && rec.ID != "" {
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/push_subscriptions/records/%s", rec.ID), payload)
|
||||
}
|
||||
}
|
||||
return s.pb.post(ctx, "/api/collections/push_subscriptions/records", payload, nil)
|
||||
}
|
||||
|
||||
// DeletePushSubscription removes a Web Push subscription by endpoint.
|
||||
func (s *Store) DeletePushSubscription(ctx context.Context, userID, endpoint string) error {
|
||||
filter := fmt.Sprintf("user_id=%q&&endpoint=%q", userID, endpoint)
|
||||
items, err := s.pb.listAll(ctx, "push_subscriptions", filter, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("DeletePushSubscription list: %w", err)
|
||||
}
|
||||
for _, raw := range items {
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
|
||||
_ = s.pb.delete(ctx, fmt.Sprintf("/api/collections/push_subscriptions/records/%s", rec.ID))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListPushSubscriptionsByBook returns all push subscriptions belonging to users
|
||||
// who have the given book slug in their library (user_library collection).
|
||||
func (s *Store) ListPushSubscriptionsByBook(ctx context.Context, slug string) ([]PushSubscription, error) {
|
||||
// Find all users who have this book in their library
|
||||
libFilter := fmt.Sprintf("slug=%q&&user_id!=''", slug)
|
||||
libItems, err := s.pb.listAll(ctx, "user_library", libFilter, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ListPushSubscriptionsByBook list library: %w", err)
|
||||
}
|
||||
|
||||
// Collect unique user IDs
|
||||
seen := make(map[string]bool)
|
||||
var userIDs []string
|
||||
for _, raw := range libItems {
|
||||
var rec struct {
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
if json.Unmarshal(raw, &rec) == nil && rec.UserID != "" && !seen[rec.UserID] {
|
||||
seen[rec.UserID] = true
|
||||
userIDs = append(userIDs, rec.UserID)
|
||||
}
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Build OR filter for push_subscriptions
|
||||
parts := make([]string, len(userIDs))
|
||||
for i, uid := range userIDs {
|
||||
parts[i] = fmt.Sprintf("user_id=%q", uid)
|
||||
}
|
||||
subFilter := strings.Join(parts, "||")
|
||||
subItems, err := s.pb.listAll(ctx, "push_subscriptions", subFilter, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ListPushSubscriptionsByBook list subs: %w", err)
|
||||
}
|
||||
|
||||
subs := make([]PushSubscription, 0, len(subItems))
|
||||
for _, raw := range subItems {
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
P256DH string `json:"p256dh"`
|
||||
Auth string `json:"auth"`
|
||||
}
|
||||
if json.Unmarshal(raw, &rec) == nil && rec.Endpoint != "" {
|
||||
subs = append(subs, PushSubscription{
|
||||
ID: rec.ID,
|
||||
UserID: rec.UserID,
|
||||
Endpoint: rec.Endpoint,
|
||||
P256DH: rec.P256DH,
|
||||
Auth: rec.Auth,
|
||||
})
|
||||
}
|
||||
}
|
||||
return subs, nil
|
||||
}
|
||||
|
||||
147
backend/internal/webpush/webpush.go
Normal file
147
backend/internal/webpush/webpush.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// 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] + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user