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
+174
View File
@@ -0,0 +1,174 @@
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"))
}
+152
View File
@@ -0,0 +1,152 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
"go.uber.org/zap"
)
const testUser = "user-a"
func testAuthHandler(t *testing.T) *BroadcastingAuthHandler {
t.Helper()
rt := realtime.NewSoketiClient("app-1", "test-key", "test-secret", "ws.example.com", 443, true, zap.NewNop())
if rt == nil {
t.Fatal("realtime client is nil")
}
// A second rule stands in for the chat conversation channels, proving the
// rule table is what decides access rather than anything hardcoded.
convRule := ChannelRule{
Prefix: "presence-conversation.",
Authorize: func(_ context.Context, userID, suffix string) (bool, error) {
return suffix == "open-to-user-a" && userID == testUser, nil
},
}
return NewBroadcastingAuthHandler(rt, zap.NewNop(), NotificationsRule(), convRule)
}
// serve posts a form-encoded auth request as userID ("" = unauthenticated).
func serve(t *testing.T, h *BroadcastingAuthHandler, userID, socketID, channel string) *httptest.ResponseRecorder {
t.Helper()
form := url.Values{"socket_id": {socketID}, "channel_name": {channel}}
req := httptest.NewRequest(http.MethodPost, "/api/v1/broadcasting/auth", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if userID != "" {
req = req.WithContext(middleware.WithUserID(req.Context(), userID))
}
rec := httptest.NewRecorder()
h.Handle(rec, req)
return rec
}
func TestBroadcastingAuth_Authorization(t *testing.T) {
h := testAuthHandler(t)
tests := []struct {
name string
userID string
channel string
want int
}{
{"own notifications channel", testUser, "private-notifications." + testUser, http.StatusOK},
{"another user's notifications", testUser, "private-notifications.user-b", http.StatusForbidden},
{"unauthenticated", "", "private-notifications." + testUser, http.StatusUnauthorized},
{"unknown channel family", testUser, "private-secrets.42", http.StatusForbidden},
{"legacy public channel is not signable", testUser, "notifications." + testUser, http.StatusForbidden},
{"empty suffix", testUser, "private-notifications.", http.StatusForbidden},
{"presence channel allowed by rule", testUser, "presence-conversation.open-to-user-a", http.StatusOK},
{"presence channel denied by rule", testUser, "presence-conversation.someone-elses", http.StatusForbidden},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
rec := serve(t, h, tc.userID, "1234.5678", tc.channel)
if rec.Code != tc.want {
t.Errorf("status = %d, want %d (body %s)", rec.Code, tc.want, rec.Body.String())
}
})
}
}
// A prefix match must not be mistaken for authorization: "…user-a-evil" starts
// with the victim's id but is a different channel.
func TestBroadcastingAuth_SuffixIsComparedExactly(t *testing.T) {
rec := serve(t, testAuthHandler(t), testUser, "1234.5678", "private-notifications."+testUser+"-evil")
if rec.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403", rec.Code)
}
}
func TestBroadcastingAuth_SignatureShape(t *testing.T) {
rec := serve(t, testAuthHandler(t), testUser, "1234.5678", "private-notifications."+testUser)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var body struct {
Auth string `json:"auth"`
ChannelData string `json:"channel_data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
if !strings.HasPrefix(body.Auth, "test-key:") {
t.Errorf("auth = %q, want a <key>:<signature> pair", body.Auth)
}
// channel_data belongs to presence channels only; sending it for a private
// channel would make Soketi reject the subscription.
if body.ChannelData != "" {
t.Errorf("channel_data = %q, want empty for a private channel", body.ChannelData)
}
}
func TestBroadcastingAuth_PresenceReturnsChannelData(t *testing.T) {
rec := serve(t, testAuthHandler(t), testUser, "1234.5678", "presence-conversation.open-to-user-a")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var body struct {
ChannelData string `json:"channel_data"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &body)
if !strings.Contains(body.ChannelData, `"user_id":"`+testUser+`"`) {
t.Errorf("channel_data = %q, want the caller's user_id", body.ChannelData)
}
}
func TestBroadcastingAuth_AcceptsJSONBody(t *testing.T) {
body := `{"socket_id":"1234.5678","channel_name":"private-notifications.` + testUser + `"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/broadcasting/auth", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(middleware.WithUserID(req.Context(), testUser))
rec := httptest.NewRecorder()
testAuthHandler(t).Handle(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
}
}
func TestBroadcastingAuth_MissingParams(t *testing.T) {
h := testAuthHandler(t)
if rec := serve(t, h, testUser, "", "private-notifications."+testUser); rec.Code != http.StatusBadRequest {
t.Errorf("missing socket_id: status = %d, want 400", rec.Code)
}
if rec := serve(t, h, testUser, "1234.5678", ""); rec.Code != http.StatusBadRequest {
t.Errorf("missing channel_name: status = %d, want 400", rec.Code)
}
}
// With realtime disabled there is no secret to sign with; the endpoint must
// say so rather than hand back an unusable signature.
func TestBroadcastingAuth_UnconfiguredRealtime(t *testing.T) {
h := NewBroadcastingAuthHandler(nil, zap.NewNop(), NotificationsRule())
if rec := serve(t, h, testUser, "1234.5678", "private-notifications."+testUser); rec.Code != http.StatusServiceUnavailable {
t.Errorf("status = %d, want 503", rec.Code)
}
}
+18 -6
View File
@@ -10,14 +10,26 @@ import (
"strings"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
"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."
// userIDFromNotifChannel extracts the user id from a per-user notifications
// channel, matching both the private name and the legacy public one.
//
// Because these channels are keyed by user, a `channel_vacated` webhook on one
// means that user's *last* connection dropped — i.e. they went offline across
// all their tabs — so we can evict their feed seen-set. Both names are matched
// for the duration of the private-channel migration: clients running a
// service-worker-cached bundle still sit on the legacy channel, and matching
// only one name would silently stop evicting for half the users.
func userIDFromNotifChannel(channel string) (string, bool) {
if uid, ok := strings.CutPrefix(channel, realtime.UserChannelPrefix); ok {
return uid, uid != ""
}
uid, ok := strings.CutPrefix(channel, realtime.LegacyUserChannelPrefix)
return uid, ok && uid != ""
}
// SoketiWebhookHandler receives Soketi (Pusher-compatible) webhooks. Soketi
// signs each delivery with the app secret (X-Pusher-Signature = hex
@@ -83,7 +95,7 @@ func (h *SoketiWebhookHandler) Handle(w http.ResponseWriter, r *http.Request) {
if e.Name != "channel_vacated" {
continue
}
if uid, ok := strings.CutPrefix(e.Channel, notifChannelPrefix); ok && uid != "" {
if uid, ok := userIDFromNotifChannel(e.Channel); ok {
h.feed.Reset(ctx, uid)
cleared++
}
+31
View File
@@ -0,0 +1,31 @@
package handler
import "testing"
// Both channel names must resolve during the private-channel migration:
// matching only one would silently stop evicting feed seen-sets for every
// client still on the other name.
func TestUserIDFromNotifChannel(t *testing.T) {
tests := []struct {
channel string
wantID string
wantOK bool
}{
{"private-notifications.user-a", "user-a", true},
{"notifications.user-a", "user-a", true},
{"private-notifications.", "", false},
{"notifications.", "", false},
{"presence-conversation.c1", "", false},
{"", "", false},
// Guards against a naive suffix split: the private name must not be
// mistaken for the legacy one and yield a mangled id.
{"private-notifications.notifications.x", "notifications.x", true},
}
for _, tc := range tests {
gotID, gotOK := userIDFromNotifChannel(tc.channel)
if gotOK != tc.wantOK || (tc.wantOK && gotID != tc.wantID) {
t.Errorf("userIDFromNotifChannel(%q) = (%q, %v), want (%q, %v)",
tc.channel, gotID, gotOK, tc.wantID, tc.wantOK)
}
}
}