From b95c811898d5a8ae948a9ee003e4d671b6d76651 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 11 Apr 2026 14:59:21 +0500 Subject: [PATCH] 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 --- backend/cmd/runner/main.go | 12 ++ backend/go.mod | 1 + backend/go.sum | 67 ++++++++ backend/internal/backend/handlers_push.go | 87 ++++++++++ backend/internal/backend/server.go | 5 + backend/internal/config/config.go | 20 +++ backend/internal/domain/domain.go | 2 + backend/internal/orchestrator/orchestrator.go | 2 + backend/internal/runner/runner.go | 18 +++ backend/internal/storage/store.go | 113 +++++++++++++ backend/internal/webpush/webpush.go | 147 +++++++++++++++++ ui/src/app.html | 1 + ui/src/routes/+page.svelte | 13 +- .../routes/api/push-subscription/+server.ts | 63 ++++++++ ui/src/routes/profile/+page.svelte | 148 ++++++++++++++++++ ui/src/service-worker.ts | 73 +++++++++ ui/static/manifest.webmanifest | 14 ++ 17 files changed, 779 insertions(+), 7 deletions(-) create mode 100644 backend/internal/backend/handlers_push.go create mode 100644 backend/internal/webpush/webpush.go create mode 100644 ui/src/routes/api/push-subscription/+server.ts create mode 100644 ui/src/service-worker.ts create mode 100644 ui/static/manifest.webmanifest diff --git a/backend/cmd/runner/main.go b/backend/cmd/runner/main.go index b4cd4f6..5ed7bb9 100644 --- a/backend/cmd/runner/main.go +++ b/backend/cmd/runner/main.go @@ -34,6 +34,7 @@ import ( "github.com/libnovel/backend/internal/runner" "github.com/libnovel/backend/internal/storage" "github.com/libnovel/backend/internal/taskqueue" + "github.com/libnovel/backend/internal/webpush" ) // version and commit are set at build time via -ldflags. @@ -190,6 +191,15 @@ func run() error { log.Info("runner: poll mode — using PocketBase for task dispatch") } + // ── Web Push ───────────────────────────────────────────────────────────── + var pushSender *webpush.Sender + if cfg.VAPID.PublicKey != "" && cfg.VAPID.PrivateKey != "" { + pushSender = webpush.New(cfg.VAPID.PublicKey, cfg.VAPID.PrivateKey, cfg.VAPID.Subject, log) + log.Info("runner: web push notifications enabled") + } else { + log.Info("runner: VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY not set — push notifications disabled") + } + deps := runner.Dependencies{ Consumer: consumer, BookWriter: store, @@ -207,6 +217,8 @@ func run() error { CFAI: cfaiClient, LibreTranslate: ltClient, Notifier: store, + WebPush: pushSender, + Store: store, Log: log, } r := runner.New(rCfg, deps) diff --git a/backend/go.mod b/backend/go.mod index 9ccfe05..b58c870 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -24,6 +24,7 @@ require ( ) require ( + github.com/SherClockHolmes/webpush-go v1.4.0 // indirect github.com/andybalholm/brotli v1.1.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect diff --git a/backend/go.sum b/backend/go.sum index 3c7a96e..e9a22d5 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,5 @@ +github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= +github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -33,10 +35,12 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -112,6 +116,7 @@ github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= @@ -154,18 +159,80 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ= golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= diff --git a/backend/internal/backend/handlers_push.go b/backend/internal/backend/handlers_push.go new file mode 100644 index 0000000..7fe7324 --- /dev/null +++ b/backend/internal/backend/handlers_push.go @@ -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}) +} diff --git a/backend/internal/backend/server.go b/backend/internal/backend/server.go index 9132c7d..b6e087c 100644 --- a/backend/internal/backend/server.go +++ b/backend/internal/backend/server.go @@ -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) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 2267f57..d76233a 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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"), + }, } } diff --git a/backend/internal/domain/domain.go b/backend/internal/domain/domain.go index 4d2689e..d9f1963 100644 --- a/backend/internal/domain/domain.go +++ b/backend/internal/domain/domain.go @@ -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"` diff --git a/backend/internal/orchestrator/orchestrator.go b/backend/internal/orchestrator/orchestrator.go index 6331a6f..a97917a 100644 --- a/backend/internal/orchestrator/orchestrator.go +++ b/backend/internal/orchestrator/orchestrator.go @@ -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) diff --git a/backend/internal/runner/runner.go b/backend/internal/runner/runner.go index 56b2179..620fc6e 100644 --- a/backend/internal/runner/runner.go +++ b/backend/internal/runner/runner.go @@ -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", diff --git a/backend/internal/storage/store.go b/backend/internal/storage/store.go index c42ce32..5e5c5d4 100644 --- a/backend/internal/storage/store.go +++ b/backend/internal/storage/store.go @@ -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 +} diff --git a/backend/internal/webpush/webpush.go b/backend/internal/webpush/webpush.go new file mode 100644 index 0000000..9cff793 --- /dev/null +++ b/backend/internal/webpush/webpush.go @@ -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] + "..." +} diff --git a/ui/src/app.html b/ui/src/app.html index 3cef78f..0b7e289 100644 --- a/ui/src/app.html +++ b/ui/src/app.html @@ -3,6 +3,7 @@ + diff --git a/ui/src/routes/+page.svelte b/ui/src/routes/+page.svelte index 29d5d98..25f0b03 100644 --- a/ui/src/routes/+page.svelte +++ b/ui/src/routes/+page.svelte @@ -179,17 +179,16 @@ {m.home_chapter_badge({ n: String(heroBook.chapter) })} - + Info + {#each parseGenres(heroBook.book.genres).slice(0, 2) as genre} {genre} {/each} diff --git a/ui/src/routes/api/push-subscription/+server.ts b/ui/src/routes/api/push-subscription/+server.ts new file mode 100644 index 0000000..cefbe12 --- /dev/null +++ b/ui/src/routes/api/push-subscription/+server.ts @@ -0,0 +1,63 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { backendFetch } from '$lib/server/scraper'; + +/** + * POST /api/push-subscription + * Registers a browser push subscription for the current user. + * Body: { endpoint, keys: { p256dh, auth } } + */ +export const POST: RequestHandler = async ({ request, locals }) => { + if (!locals.user) throw error(401, 'Login required'); + + const body = await request.json().catch(() => null); + if (!body?.endpoint || !body?.keys?.p256dh || !body?.keys?.auth) { + throw error(400, 'Invalid push subscription object'); + } + + const res = await backendFetch('/api/push-subscriptions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: locals.user.id, + endpoint: body.endpoint, + p256dh: body.keys.p256dh, + auth: body.keys.auth, + }), + }); + + if (!res.ok) { + const msg = await res.text().catch(() => 'backend error'); + throw error(res.status, msg); + } + + return json({ success: true }); +}; + +/** + * DELETE /api/push-subscription + * Unregisters a push subscription by endpoint. + * Body: { endpoint } + */ +export const DELETE: RequestHandler = async ({ request, locals }) => { + if (!locals.user) throw error(401, 'Login required'); + + const body = await request.json().catch(() => null); + if (!body?.endpoint) throw error(400, 'endpoint required'); + + const res = await backendFetch('/api/push-subscriptions', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: locals.user.id, + endpoint: body.endpoint, + }), + }); + + if (!res.ok) { + const msg = await res.text().catch(() => 'backend error'); + throw error(res.status, msg); + } + + return json({ success: true }); +}; diff --git a/ui/src/routes/profile/+page.svelte b/ui/src/routes/profile/+page.svelte index ada0abf..d2d54bc 100644 --- a/ui/src/routes/profile/+page.svelte +++ b/ui/src/routes/profile/+page.svelte @@ -233,6 +233,96 @@ } catch { return iso; } } + // ── Push notifications ──────────────────────────────────────────────────────── + type PushState = 'unsupported' | 'default' | 'subscribed' | 'denied' | 'loading'; + let pushState = $state('unsupported'); + let pushError = $state(''); + + $effect(() => { + if (!browser) return; + if (!('serviceWorker' in navigator) || !('PushManager' in window)) { + pushState = 'unsupported'; + return; + } + // Check current permission / subscription state + (async () => { + const perm = Notification.permission; + if (perm === 'denied') { pushState = 'denied'; return; } + const reg = await navigator.serviceWorker.getRegistration('/'); + if (!reg) { pushState = 'default'; return; } + const sub = await reg.pushManager.getSubscription(); + pushState = sub ? 'subscribed' : 'default'; + })(); + }); + + async function subscribePush() { + pushError = ''; + pushState = 'loading'; + try { + // Fetch VAPID public key from backend + const keyRes = await fetch('/api/push-subscriptions/vapid-public-key'); + if (!keyRes.ok) { pushError = 'Push notifications are not configured on this server.'; pushState = 'default'; return; } + const { public_key } = await keyRes.json() as { public_key: string }; + + // Register / get existing service worker + const reg = await navigator.serviceWorker.ready; + + // Subscribe with VAPID + const sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(public_key), + }); + + const json = sub.toJSON() as { + endpoint: string; + keys: { p256dh: string; auth: string }; + }; + + const saveRes = await fetch('/api/push-subscription', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(json), + }); + if (!saveRes.ok) { pushError = 'Failed to save subscription. Please try again.'; pushState = 'default'; return; } + + pushState = 'subscribed'; + } catch (e) { + pushError = e instanceof Error ? e.message : 'Failed to enable notifications.'; + pushState = Notification.permission === 'denied' ? 'denied' : 'default'; + } + } + + async function unsubscribePush() { + pushError = ''; + pushState = 'loading'; + try { + const reg = await navigator.serviceWorker.getRegistration('/'); + const sub = await reg?.pushManager.getSubscription(); + if (sub) { + await fetch('/api/push-subscription', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ endpoint: sub.endpoint }), + }); + await sub.unsubscribe(); + } + pushState = 'default'; + } catch (e) { + pushError = e instanceof Error ? e.message : 'Failed to disable notifications.'; + pushState = 'subscribed'; + } + } + + /** Convert a base64url VAPID public key to a Uint8Array for PushManager.subscribe(). */ + function urlBase64ToUint8Array(base64String: string): ArrayBuffer { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const raw = atob(base64); + const arr = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i); + return arr.buffer as ArrayBuffer; + } + function parseUA(ua: string): string { if (!ua) return 'Unknown browser'; if (/Mobile/i.test(ua)) { @@ -634,6 +724,64 @@ {/if} + + {#if pushState !== 'unsupported'} +
+
+
+

Push notifications

+

+ {#if pushState === 'subscribed'} + You'll receive a push notification when new chapters are added to books in your library. + {:else if pushState === 'denied'} + Notifications are blocked by your browser. Change the permission in your browser settings. + {:else} + Get notified when new chapters arrive for books in your library. + {/if} +

+ {#if pushError} +

{pushError}

+ {/if} +
+
+ {#if pushState === 'subscribed'} + + {:else if pushState === 'denied'} + Blocked + {:else} + + {/if} +
+
+
+ {/if} +