package handler import ( "encoding/base64" "encoding/json" "net/http" "net/url" "strings" "github.com/prabhupadasays/spsays-bff/internal/middleware" "github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/internal/service" "github.com/prabhupadasays/spsays-bff/pkg/response" ) // Subscription limits. The endpoint length cap + host allow-list guard against // junk/abusive rows and SSRF (the BFF POSTs to the endpoint when sending), and // the per-user device cap bounds fan-out. const ( maxEndpointLen = 2048 maxKeyLen = 256 maxDevicesPerUser = 10 // RFC 8291 fixes the key sizes: p256dh is an uncompressed P-256 point, // auth a 16-byte secret. Anything else is guaranteed to fail at send time. p256dhLen = 65 authLen = 16 ) // validatePushEndpoint requires an https URL belonging to a known browser push // service — preventing the endpoint from pointing at internal/arbitrary hosts // (SSRF) and keeping the table free of junk. func validatePushEndpoint(endpoint string) bool { if l := len(endpoint); l == 0 || l > maxEndpointLen { return false } u, err := url.Parse(endpoint) if err != nil || u.Scheme != "https" || u.Host == "" { return false } host := strings.ToLower(u.Hostname()) switch host { case "fcm.googleapis.com", "updates.push.services.mozilla.com": return true // Chrome/Brave/Edge (FCM), Firefox } // Safari/iOS (*.push.apple.com) and Windows/Edge WNS (*.notify.windows.com). for _, suffix := range []string{".push.apple.com", ".notify.windows.com", ".wns.windows.com"} { if strings.HasSuffix(host, suffix) { return true } } return false } // validateSubscriptionKey checks that a client key is base64(url)-decodable // to exactly `want` bytes — the same decoding webpush-go applies at send // time, so anything accepted here is actually deliverable. func validateSubscriptionKey(key string, want int) bool { if l := len(key); l == 0 || l > maxKeyLen { return false } trimmed := strings.TrimRight(key, "=") b, err := base64.RawURLEncoding.DecodeString(trimmed) if err != nil { b, err = base64.RawStdEncoding.DecodeString(trimmed) } return err == nil && len(b) == want } // PushHandler serves Web Push subscription management. The VAPID public key // is handed to the browser so it can subscribe; subscriptions are stored per // user. All routes require auth. type PushHandler struct { push *service.PushSender // optional — nil when VAPID isn't configured repo *repository.PushRepo } func NewPushHandler(push *service.PushSender, repo *repository.PushRepo) *PushHandler { return &PushHandler{push: push, repo: repo} } // Key — GET /push/key. Returns the VAPID public key + whether push is on. func (h *PushHandler) Key(w http.ResponseWriter, r *http.Request) { response.JSON(w, http.StatusOK, struct { PublicKey string `json:"public_key"` Enabled bool `json:"enabled"` }{ PublicKey: h.push.PublicKey(), Enabled: h.push.Enabled(), }) } type subscribeReq struct { Endpoint string `json:"endpoint"` Keys struct { P256dh string `json:"p256dh"` Auth string `json:"auth"` } `json:"keys"` } // Subscribe — POST /push/subscribe. Stores (or refreshes) the caller's push // subscription. Body is the browser PushSubscription JSON. func (h *PushHandler) Subscribe(w http.ResponseWriter, r *http.Request) { if h.repo == nil || !h.push.Enabled() { response.Error(w, http.StatusServiceUnavailable, "push not configured") return } uid := middleware.UserID(r.Context()) var req subscribeReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } if req.Endpoint == "" || req.Keys.P256dh == "" || req.Keys.Auth == "" { response.Error(w, http.StatusBadRequest, "missing subscription fields") return } if !validatePushEndpoint(req.Endpoint) { response.Error(w, http.StatusBadRequest, "invalid push endpoint") return } if !validateSubscriptionKey(req.Keys.P256dh, p256dhLen) || !validateSubscriptionKey(req.Keys.Auth, authLen) { response.Error(w, http.StatusBadRequest, "invalid subscription keys") return } if err := h.repo.Upsert(r.Context(), uid, req.Endpoint, req.Keys.P256dh, req.Keys.Auth, r.UserAgent()); err != nil { response.Error(w, http.StatusInternalServerError, "could not save subscription") return } // Keep only the most-recent N devices per user (best-effort). _ = h.repo.PruneToMostRecent(r.Context(), uid, maxDevicesPerUser) response.NoContent(w) } type unsubscribeReq struct { Endpoint string `json:"endpoint"` } // Unsubscribe — POST /push/unsubscribe. Removes one of the caller's own // subscriptions by endpoint (scoped to the user so nobody can delete another // account's subscription by guessing/replaying an endpoint). func (h *PushHandler) Unsubscribe(w http.ResponseWriter, r *http.Request) { if h.repo == nil { response.NoContent(w) return } uid := middleware.UserID(r.Context()) var req unsubscribeReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Endpoint) == "" { response.Error(w, http.StatusBadRequest, "missing endpoint") return } if err := h.repo.DeleteByEndpointForUser(r.Context(), uid, req.Endpoint); err != nil { response.Error(w, http.StatusInternalServerError, "could not remove subscription") return } response.NoContent(w) }