fix im p0 backend issues

This commit is contained in:
richjohnrambo
2026-09-07 15:12:14 +08:00
parent b92154bece
commit 7e8fc8237b
7 changed files with 371 additions and 13 deletions
+137 -4
View File
@@ -109,10 +109,18 @@ func normalizeCallInvite(head map[string]any, content any) (map[string]any, any,
if state != "started" {
return nil, nil, types.ErrMalformed
}
call, ok := content.(map[string]any)
if !ok {
if typed, typedOK := content.(types.KVMap); typedOK {
call = map[string]any(typed)
call := callInvitePayloadMap(content)
if len(call) == 0 || !hasCallIdentity(call) {
headCall := callInvitePayloadMap(head)
if len(call) == 0 {
call = headCall
} else {
for key, value := range headCall {
if _, exists := call[key]; !exists {
call[key] = value
}
}
normalizeCallPayloadAliases(call)
}
}
if len(call) == 0 {
@@ -123,6 +131,16 @@ func normalizeCallInvite(head map[string]any, content any) (map[string]any, any,
if callType == "" {
callType, _ = call["media_type"].(string)
}
if callType == "" {
callType, _ = call["type"].(string)
}
if callType == "" {
if aonly, _ := call["aonly"].(bool); aonly {
callType = constCallTypeAudio
} else {
callType = constCallTypeVideo
}
}
callType = strings.ToLower(strings.TrimSpace(callType))
if strings.TrimSpace(callID) == "" || (callType != constCallTypeAudio && callType != constCallTypeVideo) {
return nil, nil, types.ErrMalformed
@@ -147,6 +165,121 @@ func normalizeCallInvite(head map[string]any, content any) (map[string]any, any,
return normalizedHead, normalizedContent, nil
}
func callInvitePayloadMap(content any) map[string]any {
call := anyMap(content)
if len(call) == 0 {
return nil
}
normalizeCallPayloadAliases(call)
if hasCallIdentity(call) {
return call
}
if draftyCall := draftyVideoCallPayload(call); len(draftyCall) > 0 {
for key, value := range call {
if _, exists := draftyCall[key]; !exists && key != "fmt" && key != "ent" && key != "txt" {
draftyCall[key] = value
}
}
normalizeCallPayloadAliases(draftyCall)
return draftyCall
}
return call
}
func anyMap(value any) map[string]any {
if typed, ok := value.(map[string]any); ok {
return typed
}
if typed, ok := value.(types.KVMap); ok {
return map[string]any(typed)
}
return nil
}
func normalizeCallPayloadAliases(call map[string]any) {
if call == nil {
return
}
if _, ok := call["callId"]; !ok {
for _, key := range []string{"call_id", "id"} {
if value, exists := call[key]; exists {
call["callId"] = value
break
}
}
}
if _, ok := call["callType"]; !ok {
for _, key := range []string{"call_type", "mediaType", "media_type"} {
if value, exists := call[key]; exists {
call["callType"] = value
break
}
}
}
}
func hasCallIdentity(call map[string]any) bool {
if call == nil {
return false
}
callID, _ := call["callId"].(string)
if strings.TrimSpace(callID) == "" {
return false
}
callType, _ := call["callType"].(string)
if strings.TrimSpace(callType) != "" {
return true
}
if mediaType, _ := call["media_type"].(string); strings.TrimSpace(mediaType) != "" {
return true
}
if _, ok := call["aonly"].(bool); ok {
return true
}
return false
}
func draftyVideoCallPayload(doc map[string]any) map[string]any {
for _, raw := range anySlice(doc["ent"]) {
entity := anyMap(raw)
if entityType, _ := entity["tp"].(string); entityType != "VC" {
continue
}
if data := anyMap(entity["data"]); len(data) > 0 {
return data
}
return map[string]any{}
}
for _, raw := range anySlice(doc["fmt"]) {
format := anyMap(raw)
if formatType, _ := format["tp"].(string); formatType == "VC" {
return map[string]any{}
}
}
return nil
}
func anySlice(value any) []any {
switch typed := value.(type) {
case []any:
return typed
case []map[string]any:
items := make([]any, 0, len(typed))
for _, item := range typed {
items = append(items, item)
}
return items
case []types.KVMap:
items := make([]any, 0, len(typed))
for _, item := range typed {
items = append(items, item)
}
return items
default:
return nil
}
}
func callPayloadString(payload any, key string) string {
data, ok := payload.(map[string]any)
if !ok {
+2 -1
View File
@@ -445,7 +445,8 @@ func (a *adapter) TenantGroupInvitationAccept(id t.TenantID, invitationID string
return &inv, nil
}
var deletedAt sql.NullTime
err = tx.QueryRowxContext(ctx, "SELECT DeletedAt FROM subscriptions WHERE Topic=? AND User=?", inv.Topic, uid.String()).Scan(&deletedAt)
err = tx.QueryRowxContext(ctx, "SELECT deletedat FROM im_subscriptions WHERE topic=? AND userid=?",
inv.Topic, decodeUID(uid)).Scan(&deletedAt)
if err != nil && err != sql.ErrNoRows {
return nil, err
}
+13 -4
View File
@@ -3570,6 +3570,7 @@ func serveIMFavoriteBatchCreate(wrt http.ResponseWriter, req *http.Request) {
failed = append(failed, map[string]any{"topic": sourceTopic, "seq": src.Seq, "reason": relationErrorReason(err)})
continue
}
idempotencyKey := favoriteItemIdempotencyKey(req.Header.Get("Idempotency-Key"), sourceTopic, src.Seq)
fav := &types.FavoriteItem{
TenantID: principal.TenantID,
FavoriteID: "fav_" + store.Store.GetUidString(),
@@ -3580,7 +3581,7 @@ func serveIMFavoriteBatchCreate(wrt http.ResponseWriter, req *http.Request) {
Snapshot: favoriteSnapshot(msg),
AttachmentRefs: favoriteAttachmentRefs(msg),
Availability: messageAvailability(*msg),
IdempotencyKey: strings.TrimSpace(req.Header.Get("Idempotency-Key")),
IdempotencyKey: idempotencyKey,
CollectedAt: now,
CreatedAt: now,
UpdatedAt: now,
@@ -4021,13 +4022,13 @@ func serveIMMessageContext(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message context route not found", nil)
return
}
principal, _, ok := authenticateTopicReader(wrt, req, topic)
principal, normalizedTopic, ok := authenticateTopicReader(wrt, req, topic)
if !ok {
return
}
before := parseIMLimit(req.URL.Query().Get("before"), 20, 100)
after := parseIMLimit(req.URL.Query().Get("after"), 20, 100)
msgs, err := store.Messages.GetAll(principal.TenantID, topic, principal.Uid, &types.QueryOpt{Since: seq - int64(before), Before: seq + int64(after) + 1, Limit: before + after + 1})
msgs, err := store.Messages.GetAll(principal.TenantID, normalizedTopic, principal.Uid, &types.QueryOpt{Since: seq - int64(before), Before: seq + int64(after) + 1, Limit: before + after + 1})
if err != nil {
writeIMRelationError(wrt, req, err)
return
@@ -4036,7 +4037,7 @@ func serveIMMessageContext(wrt http.ResponseWriter, req *http.Request) {
for _, msg := range msgs {
items = append(items, messageContextPayload(msg))
}
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "anchor_seq": seq, "items": items})
writeIMSuccess(wrt, req, map[string]any{"topic": normalizedTopic, "anchor_seq": seq, "items": items})
}
func serveIMTopicMedia(wrt http.ResponseWriter, req *http.Request) {
@@ -9353,6 +9354,14 @@ func normalizeIMFavoriteSourceTopic(viewer types.Uid, topic string) (string, boo
return normalizeIMUserVisibleTopic(viewer, topic)
}
func favoriteItemIdempotencyKey(requestKey, topic string, seq int64) string {
requestKey = strings.TrimSpace(requestKey)
if requestKey == "" {
return ""
}
return stableIMEventID("favorite", requestKey, strings.TrimSpace(topic), seq)
}
func normalizeIMUserVisibleTopic(viewer types.Uid, topic string) (string, bool) {
topic = strings.TrimSpace(topic)
if topic == "" {
+28 -4
View File
@@ -4870,6 +4870,17 @@ func TestServeIMFavoriteBatchCreateCountsExisting(t *testing.T) {
}
}
func TestFavoriteItemIdempotencyKeyIncludesMessageIdentity(t *testing.T) {
first := favoriteItemIdempotencyKey("same-key", "grpAlpha", 1)
second := favoriteItemIdempotencyKey("same-key", "grpAlpha", 2)
if first == "" || second == "" || first == second {
t.Fatalf("favorite item idempotency key must be stable per source message: %q %q", first, second)
}
if favoriteItemIdempotencyKey("", "grpAlpha", 1) != "" {
t.Fatal("empty request idempotency key should stay empty")
}
}
func TestServeIMFavoriteBatchCreateRejectsUnreadableSource(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -5694,10 +5705,10 @@ func TestServeIMTopicReadActionsNormalizeP2PUserTopic(t *testing.T) {
oldStore := store.Store
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(2)
tokenAuth.EXPECT().IsInitialized().Return(true).Times(2)
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(3)
tokenAuth.EXPECT().IsInitialized().Return(true).Times(3)
tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")).
Return(&auth.Rec{TenantID: 7, Uid: actor, AuthLevel: auth.LevelAuth}, nil, nil).Times(2)
Return(&auth.Rec{TenantID: 7, Uid: actor, AuthLevel: auth.LevelAuth}, nil, nil).Times(3)
store.Store = mockStore
oldSubs := store.Subs
oldMessages := store.Messages
@@ -5717,10 +5728,11 @@ func TestServeIMTopicReadActionsNormalizeP2PUserTopic(t *testing.T) {
Topic: normalizedTopic,
ModeWant: types.ModeCPublic,
ModeGiven: types.ModeCPublic,
}, nil).Times(2)
}, nil).Times(3)
textMsg := types.Message{TenantID: 7, SeqId: 9, Topic: normalizedTopic, From: actor.String(), Head: types.KVMap{"type": "text"}, Content: map[string]any{"text": "好的"}}
imageMsg := types.Message{TenantID: 7, SeqId: 10, Topic: normalizedTopic, From: actor.String(), Head: types.KVMap{"type": "image"}, Content: map[string]any{"file_id": "file-1", "mime": "image/png"}}
messages.EXPECT().Search(types.TenantID(7), normalizedTopic, actor, "好的", "text", "", 50).Return([]types.Message{textMsg}, "", nil)
messages.EXPECT().GetAll(types.TenantID(7), normalizedTopic, actor, gomock.Any()).Return([]types.Message{textMsg}, nil)
messages.EXPECT().Search(types.TenantID(7), normalizedTopic, actor, "", "image", "", 50).Return([]types.Message{imageMsg}, "", nil)
searchReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/"+peer.UserId()+"/messages/search?q=%E5%A5%BD%E7%9A%84&type=text", nil)
@@ -5731,6 +5743,18 @@ func TestServeIMTopicReadActionsNormalizeP2PUserTopic(t *testing.T) {
t.Fatalf("search status: got %d body=%s", searchRec.Code, searchRec.Body.String())
}
contextReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/"+peer.UserId()+"/messages/9/context", nil)
contextReq.Header.Set("Authorization", "Bearer token-1")
contextRec := httptest.NewRecorder()
serveIMTopicMessageAction(contextRec, contextReq)
if contextRec.Code != http.StatusOK {
t.Fatalf("context status: got %d body=%s", contextRec.Code, contextRec.Body.String())
}
contextData := decodeIMHTTPResponse(t, contextRec).Data.(map[string]any)
if contextData["topic"] != normalizedTopic {
t.Fatalf("context topic was not normalized: %#v", contextData)
}
mediaReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/"+peer.UserId()+"/media?type=image", nil)
mediaReq.Header.Set("Authorization", "Bearer token-1")
mediaRec := httptest.NewRecorder()
+55
View File
@@ -1286,6 +1286,10 @@ func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, miss
// Record deviceId used in this session
if s.deviceID != "" {
isNewDevice, deviceErr := isNewLoginDevice(s.tenantID, rec.Uid, s.deviceID)
if deviceErr != nil {
logs.Warn.Println("s.onLogin: failed to check known device", rec.Uid, deviceErr, s.sid)
}
if err := store.Devices.Update(s.tenantID, rec.Uid, "", &types.DeviceDef{
TenantID: s.tenantID,
DeviceId: s.deviceID,
@@ -1294,6 +1298,8 @@ func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, miss
Lang: s.lang,
}); err != nil {
logs.Warn.Println("failed to update device record", err)
} else if isNewDevice {
createLoginDeviceSecurityNotice(s.tenantID, rec.Uid, s.deviceID, s.platf, s.userAgent, timestamp)
}
}
if !s.uid.IsZero() {
@@ -1310,6 +1316,55 @@ func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, miss
return reply
}
func isNewLoginDevice(tenantID types.TenantID, uid types.Uid, deviceID string) (bool, error) {
if store.Devices == nil || strings.TrimSpace(deviceID) == "" || uid.IsZero() {
return false, nil
}
devices, _, err := store.Devices.GetAll(tenantID, uid)
if err != nil {
return false, err
}
for _, device := range devices[uid] {
if device.DeviceId == deviceID {
return false, nil
}
}
return true, nil
}
func createLoginDeviceSecurityNotice(tenantID types.TenantID, uid types.Uid, deviceID, platform, userAgent string, at time.Time) {
if store.Security == nil || uid.IsZero() || strings.TrimSpace(deviceID) == "" {
return
}
eventID := stableIMEventID("security.login_device", uid.UserId(), deviceID)
if err := createIMSystemNotification(tenantID, uid, eventID, "security.login_device",
"新设备登录提醒", loginDeviceSecurityNoticeSummary(platform, userAgent),
map[string]any{
"scene": "login_device",
"device_id": deviceID,
"platform": strings.TrimSpace(platform),
"user_agent": strings.TrimSpace(userAgent),
"login_at": at.UTC().Format(time.RFC3339Nano),
}); err != nil {
logs.Warn.Println("s.onLogin: failed to create login device notification", uid, err)
}
}
func loginDeviceSecurityNoticeSummary(platform, userAgent string) string {
platform = strings.TrimSpace(platform)
if platform == "" {
platform = "未知设备"
}
userAgent = strings.TrimSpace(userAgent)
if userAgent == "" {
return platform + " 登录了你的账号"
}
if len([]rune(userAgent)) > 80 {
userAgent = string([]rune(userAgent)[:80])
}
return platform + " 登录了你的账号:" + userAgent
}
func currentNodeID() string {
if globals.cluster != nil && globals.cluster.thisNodeName != "" {
return globals.cluster.thisNodeName
+76
View File
@@ -19,6 +19,48 @@ type testTenantStore struct {
err error
}
type testLoginDeviceStore struct {
devices map[types.Uid][]types.DeviceDef
updates int
err error
}
func (s *testLoginDeviceStore) Update(tenantID types.TenantID, uid types.Uid, oldDeviceID string, dev *types.DeviceDef) error {
s.updates++
if s.err != nil {
return s.err
}
if dev != nil {
dev.TenantID = tenantID
s.devices[uid] = append(s.devices[uid], *dev)
}
return nil
}
func (s *testLoginDeviceStore) GetAll(_ types.TenantID, uids ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) {
if s.err != nil {
return nil, 0, s.err
}
result := make(map[types.Uid][]types.DeviceDef)
count := 0
for _, uid := range uids {
result[uid] = append([]types.DeviceDef(nil), s.devices[uid]...)
count += len(result[uid])
}
return result, count, nil
}
func (s *testLoginDeviceStore) Delete(_ types.TenantID, uid types.Uid, deviceID string) error {
devices := s.devices[uid]
for i, device := range devices {
if device.DeviceId == deviceID {
s.devices[uid] = append(devices[:i], devices[i+1:]...)
return nil
}
}
return types.ErrNotFound
}
func (s testTenantStore) GetByCode(string) (*types.Tenant, error) {
return s.tenant, s.err
}
@@ -46,6 +88,40 @@ func test_makeSession(uid types.Uid) *Session {
}
}
func TestLoginDeviceSecurityNoticeOnlyForNewDevice(t *testing.T) {
uid := types.Uid(42)
devices := &testLoginDeviceStore{devices: map[types.Uid][]types.DeviceDef{
uid: {{TenantID: 7, DeviceId: "known-device", Platform: "ios"}},
}}
security := &testSecurityStore{}
oldDevices := store.Devices
oldSecurity := store.Security
store.Devices = devices
store.Security = security
t.Cleanup(func() {
store.Devices = oldDevices
store.Security = oldSecurity
})
known, err := isNewLoginDevice(7, uid, "known-device")
if err != nil || known {
t.Fatalf("known device detected as new: new=%v err=%v", known, err)
}
newDevice, err := isNewLoginDevice(7, uid, "new-device")
if err != nil || !newDevice {
t.Fatalf("new device was not detected: new=%v err=%v", newDevice, err)
}
createLoginDeviceSecurityNotice(7, uid, "new-device", "ios", "UnitTestUA", time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC))
if len(security.notifications) != 1 {
t.Fatalf("expected one security notification, got %d", len(security.notifications))
}
notification := security.notifications[0]
if notification.EventType != "security.login_device" || notification.Title == "" || notification.Action["device_id"] != "new-device" {
t.Fatalf("unexpected login device notification: %#v", notification)
}
}
func TestDispatchHello(t *testing.T) {
oldTenants := store.Tenants
store.Tenants = testTenantStore{tenant: &types.Tenant{
+60
View File
@@ -493,6 +493,66 @@ func TestHandleBroadcastCall(t *testing.T) {
}
}
func TestHandleBroadcastDraftyCallInvite(t *testing.T) {
numUsers := 2
helper := TopicTestHelper{}
helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test" /*attach=*/, true)
oldThirdParty := store.ThirdPartyConfig
globals.iceServers = []iceServer{{Username: "dummy"}}
store.ThirdPartyConfig = testThirdPartyConfigStore{
"audio_call_enabled": "1",
"video_call_enabled": "1",
}
helper.topic.lastID = 5
defer func() {
globals.iceServers = nil
store.ThirdPartyConfig = oldThirdParty
helper.tearDown()
}()
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).
Return(&types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil)
from := helper.uids[0].UserId()
msg := &ClientComMessage{
AsUser: from,
Original: from,
Pub: &MsgClientPub{
Topic: "p2p",
Head: map[string]any{"webrtc": "started", "mime": "text/x-drafty"},
Content: map[string]any{
"txt": " ",
"ent": []any{map[string]any{
"tp": "VC",
"data": map[string]any{
"call_id": "call-drafty-1",
"aonly": true,
"state": "started",
},
}},
},
NoEcho: true,
},
sess: helper.sessions[0],
}
helper.topic.handleClientMsg(msg)
helper.finish()
if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError {
t.Fatal(errorMsgs[0].Ctrl.Text)
}
if len(helper.results[1].messages) != 1 {
t.Fatalf("callee: expected 1 message, got %d", len(helper.results[1].messages))
}
data := helper.results[1].messages[0].(*ServerComMessage).Data
content := data.Content.(map[string]any)
if content["callId"] != "call-drafty-1" || content["callType"] != "audio" || content["video"] != false {
t.Fatalf("unexpected normalized Drafty call content: %#v", content)
}
if data.Head["x-im-type"] != "call" || data.Head["mime"] != "text/x-drafty" {
t.Fatalf("unexpected normalized Drafty call head: %#v", data.Head)
}
}
func TestHandleBroadcastCallRequiresStructuredInvite(t *testing.T) {
numUsers := 2
helper := TopicTestHelper{}