fix im p0 backend issues
This commit is contained in:
+137
-4
@@ -109,10 +109,18 @@ func normalizeCallInvite(head map[string]any, content any) (map[string]any, any,
|
|||||||
if state != "started" {
|
if state != "started" {
|
||||||
return nil, nil, types.ErrMalformed
|
return nil, nil, types.ErrMalformed
|
||||||
}
|
}
|
||||||
call, ok := content.(map[string]any)
|
call := callInvitePayloadMap(content)
|
||||||
if !ok {
|
if len(call) == 0 || !hasCallIdentity(call) {
|
||||||
if typed, typedOK := content.(types.KVMap); typedOK {
|
headCall := callInvitePayloadMap(head)
|
||||||
call = map[string]any(typed)
|
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 {
|
if len(call) == 0 {
|
||||||
@@ -123,6 +131,16 @@ func normalizeCallInvite(head map[string]any, content any) (map[string]any, any,
|
|||||||
if callType == "" {
|
if callType == "" {
|
||||||
callType, _ = call["media_type"].(string)
|
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))
|
callType = strings.ToLower(strings.TrimSpace(callType))
|
||||||
if strings.TrimSpace(callID) == "" || (callType != constCallTypeAudio && callType != constCallTypeVideo) {
|
if strings.TrimSpace(callID) == "" || (callType != constCallTypeAudio && callType != constCallTypeVideo) {
|
||||||
return nil, nil, types.ErrMalformed
|
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
|
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 {
|
func callPayloadString(payload any, key string) string {
|
||||||
data, ok := payload.(map[string]any)
|
data, ok := payload.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -445,7 +445,8 @@ func (a *adapter) TenantGroupInvitationAccept(id t.TenantID, invitationID string
|
|||||||
return &inv, nil
|
return &inv, nil
|
||||||
}
|
}
|
||||||
var deletedAt sql.NullTime
|
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 {
|
if err != nil && err != sql.ErrNoRows {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-4
@@ -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)})
|
failed = append(failed, map[string]any{"topic": sourceTopic, "seq": src.Seq, "reason": relationErrorReason(err)})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
idempotencyKey := favoriteItemIdempotencyKey(req.Header.Get("Idempotency-Key"), sourceTopic, src.Seq)
|
||||||
fav := &types.FavoriteItem{
|
fav := &types.FavoriteItem{
|
||||||
TenantID: principal.TenantID,
|
TenantID: principal.TenantID,
|
||||||
FavoriteID: "fav_" + store.Store.GetUidString(),
|
FavoriteID: "fav_" + store.Store.GetUidString(),
|
||||||
@@ -3580,7 +3581,7 @@ func serveIMFavoriteBatchCreate(wrt http.ResponseWriter, req *http.Request) {
|
|||||||
Snapshot: favoriteSnapshot(msg),
|
Snapshot: favoriteSnapshot(msg),
|
||||||
AttachmentRefs: favoriteAttachmentRefs(msg),
|
AttachmentRefs: favoriteAttachmentRefs(msg),
|
||||||
Availability: messageAvailability(*msg),
|
Availability: messageAvailability(*msg),
|
||||||
IdempotencyKey: strings.TrimSpace(req.Header.Get("Idempotency-Key")),
|
IdempotencyKey: idempotencyKey,
|
||||||
CollectedAt: now,
|
CollectedAt: now,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: 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)
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message context route not found", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
principal, _, ok := authenticateTopicReader(wrt, req, topic)
|
principal, normalizedTopic, ok := authenticateTopicReader(wrt, req, topic)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
before := parseIMLimit(req.URL.Query().Get("before"), 20, 100)
|
before := parseIMLimit(req.URL.Query().Get("before"), 20, 100)
|
||||||
after := parseIMLimit(req.URL.Query().Get("after"), 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 {
|
if err != nil {
|
||||||
writeIMRelationError(wrt, req, err)
|
writeIMRelationError(wrt, req, err)
|
||||||
return
|
return
|
||||||
@@ -4036,7 +4037,7 @@ func serveIMMessageContext(wrt http.ResponseWriter, req *http.Request) {
|
|||||||
for _, msg := range msgs {
|
for _, msg := range msgs {
|
||||||
items = append(items, messageContextPayload(msg))
|
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) {
|
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)
|
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) {
|
func normalizeIMUserVisibleTopic(viewer types.Uid, topic string) (string, bool) {
|
||||||
topic = strings.TrimSpace(topic)
|
topic = strings.TrimSpace(topic)
|
||||||
if topic == "" {
|
if topic == "" {
|
||||||
|
|||||||
+28
-4
@@ -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) {
|
func TestServeIMFavoriteBatchCreateRejectsUnreadableSource(t *testing.T) {
|
||||||
ctrl := gomock.NewController(t)
|
ctrl := gomock.NewController(t)
|
||||||
defer ctrl.Finish()
|
defer ctrl.Finish()
|
||||||
@@ -5694,10 +5705,10 @@ func TestServeIMTopicReadActionsNormalizeP2PUserTopic(t *testing.T) {
|
|||||||
oldStore := store.Store
|
oldStore := store.Store
|
||||||
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
|
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
|
||||||
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
|
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
|
||||||
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(2)
|
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(3)
|
||||||
tokenAuth.EXPECT().IsInitialized().Return(true).Times(2)
|
tokenAuth.EXPECT().IsInitialized().Return(true).Times(3)
|
||||||
tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")).
|
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
|
store.Store = mockStore
|
||||||
oldSubs := store.Subs
|
oldSubs := store.Subs
|
||||||
oldMessages := store.Messages
|
oldMessages := store.Messages
|
||||||
@@ -5717,10 +5728,11 @@ func TestServeIMTopicReadActionsNormalizeP2PUserTopic(t *testing.T) {
|
|||||||
Topic: normalizedTopic,
|
Topic: normalizedTopic,
|
||||||
ModeWant: types.ModeCPublic,
|
ModeWant: types.ModeCPublic,
|
||||||
ModeGiven: 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": "好的"}}
|
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"}}
|
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().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)
|
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)
|
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())
|
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 := httptest.NewRequest(http.MethodGet, "/v1/im/topics/"+peer.UserId()+"/media?type=image", nil)
|
||||||
mediaReq.Header.Set("Authorization", "Bearer token-1")
|
mediaReq.Header.Set("Authorization", "Bearer token-1")
|
||||||
mediaRec := httptest.NewRecorder()
|
mediaRec := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -1286,6 +1286,10 @@ func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, miss
|
|||||||
|
|
||||||
// Record deviceId used in this session
|
// Record deviceId used in this session
|
||||||
if s.deviceID != "" {
|
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{
|
if err := store.Devices.Update(s.tenantID, rec.Uid, "", &types.DeviceDef{
|
||||||
TenantID: s.tenantID,
|
TenantID: s.tenantID,
|
||||||
DeviceId: s.deviceID,
|
DeviceId: s.deviceID,
|
||||||
@@ -1294,6 +1298,8 @@ func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, miss
|
|||||||
Lang: s.lang,
|
Lang: s.lang,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
logs.Warn.Println("failed to update device record", err)
|
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() {
|
if !s.uid.IsZero() {
|
||||||
@@ -1310,6 +1316,55 @@ func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, miss
|
|||||||
return reply
|
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 {
|
func currentNodeID() string {
|
||||||
if globals.cluster != nil && globals.cluster.thisNodeName != "" {
|
if globals.cluster != nil && globals.cluster.thisNodeName != "" {
|
||||||
return globals.cluster.thisNodeName
|
return globals.cluster.thisNodeName
|
||||||
|
|||||||
@@ -19,6 +19,48 @@ type testTenantStore struct {
|
|||||||
err error
|
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) {
|
func (s testTenantStore) GetByCode(string) (*types.Tenant, error) {
|
||||||
return s.tenant, s.err
|
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) {
|
func TestDispatchHello(t *testing.T) {
|
||||||
oldTenants := store.Tenants
|
oldTenants := store.Tenants
|
||||||
store.Tenants = testTenantStore{tenant: &types.Tenant{
|
store.Tenants = testTenantStore{tenant: &types.Tenant{
|
||||||
|
|||||||
@@ -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) {
|
func TestHandleBroadcastCallRequiresStructuredInvite(t *testing.T) {
|
||||||
numUsers := 2
|
numUsers := 2
|
||||||
helper := TopicTestHelper{}
|
helper := TopicTestHelper{}
|
||||||
|
|||||||
Reference in New Issue
Block a user