feat: add private r2 bucket
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
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
|
||||
// "<key>:<hmac_sha256(socket_id:channel_name, secret)>". 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 "<key>:<signature>" 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
|
||||
}
|
||||
Reference in New Issue
Block a user