package handler import ( "context" "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 // chat records presence so a message doesn't push to someone who is // already looking at the app. Optional. chat ChatPresence logger *zap.Logger } // ChatPresence is the slice of ChatService this handler needs — narrow so the // webhook doesn't depend on the whole chat surface. type ChatPresence interface { MarkPresence(ctx context.Context, userID string, online bool) } func (h *SoketiWebhookHandler) presence(ctx context.Context, userID string, online bool) { if h.chat == nil { return } h.chat.MarkPresence(ctx, userID, online) } func NewSoketiWebhookHandler(appKey, appSecret string, feed *repository.FeedRepo, chat ChatPresence, logger *zap.Logger) *SoketiWebhookHandler { return &SoketiWebhookHandler{ appKey: appKey, appSecret: appSecret, feed: feed, chat: chat, 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 { uid, ok := userIDFromNotifChannel(e.Channel) if !ok { continue } switch e.Name { case "channel_occupied": // This user has a live connection, so chat push is suppressed // for them — they will see the message in the app. h.presence(ctx, uid, true) case "channel_vacated": // Their last tab closed: they are offline. Evict the feed // seen-set and let chat push resume. h.feed.Reset(ctx, uid) h.presence(ctx, uid, false) 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)) }