fix: feed repitition
This commit is contained in:
@@ -259,6 +259,14 @@ func (h *PostHandler) Feed(w http.ResponseWriter, r *http.Request) {
|
||||
response.JSON(w, http.StatusOK, posts)
|
||||
}
|
||||
|
||||
// FeedReset — POST /feed/reset. Clears the caller's home-feed seen-set so the
|
||||
// next page starts fresh. Called on logout and on page unload (the realtime
|
||||
// socket dropping) to evict the set promptly rather than waiting for its TTL.
|
||||
func (h *PostHandler) FeedReset(w http.ResponseWriter, r *http.Request) {
|
||||
h.svc.ResetFeed(r.Context(), middleware.UserID(r.Context()))
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// FeedNewUsers — GET /feed/new-users. Up to 5 accounts that joined in the last
|
||||
// 24h, for the feed's "new on the platform" discovery strip. Returns [] when
|
||||
// there are none (the client hides the section).
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// notifChannelPrefix is the public per-user channel the frontend subscribes to
|
||||
// for realtime notifications. Because it's keyed by user, a `channel_vacated`
|
||||
// webhook on it means that user's *last* connection dropped — i.e. they went
|
||||
// offline (across all their tabs) — so we can evict their feed seen-set.
|
||||
const notifChannelPrefix = "notifications."
|
||||
|
||||
// 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 := strings.CutPrefix(e.Channel, notifChannelPrefix); ok && uid != "" {
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user