feat: add private r2 bucket

This commit is contained in:
Krispire Admin
2026-08-29 01:11:22 +05:30
parent 623935df22
commit 3b10e726b7
12 changed files with 731 additions and 14 deletions
+119
View File
@@ -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
}
+93
View File
@@ -0,0 +1,93 @@
package realtime
import (
"errors"
"strings"
"testing"
"go.uber.org/zap"
)
// testClient builds a client with fixed credentials so signatures are
// reproducible.
func testClient(t *testing.T) *SoketiClient {
t.Helper()
c := NewSoketiClient("app-1", "test-key", "test-secret", "ws.example.com", 443, true, zap.NewNop())
if c == nil {
t.Fatal("NewSoketiClient returned nil for a fully configured client")
}
return c
}
// The expected signatures are HMAC-SHA256 vectors computed independently of
// this package. Recomputing them with the same code under test would only
// prove the code equals itself; a fixed vector catches a change to the
// string-to-sign format, which is what Soketi actually verifies against.
func TestAuthorizeChannel_MatchesKnownVector(t *testing.T) {
auth, err := testClient(t).AuthorizeChannel("1234.5678", "private-notifications.user-a")
if err != nil {
t.Fatalf("AuthorizeChannel: %v", err)
}
const want = "test-key:9c436d58a9d030f68b35d636bb3e930c471d5c11edec6745c969e4eee1c3e3df"
if auth != want {
t.Errorf("auth = %q, want %q", auth, want)
}
}
func TestAuthorizePresence_MatchesKnownVector(t *testing.T) {
auth, channelData, err := testClient(t).AuthorizePresence(
"1234.5678", "presence-conversation.c1", PresenceData{UserID: "user-a"})
if err != nil {
t.Fatalf("AuthorizePresence: %v", err)
}
if channelData != `{"user_id":"user-a"}` {
t.Errorf("channel_data = %q", channelData)
}
const want = "test-key:cf5a222b506d46383cb4edac7707ba620e4ba2bbb83f64d1cf6463b4241efd24"
if auth != want {
t.Errorf("auth = %q, want %q", auth, want)
}
}
// Presence signing folds channel_data into the signed string; routing a
// presence channel through AuthorizeChannel would produce a signature Soketi
// rejects, so it's refused outright rather than silently mis-signed.
func TestAuthorizeChannel_RejectsPresenceChannel(t *testing.T) {
if _, err := testClient(t).AuthorizeChannel("1234.5678", "presence-conversation.c1"); err == nil {
t.Fatal("expected an error for a presence channel")
}
}
func TestAuthorize_NilClientReportsNotConfigured(t *testing.T) {
var c *SoketiClient
if _, err := c.AuthorizeChannel("1234.5678", "private-x"); !errors.Is(err, ErrNotConfigured) {
t.Errorf("AuthorizeChannel err = %v, want ErrNotConfigured", err)
}
if _, _, err := c.AuthorizePresence("1234.5678", "presence-x", PresenceData{UserID: "u"}); !errors.Is(err, ErrNotConfigured) {
t.Errorf("AuthorizePresence err = %v, want ErrNotConfigured", err)
}
}
func TestAuthorize_RequiresArguments(t *testing.T) {
c := testClient(t)
if _, err := c.AuthorizeChannel("", "private-x"); err == nil {
t.Error("expected an error for an empty socket_id")
}
if _, err := c.AuthorizeChannel("1234.5678", ""); err == nil {
t.Error("expected an error for an empty channel")
}
if _, _, err := c.AuthorizePresence("1234.5678", "presence-x", PresenceData{}); err == nil {
t.Error("expected an error for presence data with no user_id")
}
}
func TestUserChannels(t *testing.T) {
if got := UserChannel("u1"); got != "private-notifications.u1" {
t.Errorf("UserChannel = %q", got)
}
// The legacy name must stay public-prefixed: it exists so already-cached
// clients keep receiving events during the migration.
if got := LegacyUserChannel("u1"); got != "notifications.u1" || strings.HasPrefix(got, "private-") {
t.Errorf("LegacyUserChannel = %q", got)
}
}