147 lines
4.9 KiB
Go
147 lines
4.9 KiB
Go
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))
|
|
}
|