package realtime import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "strings" "go.uber.org/zap" ) // ErrNotConfigured is returned by the authorize helpers when realtime is // disabled. Callers should surface it as a 503: without Soketi there is no // subscription to authorize, and returning a bogus signature would just fail // opaquely at the client. var ErrNotConfigured = errors.New("realtime is not configured") // Channel-name prefixes, exported so consumers that parse channel names // (the Soketi webhook) stay in step with the ones that build them. const ( UserChannelPrefix = "private-notifications." LegacyUserChannelPrefix = "notifications." ) // UserChannel is the per-user private channel carrying notification nudges. // Private (not public) so a subscription requires a signature the BFF only // issues to that user — without it, anyone who learns a user's UUID can read // their notification stream. func UserChannel(userID string) string { return UserChannelPrefix + userID } // LegacyUserChannel is the public channel the frontend used before private // channels existed. Still published to during the migration window so // service-worker-cached bundles keep working; delete once clients have // rolled over. See TriggerUser. func LegacyUserChannel(userID string) string { return LegacyUserChannelPrefix + userID } // AuthorizeChannel signs a private-channel subscription. // // Pusher's scheme: the client sends its socket_id and the channel it wants; // the app server — the only party holding the app secret — returns // ":". Soketi verifies with // its own copy of the secret. The signature is the whole authorization, so // callers MUST check the caller may access the channel before calling this. func (c *SoketiClient) AuthorizeChannel(socketID, channel string) (string, error) { if c == nil { return "", ErrNotConfigured } if socketID == "" || channel == "" { return "", errors.New("socket_id and channel_name are required") } if strings.HasPrefix(channel, "presence-") { return "", errors.New("presence channels must use AuthorizePresence") } return c.sign(socketID + ":" + channel), nil } // PresenceData is the member identity Soketi publishes to everyone else on a // presence channel. It is echoed to other subscribers verbatim, so UserInfo // must carry only what those members may already see — never an email. type PresenceData struct { UserID string `json:"user_id"` UserInfo map[string]any `json:"user_info,omitempty"` } // AuthorizePresence signs a presence-channel subscription, returning both the // auth signature and the channel_data the client must echo back. Presence // folds channel_data into the signed string, so the member identity can't be // tampered with client-side. func (c *SoketiClient) AuthorizePresence(socketID, channel string, data PresenceData) (auth, channelData string, err error) { if c == nil { return "", "", ErrNotConfigured } if socketID == "" || channel == "" { return "", "", errors.New("socket_id and channel_name are required") } if data.UserID == "" { return "", "", errors.New("presence data requires a user_id") } encoded, err := json.Marshal(data) if err != nil { return "", "", fmt.Errorf("marshal channel_data: %w", err) } channelData = string(encoded) return c.sign(socketID + ":" + channel + ":" + channelData), channelData, nil } // sign returns the ":" auth string for an already-built // string-to-sign. func (c *SoketiClient) sign(stringToSign string) string { mac := hmac.New(sha256.New, []byte(c.secret)) mac.Write([]byte(stringToSign)) return c.key + ":" + hex.EncodeToString(mac.Sum(nil)) } // TriggerUser publishes a per-user event to both the private channel and the // legacy public one. // // Dual-publish exists only for the migration: the frontend is a PWA, so a // service-worker-cached bundle can keep subscribing to the public channel for // a while after deploy, and a straight rename would silently stop its // notifications. Once clients have rolled over, drop the legacy Trigger and // this becomes a single publish. // // The private publish decides the error result; a legacy failure is not worth // failing the caller over. func (c *SoketiClient) TriggerUser(ctx context.Context, userID, event string, data any) error { if c == nil { return nil } err := c.Trigger(ctx, UserChannel(userID), event, data) if legacyErr := c.Trigger(ctx, LegacyUserChannel(userID), event, data); legacyErr != nil { c.logger.Debug("realtime: legacy channel publish failed", zap.Error(legacyErr)) } return err }