feat: notification enhancement
This commit is contained in:
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
@@ -51,6 +52,48 @@ func (h *NotificationHandler) MarkAllRead(w http.ResponseWriter, r *http.Request
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// GetPrefs — GET /notifications/prefs. The resolved Settings matrix:
|
||||
// {"prefs": {"follows": {"in_app": true, "email": true, "push": true}, …}}.
|
||||
func (h *NotificationHandler) GetPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
matrix, err := h.svc.PrefMatrix(r.Context(), uid)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load notification preferences")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{"prefs": matrix})
|
||||
}
|
||||
|
||||
// UpdatePrefs — PUT /notifications/prefs { event, channel, enabled }. Writes
|
||||
// one matrix cell; returns the full re-resolved matrix so the client can
|
||||
// render authoritative state.
|
||||
func (h *NotificationHandler) UpdatePrefs(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
var req struct {
|
||||
Event string `json:"event"`
|
||||
Channel string `json:"channel"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if err := h.svc.SetPref(r.Context(), uid, req.Event, req.Channel, req.Enabled); err != nil {
|
||||
if errors.Is(err, service.ErrUnknownPref) {
|
||||
response.Error(w, http.StatusBadRequest, "unknown event or channel")
|
||||
return
|
||||
}
|
||||
response.Error(w, http.StatusInternalServerError, "could not update notification preferences")
|
||||
return
|
||||
}
|
||||
matrix, err := h.svc.PrefMatrix(r.Context(), uid)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load notification preferences")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{"prefs": matrix})
|
||||
}
|
||||
|
||||
type emailPrefsResp struct {
|
||||
LikeEmails bool `json:"like_emails"`
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -19,6 +20,11 @@ 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
|
||||
@@ -46,6 +52,21 @@ func validatePushEndpoint(endpoint string) bool {
|
||||
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.
|
||||
@@ -98,8 +119,9 @@ func (h *PushHandler) Subscribe(w http.ResponseWriter, r *http.Request) {
|
||||
response.Error(w, http.StatusBadRequest, "invalid push endpoint")
|
||||
return
|
||||
}
|
||||
if len(req.Keys.P256dh) > maxKeyLen || len(req.Keys.Auth) > maxKeyLen {
|
||||
response.Error(w, http.StatusBadRequest, "subscription keys too long")
|
||||
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 {
|
||||
@@ -115,18 +137,21 @@ type unsubscribeReq struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
}
|
||||
|
||||
// Unsubscribe — POST /push/unsubscribe. Removes a subscription by 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.DeleteByEndpoint(r.Context(), req.Endpoint); err != nil {
|
||||
if err := h.repo.DeleteByEndpointForUser(r.Context(), uid, req.Endpoint); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not remove subscription")
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user