160 lines
5.3 KiB
Go
160 lines
5.3 KiB
Go
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)
|
|
}
|