package handler import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "net/http" "strings" "github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/pkg/realtime" "go.uber.org/zap" ) // userIDFromNotifChannel extracts the user id from a per-user notifications // channel, matching both the private name and the legacy public one. // // Because these channels are keyed by user, a `channel_vacated` webhook on one // means that user's *last* connection dropped — i.e. they went offline across // all their tabs — so we can evict their feed seen-set. Both names are matched // for the duration of the private-channel migration: clients running a // service-worker-cached bundle still sit on the legacy channel, and matching // only one name would silently stop evicting for half the users. func userIDFromNotifChannel(channel string) (string, bool) { if uid, ok := strings.CutPrefix(channel, realtime.UserChannelPrefix); ok { return uid, uid != "" } uid, ok := strings.CutPrefix(channel, realtime.LegacyUserChannelPrefix) return uid, ok && uid != "" } // SoketiWebhookHandler receives Soketi (Pusher-compatible) webhooks. Soketi // signs each delivery with the app secret (X-Pusher-Signature = hex // HMAC-SHA256 of the raw body); we verify before acting. On channel_vacated // for a per-user notifications channel we clear that user's home-feed seen-set // — prompt cleanup of a true offline/disconnect, complementing the page-unload // beacon and the set's own TTL. type SoketiWebhookHandler struct { appKey string appSecret string feed *repository.FeedRepo logger *zap.Logger } func NewSoketiWebhookHandler(appKey, appSecret string, feed *repository.FeedRepo, logger *zap.Logger) *SoketiWebhookHandler { return &SoketiWebhookHandler{appKey: appKey, appSecret: appSecret, feed: feed, logger: logger} } type soketiWebhookBody struct { Events []struct { Name string `json:"name"` Channel string `json:"channel"` } `json:"events"` } // Handle — POST /webhooks/soketi. Public route: the HMAC signature is the // authentication, not a session cookie. Returns 200 on a valid signature so // Soketi doesn't retry; 401 on a bad/absent signature. func (h *SoketiWebhookHandler) Handle(w http.ResponseWriter, r *http.Request) { // Without a configured secret there's nothing to verify against — refuse // rather than trust unsigned input. if h.appSecret == "" { w.WriteHeader(http.StatusServiceUnavailable) return } body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MiB cap if err != nil { w.WriteHeader(http.StatusBadRequest) return } if key := r.Header.Get("X-Pusher-Key"); h.appKey != "" && key != h.appKey { h.logger.Warn("soketi webhook: app key mismatch", zap.String("got", key)) w.WriteHeader(http.StatusUnauthorized) return } if !validSoketiSignature(h.appSecret, r.Header.Get("X-Pusher-Signature"), body) { // Almost always a secret mismatch between Soketi and the BFF. h.logger.Warn("soketi webhook: bad signature", zap.Int("body_bytes", len(body))) w.WriteHeader(http.StatusUnauthorized) return } var payload soketiWebhookBody if err := json.Unmarshal(body, &payload); err != nil { h.logger.Warn("soketi webhook: bad payload", zap.Error(err)) w.WriteHeader(http.StatusBadRequest) return } ctx := r.Context() cleared := 0 for _, e := range payload.Events { // A per-user notifications channel emptying = that user is offline. if e.Name != "channel_vacated" { continue } if uid, ok := userIDFromNotifChannel(e.Channel); ok { h.feed.Reset(ctx, uid) cleared++ } } h.logger.Debug("soketi webhook: verified", zap.Int("events", len(payload.Events)), zap.Int("feed_sets_cleared", cleared)) w.WriteHeader(http.StatusOK) } // validSoketiSignature is a constant-time check of the hex HMAC-SHA256 of the // raw body keyed by the app secret. func validSoketiSignature(secret, provided string, body []byte) bool { if provided == "" { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write(body) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(provided)) }