- 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
88 lines
2.7 KiB
Go
88 lines
2.7 KiB
Go
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})
|
|
}
|