Переглянути джерело

webapi: implement block/unblock

Mike 20 годин тому
батько
коміт
68e77213bc

+ 10 - 0
server/webapi/handlers/aim_stub.go

@@ -21,6 +21,16 @@ func (h *AimStubHandler) SetForwardDomain(w http.ResponseWriter, r *http.Request
 	SendResponse(w, r, resp, h.Logger)
 }
 
+// ReportAction acknowledges a client-side UI telemetry ping. The Web AIM client
+// fires this on menu clicks and similar interactions with an action param of the
+// form "type=click,id=block-user-chatmenu"; it ignores the response.
+func (h *AimStubHandler) ReportAction(w http.ResponseWriter, r *http.Request) {
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	SendResponse(w, r, resp, h.Logger)
+}
+
 // GetData returns empty client-side data blobs (buddy list favorites, etc.).
 func (h *AimStubHandler) GetData(w http.ResponseWriter, r *http.Request) {
 	resp := BaseResponse{}

+ 10 - 1
server/webapi/handlers/preference.go

@@ -406,6 +406,12 @@ func (h *PreferenceHandler) SetPermitDeny(w http.ResponseWriter, r *http.Request
 
 	pdd := permitDenyData(fl.Items())
 
+	// The client reads the privacy state it renders (blocked buddies, the
+	// block/unblock menu label) only from the permitDeny event, and it sees no
+	// SNAC for the write it just made. Without this the block takes effect
+	// server-side but the UI keeps showing the buddy as unblocked.
+	session.EventQueue.Push(types.EventTypePermitDeny, pdd)
+
 	h.Logger.DebugContext(ctx, "permit/deny settings updated",
 		"screenName", session.ScreenName.String(),
 		"pdMode", pdd.PDMode,
@@ -421,7 +427,10 @@ func (h *PreferenceHandler) SetPermitDeny(w http.ResponseWriter, r *http.Request
 }
 
 func permitDenyData(fl []wire.FeedbagItem) PermitDenyData {
-	pdd := PermitDenyData{}
+	// A feedbag with no PD info item means no restrictions, the same default the
+	// feedbag store applies. The client drives its block flow off pdMode and
+	// sends no permit/deny change at all when the mode is absent.
+	pdd := PermitDenyData{PDMode: "permitAll"}
 	for _, item := range fl {
 		switch item.ClassID {
 		case wire.FeedbagClassIDDeny:

+ 62 - 0
server/webapi/handlers/preference_test.go

@@ -1,6 +1,7 @@
 package handlers
 
 import (
+	"context"
 	"log/slog"
 	"net/http"
 	"net/http/httptest"
@@ -9,6 +10,7 @@ import (
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
 
+	"github.com/mk6i/open-oscar-server/server/webapi/types"
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
 )
@@ -175,6 +177,66 @@ func TestEffectiveBuddyPrefs_AppliesDefaultsWhenNothingSet(t *testing.T) {
 	assert.Equal(t, 0, got["sortBuddyList"], "sortBuddyList defaults false")
 }
 
+func TestPreferenceHandler_SetPermitDeny_QueuesPermitDenyEvent(t *testing.T) {
+	// The client renders blocked buddies from the permitDeny event alone, and it
+	// sees no SNAC for its own write, so the handler has to queue the new state.
+	fs := &MockFeedbagService{}
+	oscarInstance := state.NewSession().AddInstance()
+	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
+
+	fs.On("Query", mock.Anything, oscarInstance, mock.Anything).
+		Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil)
+	fs.On("UpsertItem", mock.Anything, oscarInstance, mock.Anything, mock.Anything).
+		Return(nil, nil)
+
+	handler := &PreferenceHandler{
+		SessionManager: sessionMgr,
+		FeedbagService: fs,
+		Logger:         slog.Default(),
+	}
+
+	req, _ := http.NewRequest("GET", "/preference/setPermitDeny?aimsid="+aimsid+"&pdMode=denySome&pdBlock=BlockedUser", nil)
+	rr := httptest.NewRecorder()
+	requireSession(handler.SessionManager, handler.SetPermitDeny).ServeHTTP(rr, req)
+	assert.Equal(t, http.StatusOK, rr.Code)
+
+	session, err := sessionMgr.GetSession(context.Background(), aimsid)
+	assert.NoError(t, err)
+
+	var pdd PermitDenyData
+	var found bool
+	for _, event := range session.EventQueue.GetAllEvents() {
+		if event.Type == types.EventTypePermitDeny {
+			pdd, found = event.Data.(PermitDenyData)
+		}
+	}
+	assert.True(t, found, "expected a permitDeny event to be queued")
+	assert.Equal(t, "denySome", pdd.PDMode)
+	assert.Equal(t, []string{"blockeduser"}, pdd.DenyList)
+}
+
+func TestPermitDenyData_DefaultsToPermitAllWithoutPDInfo(t *testing.T) {
+	got := permitDenyData([]wire.FeedbagItem{
+		{ClassID: wire.FeedbagClassIDDeny, Name: "blockeduser"},
+	})
+
+	assert.Equal(t, "permitAll", got.PDMode)
+	assert.Equal(t, []string{"blockeduser"}, got.DenyList)
+}
+
+func TestPermitDenyData_PDInfoModeWins(t *testing.T) {
+	var tlvs wire.TLVList
+	tlvs.Append(wire.NewTLVBE(wire.FeedbagAttributesPdMode, uint8(wire.FeedbagPDModePermitSome)))
+
+	got := permitDenyData([]wire.FeedbagItem{
+		{ClassID: wire.FeedbagClassIdPdinfo, TLVLBlock: wire.TLVLBlock{TLVList: tlvs}},
+		{ClassID: wire.FeedbagClassIDPermit, Name: "alloweduser"},
+	})
+
+	assert.Equal(t, "permitSome", got.PDMode)
+	assert.Equal(t, []string{"alloweduser"}, got.PermitList)
+}
+
 func TestPreferenceHandler_SetPreferences_NoOSCARSession(t *testing.T) {
 	fs := &MockFeedbagService{}
 	sessionMgr, aimsid := createTestSessionManager("webonly") // nil OSCARSession

+ 21 - 0
server/webapi/handlers/session.go

@@ -478,6 +478,27 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 			if authToken != "" {
 				session.EventQueue.Push(types.EventTypePreference, prefPayload)
 			}
+		case types.EventTypePermitDeny:
+			// The client keeps its privacy state solely in the model this event
+			// populates. Both the block/unblock menu action and the "blocked"
+			// presence state read that model and no-op silently while it is
+			// empty, so the session has to start with one.
+			var pdPayload interface{} = PermitDenyData{PDMode: "permitAll"}
+			if authToken != "" && session.OSCARSession != nil {
+				pdd, err := session.PermitDenyRefresher(ctx)
+				if err != nil {
+					h.Logger.ErrorContext(ctx, "failed to get permit/deny settings", "err", err.Error())
+				} else {
+					pdPayload = pdd
+				}
+			}
+			if resp.Response.Data.Events == nil {
+				resp.Response.Data.Events = make(map[string]interface{})
+			}
+			resp.Response.Data.Events["permitDeny"] = pdPayload
+			if authToken != "" {
+				session.EventQueue.Push(types.EventTypePermitDeny, pdPayload)
+			}
 		}
 	}
 

+ 1 - 0
server/webapi/server.go

@@ -185,6 +185,7 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		aimStub := &handlers.AimStubHandler{Logger: logger}
 		mux.Handle("GET /aim/setForwardDomain", stubRoute(aimStub.SetForwardDomain))
 		mux.Handle("GET /aim/getData", stubRoute(aimStub.GetData))
+		mux.Handle("GET /aim/reportAction", stubRoute(aimStub.ReportAction))
 
 		conversationStub := &handlers.ConversationStubHandler{
 			SessionManager: sessionManager,

+ 20 - 14
state/webapi_session.go

@@ -536,21 +536,27 @@ func (s *WebAPISession) handleFeedbagMessage(msg wire.SNACMessage) {
 				s.EventQueue.Push(types.EventTypeBuddyList, map[string]interface{}{"groups": groups})
 			}
 		}
-		if msg.Frame.SubGroup == wire.FeedbagUpdateItem && s.PermitDenyRefresher != nil {
-			body, ok := msg.Body.(wire.SNAC_0x13_0x09_FeedbagUpdateItem)
-			if ok {
-				for _, item := range body.Items {
-					if item.ClassID == wire.FeedbagClassIDPermit ||
-						item.ClassID == wire.FeedbagClassIDDeny ||
-						item.ClassID == wire.FeedbagClassIdPdinfo {
-						pdd, err := s.PermitDenyRefresher(s.ctx)
-						if err != nil {
-							s.logger.Error("failed to refresh permit/deny after feedbag change", "err", err)
-						} else {
-							s.EventQueue.Push(types.EventTypePermitDeny, pdd)
-						}
-						break
+		if s.PermitDenyRefresher != nil {
+			// An insert and an update both relay an UpdateItem body; only a
+			// delete carries a DeleteItem body.
+			var items []wire.FeedbagItem
+			switch body := msg.Body.(type) {
+			case wire.SNAC_0x13_0x09_FeedbagUpdateItem:
+				items = body.Items
+			case wire.SNAC_0x13_0x0A_FeedbagDeleteItem:
+				items = body.Items
+			}
+			for _, item := range items {
+				if item.ClassID == wire.FeedbagClassIDPermit ||
+					item.ClassID == wire.FeedbagClassIDDeny ||
+					item.ClassID == wire.FeedbagClassIdPdinfo {
+					pdd, err := s.PermitDenyRefresher(s.ctx)
+					if err != nil {
+						s.logger.Error("failed to refresh permit/deny after feedbag change", "err", err)
+					} else {
+						s.EventQueue.Push(types.EventTypePermitDeny, pdd)
 					}
+					break
 				}
 			}
 		}

+ 70 - 0
state/webapi_session_test.go

@@ -694,6 +694,76 @@ func TestWebAPISession_FeedbagSNACInvalidatesAliasCache(t *testing.T) {
 	assert.Equal(t, "MIKE", arrive().Friendly)
 }
 
+// Permit/deny changes from another of the owner's clients arrive as an insert,
+// an update, or a delete, and all three have to refresh the client's privacy
+// state.
+func TestWebAPISession_FeedbagSNACRefreshesPermitDeny(t *testing.T) {
+	denyItem := wire.FeedbagItem{ClassID: wire.FeedbagClassIDDeny, Name: "blockeduser"}
+	buddyItem := wire.FeedbagItem{ClassID: wire.FeedbagClassIdBuddy, Name: "friend"}
+
+	tests := []struct {
+		name      string
+		subGroup  uint16
+		body      any
+		wantEvent bool
+	}{
+		{
+			name:      "insert relays an update body",
+			subGroup:  wire.FeedbagInsertItem,
+			body:      wire.SNAC_0x13_0x09_FeedbagUpdateItem{Items: []wire.FeedbagItem{denyItem}},
+			wantEvent: true,
+		},
+		{
+			name:      "update",
+			subGroup:  wire.FeedbagUpdateItem,
+			body:      wire.SNAC_0x13_0x09_FeedbagUpdateItem{Items: []wire.FeedbagItem{denyItem}},
+			wantEvent: true,
+		},
+		{
+			name:      "delete",
+			subGroup:  wire.FeedbagDeleteItem,
+			body:      wire.SNAC_0x13_0x0A_FeedbagDeleteItem{Items: []wire.FeedbagItem{denyItem}},
+			wantEvent: true,
+		},
+		{
+			name:      "buddy item only",
+			subGroup:  wire.FeedbagInsertItem,
+			body:      wire.SNAC_0x13_0x09_FeedbagUpdateItem{Items: []wire.FeedbagItem{buddyItem}},
+			wantEvent: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			sess := &WebAPISession{
+				ScreenName: DisplayScreenName("me"),
+				EventQueue: types.NewEventQueue(10),
+				logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
+				PermitDenyRefresher: func(_ context.Context) (interface{}, error) {
+					return map[string]any{"pdMode": "denySome"}, nil
+				},
+			}
+
+			sess.handleFeedbagMessage(wire.SNACMessage{
+				Frame: wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: tt.subGroup},
+				Body:  tt.body,
+			})
+
+			var got int
+			for _, event := range sess.EventQueue.GetAllEvents() {
+				if event.Type == types.EventTypePermitDeny {
+					got++
+				}
+			}
+			if tt.wantEvent {
+				assert.Equal(t, 1, got)
+			} else {
+				assert.Zero(t, got)
+			}
+		})
+	}
+}
+
 // A session sees no SNAC for feedbag writes it makes itself, so the handlers that
 // perform those writes invalidate the cache directly.
 func TestWebAPISession_InvalidateAliases(t *testing.T) {