feat: add private r2 bucket
This commit is contained in:
@@ -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"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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++
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user