feat: web push notifications for new chapters
All checks were successful
Release / Test backend (push) Successful in 4m12s
Release / Check ui (push) Successful in 1m53s
Release / Docker (push) Successful in 5m46s
Release / Gitea Release (push) Successful in 35s

- 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:
root
2026-04-11 14:59:21 +05:00
parent 3a9f3b773e
commit b95c811898
17 changed files with 779 additions and 7 deletions

View 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})
}

View File

@@ -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)

View File

@@ -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"),
},
}
}

View File

@@ -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"`

View File

@@ -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)

View File

@@ -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",

View File

@@ -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
}

View 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] + "..."
}