package handler import ( "context" "encoding/json" "errors" "net/http" "strings" "github.com/prabhupadasays/spsays-bff/internal/middleware" "github.com/prabhupadasays/spsays-bff/pkg/realtime" "github.com/prabhupadasays/spsays-bff/pkg/response" "go.uber.org/zap" ) // ChannelRule authorizes one family of channels. // // Suffix is whatever follows Prefix in the channel name (a user ID today, a // conversation ID once chat lands). Authorize reports whether userID — the // authenticated caller — may subscribe. Returning an error means "couldn't // decide" (a lookup failed) and surfaces as a 500; returning (false, nil) // means "decided: no" and surfaces as a 403. type ChannelRule struct { Prefix string Authorize func(ctx context.Context, userID, suffix string) (bool, error) } // BroadcastingAuthHandler implements the Pusher channel-authorization // endpoint that pusher-js calls (as `authEndpoint`) before subscribing to a // private- or presence- channel. // // This is the gate that makes private channels private: Soketi will not honour // a subscription without a signature, and the signature is only minted here, // after RequireAuth has established who the caller is and a rule has agreed // they may listen. Unknown channels are denied by default, so adding a channel // family is an explicit act. type BroadcastingAuthHandler struct { rt *realtime.SoketiClient rules []ChannelRule logger *zap.Logger } func NewBroadcastingAuthHandler(rt *realtime.SoketiClient, logger *zap.Logger, rules ...ChannelRule) *BroadcastingAuthHandler { return &BroadcastingAuthHandler{rt: rt, rules: rules, logger: logger} } // NotificationsRule allows a user to subscribe to their own notification // channel and nobody else's. func NotificationsRule() ChannelRule { return ChannelRule{ Prefix: "private-notifications.", Authorize: func(_ context.Context, userID, suffix string) (bool, error) { return suffix != "" && suffix == userID, nil }, } } type broadcastingAuthResp struct { Auth string `json:"auth"` ChannelData string `json:"channel_data,omitempty"` } // writeAuth emits the auth payload BARE, deliberately bypassing the API's // {success, data} envelope. // // This endpoint speaks Pusher's protocol, not our API's: pusher-js reads // `auth` off the top level of the parsed body, so an enveloped response makes // every private subscription fail. Errors still use response.Error — the // client only checks the status code on that path. func writeAuth(w http.ResponseWriter, payload broadcastingAuthResp) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(payload) } // Handle — POST /api/v1/broadcasting/auth. Session-authenticated (the cookie // travels because the frontend sets credentials: "include"). // // pusher-js posts application/x-www-form-urlencoded by default; JSON is // accepted too so a custom client isn't forced into form encoding. func (h *BroadcastingAuthHandler) Handle(w http.ResponseWriter, r *http.Request) { userID := middleware.UserID(r.Context()) if userID == "" { // RequireAuth should have caught this; refuse rather than sign. response.Error(w, http.StatusUnauthorized, "authentication required") return } socketID, channel := parseBroadcastingAuthReq(r) if socketID == "" || channel == "" { response.Error(w, http.StatusBadRequest, "socket_id and channel_name are required") return } rule, suffix, found := h.match(channel) if !found { // Default-deny. An unrecognised channel is a client bug or an // probe — either way there's nothing to sign. h.logger.Warn("broadcasting auth: unknown channel", zap.String("channel", channel), zap.String("user_id", userID)) response.Error(w, http.StatusForbidden, "channel not available") return } allowed, err := rule.Authorize(r.Context(), userID, suffix) if err != nil { h.logger.Error("broadcasting auth: authorize failed", zap.String("channel", channel), zap.Error(err)) response.Error(w, http.StatusInternalServerError, "could not authorize channel") return } if !allowed { h.logger.Warn("broadcasting auth: denied", zap.String("channel", channel), zap.String("user_id", userID)) response.Error(w, http.StatusForbidden, "not permitted on this channel") return } if strings.HasPrefix(channel, "presence-") { auth, channelData, err := h.rt.AuthorizePresence(socketID, channel, realtime.PresenceData{UserID: userID}) if err != nil { h.authError(w, err) return } writeAuth(w, broadcastingAuthResp{Auth: auth, ChannelData: channelData}) return } auth, err := h.rt.AuthorizeChannel(socketID, channel) if err != nil { h.authError(w, err) return } writeAuth(w, broadcastingAuthResp{Auth: auth}) } // match finds the rule owning this channel and returns the part of the name // after its prefix. func (h *BroadcastingAuthHandler) match(channel string) (ChannelRule, string, bool) { for _, rule := range h.rules { if strings.HasPrefix(channel, rule.Prefix) { return rule, strings.TrimPrefix(channel, rule.Prefix), true } } return ChannelRule{}, "", false } func (h *BroadcastingAuthHandler) authError(w http.ResponseWriter, err error) { if errors.Is(err, realtime.ErrNotConfigured) { response.Error(w, http.StatusServiceUnavailable, "realtime is not configured") return } h.logger.Error("broadcasting auth: signing failed", zap.Error(err)) response.Error(w, http.StatusBadRequest, err.Error()) } // parseBroadcastingAuthReq reads socket_id / channel_name from either a form // body (pusher-js default) or a JSON body. func parseBroadcastingAuthReq(r *http.Request) (socketID, channel string) { if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { var body struct { SocketID string `json:"socket_id"` ChannelName string `json:"channel_name"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { return "", "" } return strings.TrimSpace(body.SocketID), strings.TrimSpace(body.ChannelName) } if err := r.ParseForm(); err != nil { return "", "" } return strings.TrimSpace(r.PostFormValue("socket_id")), strings.TrimSpace(r.PostFormValue("channel_name")) }