Mono repo -> Master (#22553)
Combines the following repositories into one: https://github.com/mattermost/mattermost-server https://github.com/mattermost/mattermost-webapp https://github.com/mattermost/focalboard https://github.com/mattermost/mattermost-plugin-playbooks
Этот коммит содержится в:
167
server/platform/services/sharedchannel/attachment.go
Обычный файл
167
server/platform/services/sharedchannel/attachment.go
Обычный файл
@@ -0,0 +1,167 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sharedchannel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/channels/app/request"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/services/remotecluster"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
// postsToAttachments returns the file attachments for a slice of posts that need to be synchronized.
|
||||
func (scs *Service) shouldSyncAttachment(fi *model.FileInfo, rc *model.RemoteCluster) bool {
|
||||
sca, err := scs.server.GetStore().SharedChannel().GetAttachment(fi.Id, rc.RemoteId)
|
||||
if err != nil {
|
||||
if _, ok := err.(errNotFound); !ok {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceError, "error fetching shared channel attachment",
|
||||
mlog.String("file_id", fi.Id),
|
||||
mlog.String("remote_id", rc.RemoteId),
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
// no record so sync is needed
|
||||
return true
|
||||
}
|
||||
|
||||
return sca.LastSyncAt < fi.UpdateAt
|
||||
}
|
||||
|
||||
// sendAttachmentForRemote asynchronously sends a file attachment to a remote cluster.
|
||||
func (scs *Service) sendAttachmentForRemote(fi *model.FileInfo, post *model.Post, rc *model.RemoteCluster) error {
|
||||
rcs := scs.server.GetRemoteClusterService()
|
||||
if rcs == nil {
|
||||
return fmt.Errorf("cannot update remote cluster for remote id %s; Remote Cluster Service not enabled", rc.RemoteId)
|
||||
}
|
||||
|
||||
us := &model.UploadSession{
|
||||
Id: model.NewId(),
|
||||
Type: model.UploadTypeAttachment,
|
||||
UserId: post.UserId,
|
||||
ChannelId: post.ChannelId,
|
||||
Filename: fi.Name,
|
||||
FileSize: fi.Size,
|
||||
RemoteId: rc.RemoteId,
|
||||
ReqFileId: fi.Id,
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(us)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg := model.NewRemoteClusterMsg(TopicUploadCreate, payload)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), remotecluster.SendTimeout)
|
||||
defer cancel()
|
||||
|
||||
var usResp model.UploadSession
|
||||
var respErr error
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
|
||||
// creating the upload session on the remote server needs to be done synchronously.
|
||||
err = rcs.SendMsg(ctx, msg, rc, func(msg model.RemoteClusterMsg, rc *model.RemoteCluster, resp *remotecluster.Response, err error) {
|
||||
defer wg.Done()
|
||||
if err != nil {
|
||||
respErr = err
|
||||
return
|
||||
}
|
||||
if !resp.IsSuccess() {
|
||||
respErr = errors.New(resp.Err)
|
||||
return
|
||||
}
|
||||
respErr = json.Unmarshal(resp.Payload, &usResp)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("error sending create upload session to remote %s for post %s: %w", rc.RemoteId, post.Id, err)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if respErr != nil {
|
||||
return fmt.Errorf("invalid create upload session response for remote %s and post %s: %w", rc.RemoteId, post.Id, respErr)
|
||||
}
|
||||
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), remotecluster.SendFileTimeout)
|
||||
defer cancel2()
|
||||
|
||||
return rcs.SendFile(ctx2, &usResp, fi, rc, scs.app, func(us *model.UploadSession, rc *model.RemoteCluster, resp *remotecluster.Response, err error) {
|
||||
if err != nil {
|
||||
return // this means the response could not be parsed; already logged
|
||||
}
|
||||
|
||||
if !resp.IsSuccess() {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceError, "send file failed",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.String("uploadId", usResp.Id),
|
||||
mlog.String("err", resp.Err),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// response payload should be a model.FileInfo.
|
||||
var fi model.FileInfo
|
||||
if err2 := json.Unmarshal(resp.Payload, &fi); err2 != nil {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceError, "invalid file info response after send file",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.String("uploadId", usResp.Id),
|
||||
mlog.Err(err2),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// save file attachment record in SharedChannelAttachments table
|
||||
sca := &model.SharedChannelAttachment{
|
||||
FileId: fi.Id,
|
||||
RemoteId: rc.RemoteId,
|
||||
}
|
||||
if _, err2 := scs.server.GetStore().SharedChannel().UpsertAttachment(sca); err2 != nil {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceError, "error saving SharedChannelAttachment",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.String("uploadId", usResp.Id),
|
||||
mlog.Err(err2),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "send file successful",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.String("uploadId", usResp.Id),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// onReceiveUploadCreate is called when a message requesting to create an upload session is received. An upload session is
|
||||
// created and the id returned in the response.
|
||||
func (scs *Service) onReceiveUploadCreate(msg model.RemoteClusterMsg, rc *model.RemoteCluster, response *remotecluster.Response) error {
|
||||
var us model.UploadSession
|
||||
|
||||
if err := json.Unmarshal(msg.Payload, &us); err != nil {
|
||||
return fmt.Errorf("invalid upload session request: %w", err)
|
||||
}
|
||||
|
||||
// make sure channel is shared for the remote sender
|
||||
if _, err := scs.server.GetStore().SharedChannel().GetRemoteByIds(us.ChannelId, rc.RemoteId); err != nil {
|
||||
return fmt.Errorf("could not validate upload session for remote: %w", err)
|
||||
}
|
||||
|
||||
us.RemoteId = rc.RemoteId // don't let remotes try to impersonate each other
|
||||
|
||||
// create upload session.
|
||||
usSaved, appErr := scs.app.CreateUploadSession(request.EmptyContext(scs.server.Log()), &us)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
response.SetPayload(usSaved)
|
||||
return nil
|
||||
}
|
||||
219
server/platform/services/sharedchannel/channelinvite.go
Обычный файл
219
server/platform/services/sharedchannel/channelinvite.go
Обычный файл
@@ -0,0 +1,219 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sharedchannel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/channels/app/request"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/services/remotecluster"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
// channelInviteMsg represents an invitation for a remote cluster to start sharing a channel.
|
||||
type channelInviteMsg struct {
|
||||
ChannelId string `json:"channel_id"`
|
||||
TeamId string `json:"team_id"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Header string `json:"header"`
|
||||
Purpose string `json:"purpose"`
|
||||
Type model.ChannelType `json:"type"`
|
||||
DirectParticipantIDs []string `json:"direct_participant_ids"`
|
||||
}
|
||||
|
||||
type InviteOption func(msg *channelInviteMsg)
|
||||
|
||||
func WithDirectParticipantID(participantID string) InviteOption {
|
||||
return func(msg *channelInviteMsg) {
|
||||
msg.DirectParticipantIDs = append(msg.DirectParticipantIDs, participantID)
|
||||
}
|
||||
}
|
||||
|
||||
// SendChannelInvite asynchronously sends a channel invite to a remote cluster. The remote cluster is
|
||||
// expected to create a new channel with the same channel id, and respond with status OK.
|
||||
// If an error occurs on the remote cluster then an ephemeral message is posted to in the channel for userId.
|
||||
func (scs *Service) SendChannelInvite(channel *model.Channel, userId string, rc *model.RemoteCluster, options ...InviteOption) error {
|
||||
rcs := scs.server.GetRemoteClusterService()
|
||||
if rcs == nil {
|
||||
return fmt.Errorf("cannot invite remote cluster for channel id %s; Remote Cluster Service not enabled", channel.Id)
|
||||
}
|
||||
|
||||
sc, err := scs.server.GetStore().SharedChannel().Get(channel.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
invite := channelInviteMsg{
|
||||
ChannelId: channel.Id,
|
||||
TeamId: rc.RemoteTeamId,
|
||||
ReadOnly: sc.ReadOnly,
|
||||
Name: sc.ShareName,
|
||||
DisplayName: sc.ShareDisplayName,
|
||||
Header: sc.ShareHeader,
|
||||
Purpose: sc.SharePurpose,
|
||||
Type: channel.Type,
|
||||
}
|
||||
|
||||
for _, option := range options {
|
||||
option(&invite)
|
||||
}
|
||||
|
||||
json, err := json.Marshal(invite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg := model.NewRemoteClusterMsg(TopicChannelInvite, json)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), remotecluster.SendTimeout)
|
||||
defer cancel()
|
||||
|
||||
return rcs.SendMsg(ctx, msg, rc, func(msg model.RemoteClusterMsg, rc *model.RemoteCluster, resp *remotecluster.Response, err error) {
|
||||
if err != nil || !resp.IsSuccess() {
|
||||
scs.sendEphemeralPost(channel.Id, userId, fmt.Sprintf("Error sending channel invite for %s: %s", rc.DisplayName, combineErrors(err, resp.Err)))
|
||||
return
|
||||
}
|
||||
|
||||
scr := &model.SharedChannelRemote{
|
||||
ChannelId: sc.ChannelId,
|
||||
CreatorId: userId,
|
||||
RemoteId: rc.RemoteId,
|
||||
IsInviteAccepted: true,
|
||||
IsInviteConfirmed: true,
|
||||
}
|
||||
if _, err = scs.server.GetStore().SharedChannel().SaveRemote(scr); err != nil {
|
||||
scs.sendEphemeralPost(channel.Id, userId, fmt.Sprintf("Error confirming channel invite for %s: %v", rc.DisplayName, err))
|
||||
return
|
||||
}
|
||||
scs.NotifyChannelChanged(sc.ChannelId)
|
||||
scs.sendEphemeralPost(channel.Id, userId, fmt.Sprintf("`%s` has been added to channel.", rc.DisplayName))
|
||||
})
|
||||
}
|
||||
|
||||
func combineErrors(err error, serror string) string {
|
||||
var sb strings.Builder
|
||||
if err != nil {
|
||||
sb.WriteString(err.Error())
|
||||
}
|
||||
if serror != "" {
|
||||
if sb.Len() > 0 {
|
||||
sb.WriteString("; ")
|
||||
}
|
||||
sb.WriteString(serror)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (scs *Service) onReceiveChannelInvite(msg model.RemoteClusterMsg, rc *model.RemoteCluster, _ *remotecluster.Response) error {
|
||||
if len(msg.Payload) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var invite channelInviteMsg
|
||||
|
||||
if err := json.Unmarshal(msg.Payload, &invite); err != nil {
|
||||
return fmt.Errorf("invalid channel invite: %w", err)
|
||||
}
|
||||
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Channel invite received",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.String("channel_id", invite.ChannelId),
|
||||
mlog.String("channel_name", invite.Name),
|
||||
mlog.String("team_id", invite.TeamId),
|
||||
)
|
||||
|
||||
// create channel if it doesn't exist; the channel may already exist, such as if it was shared then unshared at some point.
|
||||
channel, err := scs.server.GetStore().Channel().Get(invite.ChannelId, true)
|
||||
if err != nil {
|
||||
if channel, err = scs.handleChannelCreation(invite, rc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if invite.ReadOnly {
|
||||
if err := scs.makeChannelReadOnly(channel); err != nil {
|
||||
return fmt.Errorf("cannot make channel readonly `%s`: %w", invite.ChannelId, err)
|
||||
}
|
||||
}
|
||||
|
||||
sharedChannel := &model.SharedChannel{
|
||||
ChannelId: channel.Id,
|
||||
TeamId: channel.TeamId,
|
||||
Home: false,
|
||||
ReadOnly: invite.ReadOnly,
|
||||
ShareName: channel.Name,
|
||||
ShareDisplayName: channel.DisplayName,
|
||||
SharePurpose: channel.Purpose,
|
||||
ShareHeader: channel.Header,
|
||||
CreatorId: rc.CreatorId,
|
||||
RemoteId: rc.RemoteId,
|
||||
Type: channel.Type,
|
||||
}
|
||||
|
||||
if _, err := scs.server.GetStore().SharedChannel().Save(sharedChannel); err != nil {
|
||||
scs.app.PermanentDeleteChannel(request.EmptyContext(scs.server.Log()), channel)
|
||||
return fmt.Errorf("cannot create shared channel (channel_id=%s): %w", invite.ChannelId, err)
|
||||
}
|
||||
|
||||
sharedChannelRemote := &model.SharedChannelRemote{
|
||||
Id: model.NewId(),
|
||||
ChannelId: channel.Id,
|
||||
CreatorId: channel.CreatorId,
|
||||
IsInviteAccepted: true,
|
||||
IsInviteConfirmed: true,
|
||||
RemoteId: rc.RemoteId,
|
||||
}
|
||||
|
||||
if _, err := scs.server.GetStore().SharedChannel().SaveRemote(sharedChannelRemote); err != nil {
|
||||
scs.app.PermanentDeleteChannel(request.EmptyContext(scs.server.Log()), channel)
|
||||
scs.server.GetStore().SharedChannel().Delete(sharedChannel.ChannelId)
|
||||
return fmt.Errorf("cannot create shared channel remote (channel_id=%s): %w", invite.ChannelId, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (scs *Service) handleChannelCreation(invite channelInviteMsg, rc *model.RemoteCluster) (*model.Channel, error) {
|
||||
if invite.Type == model.ChannelTypeDirect {
|
||||
return scs.createDirectChannel(invite)
|
||||
}
|
||||
|
||||
channelNew := &model.Channel{
|
||||
Id: invite.ChannelId,
|
||||
TeamId: invite.TeamId,
|
||||
Type: invite.Type,
|
||||
DisplayName: invite.DisplayName,
|
||||
Name: invite.Name,
|
||||
Header: invite.Header,
|
||||
Purpose: invite.Purpose,
|
||||
CreatorId: rc.CreatorId,
|
||||
Shared: model.NewBool(true),
|
||||
}
|
||||
|
||||
// check user perms?
|
||||
channel, appErr := scs.app.CreateChannelWithUser(request.EmptyContext(scs.server.Log()), channelNew, rc.CreatorId)
|
||||
if appErr != nil {
|
||||
return nil, fmt.Errorf("cannot create channel `%s`: %w", invite.ChannelId, appErr)
|
||||
}
|
||||
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func (scs *Service) createDirectChannel(invite channelInviteMsg) (*model.Channel, error) {
|
||||
if len(invite.DirectParticipantIDs) != 2 {
|
||||
return nil, fmt.Errorf("cannot create direct channel `%s` insufficient participant count `%d`", invite.ChannelId, len(invite.DirectParticipantIDs))
|
||||
}
|
||||
|
||||
channel, err := scs.app.GetOrCreateDirectChannel(request.EmptyContext(scs.server.Log()), invite.DirectParticipantIDs[0], invite.DirectParticipantIDs[1], model.WithID(invite.ChannelId))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create direct channel `%s`: %w", invite.ChannelId, err)
|
||||
}
|
||||
|
||||
return channel, nil
|
||||
}
|
||||
195
server/platform/services/sharedchannel/channelinvite_test.go
Обычный файл
195
server/platform/services/sharedchannel/channelinvite_test.go
Обычный файл
@@ -0,0 +1,195 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sharedchannel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/plugin/plugintest/mock"
|
||||
"github.com/mattermost/mattermost-server/v6/server/channels/store/storetest/mocks"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func TestOnReceiveChannelInvite(t *testing.T) {
|
||||
t.Run("when msg payload is empty, it does nothing", func(t *testing.T) {
|
||||
mockServer := &MockServerIface{}
|
||||
mockLogger, err := mlog.NewLogger()
|
||||
require.NoError(t, err)
|
||||
mockServer.On("Log").Return(mockLogger)
|
||||
mockApp := &MockAppIface{}
|
||||
scs := &Service{
|
||||
server: mockServer,
|
||||
app: mockApp,
|
||||
}
|
||||
|
||||
mockStore := &mocks.Store{}
|
||||
mockServer = scs.server.(*MockServerIface)
|
||||
mockServer.On("GetStore").Return(mockStore)
|
||||
|
||||
remoteCluster := &model.RemoteCluster{}
|
||||
msg := model.RemoteClusterMsg{}
|
||||
|
||||
err = scs.onReceiveChannelInvite(msg, remoteCluster, nil)
|
||||
require.NoError(t, err)
|
||||
mockStore.AssertNotCalled(t, "Channel")
|
||||
})
|
||||
|
||||
t.Run("when invitation prescribes a readonly channel, it does create a readonly channel", func(t *testing.T) {
|
||||
mockServer := &MockServerIface{}
|
||||
mockLogger, err := mlog.NewLogger()
|
||||
require.NoError(t, err)
|
||||
mockServer.On("Log").Return(mockLogger)
|
||||
mockApp := &MockAppIface{}
|
||||
scs := &Service{
|
||||
server: mockServer,
|
||||
app: mockApp,
|
||||
}
|
||||
|
||||
mockStore := &mocks.Store{}
|
||||
remoteCluster := &model.RemoteCluster{Name: "test"}
|
||||
invitation := channelInviteMsg{
|
||||
ChannelId: model.NewId(),
|
||||
TeamId: model.NewId(),
|
||||
ReadOnly: true,
|
||||
Type: "0",
|
||||
}
|
||||
payload, err := json.Marshal(invitation)
|
||||
require.NoError(t, err)
|
||||
|
||||
msg := model.RemoteClusterMsg{
|
||||
Payload: payload,
|
||||
}
|
||||
mockChannelStore := mocks.ChannelStore{}
|
||||
mockSharedChannelStore := mocks.SharedChannelStore{}
|
||||
channel := &model.Channel{}
|
||||
|
||||
mockChannelStore.On("Get", invitation.ChannelId, true).Return(channel, nil)
|
||||
mockSharedChannelStore.On("Save", mock.Anything).Return(nil, nil)
|
||||
mockSharedChannelStore.On("SaveRemote", mock.Anything).Return(nil, nil)
|
||||
mockStore.On("Channel").Return(&mockChannelStore)
|
||||
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
|
||||
|
||||
mockServer = scs.server.(*MockServerIface)
|
||||
mockServer.On("GetStore").Return(mockStore)
|
||||
createPostPermission := model.ChannelModeratedPermissionsMap[model.PermissionCreatePost.Id]
|
||||
createReactionPermission := model.ChannelModeratedPermissionsMap[model.PermissionAddReaction.Id]
|
||||
updateMap := model.ChannelModeratedRolesPatch{
|
||||
Guests: model.NewBool(false),
|
||||
Members: model.NewBool(false),
|
||||
}
|
||||
|
||||
readonlyChannelModerations := []*model.ChannelModerationPatch{
|
||||
{
|
||||
Name: &createPostPermission,
|
||||
Roles: &updateMap,
|
||||
},
|
||||
{
|
||||
Name: &createReactionPermission,
|
||||
Roles: &updateMap,
|
||||
},
|
||||
}
|
||||
mockApp.On("PatchChannelModerationsForChannel", mock.Anything, channel, readonlyChannelModerations).Return(nil, nil)
|
||||
defer mockApp.AssertExpectations(t)
|
||||
|
||||
err = scs.onReceiveChannelInvite(msg, remoteCluster, nil)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("when invitation prescribes a readonly channel and readonly update fails, it returns an error", func(t *testing.T) {
|
||||
mockServer := &MockServerIface{}
|
||||
mockLogger, err := mlog.NewLogger()
|
||||
require.NoError(t, err)
|
||||
mockServer.On("Log").Return(mockLogger)
|
||||
mockApp := &MockAppIface{}
|
||||
scs := &Service{
|
||||
server: mockServer,
|
||||
app: mockApp,
|
||||
}
|
||||
|
||||
mockStore := &mocks.Store{}
|
||||
remoteCluster := &model.RemoteCluster{Name: "test2"}
|
||||
invitation := channelInviteMsg{
|
||||
ChannelId: model.NewId(),
|
||||
TeamId: model.NewId(),
|
||||
ReadOnly: true,
|
||||
Type: "0",
|
||||
}
|
||||
payload, err := json.Marshal(invitation)
|
||||
require.NoError(t, err)
|
||||
|
||||
msg := model.RemoteClusterMsg{
|
||||
Payload: payload,
|
||||
}
|
||||
mockChannelStore := mocks.ChannelStore{}
|
||||
channel := &model.Channel{}
|
||||
|
||||
mockChannelStore.On("Get", invitation.ChannelId, true).Return(channel, nil)
|
||||
mockStore.On("Channel").Return(&mockChannelStore)
|
||||
|
||||
mockServer = scs.server.(*MockServerIface)
|
||||
mockServer.On("GetStore").Return(mockStore)
|
||||
appErr := model.NewAppError("foo", "bar", nil, "boom", http.StatusBadRequest)
|
||||
|
||||
mockApp.On("PatchChannelModerationsForChannel", mock.Anything, channel, mock.Anything).Return(nil, appErr)
|
||||
defer mockApp.AssertExpectations(t)
|
||||
|
||||
err = scs.onReceiveChannelInvite(msg, remoteCluster, nil)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, fmt.Sprintf("cannot make channel readonly `%s`: foo: bar, boom", invitation.ChannelId), err.Error())
|
||||
})
|
||||
|
||||
t.Run("when invitation prescribes a direct channel, it does create a direct channel", func(t *testing.T) {
|
||||
mockServer := &MockServerIface{}
|
||||
mockLogger, err := mlog.NewLogger()
|
||||
require.NoError(t, err)
|
||||
mockServer.On("Log").Return(mockLogger)
|
||||
mockApp := &MockAppIface{}
|
||||
scs := &Service{
|
||||
server: mockServer,
|
||||
app: mockApp,
|
||||
}
|
||||
|
||||
mockStore := &mocks.Store{}
|
||||
remoteCluster := &model.RemoteCluster{Name: "test3", CreatorId: model.NewId()}
|
||||
invitation := channelInviteMsg{
|
||||
ChannelId: model.NewId(),
|
||||
TeamId: model.NewId(),
|
||||
ReadOnly: false,
|
||||
Type: model.ChannelTypeDirect,
|
||||
DirectParticipantIDs: []string{model.NewId(), model.NewId()},
|
||||
}
|
||||
payload, err := json.Marshal(invitation)
|
||||
require.NoError(t, err)
|
||||
|
||||
msg := model.RemoteClusterMsg{
|
||||
Payload: payload,
|
||||
}
|
||||
mockChannelStore := mocks.ChannelStore{}
|
||||
mockSharedChannelStore := mocks.SharedChannelStore{}
|
||||
channel := &model.Channel{}
|
||||
|
||||
mockChannelStore.On("Get", invitation.ChannelId, true).Return(nil, errors.New("boom"))
|
||||
mockSharedChannelStore.On("Save", mock.Anything).Return(nil, nil)
|
||||
mockSharedChannelStore.On("SaveRemote", mock.Anything).Return(nil, nil)
|
||||
mockStore.On("Channel").Return(&mockChannelStore)
|
||||
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
|
||||
|
||||
mockServer = scs.server.(*MockServerIface)
|
||||
mockServer.On("GetStore").Return(mockStore)
|
||||
|
||||
mockApp.On("GetOrCreateDirectChannel", mock.AnythingOfType("*request.Context"), invitation.DirectParticipantIDs[0], invitation.DirectParticipantIDs[1], mock.AnythingOfType("model.ChannelOption")).Return(channel, nil)
|
||||
defer mockApp.AssertExpectations(t)
|
||||
|
||||
err = scs.onReceiveChannelInvite(msg, remoteCluster, nil)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
398
server/platform/services/sharedchannel/mock_AppIface_test.go
Обычный файл
398
server/platform/services/sharedchannel/mock_AppIface_test.go
Обычный файл
@@ -0,0 +1,398 @@
|
||||
// Code generated by mockery v2.10.4. DO NOT EDIT.
|
||||
|
||||
// Regenerate this file using `make sharedchannel-mocks`.
|
||||
|
||||
package sharedchannel
|
||||
|
||||
import (
|
||||
filestore "github.com/mattermost/mattermost-server/v6/server/platform/shared/filestore"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
|
||||
model "github.com/mattermost/mattermost-server/v6/model"
|
||||
|
||||
request "github.com/mattermost/mattermost-server/v6/server/channels/app/request"
|
||||
)
|
||||
|
||||
// MockAppIface is an autogenerated mock type for the AppIface type
|
||||
type MockAppIface struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// AddUserToChannel provides a mock function with given fields: c, user, channel, skipTeamMemberIntegrityCheck
|
||||
func (_m *MockAppIface) AddUserToChannel(c request.CTX, user *model.User, channel *model.Channel, skipTeamMemberIntegrityCheck bool) (*model.ChannelMember, *model.AppError) {
|
||||
ret := _m.Called(c, user, channel, skipTeamMemberIntegrityCheck)
|
||||
|
||||
var r0 *model.ChannelMember
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, *model.User, *model.Channel, bool) *model.ChannelMember); ok {
|
||||
r0 = rf(c, user, channel, skipTeamMemberIntegrityCheck)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.ChannelMember)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, *model.User, *model.Channel, bool) *model.AppError); ok {
|
||||
r1 = rf(c, user, channel, skipTeamMemberIntegrityCheck)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// AddUserToTeamByTeamId provides a mock function with given fields: c, teamId, user
|
||||
func (_m *MockAppIface) AddUserToTeamByTeamId(c *request.Context, teamId string, user *model.User) *model.AppError {
|
||||
ret := _m.Called(c, teamId, user)
|
||||
|
||||
var r0 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(*request.Context, string, *model.User) *model.AppError); ok {
|
||||
r0 = rf(c, teamId, user)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// CreateChannelWithUser provides a mock function with given fields: c, channel, userId
|
||||
func (_m *MockAppIface) CreateChannelWithUser(c request.CTX, channel *model.Channel, userId string) (*model.Channel, *model.AppError) {
|
||||
ret := _m.Called(c, channel, userId)
|
||||
|
||||
var r0 *model.Channel
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, *model.Channel, string) *model.Channel); ok {
|
||||
r0 = rf(c, channel, userId)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.Channel)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, *model.Channel, string) *model.AppError); ok {
|
||||
r1 = rf(c, channel, userId)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// CreatePost provides a mock function with given fields: c, post, channel, triggerWebhooks, setOnline
|
||||
func (_m *MockAppIface) CreatePost(c request.CTX, post *model.Post, channel *model.Channel, triggerWebhooks bool, setOnline bool) (*model.Post, *model.AppError) {
|
||||
ret := _m.Called(c, post, channel, triggerWebhooks, setOnline)
|
||||
|
||||
var r0 *model.Post
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, *model.Post, *model.Channel, bool, bool) *model.Post); ok {
|
||||
r0 = rf(c, post, channel, triggerWebhooks, setOnline)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.Post)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, *model.Post, *model.Channel, bool, bool) *model.AppError); ok {
|
||||
r1 = rf(c, post, channel, triggerWebhooks, setOnline)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// CreateUploadSession provides a mock function with given fields: c, us
|
||||
func (_m *MockAppIface) CreateUploadSession(c request.CTX, us *model.UploadSession) (*model.UploadSession, *model.AppError) {
|
||||
ret := _m.Called(c, us)
|
||||
|
||||
var r0 *model.UploadSession
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, *model.UploadSession) *model.UploadSession); ok {
|
||||
r0 = rf(c, us)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.UploadSession)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, *model.UploadSession) *model.AppError); ok {
|
||||
r1 = rf(c, us)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// DeletePost provides a mock function with given fields: c, postID, deleteByID
|
||||
func (_m *MockAppIface) DeletePost(c request.CTX, postID string, deleteByID string) (*model.Post, *model.AppError) {
|
||||
ret := _m.Called(c, postID, deleteByID)
|
||||
|
||||
var r0 *model.Post
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string, string) *model.Post); ok {
|
||||
r0 = rf(c, postID, deleteByID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.Post)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, string, string) *model.AppError); ok {
|
||||
r1 = rf(c, postID, deleteByID)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// DeleteReactionForPost provides a mock function with given fields: c, reaction
|
||||
func (_m *MockAppIface) DeleteReactionForPost(c *request.Context, reaction *model.Reaction) *model.AppError {
|
||||
ret := _m.Called(c, reaction)
|
||||
|
||||
var r0 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(*request.Context, *model.Reaction) *model.AppError); ok {
|
||||
r0 = rf(c, reaction)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// FileReader provides a mock function with given fields: path
|
||||
func (_m *MockAppIface) FileReader(path string) (filestore.ReadCloseSeeker, *model.AppError) {
|
||||
ret := _m.Called(path)
|
||||
|
||||
var r0 filestore.ReadCloseSeeker
|
||||
if rf, ok := ret.Get(0).(func(string) filestore.ReadCloseSeeker); ok {
|
||||
r0 = rf(path)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(filestore.ReadCloseSeeker)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(1).(func(string) *model.AppError); ok {
|
||||
r1 = rf(path)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetOrCreateDirectChannel provides a mock function with given fields: c, userId, otherUserId, channelOptions
|
||||
func (_m *MockAppIface) GetOrCreateDirectChannel(c request.CTX, userId string, otherUserId string, channelOptions ...model.ChannelOption) (*model.Channel, *model.AppError) {
|
||||
_va := make([]interface{}, len(channelOptions))
|
||||
for _i := range channelOptions {
|
||||
_va[_i] = channelOptions[_i]
|
||||
}
|
||||
var _ca []interface{}
|
||||
_ca = append(_ca, c, userId, otherUserId)
|
||||
_ca = append(_ca, _va...)
|
||||
ret := _m.Called(_ca...)
|
||||
|
||||
var r0 *model.Channel
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string, string, ...model.ChannelOption) *model.Channel); ok {
|
||||
r0 = rf(c, userId, otherUserId, channelOptions...)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.Channel)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, string, string, ...model.ChannelOption) *model.AppError); ok {
|
||||
r1 = rf(c, userId, otherUserId, channelOptions...)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetProfileImage provides a mock function with given fields: user
|
||||
func (_m *MockAppIface) GetProfileImage(user *model.User) ([]byte, bool, *model.AppError) {
|
||||
ret := _m.Called(user)
|
||||
|
||||
var r0 []byte
|
||||
if rf, ok := ret.Get(0).(func(*model.User) []byte); ok {
|
||||
r0 = rf(user)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]byte)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 bool
|
||||
if rf, ok := ret.Get(1).(func(*model.User) bool); ok {
|
||||
r1 = rf(user)
|
||||
} else {
|
||||
r1 = ret.Get(1).(bool)
|
||||
}
|
||||
|
||||
var r2 *model.AppError
|
||||
if rf, ok := ret.Get(2).(func(*model.User) *model.AppError); ok {
|
||||
r2 = rf(user)
|
||||
} else {
|
||||
if ret.Get(2) != nil {
|
||||
r2 = ret.Get(2).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1, r2
|
||||
}
|
||||
|
||||
// InvalidateCacheForUser provides a mock function with given fields: userID
|
||||
func (_m *MockAppIface) InvalidateCacheForUser(userID string) {
|
||||
_m.Called(userID)
|
||||
}
|
||||
|
||||
// MentionsToTeamMembers provides a mock function with given fields: c, message, teamID
|
||||
func (_m *MockAppIface) MentionsToTeamMembers(c request.CTX, message string, teamID string) model.UserMentionMap {
|
||||
ret := _m.Called(c, message, teamID)
|
||||
|
||||
var r0 model.UserMentionMap
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string, string) model.UserMentionMap); ok {
|
||||
r0 = rf(c, message, teamID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(model.UserMentionMap)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// NotifySharedChannelUserUpdate provides a mock function with given fields: user
|
||||
func (_m *MockAppIface) NotifySharedChannelUserUpdate(user *model.User) {
|
||||
_m.Called(user)
|
||||
}
|
||||
|
||||
// PatchChannelModerationsForChannel provides a mock function with given fields: c, channel, channelModerationsPatch
|
||||
func (_m *MockAppIface) PatchChannelModerationsForChannel(c request.CTX, channel *model.Channel, channelModerationsPatch []*model.ChannelModerationPatch) ([]*model.ChannelModeration, *model.AppError) {
|
||||
ret := _m.Called(c, channel, channelModerationsPatch)
|
||||
|
||||
var r0 []*model.ChannelModeration
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, *model.Channel, []*model.ChannelModerationPatch) []*model.ChannelModeration); ok {
|
||||
r0 = rf(c, channel, channelModerationsPatch)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*model.ChannelModeration)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, *model.Channel, []*model.ChannelModerationPatch) *model.AppError); ok {
|
||||
r1 = rf(c, channel, channelModerationsPatch)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// PermanentDeleteChannel provides a mock function with given fields: c, channel
|
||||
func (_m *MockAppIface) PermanentDeleteChannel(c request.CTX, channel *model.Channel) *model.AppError {
|
||||
ret := _m.Called(c, channel)
|
||||
|
||||
var r0 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, *model.Channel) *model.AppError); ok {
|
||||
r0 = rf(c, channel)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// SaveReactionForPost provides a mock function with given fields: c, reaction
|
||||
func (_m *MockAppIface) SaveReactionForPost(c *request.Context, reaction *model.Reaction) (*model.Reaction, *model.AppError) {
|
||||
ret := _m.Called(c, reaction)
|
||||
|
||||
var r0 *model.Reaction
|
||||
if rf, ok := ret.Get(0).(func(*request.Context, *model.Reaction) *model.Reaction); ok {
|
||||
r0 = rf(c, reaction)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.Reaction)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(1).(func(*request.Context, *model.Reaction) *model.AppError); ok {
|
||||
r1 = rf(c, reaction)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// SendEphemeralPost provides a mock function with given fields: c, userId, post
|
||||
func (_m *MockAppIface) SendEphemeralPost(c request.CTX, userId string, post *model.Post) *model.Post {
|
||||
ret := _m.Called(c, userId, post)
|
||||
|
||||
var r0 *model.Post
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string, *model.Post) *model.Post); ok {
|
||||
r0 = rf(c, userId, post)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.Post)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// UpdatePost provides a mock function with given fields: c, post, safeUpdate
|
||||
func (_m *MockAppIface) UpdatePost(c *request.Context, post *model.Post, safeUpdate bool) (*model.Post, *model.AppError) {
|
||||
ret := _m.Called(c, post, safeUpdate)
|
||||
|
||||
var r0 *model.Post
|
||||
if rf, ok := ret.Get(0).(func(*request.Context, *model.Post, bool) *model.Post); ok {
|
||||
r0 = rf(c, post, safeUpdate)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.Post)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(1).(func(*request.Context, *model.Post, bool) *model.AppError); ok {
|
||||
r1 = rf(c, post, safeUpdate)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
118
server/platform/services/sharedchannel/mock_ServerIface_test.go
Обычный файл
118
server/platform/services/sharedchannel/mock_ServerIface_test.go
Обычный файл
@@ -0,0 +1,118 @@
|
||||
// Code generated by mockery v2.10.4. DO NOT EDIT.
|
||||
|
||||
// Regenerate this file using `make sharedchannel-mocks`.
|
||||
|
||||
package sharedchannel
|
||||
|
||||
import (
|
||||
mlog "github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
|
||||
model "github.com/mattermost/mattermost-server/v6/model"
|
||||
|
||||
remotecluster "github.com/mattermost/mattermost-server/v6/server/platform/services/remotecluster"
|
||||
|
||||
store "github.com/mattermost/mattermost-server/v6/server/channels/store"
|
||||
)
|
||||
|
||||
// MockServerIface is an autogenerated mock type for the ServerIface type
|
||||
type MockServerIface struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// AddClusterLeaderChangedListener provides a mock function with given fields: listener
|
||||
func (_m *MockServerIface) AddClusterLeaderChangedListener(listener func()) string {
|
||||
ret := _m.Called(listener)
|
||||
|
||||
var r0 string
|
||||
if rf, ok := ret.Get(0).(func(func()) string); ok {
|
||||
r0 = rf(listener)
|
||||
} else {
|
||||
r0 = ret.Get(0).(string)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Config provides a mock function with given fields:
|
||||
func (_m *MockServerIface) Config() *model.Config {
|
||||
ret := _m.Called()
|
||||
|
||||
var r0 *model.Config
|
||||
if rf, ok := ret.Get(0).(func() *model.Config); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.Config)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// GetRemoteClusterService provides a mock function with given fields:
|
||||
func (_m *MockServerIface) GetRemoteClusterService() remotecluster.RemoteClusterServiceIFace {
|
||||
ret := _m.Called()
|
||||
|
||||
var r0 remotecluster.RemoteClusterServiceIFace
|
||||
if rf, ok := ret.Get(0).(func() remotecluster.RemoteClusterServiceIFace); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(remotecluster.RemoteClusterServiceIFace)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// GetStore provides a mock function with given fields:
|
||||
func (_m *MockServerIface) GetStore() store.Store {
|
||||
ret := _m.Called()
|
||||
|
||||
var r0 store.Store
|
||||
if rf, ok := ret.Get(0).(func() store.Store); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(store.Store)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// IsLeader provides a mock function with given fields:
|
||||
func (_m *MockServerIface) IsLeader() bool {
|
||||
ret := _m.Called()
|
||||
|
||||
var r0 bool
|
||||
if rf, ok := ret.Get(0).(func() bool); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
r0 = ret.Get(0).(bool)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Log provides a mock function with given fields:
|
||||
func (_m *MockServerIface) Log() *mlog.Logger {
|
||||
ret := _m.Called()
|
||||
|
||||
var r0 *mlog.Logger
|
||||
if rf, ok := ret.Get(0).(func() *mlog.Logger); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*mlog.Logger)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// RemoveClusterLeaderChangedListener provides a mock function with given fields: id
|
||||
func (_m *MockServerIface) RemoveClusterLeaderChangedListener(id string) {
|
||||
_m.Called(id)
|
||||
}
|
||||
43
server/platform/services/sharedchannel/msg.go
Обычный файл
43
server/platform/services/sharedchannel/msg.go
Обычный файл
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sharedchannel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
// syncMsg represents a change in content (post add/edit/delete, reaction add/remove, users).
|
||||
// It is sent to remote clusters as the payload of a `RemoteClusterMsg`.
|
||||
type syncMsg struct {
|
||||
Id string `json:"id"`
|
||||
ChannelId string `json:"channel_id"`
|
||||
Users map[string]*model.User `json:"users,omitempty"`
|
||||
Posts []*model.Post `json:"posts,omitempty"`
|
||||
Reactions []*model.Reaction `json:"reactions,omitempty"`
|
||||
}
|
||||
|
||||
func newSyncMsg(channelID string) *syncMsg {
|
||||
return &syncMsg{
|
||||
Id: model.NewId(),
|
||||
ChannelId: channelID,
|
||||
}
|
||||
}
|
||||
|
||||
func (sm *syncMsg) ToJSON() ([]byte, error) {
|
||||
b, err := json.Marshal(sm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (sm *syncMsg) String() string {
|
||||
json, err := sm.ToJSON()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(json)
|
||||
}
|
||||
84
server/platform/services/sharedchannel/permalink.go
Обычный файл
84
server/platform/services/sharedchannel/permalink.go
Обычный файл
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sharedchannel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/i18n"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
var (
|
||||
// Team name regex taken from model.IsValidTeamName
|
||||
permaLinkRegex = regexp.MustCompile(`https?://[0-9.\-A-Za-z]+/[a-z0-9]+([a-z\-0-9]+|(__)?)[a-z0-9]+/pl/([a-zA-Z0-9]+)`)
|
||||
permaLinkSharedRegex = regexp.MustCompile(`https?://[0-9.\-A-Za-z]+/[a-z0-9]+([a-z\-0-9]+|(__)?)[a-z0-9]+/plshared/([a-zA-Z0-9]+)`)
|
||||
)
|
||||
|
||||
const (
|
||||
permalinkMarker = "plshared"
|
||||
)
|
||||
|
||||
// processPermalinkToRemote processes all permalinks going towards a remote site.
|
||||
func (scs *Service) processPermalinkToRemote(p *model.Post) string {
|
||||
var sent bool
|
||||
return permaLinkRegex.ReplaceAllStringFunc(p.Message, func(msg string) string {
|
||||
// Extract the postID (This is simple enough not to warrant full-blown URL parsing.)
|
||||
lastSlash := strings.LastIndexByte(msg, '/')
|
||||
postID := msg[lastSlash+1:]
|
||||
opts := model.GetPostsOptions{
|
||||
SkipFetchThreads: true,
|
||||
}
|
||||
postList, err := scs.server.GetStore().Post().Get(context.Background(), postID, opts, "", map[string]bool{})
|
||||
if err != nil {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceWarn, "Unable to get post during replacing permalinks", mlog.Err(err))
|
||||
return msg
|
||||
}
|
||||
if len(postList.Order) == 0 {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceWarn, "No post found for permalink", mlog.String("postID", postID))
|
||||
return msg
|
||||
}
|
||||
|
||||
// If postID is for a different channel
|
||||
if postList.Posts[postList.Order[0]].ChannelId != p.ChannelId {
|
||||
// Send ephemeral message to OP (only once per message).
|
||||
if !sent {
|
||||
scs.sendEphemeralPost(p.ChannelId, p.UserId, i18n.T("sharedchannel.permalink.not_found"))
|
||||
sent = true
|
||||
}
|
||||
// But don't modify msg
|
||||
return msg
|
||||
}
|
||||
|
||||
// Otherwise, modify pl to plshared as a marker to be replaced by remote sites
|
||||
return strings.Replace(msg, "/pl/", "/"+permalinkMarker+"/", 1)
|
||||
})
|
||||
}
|
||||
|
||||
// processPermalinkFromRemote processes all permalinks coming from a remote site.
|
||||
func (scs *Service) processPermalinkFromRemote(p *model.Post, team *model.Team) string {
|
||||
return permaLinkSharedRegex.ReplaceAllStringFunc(p.Message, func(remoteLink string) string {
|
||||
// Extract host name
|
||||
parsed, err := url.Parse(remoteLink)
|
||||
if err != nil {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceWarn, "Unable to parse the remote link during replacing permalinks", mlog.Err(err))
|
||||
return remoteLink
|
||||
}
|
||||
|
||||
// Replace with local SiteURL
|
||||
parsed.Scheme = scs.siteURL.Scheme
|
||||
parsed.Host = scs.siteURL.Host
|
||||
|
||||
// Replace team name with local team
|
||||
teamEnd := strings.Index(parsed.Path, "/"+permalinkMarker)
|
||||
parsed.Path = "/" + team.Name + parsed.Path[teamEnd:]
|
||||
|
||||
// Replace plshared with pl
|
||||
return strings.Replace(parsed.String(), "/"+permalinkMarker+"/", "/pl/", 1)
|
||||
})
|
||||
}
|
||||
112
server/platform/services/sharedchannel/permalink_test.go
Обычный файл
112
server/platform/services/sharedchannel/permalink_test.go
Обычный файл
@@ -0,0 +1,112 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sharedchannel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/plugin/plugintest/mock"
|
||||
"github.com/mattermost/mattermost-server/v6/server/channels/store/storetest/mocks"
|
||||
"github.com/mattermost/mattermost-server/v6/server/channels/utils"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func TestProcessPermalinkToRemote(t *testing.T) {
|
||||
scs := &Service{
|
||||
server: &MockServerIface{},
|
||||
app: &MockAppIface{},
|
||||
}
|
||||
|
||||
mockStore := &mocks.Store{}
|
||||
mockPostStore := mocks.PostStore{}
|
||||
utils.TranslationsPreInit()
|
||||
|
||||
pl := &model.PostList{}
|
||||
mockPostStore.On("Get", context.Background(), "postID", model.GetPostsOptions{SkipFetchThreads: true}, "", map[string]bool{}).Return(pl, nil)
|
||||
|
||||
mockStore.On("Post").Return(&mockPostStore)
|
||||
|
||||
mockServer := scs.server.(*MockServerIface)
|
||||
mockServer.On("GetStore").Return(mockStore)
|
||||
mockServer.On("Log").Return(mlog.NewLogger())
|
||||
|
||||
mockApp := scs.app.(*MockAppIface)
|
||||
mockApp.On("SendEphemeralPost", mock.Anything, "user", mock.AnythingOfType("*model.Post")).Return(&model.Post{}).Times(1)
|
||||
defer mockApp.AssertExpectations(t)
|
||||
|
||||
t.Run("same channel", func(t *testing.T) {
|
||||
post := &model.Post{
|
||||
Message: "hello world https://comm.matt.com/team/pl/postID link",
|
||||
ChannelId: "sourceChan",
|
||||
UserId: "user",
|
||||
}
|
||||
|
||||
*pl = model.PostList{
|
||||
Order: []string{"1"},
|
||||
Posts: map[string]*model.Post{
|
||||
"1": {
|
||||
ChannelId: "sourceChan",
|
||||
UserId: "user",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out := scs.processPermalinkToRemote(post)
|
||||
assert.Equal(t, "hello world https://comm.matt.com/team/plshared/postID link", out)
|
||||
})
|
||||
|
||||
t.Run("different channel", func(t *testing.T) {
|
||||
post := &model.Post{
|
||||
Message: "hello world https://comm.matt.com/team/pl/postID link https://comm.matt.com/team/pl/postID ",
|
||||
ChannelId: "sourceChan",
|
||||
UserId: "user",
|
||||
}
|
||||
|
||||
*pl = model.PostList{
|
||||
Order: []string{"1"},
|
||||
Posts: map[string]*model.Post{
|
||||
"1": {
|
||||
ChannelId: "otherChan",
|
||||
},
|
||||
},
|
||||
}
|
||||
out := scs.processPermalinkToRemote(post)
|
||||
assert.Equal(t, "hello world https://comm.matt.com/team/pl/postID link https://comm.matt.com/team/pl/postID ", out)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProcessPermalinkFromRemote(t *testing.T) {
|
||||
t.Run("has match", func(t *testing.T) {
|
||||
parsed, _ := url.Parse("http://mysite.com")
|
||||
scs := &Service{
|
||||
server: &MockServerIface{},
|
||||
siteURL: parsed,
|
||||
}
|
||||
|
||||
out := scs.processPermalinkFromRemote(&model.Post{Message: "hello world https://comm.matt.com/team/plshared/postID link"},
|
||||
&model.Team{Name: "myteam"})
|
||||
assert.Equal(t,
|
||||
"hello world http://mysite.com/myteam/pl/postID link",
|
||||
out)
|
||||
})
|
||||
|
||||
t.Run("does not match", func(t *testing.T) {
|
||||
parsed, _ := url.Parse("http://mysite.com")
|
||||
scs := &Service{
|
||||
server: &MockServerIface{},
|
||||
siteURL: parsed,
|
||||
}
|
||||
|
||||
out := scs.processPermalinkFromRemote(&model.Post{Message: "hello world https://comm.matt.com/team/pl/postID link"},
|
||||
&model.Team{Name: "myteam"})
|
||||
assert.Equal(t,
|
||||
"hello world https://comm.matt.com/team/pl/postID link",
|
||||
out)
|
||||
})
|
||||
}
|
||||
16
server/platform/services/sharedchannel/response.go
Обычный файл
16
server/platform/services/sharedchannel/response.go
Обычный файл
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sharedchannel
|
||||
|
||||
type SyncResponse struct {
|
||||
UsersLastUpdateAt int64 `json:"users_last_update_at"`
|
||||
UserErrors []string `json:"user_errors"`
|
||||
UsersSyncd []string `json:"users_syncd"`
|
||||
|
||||
PostsLastUpdateAt int64 `json:"posts_last_update_at"`
|
||||
PostErrors []string `json:"post_errors"`
|
||||
|
||||
ReactionsLastUpdateAt int64 `json:"reactions_last_update_at"`
|
||||
ReactionErrors []string `json:"reaction_errors"`
|
||||
}
|
||||
249
server/platform/services/sharedchannel/service.go
Обычный файл
249
server/platform/services/sharedchannel/service.go
Обычный файл
@@ -0,0 +1,249 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sharedchannel
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/channels/app/request"
|
||||
"github.com/mattermost/mattermost-server/v6/server/channels/store"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/services/remotecluster"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/filestore"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
TopicSync = "sharedchannel_sync"
|
||||
TopicChannelInvite = "sharedchannel_invite"
|
||||
TopicUploadCreate = "sharedchannel_upload"
|
||||
MaxRetries = 3
|
||||
MaxPostsPerSync = 12 // a bit more than one typical screenfull of posts
|
||||
MaxUsersPerSync = 25
|
||||
NotifyRemoteOfflineThreshold = time.Second * 10
|
||||
NotifyMinimumDelay = time.Second * 2
|
||||
MaxUpsertRetries = 25
|
||||
ProfileImageSyncTimeout = time.Second * 5
|
||||
KeyRemoteUsername = "RemoteUsername"
|
||||
KeyRemoteEmail = "RemoteEmail"
|
||||
)
|
||||
|
||||
// Mocks can be re-generated with `make sharedchannel-mocks`.
|
||||
type ServerIface interface {
|
||||
Config() *model.Config
|
||||
IsLeader() bool
|
||||
AddClusterLeaderChangedListener(listener func()) string
|
||||
RemoveClusterLeaderChangedListener(id string)
|
||||
GetStore() store.Store
|
||||
Log() *mlog.Logger
|
||||
GetRemoteClusterService() remotecluster.RemoteClusterServiceIFace
|
||||
}
|
||||
|
||||
type AppIface interface {
|
||||
SendEphemeralPost(c request.CTX, userId string, post *model.Post) *model.Post
|
||||
CreateChannelWithUser(c request.CTX, channel *model.Channel, userId string) (*model.Channel, *model.AppError)
|
||||
GetOrCreateDirectChannel(c request.CTX, userId, otherUserId string, channelOptions ...model.ChannelOption) (*model.Channel, *model.AppError)
|
||||
AddUserToChannel(c request.CTX, user *model.User, channel *model.Channel, skipTeamMemberIntegrityCheck bool) (*model.ChannelMember, *model.AppError)
|
||||
AddUserToTeamByTeamId(c *request.Context, teamId string, user *model.User) *model.AppError
|
||||
PermanentDeleteChannel(c request.CTX, channel *model.Channel) *model.AppError
|
||||
CreatePost(c request.CTX, post *model.Post, channel *model.Channel, triggerWebhooks bool, setOnline bool) (savedPost *model.Post, err *model.AppError)
|
||||
UpdatePost(c *request.Context, post *model.Post, safeUpdate bool) (*model.Post, *model.AppError)
|
||||
DeletePost(c request.CTX, postID, deleteByID string) (*model.Post, *model.AppError)
|
||||
SaveReactionForPost(c *request.Context, reaction *model.Reaction) (*model.Reaction, *model.AppError)
|
||||
DeleteReactionForPost(c *request.Context, reaction *model.Reaction) *model.AppError
|
||||
PatchChannelModerationsForChannel(c request.CTX, channel *model.Channel, channelModerationsPatch []*model.ChannelModerationPatch) ([]*model.ChannelModeration, *model.AppError)
|
||||
CreateUploadSession(c request.CTX, us *model.UploadSession) (*model.UploadSession, *model.AppError)
|
||||
FileReader(path string) (filestore.ReadCloseSeeker, *model.AppError)
|
||||
MentionsToTeamMembers(c request.CTX, message, teamID string) model.UserMentionMap
|
||||
GetProfileImage(user *model.User) ([]byte, bool, *model.AppError)
|
||||
InvalidateCacheForUser(userID string)
|
||||
NotifySharedChannelUserUpdate(user *model.User)
|
||||
}
|
||||
|
||||
// errNotFound allows checking against Store.ErrNotFound errors without making Store a dependency.
|
||||
type errNotFound interface {
|
||||
IsErrNotFound() bool
|
||||
}
|
||||
|
||||
// errInvalidInput allows checking against Store.ErrInvalidInput errors without making Store a dependency.
|
||||
type errInvalidInput interface {
|
||||
InvalidInputInfo() (entity string, field string, value any)
|
||||
}
|
||||
|
||||
// Service provides shared channel synchronization.
|
||||
type Service struct {
|
||||
server ServerIface
|
||||
app AppIface
|
||||
changeSignal chan struct{}
|
||||
|
||||
// everything below guarded by `mux`
|
||||
mux sync.RWMutex
|
||||
active bool
|
||||
leaderListenerId string
|
||||
connectionStateListenerId string
|
||||
done chan struct{}
|
||||
tasks map[string]syncTask
|
||||
syncTopicListenerId string
|
||||
inviteTopicListenerId string
|
||||
uploadTopicListenerId string
|
||||
siteURL *url.URL
|
||||
}
|
||||
|
||||
// NewSharedChannelService creates a RemoteClusterService instance.
|
||||
func NewSharedChannelService(server ServerIface, app AppIface) (*Service, error) {
|
||||
service := &Service{
|
||||
server: server,
|
||||
app: app,
|
||||
changeSignal: make(chan struct{}, 1),
|
||||
tasks: make(map[string]syncTask),
|
||||
}
|
||||
parsed, err := url.Parse(*server.Config().ServiceSettings.SiteURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to parse SiteURL: %w", err)
|
||||
}
|
||||
service.siteURL = parsed
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// Start is called by the server on server start-up.
|
||||
func (scs *Service) Start() error {
|
||||
rcs := scs.server.GetRemoteClusterService()
|
||||
if rcs == nil {
|
||||
return errors.New("Shared Channel Service cannot activate: requires Remote Cluster Service")
|
||||
}
|
||||
|
||||
scs.mux.Lock()
|
||||
scs.leaderListenerId = scs.server.AddClusterLeaderChangedListener(scs.onClusterLeaderChange)
|
||||
scs.syncTopicListenerId = rcs.AddTopicListener(TopicSync, scs.onReceiveSyncMessage)
|
||||
scs.inviteTopicListenerId = rcs.AddTopicListener(TopicChannelInvite, scs.onReceiveChannelInvite)
|
||||
scs.uploadTopicListenerId = rcs.AddTopicListener(TopicUploadCreate, scs.onReceiveUploadCreate)
|
||||
scs.connectionStateListenerId = rcs.AddConnectionStateListener(scs.onConnectionStateChange)
|
||||
scs.mux.Unlock()
|
||||
|
||||
scs.onClusterLeaderChange()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown is called by the server on server shutdown.
|
||||
func (scs *Service) Shutdown() error {
|
||||
rcs := scs.server.GetRemoteClusterService()
|
||||
if rcs == nil {
|
||||
return errors.New("Shared Channel Service cannot shutdown: requires Remote Cluster Service")
|
||||
}
|
||||
|
||||
scs.mux.Lock()
|
||||
id := scs.leaderListenerId
|
||||
rcs.RemoveTopicListener(scs.syncTopicListenerId)
|
||||
scs.syncTopicListenerId = ""
|
||||
rcs.RemoveTopicListener(scs.inviteTopicListenerId)
|
||||
scs.inviteTopicListenerId = ""
|
||||
rcs.RemoveConnectionStateListener(scs.connectionStateListenerId)
|
||||
scs.connectionStateListenerId = ""
|
||||
scs.mux.Unlock()
|
||||
|
||||
scs.server.RemoveClusterLeaderChangedListener(id)
|
||||
scs.pause()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Active determines whether the service is active on the node or not.
|
||||
func (scs *Service) Active() bool {
|
||||
scs.mux.Lock()
|
||||
defer scs.mux.Unlock()
|
||||
|
||||
return scs.active
|
||||
}
|
||||
|
||||
func (scs *Service) sendEphemeralPost(channelId string, userId string, text string) {
|
||||
ephemeral := &model.Post{
|
||||
ChannelId: channelId,
|
||||
Message: text,
|
||||
CreateAt: model.GetMillis(),
|
||||
}
|
||||
scs.app.SendEphemeralPost(request.EmptyContext(scs.server.Log()), userId, ephemeral)
|
||||
}
|
||||
|
||||
// onClusterLeaderChange is called whenever the cluster leader may have changed.
|
||||
func (scs *Service) onClusterLeaderChange() {
|
||||
if scs.server.IsLeader() {
|
||||
scs.resume()
|
||||
} else {
|
||||
scs.pause()
|
||||
}
|
||||
}
|
||||
|
||||
func (scs *Service) resume() {
|
||||
scs.mux.Lock()
|
||||
defer scs.mux.Unlock()
|
||||
|
||||
if scs.active {
|
||||
return // already active
|
||||
}
|
||||
|
||||
scs.active = true
|
||||
scs.done = make(chan struct{})
|
||||
|
||||
go scs.syncLoop(scs.done)
|
||||
|
||||
scs.server.Log().Debug("Shared Channel Service active")
|
||||
}
|
||||
|
||||
func (scs *Service) pause() {
|
||||
scs.mux.Lock()
|
||||
defer scs.mux.Unlock()
|
||||
|
||||
if !scs.active {
|
||||
return // already inactive
|
||||
}
|
||||
|
||||
scs.active = false
|
||||
close(scs.done)
|
||||
scs.done = nil
|
||||
|
||||
scs.server.Log().Debug("Shared Channel Service inactive")
|
||||
}
|
||||
|
||||
// Makes the remote channel to be read-only(announcement mode, only admins can create posts and reactions).
|
||||
func (scs *Service) makeChannelReadOnly(channel *model.Channel) *model.AppError {
|
||||
createPostPermission := model.ChannelModeratedPermissionsMap[model.PermissionCreatePost.Id]
|
||||
createReactionPermission := model.ChannelModeratedPermissionsMap[model.PermissionAddReaction.Id]
|
||||
updateMap := model.ChannelModeratedRolesPatch{
|
||||
Guests: model.NewBool(false),
|
||||
Members: model.NewBool(false),
|
||||
}
|
||||
|
||||
readonlyChannelModerations := []*model.ChannelModerationPatch{
|
||||
{
|
||||
Name: &createPostPermission,
|
||||
Roles: &updateMap,
|
||||
},
|
||||
{
|
||||
Name: &createReactionPermission,
|
||||
Roles: &updateMap,
|
||||
},
|
||||
}
|
||||
|
||||
_, err := scs.app.PatchChannelModerationsForChannel(request.EmptyContext(scs.server.Log()), channel, readonlyChannelModerations)
|
||||
return err
|
||||
}
|
||||
|
||||
// onConnectionStateChange is called whenever the connection state of a remote cluster changes,
|
||||
// for example when one comes back online.
|
||||
func (scs *Service) onConnectionStateChange(rc *model.RemoteCluster, online bool) {
|
||||
if online {
|
||||
// when a previously offline remote comes back online force a sync.
|
||||
scs.ForceSyncForRemote(rc)
|
||||
}
|
||||
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Remote cluster connection status changed",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.String("remoteId", rc.RemoteId),
|
||||
mlog.Bool("online", online),
|
||||
)
|
||||
}
|
||||
399
server/platform/services/sharedchannel/sync_recv.go
Обычный файл
399
server/platform/services/sharedchannel/sync_recv.go
Обычный файл
@@ -0,0 +1,399 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sharedchannel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/channels/app/request"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/services/remotecluster"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func (scs *Service) onReceiveSyncMessage(msg model.RemoteClusterMsg, rc *model.RemoteCluster, response *remotecluster.Response) error {
|
||||
if msg.Topic != TopicSync {
|
||||
return fmt.Errorf("wrong topic, expected `%s`, got `%s`", TopicSync, msg.Topic)
|
||||
}
|
||||
|
||||
if len(msg.Payload) == 0 {
|
||||
return errors.New("empty sync message")
|
||||
}
|
||||
|
||||
if scs.server.Log().IsLevelEnabled(mlog.LvlSharedChannelServiceMessagesInbound) {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceMessagesInbound, "inbound message",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.String("msg", string(msg.Payload)),
|
||||
)
|
||||
}
|
||||
|
||||
var sm syncMsg
|
||||
|
||||
if err := json.Unmarshal(msg.Payload, &sm); err != nil {
|
||||
return fmt.Errorf("invalid sync message: %w", err)
|
||||
}
|
||||
return scs.processSyncMessage(request.EmptyContext(scs.server.Log()), &sm, rc, response)
|
||||
}
|
||||
|
||||
func (scs *Service) processSyncMessage(c request.CTX, syncMsg *syncMsg, rc *model.RemoteCluster, response *remotecluster.Response) error {
|
||||
var channel *model.Channel
|
||||
var team *model.Team
|
||||
|
||||
var err error
|
||||
syncResp := SyncResponse{
|
||||
UserErrors: make([]string, 0),
|
||||
UsersSyncd: make([]string, 0),
|
||||
PostErrors: make([]string, 0),
|
||||
ReactionErrors: make([]string, 0),
|
||||
}
|
||||
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Sync msg received",
|
||||
mlog.String("remote", rc.Name),
|
||||
mlog.String("channel_id", syncMsg.ChannelId),
|
||||
mlog.Int("user_count", len(syncMsg.Users)),
|
||||
mlog.Int("post_count", len(syncMsg.Posts)),
|
||||
mlog.Int("reaction_count", len(syncMsg.Reactions)),
|
||||
)
|
||||
|
||||
if channel, err = scs.server.GetStore().Channel().Get(syncMsg.ChannelId, true); err != nil {
|
||||
// if the channel doesn't exist then none of these sync items are going to work.
|
||||
return fmt.Errorf("channel not found processing sync message: %w", err)
|
||||
}
|
||||
|
||||
// add/update users before posts
|
||||
for _, user := range syncMsg.Users {
|
||||
if userSaved, err := scs.upsertSyncUser(c, user, channel, rc); err != nil {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceError, "Error upserting sync user",
|
||||
mlog.String("remote", rc.Name),
|
||||
mlog.String("channel_id", syncMsg.ChannelId),
|
||||
mlog.String("user_id", user.Id),
|
||||
mlog.Err(err))
|
||||
} else {
|
||||
syncResp.UsersSyncd = append(syncResp.UsersSyncd, userSaved.Id)
|
||||
if syncResp.UsersLastUpdateAt < user.UpdateAt {
|
||||
syncResp.UsersLastUpdateAt = user.UpdateAt
|
||||
}
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "User upserted via sync",
|
||||
mlog.String("remote", rc.Name),
|
||||
mlog.String("channel_id", syncMsg.ChannelId),
|
||||
mlog.String("user_id", user.Id),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for _, post := range syncMsg.Posts {
|
||||
if syncMsg.ChannelId != post.ChannelId {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceError, "ChannelId mismatch",
|
||||
mlog.String("remote", rc.Name),
|
||||
mlog.String("sm.ChannelId", syncMsg.ChannelId),
|
||||
mlog.String("sm.Post.ChannelId", post.ChannelId),
|
||||
mlog.String("PostId", post.Id),
|
||||
)
|
||||
syncResp.PostErrors = append(syncResp.PostErrors, post.Id)
|
||||
continue
|
||||
}
|
||||
|
||||
if channel.Type != model.ChannelTypeDirect && team == nil {
|
||||
var err2 error
|
||||
team, err2 = scs.server.GetStore().Channel().GetTeamForChannel(syncMsg.ChannelId)
|
||||
if err2 != nil {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceError, "Error getting Team for Channel",
|
||||
mlog.String("ChannelId", post.ChannelId),
|
||||
mlog.String("PostId", post.Id),
|
||||
mlog.String("remote", rc.Name),
|
||||
mlog.Err(err2),
|
||||
)
|
||||
syncResp.PostErrors = append(syncResp.PostErrors, post.Id)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// process perma-links for remote
|
||||
if team != nil {
|
||||
post.Message = scs.processPermalinkFromRemote(post, team)
|
||||
}
|
||||
|
||||
// add/update post
|
||||
rpost, err := scs.upsertSyncPost(post, channel, rc)
|
||||
if err != nil {
|
||||
syncResp.PostErrors = append(syncResp.PostErrors, post.Id)
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceError, "Error upserting sync post",
|
||||
mlog.String("post_id", post.Id),
|
||||
mlog.String("channel_id", post.ChannelId),
|
||||
mlog.String("remote", rc.Name),
|
||||
mlog.Err(err),
|
||||
)
|
||||
} else if syncResp.PostsLastUpdateAt < rpost.UpdateAt {
|
||||
syncResp.PostsLastUpdateAt = rpost.UpdateAt
|
||||
}
|
||||
}
|
||||
|
||||
// add/remove reactions
|
||||
for _, reaction := range syncMsg.Reactions {
|
||||
if _, err := scs.upsertSyncReaction(reaction, rc); err != nil {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceError, "Error upserting sync reaction",
|
||||
mlog.String("remote", rc.Name),
|
||||
mlog.String("user_id", reaction.UserId),
|
||||
mlog.String("post_id", reaction.PostId),
|
||||
mlog.String("emoji", reaction.EmojiName),
|
||||
mlog.Int64("delete_at", reaction.DeleteAt),
|
||||
mlog.Err(err),
|
||||
)
|
||||
} else {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Reaction upserted via sync",
|
||||
mlog.String("remote", rc.Name),
|
||||
mlog.String("user_id", reaction.UserId),
|
||||
mlog.String("post_id", reaction.PostId),
|
||||
mlog.String("emoji", reaction.EmojiName),
|
||||
mlog.Int64("delete_at", reaction.DeleteAt),
|
||||
)
|
||||
|
||||
if syncResp.ReactionsLastUpdateAt < reaction.UpdateAt {
|
||||
syncResp.ReactionsLastUpdateAt = reaction.UpdateAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response.SetPayload(syncResp)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (scs *Service) upsertSyncUser(c request.CTX, user *model.User, channel *model.Channel, rc *model.RemoteCluster) (*model.User, error) {
|
||||
var err error
|
||||
if user.RemoteId == nil || *user.RemoteId == "" {
|
||||
user.RemoteId = model.NewString(rc.RemoteId)
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
euser, err := scs.server.GetStore().User().Get(context.Background(), user.Id)
|
||||
if err != nil {
|
||||
if _, ok := err.(errNotFound); !ok {
|
||||
return nil, fmt.Errorf("error checking sync user: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var userSaved *model.User
|
||||
if euser == nil {
|
||||
if userSaved, err = scs.insertSyncUser(user, channel, rc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
patch := &model.UserPatch{
|
||||
Username: &user.Username,
|
||||
Nickname: &user.Nickname,
|
||||
FirstName: &user.FirstName,
|
||||
LastName: &user.LastName,
|
||||
Email: &user.Email,
|
||||
Props: user.Props,
|
||||
Position: &user.Position,
|
||||
Locale: &user.Locale,
|
||||
Timezone: user.Timezone,
|
||||
RemoteId: user.RemoteId,
|
||||
}
|
||||
if userSaved, err = scs.updateSyncUser(patch, euser, channel, rc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Add user to team. We do this here regardless of whether the user was
|
||||
// just created or patched since there are three steps to adding a user
|
||||
// (insert rec, add to team, add to channel) and any one could fail.
|
||||
// Instead of undoing what succeeded on any failure we simply do all steps each
|
||||
// time. AddUserToChannel & AddUserToTeamByTeamId do not error if user was already
|
||||
// added and exit quickly.
|
||||
if err := scs.app.AddUserToTeamByTeamId(request.EmptyContext(scs.server.Log()), channel.TeamId, userSaved); err != nil {
|
||||
return nil, fmt.Errorf("error adding sync user to Team: %w", err)
|
||||
}
|
||||
|
||||
// add user to channel
|
||||
if _, err := scs.app.AddUserToChannel(c, userSaved, channel, false); err != nil {
|
||||
return nil, fmt.Errorf("error adding sync user to ChannelMembers: %w", err)
|
||||
}
|
||||
return userSaved, nil
|
||||
}
|
||||
|
||||
func (scs *Service) insertSyncUser(user *model.User, channel *model.Channel, rc *model.RemoteCluster) (*model.User, error) {
|
||||
var err error
|
||||
var userSaved *model.User
|
||||
var suffix string
|
||||
|
||||
// ensure the new user is created with system_user role and random password.
|
||||
user = sanitizeUserForSync(user)
|
||||
|
||||
// save the original username and email in props (if not already done by another remote)
|
||||
if _, ok := user.GetProp(KeyRemoteUsername); !ok {
|
||||
user.SetProp(KeyRemoteUsername, user.Username)
|
||||
}
|
||||
if _, ok := user.GetProp(KeyRemoteEmail); !ok {
|
||||
user.SetProp(KeyRemoteEmail, user.Email)
|
||||
}
|
||||
|
||||
// Apply a suffix to the username until it is unique. Collisions will be quite
|
||||
// rare since we are joining a username that is unique at a remote site with a unique
|
||||
// name for that site. However we need to truncate the combined name to 64 chars and
|
||||
// that might introduce a collision.
|
||||
for i := 1; i <= MaxUpsertRetries; i++ {
|
||||
if i > 1 {
|
||||
suffix = strconv.FormatInt(int64(i), 10)
|
||||
}
|
||||
|
||||
user.Username = mungUsername(user.Username, rc.Name, suffix, model.UserNameMaxLength)
|
||||
user.Email = mungEmail(rc.Name, model.UserEmailMaxLength)
|
||||
|
||||
if userSaved, err = scs.server.GetStore().User().Save(user); err != nil {
|
||||
e, ok := err.(errInvalidInput)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
_, field, value := e.InvalidInputInfo()
|
||||
if field == "email" || field == "username" {
|
||||
// username or email collision; try again with different suffix
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceWarn, "Collision inserting sync user",
|
||||
mlog.String("field", field),
|
||||
mlog.Any("value", value),
|
||||
mlog.Int("attempt", i),
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
scs.app.NotifySharedChannelUserUpdate(userSaved)
|
||||
return userSaved, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("error inserting sync user %s: %w", user.Id, err)
|
||||
}
|
||||
|
||||
func (scs *Service) updateSyncUser(patch *model.UserPatch, user *model.User, channel *model.Channel, rc *model.RemoteCluster) (*model.User, error) {
|
||||
var err error
|
||||
var update *model.UserUpdate
|
||||
var suffix string
|
||||
|
||||
// preserve existing real username/email since Patch will over-write them;
|
||||
// the real username/email in props can be updated if they don't contain colons,
|
||||
// meaning the update is coming from the user's origin server (not munged).
|
||||
realUsername, _ := user.GetProp(KeyRemoteUsername)
|
||||
realEmail, _ := user.GetProp(KeyRemoteEmail)
|
||||
|
||||
if patch.Username != nil && !strings.Contains(*patch.Username, ":") {
|
||||
realUsername = *patch.Username
|
||||
}
|
||||
if patch.Email != nil && !strings.Contains(*patch.Email, ":") {
|
||||
realEmail = *patch.Email
|
||||
}
|
||||
|
||||
user.Patch(patch)
|
||||
user = sanitizeUserForSync(user)
|
||||
user.SetProp(KeyRemoteUsername, realUsername)
|
||||
user.SetProp(KeyRemoteEmail, realEmail)
|
||||
|
||||
// Apply a suffix to the username until it is unique.
|
||||
for i := 1; i <= MaxUpsertRetries; i++ {
|
||||
if i > 1 {
|
||||
suffix = strconv.FormatInt(int64(i), 10)
|
||||
}
|
||||
user.Username = mungUsername(user.Username, rc.Name, suffix, model.UserNameMaxLength)
|
||||
user.Email = mungEmail(rc.Name, model.UserEmailMaxLength)
|
||||
|
||||
if update, err = scs.server.GetStore().User().Update(user, false); err != nil {
|
||||
e, ok := err.(errInvalidInput)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
_, field, value := e.InvalidInputInfo()
|
||||
if field == "email" || field == "username" {
|
||||
// username or email collision; try again with different suffix
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceWarn, "Collision updating sync user",
|
||||
mlog.String("field", field),
|
||||
mlog.Any("value", value),
|
||||
mlog.Int("attempt", i),
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
scs.app.InvalidateCacheForUser(update.New.Id)
|
||||
scs.app.NotifySharedChannelUserUpdate(update.New)
|
||||
return update.New, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("error updating sync user %s: %w", user.Id, err)
|
||||
}
|
||||
|
||||
func (scs *Service) upsertSyncPost(post *model.Post, channel *model.Channel, rc *model.RemoteCluster) (*model.Post, error) {
|
||||
var appErr *model.AppError
|
||||
|
||||
post.RemoteId = model.NewString(rc.RemoteId)
|
||||
|
||||
rpost, err := scs.server.GetStore().Post().GetSingle(post.Id, true)
|
||||
if err != nil {
|
||||
if _, ok := err.(errNotFound); !ok {
|
||||
return nil, fmt.Errorf("error checking sync post: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if rpost == nil {
|
||||
// post doesn't exist; create new one
|
||||
rpost, appErr = scs.app.CreatePost(request.EmptyContext(scs.server.Log()), post, channel, true, true)
|
||||
if appErr == nil {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Created sync post",
|
||||
mlog.String("post_id", post.Id),
|
||||
mlog.String("channel_id", post.ChannelId),
|
||||
)
|
||||
}
|
||||
} else if post.DeleteAt > 0 {
|
||||
// delete post
|
||||
rpost, appErr = scs.app.DeletePost(request.EmptyContext(scs.server.Log()), post.Id, post.UserId)
|
||||
if appErr == nil {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Deleted sync post",
|
||||
mlog.String("post_id", post.Id),
|
||||
mlog.String("channel_id", post.ChannelId),
|
||||
)
|
||||
}
|
||||
} else if post.EditAt > rpost.EditAt || post.Message != rpost.Message {
|
||||
// update post
|
||||
rpost, appErr = scs.app.UpdatePost(request.EmptyContext(scs.server.Log()), post, false)
|
||||
if appErr == nil {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Updated sync post",
|
||||
mlog.String("post_id", post.Id),
|
||||
mlog.String("channel_id", post.ChannelId),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// nothing to update
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Update to sync post ignored",
|
||||
mlog.String("post_id", post.Id),
|
||||
mlog.String("channel_id", post.ChannelId),
|
||||
)
|
||||
}
|
||||
|
||||
var rerr error
|
||||
if appErr != nil {
|
||||
rerr = errors.New(appErr.Error())
|
||||
}
|
||||
return rpost, rerr
|
||||
}
|
||||
|
||||
func (scs *Service) upsertSyncReaction(reaction *model.Reaction, rc *model.RemoteCluster) (*model.Reaction, error) {
|
||||
savedReaction := reaction
|
||||
var appErr *model.AppError
|
||||
|
||||
reaction.RemoteId = model.NewString(rc.RemoteId)
|
||||
|
||||
if reaction.DeleteAt == 0 {
|
||||
savedReaction, appErr = scs.app.SaveReactionForPost(request.EmptyContext(scs.server.Log()), reaction)
|
||||
} else {
|
||||
appErr = scs.app.DeleteReactionForPost(request.EmptyContext(scs.server.Log()), reaction)
|
||||
}
|
||||
|
||||
var err error
|
||||
if appErr != nil {
|
||||
err = errors.New(appErr.Error())
|
||||
}
|
||||
return savedReaction, err
|
||||
}
|
||||
437
server/platform/services/sharedchannel/sync_send.go
Обычный файл
437
server/platform/services/sharedchannel/sync_send.go
Обычный файл
@@ -0,0 +1,437 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sharedchannel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/channels/app/request"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/services/remotecluster"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/i18n"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
type syncTask struct {
|
||||
id string
|
||||
channelID string
|
||||
remoteID string
|
||||
AddedAt time.Time
|
||||
retryCount int
|
||||
retryMsg *syncMsg
|
||||
schedule time.Time
|
||||
}
|
||||
|
||||
func newSyncTask(channelID string, remoteID string, retryMsg *syncMsg) syncTask {
|
||||
var retryID string
|
||||
if retryMsg != nil {
|
||||
retryID = retryMsg.Id
|
||||
}
|
||||
|
||||
return syncTask{
|
||||
id: channelID + remoteID + retryID, // combination of ids to avoid duplicates
|
||||
channelID: channelID,
|
||||
remoteID: remoteID, // empty means update all remote clusters
|
||||
retryMsg: retryMsg,
|
||||
schedule: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// incRetry increments the retry counter and returns true if MaxRetries not exceeded.
|
||||
func (st *syncTask) incRetry() bool {
|
||||
st.retryCount++
|
||||
return st.retryCount <= MaxRetries
|
||||
}
|
||||
|
||||
// NotifyChannelChanged is called to indicate that a shared channel has been modified,
|
||||
// thus triggering an update to all remote clusters.
|
||||
func (scs *Service) NotifyChannelChanged(channelID string) {
|
||||
if rcs := scs.server.GetRemoteClusterService(); rcs == nil {
|
||||
return
|
||||
}
|
||||
|
||||
task := newSyncTask(channelID, "", nil)
|
||||
task.schedule = time.Now().Add(NotifyMinimumDelay)
|
||||
scs.addTask(task)
|
||||
}
|
||||
|
||||
// NotifyUserProfileChanged is called to indicate that a user belonging to at least one
|
||||
// shared channel has modified their user profile (name, username, email, custom status, profile image)
|
||||
func (scs *Service) NotifyUserProfileChanged(userID string) {
|
||||
if rcs := scs.server.GetRemoteClusterService(); rcs == nil {
|
||||
return
|
||||
}
|
||||
|
||||
scusers, err := scs.server.GetStore().SharedChannel().GetUsersForUser(userID)
|
||||
if err != nil {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceError, "Failed to fetch shared channel users",
|
||||
mlog.String("userID", userID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
if len(scusers) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
notified := make(map[string]struct{})
|
||||
|
||||
for _, user := range scusers {
|
||||
// update every channel + remote combination they belong to.
|
||||
// Redundant updates (ie. to same remote for multiple channels) will be
|
||||
// filtered out.
|
||||
combo := user.ChannelId + user.RemoteId
|
||||
if _, ok := notified[combo]; ok {
|
||||
continue
|
||||
}
|
||||
notified[combo] = struct{}{}
|
||||
task := newSyncTask(user.ChannelId, user.RemoteId, nil)
|
||||
task.schedule = time.Now().Add(NotifyMinimumDelay)
|
||||
scs.addTask(task)
|
||||
}
|
||||
}
|
||||
|
||||
// ForceSyncForRemote causes all channels shared with the remote to be synchronized.
|
||||
func (scs *Service) ForceSyncForRemote(rc *model.RemoteCluster) {
|
||||
if rcs := scs.server.GetRemoteClusterService(); rcs == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// fetch all channels shared with this remote.
|
||||
opts := model.SharedChannelRemoteFilterOpts{
|
||||
RemoteId: rc.RemoteId,
|
||||
}
|
||||
scrs, err := scs.server.GetStore().SharedChannel().GetRemotes(opts)
|
||||
if err != nil {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceError, "Failed to fetch shared channel remotes",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.String("remoteId", rc.RemoteId),
|
||||
mlog.Err(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
for _, scr := range scrs {
|
||||
task := newSyncTask(scr.ChannelId, rc.RemoteId, nil)
|
||||
task.schedule = time.Now().Add(NotifyMinimumDelay)
|
||||
scs.addTask(task)
|
||||
}
|
||||
}
|
||||
|
||||
// addTask adds or re-adds a task to the queue.
|
||||
func (scs *Service) addTask(task syncTask) {
|
||||
task.AddedAt = time.Now()
|
||||
scs.mux.Lock()
|
||||
if _, ok := scs.tasks[task.id]; !ok {
|
||||
scs.tasks[task.id] = task
|
||||
}
|
||||
scs.mux.Unlock()
|
||||
|
||||
// wake up the sync goroutine
|
||||
select {
|
||||
case scs.changeSignal <- struct{}{}:
|
||||
default:
|
||||
// that's ok, the sync routine is already busy
|
||||
}
|
||||
}
|
||||
|
||||
// syncLoop is called via a dedicated goroutine to wait for notifications of channel changes and
|
||||
// updates each remote based on those changes.
|
||||
func (scs *Service) syncLoop(done chan struct{}) {
|
||||
// create a timer to periodically check the task queue, but only if there is
|
||||
// a delayed task in the queue.
|
||||
delay := time.NewTimer(NotifyMinimumDelay)
|
||||
defer stopTimer(delay)
|
||||
|
||||
// wait for channel changed signal and update for oldest task.
|
||||
for {
|
||||
select {
|
||||
case <-scs.changeSignal:
|
||||
if wait := scs.doSync(); wait > 0 {
|
||||
stopTimer(delay)
|
||||
delay.Reset(wait)
|
||||
}
|
||||
case <-delay.C:
|
||||
if wait := scs.doSync(); wait > 0 {
|
||||
delay.Reset(wait)
|
||||
}
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopTimer(timer *time.Timer) {
|
||||
timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// doSync checks the task queue for any tasks to be processed and processes all that are ready.
|
||||
// If any delayed tasks remain in queue then the duration until the next scheduled task is returned.
|
||||
func (scs *Service) doSync() time.Duration {
|
||||
var task syncTask
|
||||
var ok bool
|
||||
var shortestWait time.Duration
|
||||
|
||||
for {
|
||||
task, ok, shortestWait = scs.removeOldestTask()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if err := scs.processTask(task); err != nil {
|
||||
// put task back into map so it will update again
|
||||
if task.incRetry() {
|
||||
scs.addTask(task)
|
||||
} else {
|
||||
scs.server.Log().Error("Failed to synchronize shared channel",
|
||||
mlog.String("channelId", task.channelID),
|
||||
mlog.String("remoteId", task.remoteID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return shortestWait
|
||||
}
|
||||
|
||||
// removeOldestTask removes and returns the oldest task in the task map.
|
||||
// A task coming in via NotifyChannelChanged must stay in queue for at least
|
||||
// `NotifyMinimumDelay` to ensure we don't go nuts trying to sync during a bulk update.
|
||||
// If no tasks are available then false is returned.
|
||||
func (scs *Service) removeOldestTask() (syncTask, bool, time.Duration) {
|
||||
scs.mux.Lock()
|
||||
defer scs.mux.Unlock()
|
||||
|
||||
var oldestTask syncTask
|
||||
var oldestKey string
|
||||
var shortestWait time.Duration
|
||||
|
||||
for key, task := range scs.tasks {
|
||||
// check if task is ready
|
||||
if wait := time.Until(task.schedule); wait > 0 {
|
||||
if wait < shortestWait || shortestWait == 0 {
|
||||
shortestWait = wait
|
||||
}
|
||||
continue
|
||||
}
|
||||
// task is ready; check if it's the oldest ready task
|
||||
if task.AddedAt.Before(oldestTask.AddedAt) || oldestTask.AddedAt.IsZero() {
|
||||
oldestKey = key
|
||||
oldestTask = task
|
||||
}
|
||||
}
|
||||
|
||||
if oldestKey != "" {
|
||||
delete(scs.tasks, oldestKey)
|
||||
return oldestTask, true, shortestWait
|
||||
}
|
||||
return oldestTask, false, shortestWait
|
||||
}
|
||||
|
||||
// processTask updates one or more remote clusters with any new channel content.
|
||||
func (scs *Service) processTask(task syncTask) error {
|
||||
var err error
|
||||
var remotes []*model.RemoteCluster
|
||||
|
||||
if task.remoteID == "" {
|
||||
filter := model.RemoteClusterQueryFilter{
|
||||
InChannel: task.channelID,
|
||||
OnlyConfirmed: true,
|
||||
}
|
||||
remotes, err = scs.server.GetStore().RemoteCluster().GetAll(filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
rc, err := scs.server.GetStore().RemoteCluster().Get(task.remoteID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !rc.IsOnline() {
|
||||
return fmt.Errorf("Failed updating shared channel '%s' for offline remote cluster '%s'", task.channelID, rc.DisplayName)
|
||||
}
|
||||
remotes = []*model.RemoteCluster{rc}
|
||||
}
|
||||
|
||||
for _, rc := range remotes {
|
||||
rtask := task
|
||||
rtask.remoteID = rc.RemoteId
|
||||
if err := scs.syncForRemote(rtask, rc); err != nil {
|
||||
// retry...
|
||||
if rtask.incRetry() {
|
||||
scs.addTask(rtask)
|
||||
} else {
|
||||
scs.server.Log().Error("Failed to synchronize shared channel for remote cluster",
|
||||
mlog.String("channelId", rtask.channelID),
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (scs *Service) handlePostError(postId string, task syncTask, rc *model.RemoteCluster) {
|
||||
if task.retryMsg != nil && len(task.retryMsg.Posts) == 1 && task.retryMsg.Posts[0].Id == postId {
|
||||
// this was a retry for specific post that failed previously. Try again if within MaxRetries.
|
||||
if task.incRetry() {
|
||||
scs.addTask(task)
|
||||
} else {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceError, "error syncing post",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.String("post_id", postId),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// this post failed as part of a group of posts. Retry as an individual post.
|
||||
post, err := scs.server.GetStore().Post().GetSingle(postId, true)
|
||||
if err != nil {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceError, "error fetching post for sync retry",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.String("post_id", postId),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
syncMsg := newSyncMsg(task.channelID)
|
||||
syncMsg.Posts = []*model.Post{post}
|
||||
|
||||
scs.addTask(newSyncTask(task.channelID, task.remoteID, syncMsg))
|
||||
}
|
||||
|
||||
// notifyRemoteOffline creates an ephemeral post to the author for any posts created recently to remotes
|
||||
// that are offline.
|
||||
func (scs *Service) notifyRemoteOffline(posts []*model.Post, rc *model.RemoteCluster) {
|
||||
// only send one ephemeral post per author.
|
||||
notified := make(map[string]bool)
|
||||
|
||||
// range the slice in reverse so the newest posts are visited first; this ensures an ephemeral
|
||||
// get added where it is mostly likely to be seen.
|
||||
for i := len(posts) - 1; i >= 0; i-- {
|
||||
post := posts[i]
|
||||
if didNotify := notified[post.UserId]; didNotify {
|
||||
continue
|
||||
}
|
||||
|
||||
postCreateAt := model.GetTimeForMillis(post.CreateAt)
|
||||
|
||||
if post.DeleteAt == 0 && post.UserId != "" && time.Since(postCreateAt) < NotifyRemoteOfflineThreshold {
|
||||
T := scs.getUserTranslations(post.UserId)
|
||||
ephemeral := &model.Post{
|
||||
ChannelId: post.ChannelId,
|
||||
Message: T("sharedchannel.cannot_deliver_post", map[string]any{"Remote": rc.DisplayName}),
|
||||
CreateAt: post.CreateAt + 1,
|
||||
}
|
||||
scs.app.SendEphemeralPost(request.EmptyContext(scs.server.Log()), post.UserId, ephemeral)
|
||||
|
||||
notified[post.UserId] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (scs *Service) updateCursorForRemote(scrId string, rc *model.RemoteCluster, cursor model.GetPostsSinceForSyncCursor) {
|
||||
if err := scs.server.GetStore().SharedChannel().UpdateRemoteCursor(scrId, cursor); err != nil {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceError, "error updating cursor for shared channel remote",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.Err(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "updated cursor for remote",
|
||||
mlog.String("remote_id", rc.RemoteId),
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.Int64("last_post_update_at", cursor.LastPostUpdateAt),
|
||||
mlog.String("last_post_id", cursor.LastPostId),
|
||||
)
|
||||
}
|
||||
|
||||
func (scs *Service) getUserTranslations(userId string) i18n.TranslateFunc {
|
||||
var locale string
|
||||
user, err := scs.server.GetStore().User().Get(context.Background(), userId)
|
||||
if err == nil {
|
||||
locale = user.Locale
|
||||
}
|
||||
|
||||
if locale == "" {
|
||||
locale = model.DefaultLocale
|
||||
}
|
||||
return i18n.GetUserTranslations(locale)
|
||||
}
|
||||
|
||||
// shouldUserSync determines if a user needs to be synchronized.
|
||||
// User should be synchronized if it has no entry in the SharedChannelUsers table for the specified channel,
|
||||
// or there is an entry but the LastSyncAt is less than user.UpdateAt
|
||||
func (scs *Service) shouldUserSync(user *model.User, channelID string, rc *model.RemoteCluster) (sync bool, syncImage bool, err error) {
|
||||
// don't sync users with the remote they originated from.
|
||||
if user.RemoteId != nil && *user.RemoteId == rc.RemoteId {
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
scu, err := scs.server.GetStore().SharedChannel().GetSingleUser(user.Id, channelID, rc.RemoteId)
|
||||
if err != nil {
|
||||
if _, ok := err.(errNotFound); !ok {
|
||||
return false, false, err
|
||||
}
|
||||
|
||||
// user not in the SharedChannelUsers table, so we must add them.
|
||||
scu = &model.SharedChannelUser{
|
||||
UserId: user.Id,
|
||||
RemoteId: rc.RemoteId,
|
||||
ChannelId: channelID,
|
||||
}
|
||||
if _, err = scs.server.GetStore().SharedChannel().SaveUser(scu); err != nil {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceError, "Error adding user to shared channel users",
|
||||
mlog.String("remote_id", rc.RemoteId),
|
||||
mlog.String("user_id", user.Id),
|
||||
mlog.String("channel_id", user.Id),
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
return true, true, nil
|
||||
}
|
||||
|
||||
return user.UpdateAt > scu.LastSyncAt, user.LastPictureUpdate > scu.LastSyncAt, nil
|
||||
}
|
||||
|
||||
func (scs *Service) syncProfileImage(user *model.User, channelID string, rc *model.RemoteCluster) {
|
||||
rcs := scs.server.GetRemoteClusterService()
|
||||
if rcs == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), ProfileImageSyncTimeout)
|
||||
defer cancel()
|
||||
|
||||
rcs.SendProfileImage(ctx, user.Id, rc, scs.app, func(userId string, rc *model.RemoteCluster, resp *remotecluster.Response, err error) {
|
||||
if resp.IsSuccess() {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Users profile image synchronized",
|
||||
mlog.String("remote_id", rc.RemoteId),
|
||||
mlog.String("user_id", user.Id),
|
||||
)
|
||||
|
||||
if err2 := scs.server.GetStore().SharedChannel().UpdateUserLastSyncAt(user.Id, channelID, rc.RemoteId); err2 != nil {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceError, "Error updating users LastSyncTime after profile image update",
|
||||
mlog.String("remote_id", rc.RemoteId),
|
||||
mlog.String("user_id", user.Id),
|
||||
mlog.Err(err2),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceError, "Error synchronizing users profile image",
|
||||
mlog.String("remote_id", rc.RemoteId),
|
||||
mlog.String("user_id", user.Id),
|
||||
mlog.Err(err),
|
||||
)
|
||||
})
|
||||
}
|
||||
545
server/platform/services/sharedchannel/sync_send_remote.go
Обычный файл
545
server/platform/services/sharedchannel/sync_send_remote.go
Обычный файл
@@ -0,0 +1,545 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sharedchannel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/wiggin77/merror"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/channels/app/request"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/services/remotecluster"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
type sendSyncMsgResultFunc func(syncResp SyncResponse, err error)
|
||||
|
||||
type attachment struct {
|
||||
fi *model.FileInfo
|
||||
post *model.Post
|
||||
}
|
||||
|
||||
type syncData struct {
|
||||
task syncTask
|
||||
rc *model.RemoteCluster
|
||||
scr *model.SharedChannelRemote
|
||||
|
||||
users map[string]*model.User
|
||||
profileImages map[string]*model.User
|
||||
posts []*model.Post
|
||||
reactions []*model.Reaction
|
||||
attachments []attachment
|
||||
|
||||
resultRepeat bool
|
||||
resultNextCursor model.GetPostsSinceForSyncCursor
|
||||
}
|
||||
|
||||
func newSyncData(task syncTask, rc *model.RemoteCluster, scr *model.SharedChannelRemote) *syncData {
|
||||
return &syncData{
|
||||
task: task,
|
||||
rc: rc,
|
||||
scr: scr,
|
||||
users: make(map[string]*model.User),
|
||||
profileImages: make(map[string]*model.User),
|
||||
resultNextCursor: model.GetPostsSinceForSyncCursor{LastPostUpdateAt: scr.LastPostUpdateAt, LastPostId: scr.LastPostId},
|
||||
}
|
||||
}
|
||||
|
||||
func (sd *syncData) isEmpty() bool {
|
||||
return len(sd.users) == 0 && len(sd.profileImages) == 0 && len(sd.posts) == 0 && len(sd.reactions) == 0 && len(sd.attachments) == 0
|
||||
}
|
||||
|
||||
func (sd *syncData) isCursorChanged() bool {
|
||||
return sd.scr.LastPostUpdateAt != sd.resultNextCursor.LastPostUpdateAt || sd.scr.LastPostId != sd.resultNextCursor.LastPostId
|
||||
}
|
||||
|
||||
// syncForRemote updates a remote cluster with any new posts/reactions for a specific
|
||||
// channel. If many changes are found, only the oldest X changes are sent and the channel
|
||||
// is re-added to the task map. This ensures no channels are starved for updates even if some
|
||||
// channels are very active.
|
||||
// Returning an error forces a retry on the task.
|
||||
func (scs *Service) syncForRemote(task syncTask, rc *model.RemoteCluster) error {
|
||||
rcs := scs.server.GetRemoteClusterService()
|
||||
if rcs == nil {
|
||||
return fmt.Errorf("cannot update remote cluster %s for channel id %s; Remote Cluster Service not enabled", rc.Name, task.channelID)
|
||||
}
|
||||
|
||||
scr, err := scs.server.GetStore().SharedChannel().GetRemoteByIds(task.channelID, rc.RemoteId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// if this is retrying a failed msg, just send it again.
|
||||
if task.retryMsg != nil {
|
||||
sd := newSyncData(task, rc, scr)
|
||||
sd.users = task.retryMsg.Users
|
||||
sd.posts = task.retryMsg.Posts
|
||||
sd.reactions = task.retryMsg.Reactions
|
||||
return scs.sendSyncData(sd)
|
||||
}
|
||||
|
||||
sd := newSyncData(task, rc, scr)
|
||||
|
||||
// schedule another sync if the repeat flag is set at some point.
|
||||
defer func(rpt *bool) {
|
||||
if *rpt {
|
||||
scs.addTask(newSyncTask(task.channelID, task.remoteID, nil))
|
||||
}
|
||||
}(&sd.resultRepeat)
|
||||
|
||||
// fetch new posts or retry post.
|
||||
if err := scs.fetchPostsForSync(sd); err != nil {
|
||||
return fmt.Errorf("cannot fetch posts for sync %v: %w", sd, err)
|
||||
}
|
||||
|
||||
if !rc.IsOnline() {
|
||||
if len(sd.posts) != 0 {
|
||||
scs.notifyRemoteOffline(sd.posts, rc)
|
||||
}
|
||||
sd.resultRepeat = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetch users that have updated their user profile or image.
|
||||
if err := scs.fetchUsersForSync(sd); err != nil {
|
||||
return fmt.Errorf("cannot fetch users for sync %v: %w", sd, err)
|
||||
}
|
||||
|
||||
// fetch reactions for posts
|
||||
if err := scs.fetchReactionsForSync(sd); err != nil {
|
||||
return fmt.Errorf("cannot fetch reactions for sync %v: %w", sd, err)
|
||||
}
|
||||
|
||||
// fetch users associated with posts & reactions
|
||||
if err := scs.fetchPostUsersForSync(sd); err != nil {
|
||||
return fmt.Errorf("cannot fetch post users for sync %v: %w", sd, err)
|
||||
}
|
||||
|
||||
// filter out any posts that don't need to be sent.
|
||||
scs.filterPostsForSync(sd)
|
||||
|
||||
// fetch attachments for posts
|
||||
if err := scs.fetchPostAttachmentsForSync(sd); err != nil {
|
||||
return fmt.Errorf("cannot fetch post attachments for sync %v: %w", sd, err)
|
||||
}
|
||||
|
||||
if sd.isEmpty() {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Not sending sync data; everything filtered out",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.String("channel_id", task.channelID),
|
||||
mlog.Bool("repeat", sd.resultRepeat),
|
||||
)
|
||||
if sd.isCursorChanged() {
|
||||
scs.updateCursorForRemote(sd.scr.Id, sd.rc, sd.resultNextCursor)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Sending sync data",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.String("channel_id", task.channelID),
|
||||
mlog.Bool("repeat", sd.resultRepeat),
|
||||
mlog.Int("users", len(sd.users)),
|
||||
mlog.Int("images", len(sd.profileImages)),
|
||||
mlog.Int("posts", len(sd.posts)),
|
||||
mlog.Int("reactions", len(sd.reactions)),
|
||||
mlog.Int("attachments", len(sd.attachments)),
|
||||
)
|
||||
|
||||
return scs.sendSyncData(sd)
|
||||
}
|
||||
|
||||
// fetchUsersForSync populates the sync data with any channel users who updated their user profile
|
||||
// since the last sync.
|
||||
func (scs *Service) fetchUsersForSync(sd *syncData) error {
|
||||
filter := model.GetUsersForSyncFilter{
|
||||
ChannelID: sd.task.channelID,
|
||||
Limit: MaxUsersPerSync,
|
||||
}
|
||||
users, err := scs.server.GetStore().SharedChannel().GetUsersForSync(filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, u := range users {
|
||||
if u.GetRemoteID() != sd.rc.RemoteId {
|
||||
sd.users[u.Id] = u
|
||||
}
|
||||
}
|
||||
|
||||
filter.CheckProfileImage = true
|
||||
usersImage, err := scs.server.GetStore().SharedChannel().GetUsersForSync(filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, u := range usersImage {
|
||||
if u.GetRemoteID() != sd.rc.RemoteId {
|
||||
sd.profileImages[u.Id] = u
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchPostsForSync populates the sync data with any new posts since the last sync.
|
||||
func (scs *Service) fetchPostsForSync(sd *syncData) error {
|
||||
options := model.GetPostsSinceForSyncOptions{
|
||||
ChannelId: sd.task.channelID,
|
||||
IncludeDeleted: true,
|
||||
}
|
||||
cursor := model.GetPostsSinceForSyncCursor{
|
||||
LastPostUpdateAt: sd.scr.LastPostUpdateAt,
|
||||
LastPostId: sd.scr.LastPostId,
|
||||
}
|
||||
|
||||
posts, nextCursor, err := scs.server.GetStore().Post().GetPostsSinceForSync(options, cursor, MaxPostsPerSync)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not fetch new posts for sync: %w", err)
|
||||
}
|
||||
|
||||
// Append the posts individually, checking for root posts that might appear later in the list.
|
||||
// This is due to the UpdateAt collision handling algorithm where the order of posts is not based
|
||||
// on UpdateAt or CreateAt when the posts have the same UpdateAt value. Here we are guarding
|
||||
// against a root post with the same UpdateAt (and probably the same CreateAt) appearing later
|
||||
// in the list and must be sync'd before the child post. This is and edge case that likely only
|
||||
// happens during load testing or bulk imports.
|
||||
for _, p := range posts {
|
||||
if p.RootId != "" {
|
||||
root, err := scs.server.GetStore().Post().GetSingle(p.RootId, true)
|
||||
if err == nil {
|
||||
if (root.CreateAt >= cursor.LastPostUpdateAt || root.UpdateAt >= cursor.LastPostUpdateAt) && !containsPost(sd.posts, root) {
|
||||
sd.posts = append(sd.posts, root)
|
||||
}
|
||||
}
|
||||
}
|
||||
sd.posts = append(sd.posts, p)
|
||||
}
|
||||
|
||||
sd.resultNextCursor = nextCursor
|
||||
sd.resultRepeat = len(posts) == MaxPostsPerSync
|
||||
return nil
|
||||
}
|
||||
|
||||
func containsPost(posts []*model.Post, post *model.Post) bool {
|
||||
for _, p := range posts {
|
||||
if p.Id == post.Id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fetchReactionsForSync populates the sync data with any new reactions since the last sync.
|
||||
func (scs *Service) fetchReactionsForSync(sd *syncData) error {
|
||||
merr := merror.New()
|
||||
for _, post := range sd.posts {
|
||||
// any reactions originating from the remote cluster are filtered out
|
||||
reactions, err := scs.server.GetStore().Reaction().GetForPostSince(post.Id, sd.scr.LastPostUpdateAt, sd.rc.RemoteId, true)
|
||||
if err != nil {
|
||||
merr.Append(fmt.Errorf("could not get reactions for post %s: %w", post.Id, err))
|
||||
continue
|
||||
}
|
||||
sd.reactions = append(sd.reactions, reactions...)
|
||||
}
|
||||
return merr.ErrorOrNil()
|
||||
}
|
||||
|
||||
// fetchPostUsersForSync populates the sync data with all users associated with posts.
|
||||
func (scs *Service) fetchPostUsersForSync(sd *syncData) error {
|
||||
sc, err := scs.server.GetStore().SharedChannel().Get(sd.task.channelID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot determine teamID: %w", err)
|
||||
}
|
||||
|
||||
type p2mm struct {
|
||||
post *model.Post
|
||||
mentionMap model.UserMentionMap
|
||||
}
|
||||
|
||||
userIDs := make(map[string]p2mm)
|
||||
|
||||
for _, reaction := range sd.reactions {
|
||||
userIDs[reaction.UserId] = p2mm{}
|
||||
}
|
||||
|
||||
for _, post := range sd.posts {
|
||||
// add author
|
||||
userIDs[post.UserId] = p2mm{}
|
||||
|
||||
// get mentions and users for each mention
|
||||
mentionMap := scs.app.MentionsToTeamMembers(request.EmptyContext(scs.server.Log()), post.Message, sc.TeamId)
|
||||
for _, userID := range mentionMap {
|
||||
userIDs[userID] = p2mm{
|
||||
post: post,
|
||||
mentionMap: mentionMap,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
merr := merror.New()
|
||||
|
||||
for userID, v := range userIDs {
|
||||
user, err := scs.server.GetStore().User().Get(context.Background(), userID)
|
||||
if err != nil {
|
||||
merr.Append(fmt.Errorf("could not get user %s: %w", userID, err))
|
||||
continue
|
||||
}
|
||||
|
||||
sync, syncImage, err2 := scs.shouldUserSync(user, sd.task.channelID, sd.rc)
|
||||
if err2 != nil {
|
||||
merr.Append(fmt.Errorf("could not check should sync user %s: %w", userID, err))
|
||||
continue
|
||||
}
|
||||
|
||||
if sync {
|
||||
sd.users[user.Id] = user
|
||||
}
|
||||
|
||||
if syncImage {
|
||||
sd.profileImages[user.Id] = user
|
||||
}
|
||||
|
||||
// if this was a mention then put the real username in place of the username+remotename, but only
|
||||
// when sending to the remote that the user belongs to.
|
||||
if v.post != nil && user.RemoteId != nil && *user.RemoteId == sd.rc.RemoteId {
|
||||
fixMention(v.post, v.mentionMap, user)
|
||||
}
|
||||
}
|
||||
return merr.ErrorOrNil()
|
||||
}
|
||||
|
||||
// fetchPostAttachmentsForSync populates the sync data with any file attachments for new posts.
|
||||
func (scs *Service) fetchPostAttachmentsForSync(sd *syncData) error {
|
||||
merr := merror.New()
|
||||
for _, post := range sd.posts {
|
||||
fis, err := scs.server.GetStore().FileInfo().GetForPost(post.Id, false, true, true)
|
||||
if err != nil {
|
||||
merr.Append(fmt.Errorf("could not get file attachment info for post %s: %w", post.Id, err))
|
||||
continue
|
||||
}
|
||||
|
||||
for _, fi := range fis {
|
||||
if scs.shouldSyncAttachment(fi, sd.rc) {
|
||||
sd.attachments = append(sd.attachments, attachment{fi: fi, post: post})
|
||||
}
|
||||
}
|
||||
}
|
||||
return merr.ErrorOrNil()
|
||||
}
|
||||
|
||||
// filterPostsforSync removes any posts that do not need to sync.
|
||||
func (scs *Service) filterPostsForSync(sd *syncData) {
|
||||
filtered := make([]*model.Post, 0, len(sd.posts))
|
||||
|
||||
for _, p := range sd.posts {
|
||||
// Don't resend an existing post where only the reactions changed.
|
||||
// Posts we must send:
|
||||
// - new posts (EditAt == 0)
|
||||
// - edited posts (EditAt >= LastPostUpdateAt)
|
||||
// - deleted posts (DeleteAt > 0)
|
||||
if p.EditAt > 0 && p.EditAt < sd.scr.LastPostUpdateAt && p.DeleteAt == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Don't send a deleted post if it is just the original copy from an edit.
|
||||
if p.DeleteAt > 0 && p.OriginalId != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// don't sync a post back to the remote it came from.
|
||||
if p.GetRemoteID() == sd.rc.RemoteId {
|
||||
continue
|
||||
}
|
||||
|
||||
// parse out all permalinks in the message.
|
||||
p.Message = scs.processPermalinkToRemote(p)
|
||||
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
sd.posts = filtered
|
||||
}
|
||||
|
||||
// sendSyncData sends all the collected users, posts, reactions, images, and attachments to the
|
||||
// remote cluster.
|
||||
// The order of items sent is important: users -> attachments -> posts -> reactions -> profile images
|
||||
func (scs *Service) sendSyncData(sd *syncData) error {
|
||||
merr := merror.New()
|
||||
|
||||
sanitizeSyncData(sd)
|
||||
|
||||
// send users
|
||||
if len(sd.users) != 0 {
|
||||
if err := scs.sendUserSyncData(sd); err != nil {
|
||||
merr.Append(fmt.Errorf("cannot send user sync data: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// send attachments
|
||||
if len(sd.attachments) != 0 {
|
||||
scs.sendAttachmentSyncData(sd)
|
||||
}
|
||||
|
||||
// send posts
|
||||
if len(sd.posts) != 0 {
|
||||
if err := scs.sendPostSyncData(sd); err != nil {
|
||||
merr.Append(fmt.Errorf("cannot send post sync data: %w", err))
|
||||
}
|
||||
} else if sd.isCursorChanged() {
|
||||
scs.updateCursorForRemote(sd.scr.Id, sd.rc, sd.resultNextCursor)
|
||||
}
|
||||
|
||||
// send reactions
|
||||
if len(sd.reactions) != 0 {
|
||||
if err := scs.sendReactionSyncData(sd); err != nil {
|
||||
merr.Append(fmt.Errorf("cannot send reaction sync data: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// send user profile images
|
||||
if len(sd.profileImages) != 0 {
|
||||
scs.sendProfileImageSyncData(sd)
|
||||
}
|
||||
|
||||
return merr.ErrorOrNil()
|
||||
}
|
||||
|
||||
// sendUserSyncData sends the collected user updates to the remote cluster.
|
||||
func (scs *Service) sendUserSyncData(sd *syncData) error {
|
||||
msg := newSyncMsg(sd.task.channelID)
|
||||
msg.Users = sd.users
|
||||
|
||||
err := scs.sendSyncMsgToRemote(msg, sd.rc, func(syncResp SyncResponse, errResp error) {
|
||||
for _, userID := range syncResp.UsersSyncd {
|
||||
if err := scs.server.GetStore().SharedChannel().UpdateUserLastSyncAt(userID, sd.task.channelID, sd.rc.RemoteId); err != nil {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceError, "Cannot update shared channel user LastSyncAt",
|
||||
mlog.String("user_id", userID),
|
||||
mlog.String("channel_id", sd.task.channelID),
|
||||
mlog.String("remote_id", sd.rc.RemoteId),
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
if len(syncResp.UserErrors) != 0 {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceError, "Response indicates error for user(s) sync",
|
||||
mlog.String("channel_id", sd.task.channelID),
|
||||
mlog.String("remote_id", sd.rc.RemoteId),
|
||||
mlog.Any("users", syncResp.UserErrors),
|
||||
)
|
||||
}
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// sendAttachmentSyncData sends the collected post updates to the remote cluster.
|
||||
func (scs *Service) sendAttachmentSyncData(sd *syncData) {
|
||||
for _, a := range sd.attachments {
|
||||
if err := scs.sendAttachmentForRemote(a.fi, a.post, sd.rc); err != nil {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceError, "Cannot sync post attachment",
|
||||
mlog.String("post_id", a.post.Id),
|
||||
mlog.String("channel_id", sd.task.channelID),
|
||||
mlog.String("remote_id", sd.rc.RemoteId),
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
// updating SharedChannelAttachments with LastSyncAt is already done.
|
||||
}
|
||||
}
|
||||
|
||||
// sendPostSyncData sends the collected post updates to the remote cluster.
|
||||
func (scs *Service) sendPostSyncData(sd *syncData) error {
|
||||
msg := newSyncMsg(sd.task.channelID)
|
||||
msg.Posts = sd.posts
|
||||
|
||||
return scs.sendSyncMsgToRemote(msg, sd.rc, func(syncResp SyncResponse, errResp error) {
|
||||
if len(syncResp.PostErrors) != 0 {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceError, "Response indicates error for post(s) sync",
|
||||
mlog.String("channel_id", sd.task.channelID),
|
||||
mlog.String("remote_id", sd.rc.RemoteId),
|
||||
mlog.Any("posts", syncResp.PostErrors),
|
||||
)
|
||||
|
||||
for _, postID := range syncResp.PostErrors {
|
||||
scs.handlePostError(postID, sd.task, sd.rc)
|
||||
}
|
||||
}
|
||||
scs.updateCursorForRemote(sd.scr.Id, sd.rc, sd.resultNextCursor)
|
||||
})
|
||||
}
|
||||
|
||||
// sendReactionSyncData sends the collected reaction updates to the remote cluster.
|
||||
func (scs *Service) sendReactionSyncData(sd *syncData) error {
|
||||
msg := newSyncMsg(sd.task.channelID)
|
||||
msg.Reactions = sd.reactions
|
||||
|
||||
return scs.sendSyncMsgToRemote(msg, sd.rc, func(syncResp SyncResponse, errResp error) {
|
||||
if len(syncResp.ReactionErrors) != 0 {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceError, "Response indicates error for reactions(s) sync",
|
||||
mlog.String("channel_id", sd.task.channelID),
|
||||
mlog.String("remote_id", sd.rc.RemoteId),
|
||||
mlog.Any("reaction_posts", syncResp.ReactionErrors),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// sendProfileImageSyncData sends the collected user profile image updates to the remote cluster.
|
||||
func (scs *Service) sendProfileImageSyncData(sd *syncData) {
|
||||
for _, user := range sd.profileImages {
|
||||
scs.syncProfileImage(user, sd.task.channelID, sd.rc)
|
||||
}
|
||||
}
|
||||
|
||||
// sendSyncMsgToRemote synchronously sends the sync message to the remote cluster.
|
||||
func (scs *Service) sendSyncMsgToRemote(msg *syncMsg, rc *model.RemoteCluster, f sendSyncMsgResultFunc) error {
|
||||
rcs := scs.server.GetRemoteClusterService()
|
||||
if rcs == nil {
|
||||
return fmt.Errorf("cannot update remote cluster %s for channel id %s; Remote Cluster Service not enabled", rc.Name, msg.ChannelId)
|
||||
}
|
||||
|
||||
b, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rcMsg := model.NewRemoteClusterMsg(TopicSync, b)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), remotecluster.SendTimeout)
|
||||
defer cancel()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
|
||||
err = rcs.SendMsg(ctx, rcMsg, rc, func(rcMsg model.RemoteClusterMsg, rc *model.RemoteCluster, rcResp *remotecluster.Response, errResp error) {
|
||||
defer wg.Done()
|
||||
|
||||
var syncResp SyncResponse
|
||||
if err2 := json.Unmarshal(rcResp.Payload, &syncResp); err2 != nil {
|
||||
scs.server.Log().Log(mlog.LvlSharedChannelServiceError, "Invalid sync msg response from remote cluster",
|
||||
mlog.String("remote", rc.Name),
|
||||
mlog.String("channel_id", msg.ChannelId),
|
||||
mlog.Err(err2),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if f != nil {
|
||||
f(syncResp, errResp)
|
||||
}
|
||||
})
|
||||
|
||||
wg.Wait()
|
||||
return err
|
||||
}
|
||||
|
||||
func sanitizeSyncData(sd *syncData) {
|
||||
for id, user := range sd.users {
|
||||
sd.users[id] = sanitizeUserForSync(user)
|
||||
}
|
||||
for id, user := range sd.profileImages {
|
||||
sd.profileImages[id] = sanitizeUserForSync(user)
|
||||
}
|
||||
}
|
||||
101
server/platform/services/sharedchannel/util.go
Обычный файл
101
server/platform/services/sharedchannel/util.go
Обычный файл
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sharedchannel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
// fixMention replaces any mentions in a post for the user with the user's real username.
|
||||
func fixMention(post *model.Post, mentionMap model.UserMentionMap, user *model.User) {
|
||||
if post == nil || len(mentionMap) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
realUsername, ok := user.GetProp(KeyRemoteUsername)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// there may be more than one mention for each user so we have to walk the whole map.
|
||||
for mention, id := range mentionMap {
|
||||
if id == user.Id && strings.Contains(mention, ":") {
|
||||
post.Message = strings.ReplaceAll(post.Message, "@"+mention, "@"+realUsername)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeUserForSync(user *model.User) *model.User {
|
||||
user.Password = model.NewId()
|
||||
user.AuthData = nil
|
||||
user.AuthService = ""
|
||||
user.Roles = "system_user"
|
||||
user.AllowMarketing = false
|
||||
user.NotifyProps = model.StringMap{}
|
||||
user.LastPasswordUpdate = 0
|
||||
user.LastPictureUpdate = 0
|
||||
user.FailedAttempts = 0
|
||||
user.MfaActive = false
|
||||
user.MfaSecret = ""
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
// mungUsername creates a new username by combining username and remote cluster name, plus
|
||||
// a suffix to create uniqueness. If the resulting username exceeds the max length then
|
||||
// it is truncated and ellipses added.
|
||||
func mungUsername(username string, remotename string, suffix string, maxLen int) string {
|
||||
if suffix != "" {
|
||||
suffix = "~" + suffix
|
||||
}
|
||||
|
||||
// If the username already contains a colon then another server already munged it.
|
||||
// In that case we can split on the colon and use the existing remote name.
|
||||
// We still need to re-mung with suffix in case of collision.
|
||||
comps := strings.Split(username, ":")
|
||||
if len(comps) >= 2 {
|
||||
username = comps[0]
|
||||
remotename = strings.Join(comps[1:], "")
|
||||
}
|
||||
|
||||
var userEllipses string
|
||||
var remoteEllipses string
|
||||
|
||||
// The remotename is allowed to use up to half the maxLen, and the username gets the remaining space.
|
||||
// Username might have a suffix to account for, and remotename always has a preceding colon.
|
||||
half := maxLen / 2
|
||||
|
||||
// If the remotename is less than half the maxLen, then the left over space can be given to
|
||||
// the username.
|
||||
extra := half - (len(remotename) + 1)
|
||||
if extra < 0 {
|
||||
extra = 0
|
||||
}
|
||||
|
||||
truncUser := (len(username) + len(suffix)) - (half + extra)
|
||||
if truncUser > 0 {
|
||||
username = username[:len(username)-truncUser-3]
|
||||
userEllipses = "..."
|
||||
}
|
||||
|
||||
truncRemote := (len(remotename) + 1) - (maxLen - (len(username) + len(userEllipses) + len(suffix)))
|
||||
if truncRemote > 0 {
|
||||
remotename = remotename[:len(remotename)-truncRemote-3]
|
||||
remoteEllipses = "..."
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s%s%s:%s%s", username, suffix, userEllipses, remotename, remoteEllipses)
|
||||
}
|
||||
|
||||
// mungEmail creates a unique email address using a UID and remote name.
|
||||
func mungEmail(remotename string, maxLen int) string {
|
||||
s := fmt.Sprintf("%s@%s", model.NewId(), remotename)
|
||||
if len(s) > maxLen {
|
||||
s = s[:maxLen]
|
||||
}
|
||||
return s
|
||||
}
|
||||
76
server/platform/services/sharedchannel/util_test.go
Обычный файл
76
server/platform/services/sharedchannel/util_test.go
Обычный файл
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sharedchannel
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_mungUsername(t *testing.T) {
|
||||
type args struct {
|
||||
username string
|
||||
remotename string
|
||||
suffix string
|
||||
maxLen int
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want string
|
||||
}{
|
||||
{"everything empty", args{username: "", remotename: "", suffix: "", maxLen: 64}, ":"},
|
||||
|
||||
{"no trunc, no suffix", args{username: "bart", remotename: "example.com", suffix: "", maxLen: 64}, "bart:example.com"},
|
||||
{"no trunc, suffix", args{username: "bart", remotename: "example.com", suffix: "2", maxLen: 64}, "bart~2:example.com"},
|
||||
|
||||
{"trunc remote, no suffix", args{username: "bart", remotename: "example1234567890.com", suffix: "", maxLen: 24}, "bart:example123456789..."},
|
||||
{"trunc remote, suffix", args{username: "bart", remotename: "example1234567890.com", suffix: "2", maxLen: 24}, "bart~2:example1234567..."},
|
||||
|
||||
{"trunc both, no suffix", args{username: R(24, "A"), remotename: R(24, "B"), suffix: "", maxLen: 24}, "AAAAAAAAA...:BBBBBBBB..."},
|
||||
{"trunc both, suffix", args{username: R(24, "A"), remotename: R(24, "B"), suffix: "10", maxLen: 24}, "AAAAAA~10...:BBBBBBBB..."},
|
||||
|
||||
{"trunc user, no suffix", args{username: R(40, "A"), remotename: "abc", suffix: "", maxLen: 24}, "AAAAAAAAAAAAAAAAA...:abc"},
|
||||
{"trunc user, suffix", args{username: R(40, "A"), remotename: "abc", suffix: "11", maxLen: 24}, "AAAAAAAAAAAAAA~11...:abc"},
|
||||
|
||||
{"trunc user, remote, no suffix", args{username: R(40, "A"), remotename: "abcdefghijk", suffix: "", maxLen: 24}, "AAAAAAAAA...:abcdefghijk"},
|
||||
{"trunc user, remote, suffix", args{username: R(40, "A"), remotename: "abcdefghijk", suffix: "19", maxLen: 24}, "AAAAAA~19...:abcdefghijk"},
|
||||
|
||||
{"short user, long remote, no suffix", args{username: "bart", remotename: R(40, "B"), suffix: "", maxLen: 24}, "bart:BBBBBBBBBBBBBBBB..."},
|
||||
{"long user, short remote, no suffix", args{username: R(40, "A"), remotename: "abc.com", suffix: "", maxLen: 24}, "AAAAAAAAAAAAA...:abc.com"},
|
||||
|
||||
{"short user, long remote, suffix", args{username: "bart", remotename: R(40, "B"), suffix: "12", maxLen: 24}, "bart~12:BBBBBBBBBBBBB..."},
|
||||
{"long user, short remote, suffix", args{username: R(40, "A"), remotename: "abc.com", suffix: "12", maxLen: 24}, "AAAAAAAAAA~12...:abc.com"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := mungUsername(tt.args.username, tt.args.remotename, tt.args.suffix, tt.args.maxLen); got != tt.want {
|
||||
t.Errorf("mungUsername() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_mungUsernameFuzz(t *testing.T) {
|
||||
// ensure no index out of bounds panic for any combination
|
||||
for i := 0; i < 70; i++ {
|
||||
for j := 0; j < 70; j++ {
|
||||
for k := 0; k < 3; k++ {
|
||||
username := R(i, "A")
|
||||
remotename := R(j, "B")
|
||||
suffix := R(k, "1")
|
||||
|
||||
result := mungUsername(username, remotename, suffix, 64)
|
||||
require.LessOrEqual(t, len(result), 64)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// R returns a string with the specified string repeated `count` times.
|
||||
func R(count int, s string) string {
|
||||
return strings.Repeat(s, count)
|
||||
}
|
||||
Ссылка в новой задаче
Block a user