feat: add im home and message action flows
This commit is contained in:
@@ -13,8 +13,13 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/tinode/chat/server/auth"
|
||||
"github.com/tinode/chat/server/auth/mock_auth"
|
||||
"github.com/tinode/chat/server/store"
|
||||
"github.com/tinode/chat/server/store/mock_store"
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type testVerificationStore struct {
|
||||
@@ -94,6 +99,20 @@ func decodeIMHTTPResponse(t *testing.T, rec *httptest.ResponseRecorder) imHTTPRe
|
||||
return resp
|
||||
}
|
||||
|
||||
func expectIMBearer(t *testing.T, ctrl *gomock.Controller, token string, tenantID types.TenantID, uid types.Uid) *mock_store.MockPersistentStorageInterface {
|
||||
t.Helper()
|
||||
oldStore := store.Store
|
||||
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)).
|
||||
Return(&auth.Rec{TenantID: tenantID, Uid: uid, AuthLevel: auth.LevelAuth}, nil, nil)
|
||||
store.Store = mockStore
|
||||
t.Cleanup(func() { store.Store = oldStore })
|
||||
return mockStore
|
||||
}
|
||||
|
||||
func TestServeIMTenantResolveSuccessFromHeader(t *testing.T) {
|
||||
oldTenants := store.Tenants
|
||||
store.Tenants = testTenantStore{tenant: &types.Tenant{
|
||||
@@ -425,6 +444,427 @@ func TestServeIMVerificationCodeVerifyInvalidCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMRealNameReturnsUnverified(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
||||
oldSecurity := store.Security
|
||||
store.Security = &testSecurityStore{}
|
||||
defer func() { store.Security = oldSecurity }()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/im/security/realname", nil)
|
||||
req.Header.Set("Authorization", "Bearer token-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMRealName(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||
if data["status"] != "unverified" || data["can_submit"] != true {
|
||||
t.Fatalf("unexpected realname payload: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMRealNameSubmissionCreatesPending(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
||||
oldSecurity := store.Security
|
||||
security := &testSecurityStore{}
|
||||
store.Security = security
|
||||
defer func() { store.Security = oldSecurity }()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/security/realname/submissions",
|
||||
bytes.NewBufferString(`{"name":"张三","id_type":"cn_id","id_number":"110101199003071234"}`))
|
||||
req.Header.Set("Authorization", "Bearer token-1")
|
||||
req.Header.Set("Idempotency-Key", "idem-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMRealNameSubmissions(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if security.realName == nil || security.realName.Status != "pending" || security.realName.NameCipher == "张三" {
|
||||
t.Fatalf("unexpected stored realname: %#v", security.realName)
|
||||
}
|
||||
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||
if data["status"] != "pending" || data["submission_id"] == "" {
|
||||
t.Fatalf("unexpected response: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMIndependentPasswordSetAndVerify(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
oldSecurity := store.Security
|
||||
security := &testSecurityStore{actionToken: &types.ActionToken{Action: "set_independent_password", Target: "youth_mode"}}
|
||||
store.Security = security
|
||||
defer func() { store.Security = oldSecurity }()
|
||||
|
||||
expectIMBearer(t, ctrl, "token-set", 7, 42)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/settings/independent-password",
|
||||
bytes.NewBufferString(`{"password":"2794","purpose":"youth_mode","action_token":"action.1"}`))
|
||||
req.Header.Set("Authorization", "Bearer token-set")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMIndependentPassword(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("set status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if security.independentPassword == nil ||
|
||||
bcrypt.CompareHashAndPassword(security.independentPassword.PasswordHash, []byte("2794")) != nil {
|
||||
t.Fatalf("password was not stored as a valid hash: %#v", security.independentPassword)
|
||||
}
|
||||
|
||||
expectIMBearer(t, ctrl, "token-verify", 7, 42)
|
||||
req = httptest.NewRequest(http.MethodPost, "/v1/im/settings/independent-password/verify",
|
||||
bytes.NewBufferString(`{"password":"2794","purpose":"youth_mode","action":"close_youth_mode"}`))
|
||||
req.Header.Set("Authorization", "Bearer token-verify")
|
||||
rec = httptest.NewRecorder()
|
||||
|
||||
serveIMIndependentPasswordVerify(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("verify status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||
if data["action"] != "close_youth_mode" || data["action_token"] == "" {
|
||||
t.Fatalf("unexpected verify response: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMSecurityChallengeVerifyIssuesActionToken(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
||||
oldVerifications := store.Verifications
|
||||
oldSecurity := store.Security
|
||||
security := &testSecurityStore{}
|
||||
store.Security = security
|
||||
store.Verifications = &testVerificationStore{get: &types.VerificationCode{
|
||||
TenantID: 7,
|
||||
ChallengeID: "challenge-1",
|
||||
Scene: "sensitive_action",
|
||||
Action: "set_independent_password",
|
||||
PhoneHash: "phone-hash",
|
||||
TargetUser: 42,
|
||||
CodeHash: hashIMSecret(7, "sensitive_action", "challenge-1", "phone-hash", "123456"),
|
||||
State: types.VerificationStatePending,
|
||||
MaxAttempts: 5,
|
||||
ExpiresAt: time.Now().UTC().Add(time.Minute),
|
||||
}}
|
||||
defer func() {
|
||||
store.Verifications = oldVerifications
|
||||
store.Security = oldSecurity
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/security/challenges/challenge-1/verify",
|
||||
bytes.NewBufferString(`{"code":"123456"}`))
|
||||
req.Header.Set("Authorization", "Bearer token-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMSecurityChallengeVerify(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if security.actionToken == nil || security.actionToken.Action != "set_independent_password" || security.actionToken.Target != "youth_mode" {
|
||||
t.Fatalf("unexpected action token: %#v", security.actionToken)
|
||||
}
|
||||
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||
if data["action"] != "set_independent_password" || data["action_token"] == "" {
|
||||
t.Fatalf("unexpected response: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
||||
oldSecurity := store.Security
|
||||
store.Security = &testSecurityStore{}
|
||||
defer func() { store.Security = oldSecurity }()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil)
|
||||
req.Header.Set("Authorization", "Bearer token-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMBootstrap(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||
if data["features"] == nil || data["permissions"] == nil || data["system_topic"] == nil || data["message_policy"] == nil {
|
||||
t.Fatalf("bootstrap missing home capabilities: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMConversationSearch(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
||||
oldUsers := store.Users
|
||||
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
|
||||
store.Users = users
|
||||
defer func() { store.Users = oldUsers }()
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
alice := types.Subscription{
|
||||
ObjHeader: types.ObjHeader{UpdatedAt: now},
|
||||
Topic: "p2pAlice",
|
||||
ReadSeqId: 2,
|
||||
RecvSeqId: 3,
|
||||
}
|
||||
alice.SetPublic(map[string]any{"fn": "Alice"})
|
||||
alice.SetSeqId(5)
|
||||
alice.SetTouchedAt(now)
|
||||
bob := types.Subscription{
|
||||
ObjHeader: types.ObjHeader{UpdatedAt: now},
|
||||
Topic: "grpBob",
|
||||
ReadSeqId: 9,
|
||||
}
|
||||
bob.SetPublic(map[string]any{"fn": "Bob Group"})
|
||||
bob.SetSeqId(9)
|
||||
bob.SetTouchedAt(now)
|
||||
users.EXPECT().GetTopics(types.TenantID(7), types.Uid(42), gomock.Any()).Return([]types.Subscription{alice, bob}, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/im/search/conversations?q=alice", nil)
|
||||
req.Header.Set("Authorization", "Bearer token-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMConversationSearch(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||
items := data["items"].([]any)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items length: got %d, want 1; data=%#v", len(items), data)
|
||||
}
|
||||
item := items[0].(map[string]any)
|
||||
if item["topic"] != "p2pAlice" || item["unread"].(float64) != 3 {
|
||||
t.Fatalf("unexpected conversation item: %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMSystemNotificationsListAndRead(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
oldSecurity := store.Security
|
||||
security := &testSecurityStore{notifications: []types.SystemNotification{{
|
||||
TenantID: 7,
|
||||
UserID: 42,
|
||||
EventID: "evt-1",
|
||||
EventType: "security.new_device_login",
|
||||
Title: "新设备登录",
|
||||
Summary: "iPhone 登录",
|
||||
Action: types.KVMap{"type": "security_center"},
|
||||
CreatedAt: time.Now().UTC().Round(time.Millisecond),
|
||||
}}}
|
||||
store.Security = security
|
||||
defer func() { store.Security = oldSecurity }()
|
||||
|
||||
expectIMBearer(t, ctrl, "token-list", 7, 42)
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/im/system-notifications", nil)
|
||||
req.Header.Set("Authorization", "Bearer token-list")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMSystemNotifications(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||
if len(data["items"].([]any)) != 1 {
|
||||
t.Fatalf("unexpected list response: %#v", data)
|
||||
}
|
||||
|
||||
expectIMBearer(t, ctrl, "token-read", 7, 42)
|
||||
req = httptest.NewRequest(http.MethodPost, "/v1/im/system-notifications/evt-1/read", bytes.NewBufferString(`{}`))
|
||||
req.Header.Set("Authorization", "Bearer token-read")
|
||||
req.Header.Set("Idempotency-Key", "read-1")
|
||||
rec = httptest.NewRecorder()
|
||||
|
||||
serveIMSystemNotificationRead(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("read status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if security.readNotificationID != "evt-1" || security.notifications[0].ReadAt == nil {
|
||||
t.Fatalf("notification was not marked read: %#v", security)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMMessageReceipts(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
||||
oldSubs := store.Subs
|
||||
oldTopics := store.Topics
|
||||
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
||||
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
|
||||
store.Subs = subs
|
||||
store.Topics = topics
|
||||
defer func() {
|
||||
store.Subs = oldSubs
|
||||
store.Topics = oldTopics
|
||||
}()
|
||||
|
||||
mySub := &types.Subscription{
|
||||
TenantID: 7,
|
||||
User: "42",
|
||||
Topic: "grpReceipt",
|
||||
ModeWant: types.ModeCFull,
|
||||
ModeGiven: types.ModeCFull,
|
||||
}
|
||||
subs.EXPECT().Get(types.TenantID(7), "grpReceipt", types.Uid(42), false).Return(mySub, nil)
|
||||
readMember := types.Subscription{User: "101", ReadSeqId: 8, RecvSeqId: 8}
|
||||
readMember.SetPublic(map[string]any{"fn": "Alice"})
|
||||
deliveredMember := types.Subscription{User: "102", ReadSeqId: 4, RecvSeqId: 8}
|
||||
deliveredMember.SetPublic(map[string]any{"fn": "Bob"})
|
||||
topics.EXPECT().GetUsers(types.TenantID(7), "grpReceipt", gomock.Any()).Return([]types.Subscription{readMember, deliveredMember}, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/im/topics/grpReceipt/messages/8/receipts", nil)
|
||||
req.Header.Set("Authorization", "Bearer token-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMMessageReceipts(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||
if data["topic"] != "grpReceipt" || data["seq"].(float64) != 8 {
|
||||
t.Fatalf("unexpected receipt envelope: %#v", data)
|
||||
}
|
||||
if len(data["read"].([]any)) != 1 || len(data["delivered"].([]any)) != 2 {
|
||||
t.Fatalf("unexpected receipt items: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMMessageRecall(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
||||
oldSubs := store.Subs
|
||||
oldMessages := store.Messages
|
||||
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
||||
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
|
||||
store.Subs = subs
|
||||
store.Messages = messages
|
||||
defer func() {
|
||||
store.Subs = oldSubs
|
||||
store.Messages = oldMessages
|
||||
}()
|
||||
|
||||
subs.EXPECT().Get(types.TenantID(7), "grpRecall", types.Uid(42), false).Return(&types.Subscription{
|
||||
TenantID: 7,
|
||||
User: "42",
|
||||
Topic: "grpRecall",
|
||||
ModeWant: types.ModeCFull,
|
||||
ModeGiven: types.ModeCFull,
|
||||
}, nil)
|
||||
messages.EXPECT().GetOne(types.TenantID(7), "grpRecall", 9).Return(&types.Message{
|
||||
TenantID: 7,
|
||||
SeqId: 9,
|
||||
Topic: "grpRecall",
|
||||
From: types.Uid(42).String(),
|
||||
Version: 1,
|
||||
ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)},
|
||||
}, nil)
|
||||
messages.EXPECT().Recall(types.TenantID(7), "grpRecall", 9, types.Uid(42), 1, gomock.Any()).Return(&types.Message{
|
||||
TenantID: 7,
|
||||
SeqId: 9,
|
||||
Topic: "grpRecall",
|
||||
From: "42",
|
||||
Version: 2,
|
||||
RecalledAt: ptrTime(time.Now().UTC()),
|
||||
RecalledBy: "42",
|
||||
ObjHeader: types.ObjHeader{UpdatedAt: time.Now().UTC()},
|
||||
}, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/topics/grpRecall/messages/9/recall", bytes.NewBufferString(`{"version":1}`))
|
||||
req.Header.Set("Authorization", "Bearer token-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMMessageRecall(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||
if data["status"] != "recalled" || data["version"].(float64) != 2 {
|
||||
t.Fatalf("unexpected recall response: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMMessageRevision(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
||||
oldSubs := store.Subs
|
||||
oldMessages := store.Messages
|
||||
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
||||
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
|
||||
store.Subs = subs
|
||||
store.Messages = messages
|
||||
defer func() {
|
||||
store.Subs = oldSubs
|
||||
store.Messages = oldMessages
|
||||
}()
|
||||
|
||||
subs.EXPECT().Get(types.TenantID(7), "grpEdit", types.Uid(42), false).Return(&types.Subscription{
|
||||
TenantID: 7,
|
||||
User: "42",
|
||||
Topic: "grpEdit",
|
||||
ModeWant: types.ModeCFull,
|
||||
ModeGiven: types.ModeCFull,
|
||||
}, nil)
|
||||
messages.EXPECT().GetOne(types.TenantID(7), "grpEdit", 3).Return(&types.Message{
|
||||
TenantID: 7,
|
||||
SeqId: 3,
|
||||
Topic: "grpEdit",
|
||||
From: types.Uid(42).String(),
|
||||
Version: 1,
|
||||
Head: types.KVMap{"type": "text"},
|
||||
Content: "old",
|
||||
ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)},
|
||||
}, nil)
|
||||
messages.EXPECT().Edit(types.TenantID(7), "grpEdit", 3, types.Uid(42), 1, gomock.Any(), "new", "", gomock.Any()).Return(&types.Message{
|
||||
TenantID: 7,
|
||||
SeqId: 3,
|
||||
Topic: "grpEdit",
|
||||
From: "42",
|
||||
Version: 2,
|
||||
Head: types.KVMap{"type": "text"},
|
||||
Content: "new",
|
||||
ObjHeader: types.ObjHeader{UpdatedAt: time.Now().UTC()},
|
||||
}, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/topics/grpEdit/messages/3/revisions", bytes.NewBufferString(`{"version":1,"content":"new"}`))
|
||||
req.Header.Set("Authorization", "Bearer token-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMMessageRevision(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||
if data["status"] != "edited" || data["version"].(float64) != 2 || data["content"] != "new" {
|
||||
t.Fatalf("unexpected revision response: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func ptrTime(t time.Time) *time.Time {
|
||||
return &t
|
||||
}
|
||||
|
||||
func ptrString(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user