From a0d0bf574e0dc92d29254ecd3978c575d196d3e5 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Wed, 23 Sep 2026 11:39:16 +0800 Subject: [PATCH] unbind device push token on logout --- cmd/imwsaudit/main.go | 8 +-- server/db/mysql/tenant_adapter.go | 28 ++++++++ server/im_http.go | 69 +++++++++++++++++- server/im_http_test.go | 115 +++++++++++++++++++++++++++++- server/store/store.go | 18 +++++ 5 files changed, 229 insertions(+), 9 deletions(-) diff --git a/cmd/imwsaudit/main.go b/cmd/imwsaudit/main.go index 8c17486..0078738 100644 --- a/cmd/imwsaudit/main.go +++ b/cmd/imwsaudit/main.go @@ -364,7 +364,7 @@ func (a *audit) run() { if alice == nil || bob == nil || a.groupTopic == "" { return "FAIL: websocket client or group missing" } - text := "WS 群消息 " + suffix + text := "group audit message " + suffix seq, topic, err := alice.pubWithHeadAndPeer(bob, a.groupTopic, map[string]any{"mime": "text/plain"}, text) if err != nil { return "FAIL: " + err.Error() @@ -377,11 +377,11 @@ func (a *audit) run() { return "FAIL: websocket client missing" } head := map[string]any{"mime": "text/plain", "x-im-client-mid": "dedup-" + suffix} - first, err := alice.pubCtrl(a.groupTopic, head, "群消息幂等测试 "+suffix) + first, err := alice.pubCtrl(a.groupTopic, head, "group dedup audit "+suffix) if err != nil { return "FAIL: first pub: " + err.Error() } - second, err := alice.pubCtrl(a.groupTopic, head, "群消息幂等测试 "+suffix) + second, err := alice.pubCtrl(a.groupTopic, head, "group dedup audit "+suffix) if err != nil { return "FAIL: duplicate pub: " + err.Error() } @@ -406,7 +406,7 @@ func (a *audit) run() { return fmt.Sprintf("anchor_seq=%d items=%d", anchor, count) }) a.step("WS-22G", "群聊天记录搜索", "HTTP", "/v1/im/topics/{topic}/messages/search", func() string { - status, r, err := a.call("GET", "/v1/im/topics/"+url.PathEscape(a.groupTopic)+"/messages/search?q="+url.QueryEscape("WS 群消息")+"&type=text", nil, a.tokenB) + status, r, err := a.call("GET", "/v1/im/topics/"+url.PathEscape(a.groupTopic)+"/messages/search?q="+url.QueryEscape("group audit message")+"&type=text", nil, a.tokenB) if err != nil { return "FAIL: " + err.Error() } diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index ce41721..8cf1ced 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -3672,6 +3672,34 @@ func (a *adapter) TenantDevicePushTokenUpsert(id t.TenantID, token *t.DevicePush return token, nil } +func (a *adapter) TenantDevicePushTokenDelete(id t.TenantID, uid t.Uid, deviceID string) error { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return err + } + if tenantAdp != a { + return tenantAdp.TenantDevicePushTokenDelete(id, uid, deviceID) + } + } + deviceID = strings.TrimSpace(deviceID) + if err := validTenant(id); err != nil || uid.IsZero() || deviceID == "" { + return t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + res, err := a.db.ExecContext(ctx, "DELETE FROM im_device_push_tokens WHERE tenant_id=? AND user_id=? AND device_id=?", id, decodeUID(uid), deviceID) + if err != nil { + return err + } + if affected, _ := res.RowsAffected(); affected == 0 { + return t.ErrNotFound + } + return nil +} + func (a *adapter) TenantDevicePushTokenGetAll(id t.TenantID, uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) { return a.tenantDevicePushTokenGetAllByProviders(id, []string{"fcm", "firebase"}, uids...) } diff --git a/server/im_http.go b/server/im_http.go index 0d1b10a..aaa4409 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -447,6 +447,10 @@ type imDevicePushTokenRequest struct { NotificationPermission string `json:"notification_permission,omitempty"` } +type imAuthLogoutRequest struct { + DeviceID string `json:"device_id,omitempty"` +} + type imP2PConversationRequest struct { PeerUserID string `json:"peer_user_id"` } @@ -4369,8 +4373,7 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string return } if req.Method == http.MethodGet { - if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil && settings != nil && settings.GroupStatus == types.GroupStatusDissolved { - writeIMError(wrt, req, http.StatusGone, 30004, imReasonGroupDissolved, "group dissolved", map[string]any{"topic": topic}) + if writeIMGroupDissolvedIfGone(wrt, req, principal.TenantID, topic) { return } } @@ -4529,6 +4532,19 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string } } +func writeIMGroupDissolvedIfGone(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, topic string) bool { + if settings, err := store.GroupBusiness.GetSettings(tenantID, topic); err == nil && settings != nil && settings.GroupStatus == types.GroupStatusDissolved { + writeIMError(wrt, req, http.StatusGone, 30004, imReasonGroupDissolved, "group dissolved", map[string]any{"topic": topic}) + return true + } + group, err := store.Topics.Get(tenantID, topic) + if err != nil || group == nil || group.State != types.StateDeleted { + return false + } + writeIMError(wrt, req, http.StatusGone, 30004, imReasonGroupDissolved, "group dissolved", map[string]any{"topic": topic}) + return true +} + func serveIMGroupSettings(wrt http.ResponseWriter, req *http.Request, topic string) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) @@ -6583,6 +6599,10 @@ func serveIMAuthLogout(wrt http.ResponseWriter, req *http.Request) { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30000, imReasonMethodNotAllowed, "method not allowed", nil) return } + deviceID, ok := imLogoutDeviceID(wrt, req) + if !ok { + return + } principal, token, ttl, err := resolveIMHTTPBearerToken(req) if err != nil { writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "authentication required", nil) @@ -6597,7 +6617,50 @@ func serveIMAuthLogout(wrt http.ResponseWriter, req *http.Request) { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } - writeIMSuccess(wrt, req, map[string]any{"status": "ok"}) + pushUnbound := false + if deviceID != "" { + if deleter, ok := store.DevicePushTokens.(interface { + Delete(types.TenantID, types.Uid, string) error + }); ok { + if err = deleter.Delete(principal.TenantID, principal.Uid, deviceID); err != nil && !errors.Is(err, types.ErrNotFound) { + logs.Warn.Println("im logout: push token unbind failed", principal.TenantID, principal.Uid, deviceID, err) + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + pushUnbound = err == nil + } + if store.Devices != nil { + if err = store.Devices.Delete(principal.TenantID, principal.Uid, deviceID); err != nil && !errors.Is(err, types.ErrNotFound) { + logs.Warn.Println("im logout: legacy device unbind failed", principal.TenantID, principal.Uid, deviceID, err) + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + } + } + writeIMSuccess(wrt, req, map[string]any{"status": "ok", "device_id": deviceID, "push_unbound": pushUnbound}) +} + +func imLogoutDeviceID(wrt http.ResponseWriter, req *http.Request) (string, bool) { + deviceID := strings.TrimSpace(req.Header.Get("X-Device-ID")) + if deviceID == "" { + deviceID = strings.TrimSpace(req.Header.Get("X-IM-Device-ID")) + } + if req.Body != nil && req.Body != http.NoBody { + var body imAuthLogoutRequest + dec := json.NewDecoder(req.Body) + if err := dec.Decode(&body); err != nil && !errors.Is(err, io.EOF) { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "body"}) + return "", false + } + if strings.TrimSpace(body.DeviceID) != "" { + deviceID = strings.TrimSpace(body.DeviceID) + } + } + if strings.Contains(deviceID, "/") { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "device_id"}) + return "", false + } + return deviceID, true } func currentUserPhone(tenantID types.TenantID, uid types.Uid) (string, error) { diff --git a/server/im_http_test.go b/server/im_http_test.go index 65f209e..ee9afd9 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -235,8 +235,11 @@ type testAppReleaseStore struct { } type testDevicePushTokenStore struct { - token *types.DevicePushToken - tenantID types.TenantID + token *types.DevicePushToken + tenantID types.TenantID + deletedTenantID types.TenantID + deletedUserID types.Uid + deletedDeviceID string } type testSafetyStore struct { @@ -752,6 +755,13 @@ func (s *testDevicePushTokenStore) Upsert(tenantID types.TenantID, token *types. return token, nil } +func (s *testDevicePushTokenStore) Delete(tenantID types.TenantID, uid types.Uid, deviceID string) error { + s.deletedTenantID = tenantID + s.deletedUserID = uid + s.deletedDeviceID = deviceID + return nil +} + func (s *testSafetyStore) CreateRiskChallenge(tenantID types.TenantID, challenge *types.RiskChallenge) (*types.RiskChallenge, error) { now := time.Now().UTC().Round(time.Millisecond) challenge.CreatedAt = now @@ -2907,6 +2917,30 @@ func TestServeIMDevicePushToken(t *testing.T) { } } +func TestIMLogoutDeviceID(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/logout", bytes.NewBufferString(`{"device_id":"device-1","ignored":true}`)) + req.Header.Set("X-Device-ID", "header-device") + rec := httptest.NewRecorder() + deviceID, ok := imLogoutDeviceID(rec, req) + if !ok || deviceID != "device-1" || rec.Code != http.StatusOK { + t.Fatalf("unexpected body device id: ok=%v device_id=%q status=%d body=%s", ok, deviceID, rec.Code, rec.Body.String()) + } + + req = httptest.NewRequest(http.MethodPost, "/v1/im/auth/logout", nil) + req.Header.Set("X-IM-Device-ID", "header-device") + rec = httptest.NewRecorder() + deviceID, ok = imLogoutDeviceID(rec, req) + if !ok || deviceID != "header-device" || rec.Code != http.StatusOK { + t.Fatalf("unexpected header device id: ok=%v device_id=%q status=%d body=%s", ok, deviceID, rec.Code, rec.Body.String()) + } + + req = httptest.NewRequest(http.MethodPost, "/v1/im/auth/logout", bytes.NewBufferString(`{"device_id":"bad/device"}`)) + rec = httptest.NewRecorder() + if deviceID, ok = imLogoutDeviceID(rec, req); ok || rec.Code != http.StatusBadRequest { + t.Fatalf("invalid device id accepted: ok=%v device_id=%q status=%d body=%s", ok, deviceID, rec.Code, rec.Body.String()) + } +} + func TestServeIMConversationSearch(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -3780,6 +3814,83 @@ func TestServeIMGroupDetailPatchUsesBusinessSettingsVersion(t *testing.T) { } } +func TestServeIMGroupDetailReturnsGoneForDissolvedSettingsWithoutSubscription(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + oldStore := store.Store + oldGroupBusiness := store.GroupBusiness + mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) + tokenAuth := mock_auth.NewMockAuthHandler(ctrl) + mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth) + tokenAuth.EXPECT().IsInitialized().Return(true) + tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). + Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil) + store.Store = mockStore + store.GroupBusiness = &testGroupBusinessStore{settings: &types.GroupSettings{ + Topic: "grpAlpha", OwnerUserID: types.Uid(42), Name: "Alpha", + GroupStatus: types.GroupStatusDissolved, Version: 2, + }} + defer func() { + store.Store = oldStore + store.GroupBusiness = oldGroupBusiness + }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMGroupResource(rec, req) + + if rec.Code != http.StatusGone { + t.Fatalf("detail status: got %d body=%s", rec.Code, rec.Body.String()) + } + if reason := decodeIMHTTPResponse(t, rec).Reason; reason != imReasonGroupDissolved { + t.Fatalf("unexpected reason: %q", reason) + } +} + +func TestServeIMGroupDetailReturnsGoneForDeletedTopicWithoutSubscription(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + oldStore := store.Store + oldGroupBusiness := store.GroupBusiness + oldTopics := store.Topics + mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) + tokenAuth := mock_auth.NewMockAuthHandler(ctrl) + mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth) + tokenAuth.EXPECT().IsInitialized().Return(true) + tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). + Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil) + topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) + topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(&types.Topic{ + ObjHeader: types.ObjHeader{Id: "grpAlpha"}, + TenantID: 7, + State: types.StateDeleted, + }, nil) + store.Store = mockStore + store.Topics = topics + store.GroupBusiness = &testGroupBusinessStore{settings: &types.GroupSettings{ + Topic: "grpAlpha", OwnerUserID: types.Uid(42), Name: "Alpha", + GroupStatus: types.GroupStatusActive, Version: 1, + }} + defer func() { + store.Store = oldStore + store.Topics = oldTopics + store.GroupBusiness = oldGroupBusiness + }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMGroupResource(rec, req) + + if rec.Code != http.StatusGone { + t.Fatalf("detail status: got %d body=%s", rec.Code, rec.Body.String()) + } + if reason := decodeIMHTTPResponse(t, rec).Reason; reason != imReasonGroupDissolved { + t.Fatalf("unexpected reason: %q", reason) + } +} + func TestServeIMGroupMyProfileKeepsSubscriptionNicknameWhenBusinessMemberMissing(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/server/store/store.go b/server/store/store.go index 44d3f27..5cf48dc 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -2618,6 +2618,24 @@ func (devicePushTokenMapper) Upsert(tenantID types.TenantID, token *types.Device return tenantAdp.TenantDevicePushTokenUpsert(tenantID, token) } +func (devicePushTokenMapper) Delete(tenantID types.TenantID, uid types.Uid, deviceID string) error { + deviceID = strings.TrimSpace(deviceID) + if uid.IsZero() || deviceID == "" { + return types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return err + } + deleter, ok := tenantAdp.(interface { + TenantDevicePushTokenDelete(types.TenantID, types.Uid, string) error + }) + if !ok { + return nil + } + return deleter.TenantDevicePushTokenDelete(tenantID, uid, deviceID) +} + func (devicePushTokenMapper) GetAll(tenantID types.TenantID, uids ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) { tenantAdp, err := tenantBusinessAdapter(tenantID) if err != nil {