mirror of
https://github.com/mattermost/mattermost.git
synced 2026-05-28 04:35:04 -04:00
MM-27493 Shared channels (MVP) (#17301)
Remote Cluster Service - provides ability for multiple Mattermost cluster instances to create a trusted connection with each other and exchange messages - trusted connections are managed via slash commands (for now) - facilitates features requiring inter-cluster communication, such as Shared Channels Shared Channels Service - provides ability to shared channels between one or more Mattermost cluster instances (using trusted connection) - sharing/unsharing of channels is managed via slash commands (for now)
This commit is contained in:
parent
ff980266ac
commit
02196e04fa
137 changed files with 15137 additions and 262 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -18,11 +18,16 @@ web/static/js/libs*.js
|
|||
|
||||
config/active.dat
|
||||
config/config.json
|
||||
config/logging.json
|
||||
/plugins
|
||||
|
||||
# Enterprise imports file
|
||||
imports/imports.go
|
||||
|
||||
#license files
|
||||
*.license
|
||||
*.mattermost-license
|
||||
|
||||
# Build Targets
|
||||
.prebuild
|
||||
.npminstall
|
||||
|
|
|
|||
27
Makefile
27
Makefile
|
|
@ -1,4 +1,4 @@
|
|||
.PHONY: build package run stop run-client run-server run-haserver stop-client stop-server restart restart-server restart-client restart-haserver start-docker clean-dist clean nuke check-style check-client-style check-server-style check-unit-tests test dist prepare-enteprise run-client-tests setup-run-client-tests cleanup-run-client-tests test-client build-linux build-osx build-windows internal-test-web-client vet run-server-for-web-client-tests diff-config prepackaged-plugins prepackaged-binaries test-server test-server-ee test-server-quick test-server-race start-docker-check migrations-bindata new-migration migration-prereqs
|
||||
.PHONY: build package run stop run-client run-server run-haserver stop-haserver stop-client stop-server restart restart-server restart-client restart-haserver start-docker clean-dist clean nuke check-style check-client-style check-server-style check-unit-tests test dist prepare-enteprise run-client-tests setup-run-client-tests cleanup-run-client-tests test-client build-linux build-osx build-windows internal-test-web-client vet run-server-for-web-client-tests diff-config prepackaged-plugins prepackaged-binaries test-server test-server-ee test-server-quick test-server-race start-docker-check migrations-bindata new-migration migration-prereqs
|
||||
|
||||
ROOT := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
|
||||
|
|
@ -58,6 +58,15 @@ else
|
|||
BUILD_CLIENT = false
|
||||
endif
|
||||
|
||||
# We need current user's UID for `run-haserver` so docker compose does not run server
|
||||
# as root and mess up file permissions for devs. When running like this HOME will be blank
|
||||
# and docker will add '/', so we need to set the go-build cache location or we'll get
|
||||
# permission errors on build as it tries to create a cache in filesystem root.
|
||||
export CURRENT_UID = $(shell id -u):$(shell id -g)
|
||||
ifeq ($(HOME),/)
|
||||
export XDG_CACHE_HOME = /tmp/go-cache/
|
||||
endif
|
||||
|
||||
# Go Flags
|
||||
GOFLAGS ?= $(GOFLAGS:)
|
||||
# We need to export GOBIN to allow it to be set
|
||||
|
|
@ -170,13 +179,17 @@ ifneq (,$(findstring mysql-read-replica,$(ENABLED_DOCKER_SERVICES)))
|
|||
endif
|
||||
endif
|
||||
|
||||
run-haserver: run-client
|
||||
run-haserver:
|
||||
ifeq ($(BUILD_ENTERPRISE_READY),true)
|
||||
@echo Starting mattermost in an HA topology
|
||||
@echo Starting mattermost in an HA topology '(3 node cluster)'
|
||||
|
||||
docker-compose -f docker-compose.yaml up haproxy
|
||||
docker-compose -f docker-compose.yaml up --remove-orphans haproxy
|
||||
endif
|
||||
|
||||
stop-haserver:
|
||||
@echo Stopping docker containers for HA topology
|
||||
docker-compose stop
|
||||
|
||||
stop-docker: ## Stops the docker containers for local development.
|
||||
ifeq ($(MM_NO_DOCKER),true)
|
||||
@echo No Docker Enabled: skipping docker stop
|
||||
|
|
@ -294,6 +307,11 @@ searchengine-mocks: ## Creates mock files for searchengines.
|
|||
$(GO) get -modfile=go.tools.mod github.com/vektra/mockery/...
|
||||
$(GOBIN)/mockery -dir services/searchengine -all -output services/searchengine/mocks -note 'Regenerate this file using `make searchengine-mocks`.'
|
||||
|
||||
sharedchannel-mocks: ## Creates mock files for shared channels.
|
||||
$(GO) get -modfile=go.tools.mod github.com/vektra/mockery/...
|
||||
$(GOBIN)/mockery -dir=./services/sharedchannel -name=ServerIface -output=./services/sharedchannel -inpkg -outpkg=sharedchannel -testonly -note 'Regenerate this file using `make sharedchannel-mocks`.'
|
||||
$(GOBIN)/mockery -dir=./services/sharedchannel -name=AppIface -output=./services/sharedchannel -inpkg -outpkg=sharedchannel -testonly -note 'Regenerate this file using `make sharedchannel-mocks`.'
|
||||
|
||||
pluginapi: ## Generates api and hooks glue code for plugins
|
||||
$(GO) generate $(GOFLAGS) ./plugin
|
||||
|
||||
|
|
@ -497,6 +515,7 @@ restart-server: | stop-server run-server ## Restarts the mattermost server to pi
|
|||
restart-haserver:
|
||||
@echo Restarting mattermost in an HA topology
|
||||
|
||||
docker-compose restart follower2
|
||||
docker-compose restart follower
|
||||
docker-compose restart leader
|
||||
docker-compose restart haproxy
|
||||
|
|
|
|||
|
|
@ -125,8 +125,12 @@ type Routes struct {
|
|||
Cloud *mux.Router // 'api/v4/cloud'
|
||||
|
||||
Imports *mux.Router // 'api/v4/imports'
|
||||
|
||||
Exports *mux.Router // 'api/v4/exports'
|
||||
Export *mux.Router // 'api/v4/exports/{export_name:.+\\.zip}'
|
||||
|
||||
RemoteCluster *mux.Router // 'api/v4/remotecluster'
|
||||
SharedChannels *mux.Router // 'api/v4/sharedchannels'
|
||||
}
|
||||
|
||||
type API struct {
|
||||
|
|
@ -243,6 +247,9 @@ func Init(configservice configservice.ConfigService, globalOptionsFunc app.AppOp
|
|||
api.BaseRoutes.Exports = api.BaseRoutes.ApiRoot.PathPrefix("/exports").Subrouter()
|
||||
api.BaseRoutes.Export = api.BaseRoutes.Exports.PathPrefix("/{export_name:.+\\.zip}").Subrouter()
|
||||
|
||||
api.BaseRoutes.RemoteCluster = api.BaseRoutes.ApiRoot.PathPrefix("/remotecluster").Subrouter()
|
||||
api.BaseRoutes.SharedChannels = api.BaseRoutes.ApiRoot.PathPrefix("/sharedchannels").Subrouter()
|
||||
|
||||
api.InitUser()
|
||||
api.InitBot()
|
||||
api.InitTeam()
|
||||
|
|
@ -280,6 +287,8 @@ func Init(configservice configservice.ConfigService, globalOptionsFunc app.AppOp
|
|||
api.InitAction()
|
||||
api.InitCloud()
|
||||
api.InitImport()
|
||||
api.InitRemoteCluster()
|
||||
api.InitSharedChannels()
|
||||
api.InitExport()
|
||||
|
||||
root.Handle("/api/v4/{anything:.*}", http.HandlerFunc(api.Handle404))
|
||||
|
|
|
|||
|
|
@ -72,6 +72,26 @@ func (api *API) CloudApiKeyRequired(h func(*Context, http.ResponseWriter, *http.
|
|||
|
||||
}
|
||||
|
||||
// RemoteClusterTokenRequired provides a handler for remote cluster requests to /remotecluster endpoints.
|
||||
func (api *API) RemoteClusterTokenRequired(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler {
|
||||
handler := &web.Handler{
|
||||
GetGlobalAppOptions: api.GetGlobalAppOptions,
|
||||
HandleFunc: h,
|
||||
HandlerName: web.GetHandlerName(h),
|
||||
RequireSession: false,
|
||||
RequireCloudKey: false,
|
||||
RequireRemoteClusterToken: true,
|
||||
TrustRequester: false,
|
||||
RequireMfa: false,
|
||||
IsStatic: false,
|
||||
IsLocal: false,
|
||||
}
|
||||
if *api.ConfigService.Config().ServiceSettings.WebserverMode == "gzip" {
|
||||
return gziphandler.GzipHandler(handler)
|
||||
}
|
||||
return handler
|
||||
}
|
||||
|
||||
// ApiSessionRequiredMfa provides a handler for API endpoints which require a logged-in user session but when accessed,
|
||||
// if MFA is enabled, the MFA process is not yet complete, and therefore the requirement to have completed the MFA
|
||||
// authentication must be waived.
|
||||
|
|
|
|||
|
|
@ -618,7 +618,7 @@ func TestCreatePostCheckOnlineStatus(t *testing.T) {
|
|||
}
|
||||
case <-timeout:
|
||||
// We just skip the test instead of failing because waiting for more than 5 seconds
|
||||
// to get a response does not make sense, and it will unncessarily slow down
|
||||
// to get a response does not make sense, and it will unnecessarily slow down
|
||||
// the tests further in an already congested CI environment.
|
||||
t.Skip("timed out waiting for event")
|
||||
}
|
||||
|
|
@ -2035,7 +2035,7 @@ func TestDeletePostMessage(t *testing.T) {
|
|||
}
|
||||
case <-timeout:
|
||||
// We just skip the test instead of failing because waiting for more than 5 seconds
|
||||
// to get a response does not make sense, and it will unncessarily slow down
|
||||
// to get a response does not make sense, and it will unnecessarily slow down
|
||||
// the tests further in an already congested CI environment.
|
||||
t.Skip("timed out waiting for event")
|
||||
}
|
||||
|
|
|
|||
214
api4/remote_cluster.go
Normal file
214
api4/remote_cluster.go
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api4
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/audit"
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/services/remotecluster"
|
||||
)
|
||||
|
||||
func (api *API) InitRemoteCluster() {
|
||||
api.BaseRoutes.RemoteCluster.Handle("/ping", api.RemoteClusterTokenRequired(remoteClusterPing)).Methods("POST")
|
||||
api.BaseRoutes.RemoteCluster.Handle("/msg", api.RemoteClusterTokenRequired(remoteClusterAcceptMessage)).Methods("POST")
|
||||
api.BaseRoutes.RemoteCluster.Handle("/confirm_invite", api.RemoteClusterTokenRequired(remoteClusterConfirmInvite)).Methods("POST")
|
||||
api.BaseRoutes.RemoteCluster.Handle("/upload/{upload_id:[A-Za-z0-9]+}", api.RemoteClusterTokenRequired(uploadRemoteData)).Methods("POST")
|
||||
}
|
||||
|
||||
func remoteClusterPing(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
// make sure remote cluster service is enabled.
|
||||
if _, appErr := c.App.GetRemoteClusterService(); appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
frame, appErr := model.RemoteClusterFrameFromJSON(r.Body)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
if appErr = frame.IsValid(); appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
remoteId := c.GetRemoteID(r)
|
||||
if remoteId != frame.RemoteId {
|
||||
c.SetInvalidRemoteIdError(frame.RemoteId)
|
||||
return
|
||||
}
|
||||
|
||||
rc, err := c.App.GetRemoteCluster(frame.RemoteId)
|
||||
if err != nil {
|
||||
c.SetInvalidRemoteIdError(frame.RemoteId)
|
||||
return
|
||||
}
|
||||
|
||||
ping, err := model.RemoteClusterPingFromRawJSON(frame.Msg.Payload)
|
||||
if err != nil {
|
||||
c.SetInvalidParam("msg.payload")
|
||||
return
|
||||
}
|
||||
ping.RecvAt = model.GetMillis()
|
||||
|
||||
if metrics := c.App.Metrics(); metrics != nil {
|
||||
metrics.IncrementRemoteClusterMsgReceivedCounter(rc.RemoteId)
|
||||
}
|
||||
|
||||
resp, _ := json.Marshal(ping)
|
||||
w.Write(resp)
|
||||
}
|
||||
|
||||
func remoteClusterAcceptMessage(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
// make sure remote cluster service is running.
|
||||
service, appErr := c.App.GetRemoteClusterService()
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
frame, appErr := model.RemoteClusterFrameFromJSON(r.Body)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
if appErr = frame.IsValid(); appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord("remoteClusterAcceptMessage", audit.Fail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
|
||||
remoteId := c.GetRemoteID(r)
|
||||
if remoteId != frame.RemoteId {
|
||||
c.SetInvalidRemoteIdError(frame.RemoteId)
|
||||
return
|
||||
}
|
||||
|
||||
rc, err := c.App.GetRemoteCluster(frame.RemoteId)
|
||||
if err != nil {
|
||||
c.SetInvalidRemoteIdError(frame.RemoteId)
|
||||
return
|
||||
}
|
||||
auditRec.AddMeta("remoteCluster", rc)
|
||||
|
||||
// pass message to Remote Cluster Service and write response
|
||||
resp := service.ReceiveIncomingMsg(rc, frame.Msg)
|
||||
|
||||
b, errMarshall := json.Marshal(resp)
|
||||
if errMarshall != nil {
|
||||
c.Err = model.NewAppError("remoteClusterAcceptMessage", "api.marshal_error", nil, errMarshall.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Write(b)
|
||||
}
|
||||
|
||||
func remoteClusterConfirmInvite(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
// make sure remote cluster service is running.
|
||||
if _, appErr := c.App.GetRemoteClusterService(); appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
frame, appErr := model.RemoteClusterFrameFromJSON(r.Body)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
if appErr = frame.IsValid(); appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord("remoteClusterAcceptInvite", audit.Fail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
|
||||
remoteId := c.GetRemoteID(r)
|
||||
if remoteId != frame.RemoteId {
|
||||
c.SetInvalidRemoteIdError(frame.RemoteId)
|
||||
return
|
||||
}
|
||||
|
||||
rc, err := c.App.GetRemoteCluster(frame.RemoteId)
|
||||
if err != nil {
|
||||
c.SetInvalidRemoteIdError(frame.RemoteId)
|
||||
return
|
||||
}
|
||||
auditRec.AddMeta("remoteCluster", rc)
|
||||
|
||||
if time.Since(model.GetTimeForMillis(rc.CreateAt)) > remotecluster.InviteExpiresAfter {
|
||||
c.Err = model.NewAppError("remoteClusterAcceptMessage", "api.context.invitation_expired.error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
confirm, appErr := model.RemoteClusterInviteFromRawJSON(frame.Msg.Payload)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
rc.RemoteTeamId = confirm.RemoteTeamId
|
||||
rc.SiteURL = confirm.SiteURL
|
||||
rc.RemoteToken = confirm.Token
|
||||
|
||||
if _, err := c.App.UpdateRemoteCluster(rc); err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
ReturnStatusOK(w)
|
||||
}
|
||||
|
||||
func uploadRemoteData(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
if !*c.App.Config().FileSettings.EnableFileAttachments {
|
||||
c.Err = model.NewAppError("uploadRemoteData", "api.file.attachments.disabled.app_error",
|
||||
nil, "", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
c.RequireUploadId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord("uploadRemoteData", audit.Fail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
auditRec.AddMeta("upload_id", c.Params.UploadId)
|
||||
|
||||
us, err := c.App.GetUploadSession(c.Params.UploadId)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
if us.RemoteId != c.GetRemoteID(r) {
|
||||
c.Err = model.NewAppError("uploadRemoteData", "api.context.remote_id_mismatch.app_error",
|
||||
nil, "", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := doUploadData(c, us, r)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
|
||||
if info == nil {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write([]byte(info.ToJson()))
|
||||
}
|
||||
76
api4/shared_channel.go
Normal file
76
api4/shared_channel.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api4
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
)
|
||||
|
||||
func (api *API) InitSharedChannels() {
|
||||
api.BaseRoutes.SharedChannels.Handle("/{team_id:[A-Za-z0-9]+}", api.ApiSessionRequired(getSharedChannels)).Methods("GET")
|
||||
api.BaseRoutes.SharedChannels.Handle("/remote_info/{remote_id:[A-Za-z0-9]+}", api.ApiSessionRequired(getRemoteClusterInfo)).Methods("GET")
|
||||
}
|
||||
|
||||
func getSharedChannels(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireTeamId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// make sure remote cluster service is enabled.
|
||||
if _, appErr := c.App.GetRemoteClusterService(); appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
opts := model.SharedChannelFilterOpts{
|
||||
TeamId: c.Params.TeamId,
|
||||
}
|
||||
|
||||
channels, appErr := c.App.GetSharedChannels(c.Params.Page, c.Params.PerPage, opts)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
b, err := json.Marshal(channels)
|
||||
if err != nil {
|
||||
c.SetJSONEncodingError()
|
||||
return
|
||||
}
|
||||
w.Write(b)
|
||||
}
|
||||
|
||||
func getRemoteClusterInfo(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireRemoteId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// make sure remote cluster service is enabled.
|
||||
if _, appErr := c.App.GetRemoteClusterService(); appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
// GetRemoteClusterForUser will only return a remote if the user is a member of at
|
||||
// least one channel shared by the remote. All other cases return error.
|
||||
rc, appErr := c.App.GetRemoteClusterForUser(c.Params.RemoteId, c.App.Session().UserId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
remoteInfo := rc.ToRemoteClusterInfo()
|
||||
|
||||
b, err := json.Marshal(remoteInfo)
|
||||
if err != nil {
|
||||
c.SetJSONEncodingError()
|
||||
return
|
||||
}
|
||||
w.Write(b)
|
||||
}
|
||||
229
api4/shared_channel_test.go
Normal file
229
api4/shared_channel_test.go
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api4
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/app"
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
)
|
||||
|
||||
var (
|
||||
rnd = rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
)
|
||||
|
||||
func TestGetAllSharedChannels(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
const pages = 3
|
||||
const pageSize = 7
|
||||
|
||||
mockService := app.NewMockRemoteClusterService(nil, app.MockOptionRemoteClusterServiceWithActive(true))
|
||||
th.App.Srv().SetRemoteClusterService(mockService)
|
||||
|
||||
savedIds := make([]string, 0, pages*pageSize)
|
||||
|
||||
// make some shared channels
|
||||
for i := 0; i < pages*pageSize; i++ {
|
||||
channel := th.CreateChannelWithClientAndTeam(th.Client, model.CHANNEL_OPEN, th.BasicTeam.Id)
|
||||
sc := &model.SharedChannel{
|
||||
ChannelId: channel.Id,
|
||||
TeamId: channel.TeamId,
|
||||
Home: randomBool(),
|
||||
ShareName: fmt.Sprintf("test_share_%d", i),
|
||||
CreatorId: th.BasicChannel.CreatorId,
|
||||
RemoteId: model.NewId(),
|
||||
}
|
||||
_, err := th.App.SaveSharedChannel(sc)
|
||||
require.NoError(t, err)
|
||||
savedIds = append(savedIds, channel.Id)
|
||||
}
|
||||
sort.Strings(savedIds)
|
||||
|
||||
t.Run("get shared channels paginated", func(t *testing.T) {
|
||||
channelIds := make([]string, 0, 21)
|
||||
for i := 0; i < pages; i++ {
|
||||
channels, resp := th.Client.GetAllSharedChannels(th.BasicTeam.Id, i, pageSize)
|
||||
CheckNoError(t, resp)
|
||||
channelIds = append(channelIds, getIds(channels)...)
|
||||
}
|
||||
sort.Strings(channelIds)
|
||||
|
||||
// ids lists should now match
|
||||
assert.Equal(t, savedIds, channelIds, "id lists should match")
|
||||
})
|
||||
|
||||
t.Run("get shared channels for invalid team", func(t *testing.T) {
|
||||
channels, resp := th.Client.GetAllSharedChannels(model.NewId(), 0, 100)
|
||||
CheckNoError(t, resp)
|
||||
assert.Empty(t, channels)
|
||||
})
|
||||
}
|
||||
|
||||
func getIds(channels []*model.SharedChannel) []string {
|
||||
ids := make([]string, 0, len(channels))
|
||||
for _, c := range channels {
|
||||
ids = append(ids, c.ChannelId)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func randomBool() bool {
|
||||
return rnd.Intn(2) != 0
|
||||
}
|
||||
|
||||
func TestGetRemoteClusterById(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
mockService := app.NewMockRemoteClusterService(nil, app.MockOptionRemoteClusterServiceWithActive(true))
|
||||
th.App.Srv().SetRemoteClusterService(mockService)
|
||||
|
||||
// for this test we need a user that belongs to a channel that
|
||||
// is shared with the requested remote id.
|
||||
|
||||
// create a remote cluster
|
||||
rc := &model.RemoteCluster{
|
||||
RemoteId: model.NewId(),
|
||||
DisplayName: "Test1",
|
||||
RemoteTeamId: model.NewId(),
|
||||
SiteURL: model.NewId(),
|
||||
CreatorId: model.NewId(),
|
||||
}
|
||||
rc, appErr := th.App.AddRemoteCluster(rc)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// create a shared channel
|
||||
sc := &model.SharedChannel{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
TeamId: th.BasicChannel.TeamId,
|
||||
Home: false,
|
||||
ShareName: "test_share",
|
||||
CreatorId: th.BasicChannel.CreatorId,
|
||||
RemoteId: rc.RemoteId,
|
||||
}
|
||||
sc, err := th.App.SaveSharedChannel(sc)
|
||||
require.NoError(t, err)
|
||||
|
||||
// create a shared channel remote to connect them
|
||||
scr := &model.SharedChannelRemote{
|
||||
Id: model.NewId(),
|
||||
ChannelId: sc.ChannelId,
|
||||
CreatorId: sc.CreatorId,
|
||||
IsInviteAccepted: true,
|
||||
IsInviteConfirmed: true,
|
||||
RemoteId: sc.RemoteId,
|
||||
}
|
||||
_, err = th.App.SaveSharedChannelRemote(scr)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("valid remote, user is member", func(t *testing.T) {
|
||||
rcInfo, resp := th.Client.GetRemoteClusterInfo(rc.RemoteId)
|
||||
CheckNoError(t, resp)
|
||||
assert.Equal(t, rc.DisplayName, rcInfo.DisplayName)
|
||||
})
|
||||
|
||||
t.Run("invalid remote", func(t *testing.T) {
|
||||
_, resp := th.Client.GetRemoteClusterInfo(model.NewId())
|
||||
CheckNotFoundStatus(t, resp)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func TestCreateDirectChannelWithRemoteUser(t *testing.T) {
|
||||
t.Run("creates a local DM channel that is shared", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
Client := th.Client
|
||||
defer Client.Logout()
|
||||
|
||||
localUser := th.BasicUser
|
||||
remoteUser := th.CreateUser()
|
||||
remoteUser.RemoteId = model.NewString(model.NewId())
|
||||
remoteUser, err := th.App.UpdateUser(remoteUser, false)
|
||||
require.Nil(t, err)
|
||||
|
||||
dm, resp := Client.CreateDirectChannel(localUser.Id, remoteUser.Id)
|
||||
CheckNoError(t, resp)
|
||||
|
||||
channelName := model.GetDMNameFromIds(localUser.Id, remoteUser.Id)
|
||||
require.Equal(t, channelName, dm.Name, "dm name didn't match")
|
||||
assert.True(t, dm.IsShared())
|
||||
})
|
||||
|
||||
t.Run("sends a shared channel invitation to the remote", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
Client := th.Client
|
||||
defer Client.Logout()
|
||||
|
||||
mockService := app.NewMockSharedChannelService(nil, app.MockOptionSharedChannelServiceWithActive(true))
|
||||
th.App.Srv().SetSharedChannelSyncService(mockService)
|
||||
|
||||
localUser := th.BasicUser
|
||||
remoteUser := th.CreateUser()
|
||||
rc := &model.RemoteCluster{
|
||||
DisplayName: "test",
|
||||
Token: model.NewId(),
|
||||
CreatorId: localUser.Id,
|
||||
}
|
||||
rc, err := th.App.AddRemoteCluster(rc)
|
||||
require.Nil(t, err)
|
||||
|
||||
remoteUser.RemoteId = model.NewString(rc.RemoteId)
|
||||
remoteUser, err = th.App.UpdateUser(remoteUser, false)
|
||||
require.Nil(t, err)
|
||||
|
||||
dm, resp := Client.CreateDirectChannel(localUser.Id, remoteUser.Id)
|
||||
CheckNoError(t, resp)
|
||||
|
||||
channelName := model.GetDMNameFromIds(localUser.Id, remoteUser.Id)
|
||||
require.Equal(t, channelName, dm.Name, "dm name didn't match")
|
||||
require.True(t, dm.IsShared())
|
||||
|
||||
assert.Equal(t, 1, mockService.NumInvitations())
|
||||
})
|
||||
|
||||
t.Run("does not send a shared channel invitation to the remote when creator is remote", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
Client := th.Client
|
||||
defer Client.Logout()
|
||||
|
||||
mockService := app.NewMockSharedChannelService(nil, app.MockOptionSharedChannelServiceWithActive(true))
|
||||
th.App.Srv().SetSharedChannelSyncService(mockService)
|
||||
|
||||
localUser := th.BasicUser
|
||||
remoteUser := th.CreateUser()
|
||||
rc := &model.RemoteCluster{
|
||||
DisplayName: "test",
|
||||
Token: model.NewId(),
|
||||
CreatorId: localUser.Id,
|
||||
}
|
||||
rc, err := th.App.AddRemoteCluster(rc)
|
||||
require.Nil(t, err)
|
||||
|
||||
remoteUser.RemoteId = model.NewString(rc.RemoteId)
|
||||
remoteUser, err = th.App.UpdateUser(remoteUser, false)
|
||||
require.Nil(t, err)
|
||||
|
||||
dm, resp := Client.CreateDirectChannel(remoteUser.Id, localUser.Id)
|
||||
CheckNoError(t, resp)
|
||||
|
||||
channelName := model.GetDMNameFromIds(localUser.Id, remoteUser.Id)
|
||||
require.Equal(t, channelName, dm.Name, "dm name didn't match")
|
||||
require.True(t, dm.IsShared())
|
||||
|
||||
assert.Zero(t, mockService.NumInvitations())
|
||||
})
|
||||
}
|
||||
|
|
@ -33,6 +33,10 @@ func createUpload(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// these are not supported for client uploads; shared channels only.
|
||||
us.RemoteId = ""
|
||||
us.ReqFileId = ""
|
||||
|
||||
auditRec := c.MakeAuditRecord("createUpload", audit.Fail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
auditRec.AddMeta("upload", us)
|
||||
|
|
@ -119,33 +123,7 @@ func uploadData(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
boundary, parseErr := parseMultipartRequestHeader(r)
|
||||
if parseErr != nil && !errors.Is(parseErr, http.ErrNotMultipart) {
|
||||
c.Err = model.NewAppError("uploadData", "api.upload.upload_data.invalid_content_type",
|
||||
nil, parseErr.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var rd io.Reader
|
||||
if boundary != "" {
|
||||
mr := multipart.NewReader(r.Body, boundary)
|
||||
p, partErr := mr.NextPart()
|
||||
if partErr != nil {
|
||||
c.Err = model.NewAppError("uploadData", "api.upload.upload_data.multipart_error",
|
||||
nil, partErr.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
rd = p
|
||||
} else {
|
||||
if r.ContentLength > (us.FileSize - us.FileOffset) {
|
||||
c.Err = model.NewAppError("uploadData", "api.upload.upload_data.invalid_content_length",
|
||||
nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
rd = r.Body
|
||||
}
|
||||
|
||||
info, err := c.App.UploadData(us, rd)
|
||||
info, err := doUploadData(c, us, r)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
|
|
@ -160,3 +138,30 @@ func uploadData(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
w.Write([]byte(info.ToJson()))
|
||||
}
|
||||
|
||||
func doUploadData(c *Context, us *model.UploadSession, r *http.Request) (*model.FileInfo, *model.AppError) {
|
||||
boundary, parseErr := parseMultipartRequestHeader(r)
|
||||
if parseErr != nil && !errors.Is(parseErr, http.ErrNotMultipart) {
|
||||
return nil, model.NewAppError("uploadData", "api.upload.upload_data.invalid_content_type",
|
||||
nil, parseErr.Error(), http.StatusBadRequest)
|
||||
}
|
||||
|
||||
var rd io.Reader
|
||||
if boundary != "" {
|
||||
mr := multipart.NewReader(r.Body, boundary)
|
||||
p, partErr := mr.NextPart()
|
||||
if partErr != nil {
|
||||
return nil, model.NewAppError("uploadData", "api.upload.upload_data.multipart_error",
|
||||
nil, partErr.Error(), http.StatusBadRequest)
|
||||
}
|
||||
rd = p
|
||||
} else {
|
||||
if r.ContentLength > (us.FileSize - us.FileOffset) {
|
||||
return nil, model.NewAppError("uploadData", "api.upload.upload_data.invalid_content_length",
|
||||
nil, "", http.StatusBadRequest)
|
||||
}
|
||||
rd = r.Body
|
||||
}
|
||||
|
||||
return c.App.UploadData(us, rd)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import (
|
|||
"github.com/mattermost/mattermost-server/v5/plugin"
|
||||
"github.com/mattermost/mattermost-server/v5/services/httpservice"
|
||||
"github.com/mattermost/mattermost-server/v5/services/imageproxy"
|
||||
"github.com/mattermost/mattermost-server/v5/services/remotecluster"
|
||||
"github.com/mattermost/mattermost-server/v5/services/searchengine"
|
||||
"github.com/mattermost/mattermost-server/v5/services/timezones"
|
||||
"github.com/mattermost/mattermost-server/v5/shared/filestore"
|
||||
|
|
@ -199,6 +200,8 @@ type AppIface interface {
|
|||
GetTeamSchemeChannelRoles(teamID string) (guestRoleName string, userRoleName string, adminRoleName string, err *model.AppError)
|
||||
// GetTotalUsersStats is used for the DM list total
|
||||
GetTotalUsersStats(viewRestrictions *model.ViewUsersRestrictions) (*model.UsersStats, *model.AppError)
|
||||
// HasRemote returns whether a given channelID is present in the channel remotes or not.
|
||||
HasRemote(channelID string, remoteID string) (bool, error)
|
||||
// HubRegister registers a connection to a hub.
|
||||
HubRegister(webConn *WebConn)
|
||||
// HubStart starts all the hubs.
|
||||
|
|
@ -361,6 +364,7 @@ type AppIface interface {
|
|||
AddDirectChannels(teamID string, user *model.User) *model.AppError
|
||||
AddLdapPrivateCertificate(fileData *multipart.FileHeader) *model.AppError
|
||||
AddLdapPublicCertificate(fileData *multipart.FileHeader) *model.AppError
|
||||
AddRemoteCluster(rc *model.RemoteCluster) (*model.RemoteCluster, *model.AppError)
|
||||
AddSamlIdpCertificate(fileData *multipart.FileHeader) *model.AppError
|
||||
AddSamlPrivateCertificate(fileData *multipart.FileHeader) *model.AppError
|
||||
AddSamlPublicCertificate(fileData *multipart.FileHeader) *model.AppError
|
||||
|
|
@ -399,6 +403,7 @@ type AppIface interface {
|
|||
ChannelMembersToAdd(since int64, channelID *string) ([]*model.UserChannelIDPair, *model.AppError)
|
||||
ChannelMembersToRemove(teamID *string) ([]*model.ChannelMember, *model.AppError)
|
||||
CheckAndSendUserLimitWarningEmails() *model.AppError
|
||||
CheckCanInviteToSharedChannel(channelId string) error
|
||||
CheckForClientSideCert(r *http.Request) (string, string, string)
|
||||
CheckMandatoryS3Fields(settings *model.FileSettings) *model.AppError
|
||||
CheckPasswordAndAllCriteria(user *model.User, password string, mfaToken string) *model.AppError
|
||||
|
|
@ -483,7 +488,10 @@ type AppIface interface {
|
|||
DeletePostFiles(post *model.Post)
|
||||
DeletePreferences(userID string, preferences model.Preferences) *model.AppError
|
||||
DeleteReactionForPost(reaction *model.Reaction) *model.AppError
|
||||
DeleteRemoteCluster(remoteClusterId string) (bool, *model.AppError)
|
||||
DeleteScheme(schemeId string) (*model.Scheme, *model.AppError)
|
||||
DeleteSharedChannel(channelID string) (bool, error)
|
||||
DeleteSharedChannelRemote(id string) (bool, error)
|
||||
DeleteSidebarCategory(userID, teamID, categoryId string) *model.AppError
|
||||
DeleteToken(token *model.Token) *model.AppError
|
||||
DisableAutoResponder(userID string, asAdmin bool) *model.AppError
|
||||
|
|
@ -524,6 +532,7 @@ type AppIface interface {
|
|||
GetAllPublicTeams() ([]*model.Team, *model.AppError)
|
||||
GetAllPublicTeamsPage(offset int, limit int) ([]*model.Team, *model.AppError)
|
||||
GetAllPublicTeamsPageWithCount(offset int, limit int) (*model.TeamsWithCount, *model.AppError)
|
||||
GetAllRemoteClusters(filter model.RemoteClusterQueryFilter) ([]*model.RemoteCluster, *model.AppError)
|
||||
GetAllRoles() ([]*model.Role, *model.AppError)
|
||||
GetAllStatuses() map[string]*model.Status
|
||||
GetAllTeams() ([]*model.Team, *model.AppError)
|
||||
|
|
@ -626,7 +635,7 @@ type AppIface interface {
|
|||
GetOAuthSignupEndpoint(w http.ResponseWriter, r *http.Request, service, teamID string) (string, *model.AppError)
|
||||
GetOAuthStateToken(token string) (*model.Token, *model.AppError)
|
||||
GetOpenGraphMetadata(requestURL string) *opengraph.OpenGraph
|
||||
GetOrCreateDirectChannel(userID, otherUserID string) (*model.Channel, *model.AppError)
|
||||
GetOrCreateDirectChannel(userID, otherUserID string, channelOptions ...model.ChannelOption) (*model.Channel, *model.AppError)
|
||||
GetOutgoingWebhook(hookID string) (*model.OutgoingWebhook, *model.AppError)
|
||||
GetOutgoingWebhooksForChannelPageByUser(channelID string, userID string, page, perPage int) ([]*model.OutgoingWebhook, *model.AppError)
|
||||
GetOutgoingWebhooksForTeamPage(teamID string, page, perPage int) ([]*model.OutgoingWebhook, *model.AppError)
|
||||
|
|
@ -661,6 +670,10 @@ type AppIface interface {
|
|||
GetReactionsForPost(postID string) ([]*model.Reaction, *model.AppError)
|
||||
GetRecentlyActiveUsersForTeam(teamID string) (map[string]*model.User, *model.AppError)
|
||||
GetRecentlyActiveUsersForTeamPage(teamID string, page, perPage int, asAdmin bool, viewRestrictions *model.ViewUsersRestrictions) ([]*model.User, *model.AppError)
|
||||
GetRemoteCluster(remoteClusterId string) (*model.RemoteCluster, *model.AppError)
|
||||
GetRemoteClusterForUser(remoteID string, userID string) (*model.RemoteCluster, *model.AppError)
|
||||
GetRemoteClusterService() (remotecluster.RemoteClusterServiceIFace, *model.AppError)
|
||||
GetRemoteClusterSession(token string, remoteId string) (*model.Session, *model.AppError)
|
||||
GetRole(id string) (*model.Role, *model.AppError)
|
||||
GetRoleByName(name string) (*model.Role, *model.AppError)
|
||||
GetRolesByNames(names []string) ([]*model.Role, *model.AppError)
|
||||
|
|
@ -676,6 +689,13 @@ type AppIface interface {
|
|||
GetSession(token string) (*model.Session, *model.AppError)
|
||||
GetSessionById(sessionID string) (*model.Session, *model.AppError)
|
||||
GetSessions(userID string) ([]*model.Session, *model.AppError)
|
||||
GetSharedChannel(channelID string) (*model.SharedChannel, error)
|
||||
GetSharedChannelRemote(id string) (*model.SharedChannelRemote, error)
|
||||
GetSharedChannelRemoteByIds(channelID string, remoteID string) (*model.SharedChannelRemote, error)
|
||||
GetSharedChannelRemotes(opts model.SharedChannelRemoteFilterOpts) ([]*model.SharedChannelRemote, error)
|
||||
GetSharedChannelRemotesStatus(channelID string) ([]*model.SharedChannelRemoteStatus, error)
|
||||
GetSharedChannels(page int, perPage int, opts model.SharedChannelFilterOpts) ([]*model.SharedChannel, *model.AppError)
|
||||
GetSharedChannelsCount(opts model.SharedChannelFilterOpts) (int64, error)
|
||||
GetSidebarCategories(userID, teamID string) (*model.OrderedSidebarCategories, *model.AppError)
|
||||
GetSidebarCategory(categoryId string) (*model.SidebarCategoryWithChannels, *model.AppError)
|
||||
GetSidebarCategoryOrder(userID, teamID string) ([]string, *model.AppError)
|
||||
|
|
@ -754,6 +774,7 @@ type AppIface interface {
|
|||
HasPermissionToChannelByPost(askingUserId string, postID string, permission *model.Permission) bool
|
||||
HasPermissionToTeam(askingUserId string, teamID string, permission *model.Permission) bool
|
||||
HasPermissionToUser(askingUserId string, userID string) bool
|
||||
HasSharedChannel(channelID string) (bool, error)
|
||||
HubStop()
|
||||
ImageProxy() *imageproxy.ImageProxy
|
||||
ImageProxyAdder() func(string) string
|
||||
|
|
@ -884,6 +905,8 @@ type AppIface interface {
|
|||
SaveBrandImage(imageData *multipart.FileHeader) *model.AppError
|
||||
SaveComplianceReport(job *model.Compliance) (*model.Compliance, *model.AppError)
|
||||
SaveReactionForPost(reaction *model.Reaction) (*model.Reaction, *model.AppError)
|
||||
SaveSharedChannel(sc *model.SharedChannel) (*model.SharedChannel, error)
|
||||
SaveSharedChannelRemote(remote *model.SharedChannelRemote) (*model.SharedChannelRemote, error)
|
||||
SaveUserTermsOfService(userID, termsOfServiceId string, accepted bool) *model.AppError
|
||||
SchemesIterator(scope string, batchSize int) func() []*model.Scheme
|
||||
SearchArchivedChannels(teamID string, term string, userID string) (*model.ChannelList, *model.AppError)
|
||||
|
|
@ -942,6 +965,7 @@ type AppIface interface {
|
|||
SetProfileImage(userID string, imageData *multipart.FileHeader) *model.AppError
|
||||
SetProfileImageFromFile(userID string, file io.Reader) *model.AppError
|
||||
SetProfileImageFromMultiPartFile(userID string, file multipart.File) *model.AppError
|
||||
SetRemoteClusterLastPingAt(remoteClusterId string) *model.AppError
|
||||
SetRequestId(s string)
|
||||
SetSamlIdpCertificateFromMetadata(data []byte) *model.AppError
|
||||
SetSearchEngine(se *searchengine.Broker)
|
||||
|
|
@ -1009,9 +1033,13 @@ type AppIface interface {
|
|||
UpdatePasswordSendEmail(user *model.User, newPassword, method string) *model.AppError
|
||||
UpdatePost(post *model.Post, safeUpdate bool) (*model.Post, *model.AppError)
|
||||
UpdatePreferences(userID string, preferences model.Preferences) *model.AppError
|
||||
UpdateRemoteCluster(rc *model.RemoteCluster) (*model.RemoteCluster, *model.AppError)
|
||||
UpdateRemoteClusterTopics(remoteClusterId string, topics string) (*model.RemoteCluster, *model.AppError)
|
||||
UpdateRole(role *model.Role) (*model.Role, *model.AppError)
|
||||
UpdateScheme(scheme *model.Scheme) (*model.Scheme, *model.AppError)
|
||||
UpdateSessionsIsGuest(userID string, isGuest bool)
|
||||
UpdateSharedChannel(sc *model.SharedChannel) (*model.SharedChannel, error)
|
||||
UpdateSharedChannelRemoteNextSyncAt(id string, syncTime int64) error
|
||||
UpdateSidebarCategories(userID, teamID string, categories []*model.SidebarCategoryWithChannels) ([]*model.SidebarCategoryWithChannels, *model.AppError)
|
||||
UpdateSidebarCategoryOrder(userID, teamID string, categoryOrder []string) *model.AppError
|
||||
UpdateTeam(team *model.Team) (*model.Team, *model.AppError)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const (
|
|||
TokenLocationCookie
|
||||
TokenLocationQueryString
|
||||
TokenLocationCloudHeader
|
||||
TokenLocationRemoteClusterHeader
|
||||
)
|
||||
|
||||
func (tl TokenLocation) String() string {
|
||||
|
|
@ -34,6 +35,8 @@ func (tl TokenLocation) String() string {
|
|||
return "QueryString"
|
||||
case TokenLocationCloudHeader:
|
||||
return "CloudHeader"
|
||||
case TokenLocationRemoteClusterHeader:
|
||||
return "RemoteClusterHeader"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
|
|
@ -291,5 +294,9 @@ func ParseAuthTokenFromRequest(r *http.Request) (string, TokenLocation) {
|
|||
return token, TokenLocationCloudHeader
|
||||
}
|
||||
|
||||
if token := r.Header.Get(model.HEADER_REMOTECLUSTER_TOKEN); token != "" {
|
||||
return token, TokenLocationRemoteClusterHeader
|
||||
}
|
||||
|
||||
return "", TokenLocationNotFound
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,7 +125,6 @@ func (a *App) JoinDefaultChannels(teamID string, user *model.User, shouldBeAdmin
|
|||
message.Add("user_id", user.Id)
|
||||
message.Add("team_id", channel.TeamId)
|
||||
a.Publish(message)
|
||||
|
||||
}
|
||||
|
||||
if nErr != nil {
|
||||
|
|
@ -322,7 +321,7 @@ func (a *App) CreateChannel(channel *model.Channel, addMember bool) (*model.Chan
|
|||
return sc, nil
|
||||
}
|
||||
|
||||
func (a *App) GetOrCreateDirectChannel(userID, otherUserID string) (*model.Channel, *model.AppError) {
|
||||
func (a *App) GetOrCreateDirectChannel(userID, otherUserID string, channelOptions ...model.ChannelOption) (*model.Channel, *model.AppError) {
|
||||
channel, nErr := a.getDirectChannel(userID, otherUserID)
|
||||
if nErr != nil {
|
||||
return nil, nErr
|
||||
|
|
@ -332,7 +331,7 @@ func (a *App) GetOrCreateDirectChannel(userID, otherUserID string) (*model.Chann
|
|||
return channel, nil
|
||||
}
|
||||
|
||||
channel, err := a.createDirectChannel(userID, otherUserID)
|
||||
channel, err := a.createDirectChannel(userID, otherUserID, channelOptions...)
|
||||
if err != nil {
|
||||
if err.Id == store.ChannelExistsError {
|
||||
return channel, nil
|
||||
|
|
@ -381,11 +380,12 @@ func (a *App) handleCreationEvent(userID, otherUserID string, channel *model.Cha
|
|||
}
|
||||
|
||||
message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_DIRECT_ADDED, "", channel.Id, "", nil)
|
||||
message.Add("creator_id", userID)
|
||||
message.Add("teammate_id", otherUserID)
|
||||
a.Publish(message)
|
||||
}
|
||||
|
||||
func (a *App) createDirectChannel(userID, otherUserID string) (*model.Channel, *model.AppError) {
|
||||
func (a *App) createDirectChannel(userID string, otherUserID string, channelOptions ...model.ChannelOption) (*model.Channel, *model.AppError) {
|
||||
users, err := a.Srv().Store.User().GetMany(context.Background(), []string{userID, otherUserID})
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("CreateDirectChannel", "api.channel.create_direct_channel.invalid_user.app_error", nil, err.Error(), http.StatusBadRequest)
|
||||
|
|
@ -415,11 +415,11 @@ func (a *App) createDirectChannel(userID, otherUserID string) (*model.Channel, *
|
|||
user = users[1]
|
||||
otherUser = users[0]
|
||||
}
|
||||
return a.createDirectChannelWithUser(user, otherUser)
|
||||
return a.createDirectChannelWithUser(user, otherUser, channelOptions...)
|
||||
}
|
||||
|
||||
func (a *App) createDirectChannelWithUser(user, otherUser *model.User) (*model.Channel, *model.AppError) {
|
||||
channel, nErr := a.Srv().Store.Channel().CreateDirectChannel(user, otherUser)
|
||||
func (a *App) createDirectChannelWithUser(user, otherUser *model.User, channelOptions ...model.ChannelOption) (*model.Channel, *model.AppError) {
|
||||
channel, nErr := a.Srv().Store.Channel().CreateDirectChannel(user, otherUser, channelOptions...)
|
||||
if nErr != nil {
|
||||
var invErr *store.ErrInvalidInput
|
||||
var cErr *store.ErrConflict
|
||||
|
|
@ -460,6 +460,27 @@ func (a *App) createDirectChannelWithUser(user, otherUser *model.User) (*model.C
|
|||
}
|
||||
}
|
||||
|
||||
// When the newly created channel is shared and the creator is local
|
||||
// create a local shared channel record
|
||||
if channel.IsShared() && !user.IsRemote() {
|
||||
sc := &model.SharedChannel{
|
||||
ChannelId: channel.Id,
|
||||
TeamId: channel.TeamId,
|
||||
Home: true,
|
||||
ReadOnly: false,
|
||||
ShareName: channel.Name,
|
||||
ShareDisplayName: channel.DisplayName,
|
||||
SharePurpose: channel.Purpose,
|
||||
ShareHeader: channel.Header,
|
||||
CreatorId: user.Id,
|
||||
Type: channel.Type,
|
||||
}
|
||||
|
||||
if _, err := a.SaveSharedChannel(sc); err != nil {
|
||||
return nil, model.NewAppError("CreateDirectChannel", "app.sharedchannel.dm_channel_creation.internal_error", nil, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -617,7 +617,7 @@ func TestDynamicListArgsForBuiltin(t *testing.T) {
|
|||
th := Setup(t)
|
||||
defer th.TearDown()
|
||||
|
||||
provider := &testProvider{}
|
||||
provider := &testCommandProvider{}
|
||||
RegisterCommandProvider(provider)
|
||||
|
||||
command := provider.GetCommand(th.App, nil)
|
||||
|
|
@ -633,18 +633,18 @@ func TestDynamicListArgsForBuiltin(t *testing.T) {
|
|||
|
||||
t.Run("GetAutoCompleteListItems bad arg", func(t *testing.T) {
|
||||
suggestions := th.App.getSuggestions(emptyCmdArgs, []*model.AutocompleteData{command.AutocompleteData}, "", "bogus --badArg ", model.SYSTEM_ADMIN_ROLE_ID)
|
||||
assert.Len(t, suggestions, 0)
|
||||
assert.Empty(t, suggestions)
|
||||
})
|
||||
}
|
||||
|
||||
type testProvider struct {
|
||||
type testCommandProvider struct {
|
||||
}
|
||||
|
||||
func (p *testProvider) GetTrigger() string {
|
||||
func (p *testCommandProvider) GetTrigger() string {
|
||||
return "bogus"
|
||||
}
|
||||
|
||||
func (p *testProvider) GetCommand(a *App, T i18n.TranslateFunc) *model.Command {
|
||||
func (p *testCommandProvider) GetCommand(a *App, T i18n.TranslateFunc) *model.Command {
|
||||
top := model.NewAutocompleteData(p.GetTrigger(), "[command]", "Just a test.")
|
||||
top.AddNamedDynamicListArgument("dynaArg", "A dynamic list", "builtin:bogus", true)
|
||||
|
||||
|
|
@ -658,14 +658,14 @@ func (p *testProvider) GetCommand(a *App, T i18n.TranslateFunc) *model.Command {
|
|||
}
|
||||
}
|
||||
|
||||
func (p *testProvider) DoCommand(a *App, args *model.CommandArgs, message string) *model.CommandResponse {
|
||||
func (p *testCommandProvider) DoCommand(a *App, args *model.CommandArgs, message string) *model.CommandResponse {
|
||||
return &model.CommandResponse{
|
||||
Text: "I do nothing!",
|
||||
ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *testProvider) GetAutoCompleteListItems(a *App, commandArgs *model.CommandArgs, arg *model.AutocompleteArg, parsed, toBeParsed string) ([]model.AutocompleteListItem, error) {
|
||||
func (p *testCommandProvider) GetAutoCompleteListItems(a *App, commandArgs *model.CommandArgs, arg *model.AutocompleteArg, parsed, toBeParsed string) ([]model.AutocompleteListItem, error) {
|
||||
if arg.Name == "dynaArg" {
|
||||
return []model.AutocompleteListItem{
|
||||
{Item: "item1", Hint: "this is hint 1", HelpText: "This is help text 1."},
|
||||
|
|
|
|||
|
|
@ -282,15 +282,23 @@ func (th *TestHelper) CreateBot() *model.Bot {
|
|||
return bot
|
||||
}
|
||||
|
||||
func (th *TestHelper) CreateChannel(team *model.Team) *model.Channel {
|
||||
return th.createChannel(team, model.CHANNEL_OPEN)
|
||||
type ChannelOption func(*model.Channel)
|
||||
|
||||
func WithShared(v bool) ChannelOption {
|
||||
return func(channel *model.Channel) {
|
||||
channel.Shared = model.NewBool(v)
|
||||
}
|
||||
}
|
||||
|
||||
func (th *TestHelper) CreateChannel(team *model.Team, options ...ChannelOption) *model.Channel {
|
||||
return th.createChannel(team, model.CHANNEL_OPEN, options...)
|
||||
}
|
||||
|
||||
func (th *TestHelper) CreatePrivateChannel(team *model.Team) *model.Channel {
|
||||
return th.createChannel(team, model.CHANNEL_PRIVATE)
|
||||
}
|
||||
|
||||
func (th *TestHelper) createChannel(team *model.Team, channelType string) *model.Channel {
|
||||
func (th *TestHelper) createChannel(team *model.Team, channelType string, options ...ChannelOption) *model.Channel {
|
||||
id := model.NewId()
|
||||
|
||||
channel := &model.Channel{
|
||||
|
|
@ -301,10 +309,31 @@ func (th *TestHelper) createChannel(team *model.Team, channelType string) *model
|
|||
CreatorId: th.BasicUser.Id,
|
||||
}
|
||||
|
||||
for _, option := range options {
|
||||
option(channel)
|
||||
}
|
||||
|
||||
utils.DisableDebugLogForTest()
|
||||
var err *model.AppError
|
||||
if channel, err = th.App.CreateChannel(channel, true); err != nil {
|
||||
panic(err)
|
||||
var appErr *model.AppError
|
||||
if channel, appErr = th.App.CreateChannel(channel, true); appErr != nil {
|
||||
panic(appErr)
|
||||
}
|
||||
|
||||
if channel.IsShared() {
|
||||
id := model.NewId()
|
||||
_, err := th.App.SaveSharedChannel(&model.SharedChannel{
|
||||
ChannelId: channel.Id,
|
||||
TeamId: channel.TeamId,
|
||||
Home: false,
|
||||
ReadOnly: false,
|
||||
ShareName: "shared-" + id,
|
||||
ShareDisplayName: "shared-" + id,
|
||||
CreatorId: th.BasicUser.Id,
|
||||
RemoteId: model.NewId(),
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
utils.EnableDebugLogForTest()
|
||||
return channel
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ func (a *App) DoPostActionWithCookie(postID, actionId, userID, selectedOption st
|
|||
// Start all queries here for parallel execution
|
||||
pchan := make(chan store.StoreResult, 1)
|
||||
go func() {
|
||||
post, err := a.Srv().Store.Post().GetSingle(postID)
|
||||
post, err := a.Srv().Store.Post().GetSingle(postID, false)
|
||||
pchan <- store.StoreResult{Data: post, NErr: err}
|
||||
close(pchan)
|
||||
}()
|
||||
|
|
|
|||
|
|
@ -419,7 +419,7 @@ func TestPostActionProps(t *testing.T) {
|
|||
require.Nil(t, err)
|
||||
assert.True(t, len(clientTriggerId) == 26)
|
||||
|
||||
newPost, nErr := th.App.Srv().Store.Post().GetSingle(post.Id)
|
||||
newPost, nErr := th.App.Srv().Store.Post().GetSingle(post.Id, false)
|
||||
require.NoError(t, nErr)
|
||||
|
||||
assert.True(t, newPost.IsPinned)
|
||||
|
|
|
|||
|
|
@ -423,6 +423,7 @@ func (a *App) SendNotifications(post *model.Post, team *model.Team, channel *mod
|
|||
}
|
||||
|
||||
a.Publish(message)
|
||||
|
||||
// If this is a reply in a thread, notify participants
|
||||
if a.Config().FeatureFlags.CollapsedThreads && *a.Config().ServiceSettings.CollapsedThreads != model.COLLAPSED_THREADS_DISABLED && post.RootId != "" {
|
||||
thread, err := a.Srv().Store.Thread().Get(post.RootId)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import (
|
|||
"github.com/mattermost/mattermost-server/v5/plugin"
|
||||
"github.com/mattermost/mattermost-server/v5/services/httpservice"
|
||||
"github.com/mattermost/mattermost-server/v5/services/imageproxy"
|
||||
"github.com/mattermost/mattermost-server/v5/services/remotecluster"
|
||||
"github.com/mattermost/mattermost-server/v5/services/searchengine"
|
||||
"github.com/mattermost/mattermost-server/v5/services/timezones"
|
||||
"github.com/mattermost/mattermost-server/v5/services/tracing"
|
||||
|
|
@ -235,6 +236,28 @@ func (a *OpenTracingAppLayer) AddPublicKey(name string, key io.Reader) *model.Ap
|
|||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) AddRemoteCluster(rc *model.RemoteCluster) (*model.RemoteCluster, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.AddRemoteCluster")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.AddRemoteCluster(rc)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) AddSamlIdpCertificate(fileData *multipart.FileHeader) *model.AppError {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.AddSamlIdpCertificate")
|
||||
|
|
@ -1053,6 +1076,28 @@ func (a *OpenTracingAppLayer) CheckAndSendUserLimitWarningEmails() *model.AppErr
|
|||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) CheckCanInviteToSharedChannel(channelId string) error {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CheckCanInviteToSharedChannel")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0 := a.app.CheckCanInviteToSharedChannel(channelId)
|
||||
|
||||
if resultVar0 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar0))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) CheckForClientSideCert(r *http.Request) (string, string, string) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CheckForClientSideCert")
|
||||
|
|
@ -3088,6 +3133,28 @@ func (a *OpenTracingAppLayer) DeleteReactionForPost(reaction *model.Reaction) *m
|
|||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) DeleteRemoteCluster(remoteClusterId string) (bool, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteRemoteCluster")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.DeleteRemoteCluster(remoteClusterId)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) DeleteScheme(schemeId string) (*model.Scheme, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteScheme")
|
||||
|
|
@ -3110,6 +3177,50 @@ func (a *OpenTracingAppLayer) DeleteScheme(schemeId string) (*model.Scheme, *mod
|
|||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) DeleteSharedChannel(channelID string) (bool, error) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteSharedChannel")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.DeleteSharedChannel(channelID)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) DeleteSharedChannelRemote(id string) (bool, error) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteSharedChannelRemote")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.DeleteSharedChannelRemote(id)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) DeleteSidebarCategory(userID string, teamID string, categoryId string) *model.AppError {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteSidebarCategory")
|
||||
|
|
@ -4240,6 +4351,28 @@ func (a *OpenTracingAppLayer) GetAllPublicTeamsPageWithCount(offset int, limit i
|
|||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) GetAllRemoteClusters(filter model.RemoteClusterQueryFilter) ([]*model.RemoteCluster, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetAllRemoteClusters")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.GetAllRemoteClusters(filter)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) GetAllRoles() ([]*model.Role, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetAllRoles")
|
||||
|
|
@ -6742,7 +6875,7 @@ func (a *OpenTracingAppLayer) GetOpenGraphMetadata(requestURL string) *opengraph
|
|||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) GetOrCreateDirectChannel(userID string, otherUserID string) (*model.Channel, *model.AppError) {
|
||||
func (a *OpenTracingAppLayer) GetOrCreateDirectChannel(userID string, otherUserID string, channelOptions ...model.ChannelOption) (*model.Channel, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetOrCreateDirectChannel")
|
||||
|
||||
|
|
@ -6754,7 +6887,7 @@ func (a *OpenTracingAppLayer) GetOrCreateDirectChannel(userID string, otherUserI
|
|||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.GetOrCreateDirectChannel(userID, otherUserID)
|
||||
resultVar0, resultVar1 := a.app.GetOrCreateDirectChannel(userID, otherUserID, channelOptions...)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
|
|
@ -7629,6 +7762,94 @@ func (a *OpenTracingAppLayer) GetRecentlyActiveUsersForTeamPage(teamID string, p
|
|||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) GetRemoteCluster(remoteClusterId string) (*model.RemoteCluster, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetRemoteCluster")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.GetRemoteCluster(remoteClusterId)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) GetRemoteClusterForUser(remoteID string, userID string) (*model.RemoteCluster, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetRemoteClusterForUser")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.GetRemoteClusterForUser(remoteID, userID)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) GetRemoteClusterService() (remotecluster.RemoteClusterServiceIFace, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetRemoteClusterService")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.GetRemoteClusterService()
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) GetRemoteClusterSession(token string, remoteId string) (*model.Session, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetRemoteClusterSession")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.GetRemoteClusterSession(token, remoteId)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) GetRole(id string) (*model.Role, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetRole")
|
||||
|
|
@ -8005,6 +8226,160 @@ func (a *OpenTracingAppLayer) GetSessions(userID string) ([]*model.Session, *mod
|
|||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) GetSharedChannel(channelID string) (*model.SharedChannel, error) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetSharedChannel")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.GetSharedChannel(channelID)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) GetSharedChannelRemote(id string) (*model.SharedChannelRemote, error) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetSharedChannelRemote")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.GetSharedChannelRemote(id)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) GetSharedChannelRemoteByIds(channelID string, remoteID string) (*model.SharedChannelRemote, error) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetSharedChannelRemoteByIds")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.GetSharedChannelRemoteByIds(channelID, remoteID)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) GetSharedChannelRemotes(opts model.SharedChannelRemoteFilterOpts) ([]*model.SharedChannelRemote, error) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetSharedChannelRemotes")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.GetSharedChannelRemotes(opts)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) GetSharedChannelRemotesStatus(channelID string) ([]*model.SharedChannelRemoteStatus, error) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetSharedChannelRemotesStatus")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.GetSharedChannelRemotesStatus(channelID)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) GetSharedChannels(page int, perPage int, opts model.SharedChannelFilterOpts) ([]*model.SharedChannel, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetSharedChannels")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.GetSharedChannels(page, perPage, opts)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) GetSharedChannelsCount(opts model.SharedChannelFilterOpts) (int64, error) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetSharedChannelsCount")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.GetSharedChannelsCount(opts)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) GetSidebarCategories(userID string, teamID string) (*model.OrderedSidebarCategories, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetSidebarCategories")
|
||||
|
|
@ -9711,6 +10086,50 @@ func (a *OpenTracingAppLayer) HasPermissionToUser(askingUserId string, userID st
|
|||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) HasRemote(channelID string, remoteID string) (bool, error) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.HasRemote")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.HasRemote(channelID, remoteID)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) HasSharedChannel(channelID string) (bool, error) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.HasSharedChannel")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.HasSharedChannel(channelID)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) HubRegister(webConn *app.WebConn) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.HubRegister")
|
||||
|
|
@ -12710,6 +13129,50 @@ func (a *OpenTracingAppLayer) SaveReactionForPost(reaction *model.Reaction) (*mo
|
|||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) SaveSharedChannel(sc *model.SharedChannel) (*model.SharedChannel, error) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SaveSharedChannel")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.SaveSharedChannel(sc)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) SaveSharedChannelRemote(remote *model.SharedChannelRemote) (*model.SharedChannelRemote, error) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SaveSharedChannelRemote")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.SaveSharedChannelRemote(remote)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) SaveUserTermsOfService(userID string, termsOfServiceId string, accepted bool) *model.AppError {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SaveUserTermsOfService")
|
||||
|
|
@ -13974,6 +14437,28 @@ func (a *OpenTracingAppLayer) SetProfileImageFromMultiPartFile(userID string, fi
|
|||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) SetRemoteClusterLastPingAt(remoteClusterId string) *model.AppError {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SetRemoteClusterLastPingAt")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0 := a.app.SetRemoteClusterLastPingAt(remoteClusterId)
|
||||
|
||||
if resultVar0 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar0))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) SetSamlIdpCertificateFromMetadata(data []byte) *model.AppError {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SetSamlIdpCertificateFromMetadata")
|
||||
|
|
@ -15380,6 +15865,50 @@ func (a *OpenTracingAppLayer) UpdateProductNotices() *model.AppError {
|
|||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) UpdateRemoteCluster(rc *model.RemoteCluster) (*model.RemoteCluster, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateRemoteCluster")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.UpdateRemoteCluster(rc)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) UpdateRemoteClusterTopics(remoteClusterId string, topics string) (*model.RemoteCluster, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateRemoteClusterTopics")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.UpdateRemoteClusterTopics(remoteClusterId, topics)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) UpdateRole(role *model.Role) (*model.Role, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateRole")
|
||||
|
|
@ -15439,6 +15968,50 @@ func (a *OpenTracingAppLayer) UpdateSessionsIsGuest(userID string, isGuest bool)
|
|||
a.app.UpdateSessionsIsGuest(userID, isGuest)
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) UpdateSharedChannel(sc *model.SharedChannel) (*model.SharedChannel, error) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateSharedChannel")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.UpdateSharedChannel(sc)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) UpdateSharedChannelRemoteNextSyncAt(id string, syncTime int64) error {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateSharedChannelRemoteNextSyncAt")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store.SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0 := a.app.UpdateSharedChannelRemoteNextSyncAt(id, syncTime)
|
||||
|
||||
if resultVar0 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar0))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) UpdateSidebarCategories(userID string, teamID string, categories []*model.SidebarCategoryWithChannels) ([]*model.SidebarCategoryWithChannels, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateSidebarCategories")
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ func TestHookMessageWillBePosted(t *testing.T) {
|
|||
require.Nil(t, err)
|
||||
|
||||
assert.Equal(t, "message", post.Message)
|
||||
retrievedPost, errSingle := th.App.Srv().Store.Post().GetSingle(post.Id)
|
||||
retrievedPost, errSingle := th.App.Srv().Store.Post().GetSingle(post.Id, false)
|
||||
require.NoError(t, errSingle)
|
||||
assert.Equal(t, "message", retrievedPost.Message)
|
||||
})
|
||||
|
|
@ -225,7 +225,7 @@ func TestHookMessageWillBePosted(t *testing.T) {
|
|||
require.Nil(t, err)
|
||||
|
||||
assert.Equal(t, "message_fromplugin", post.Message)
|
||||
retrievedPost, errSingle := th.App.Srv().Store.Post().GetSingle(post.Id)
|
||||
retrievedPost, errSingle := th.App.Srv().Store.Post().GetSingle(post.Id, false)
|
||||
require.NoError(t, errSingle)
|
||||
assert.Equal(t, "message_fromplugin", retrievedPost.Message)
|
||||
})
|
||||
|
|
|
|||
10
app/post.go
10
app/post.go
|
|
@ -610,6 +610,10 @@ func (a *App) UpdatePost(post *model.Post, safeUpdate bool) (*model.Post, *model
|
|||
return nil, err
|
||||
}
|
||||
|
||||
if post.IsRemote() {
|
||||
oldPost.RemoteId = model.NewString(*post.RemoteId)
|
||||
}
|
||||
|
||||
if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil {
|
||||
var rejectionReason string
|
||||
pluginContext := a.PluginContext()
|
||||
|
|
@ -728,7 +732,7 @@ func (a *App) GetPostsSince(options model.GetPostsSinceOptions) (*model.PostList
|
|||
}
|
||||
|
||||
func (a *App) GetSinglePost(postID string) (*model.Post, *model.AppError) {
|
||||
post, err := a.Srv().Store.Post().GetSingle(postID)
|
||||
post, err := a.Srv().Store.Post().GetSingle(postID, false)
|
||||
if err != nil {
|
||||
var nfErr *store.ErrNotFound
|
||||
switch {
|
||||
|
|
@ -1012,7 +1016,7 @@ func (a *App) GetPostsForChannelAroundLastUnread(channelID, userID string, limit
|
|||
}
|
||||
|
||||
func (a *App) DeletePost(postID, deleteByID string) (*model.Post, *model.AppError) {
|
||||
post, nErr := a.Srv().Store.Post().GetSingle(postID)
|
||||
post, nErr := a.Srv().Store.Post().GetSingle(postID, false)
|
||||
if nErr != nil {
|
||||
return nil, model.NewAppError("DeletePost", "app.post.get.app_error", nil, nErr.Error(), http.StatusBadRequest)
|
||||
}
|
||||
|
|
@ -1237,7 +1241,7 @@ func (a *App) GetFileInfosForPostWithMigration(postID string) ([]*model.FileInfo
|
|||
|
||||
pchan := make(chan store.StoreResult, 1)
|
||||
go func() {
|
||||
post, err := a.Srv().Store.Post().GetSingle(postID)
|
||||
post, err := a.Srv().Store.Post().GetSingle(postID, false)
|
||||
pchan <- store.StoreResult{Data: post, NErr: err}
|
||||
close(pchan)
|
||||
}()
|
||||
|
|
|
|||
|
|
@ -2053,3 +2053,87 @@ func TestReplyToPostWithLag(t *testing.T) {
|
|||
require.NotNil(t, reply)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSharedChannelSyncForPostActions(t *testing.T) {
|
||||
t.Run("creating a post in a shared channel performs a content sync when sync service is running on that node", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
remoteClusterService := NewMockSharedChannelService(nil)
|
||||
th.App.srv.sharedChannelService = remoteClusterService
|
||||
testCluster := &testlib.FakeClusterInterface{}
|
||||
th.Server.Cluster = testCluster
|
||||
|
||||
user := th.BasicUser
|
||||
|
||||
channel := th.CreateChannel(th.BasicTeam, WithShared(true))
|
||||
|
||||
_, err := th.App.CreatePost(&model.Post{
|
||||
UserId: user.Id,
|
||||
ChannelId: channel.Id,
|
||||
Message: "Hello folks",
|
||||
}, channel, false, true)
|
||||
require.Nil(t, err, "Creating a post should not error")
|
||||
|
||||
assert.Len(t, remoteClusterService.notifications, 1)
|
||||
assert.Equal(t, channel.Id, remoteClusterService.notifications[0])
|
||||
})
|
||||
|
||||
t.Run("updating a post in a shared channel performs a content sync when sync service is running on that node", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
remoteClusterService := NewMockSharedChannelService(nil)
|
||||
th.App.srv.sharedChannelService = remoteClusterService
|
||||
testCluster := &testlib.FakeClusterInterface{}
|
||||
th.Server.Cluster = testCluster
|
||||
|
||||
user := th.BasicUser
|
||||
|
||||
channel := th.CreateChannel(th.BasicTeam, WithShared(true))
|
||||
|
||||
post, err := th.App.CreatePost(&model.Post{
|
||||
UserId: user.Id,
|
||||
ChannelId: channel.Id,
|
||||
Message: "Hello folks",
|
||||
}, channel, false, true)
|
||||
require.Nil(t, err, "Creating a post should not error")
|
||||
|
||||
_, err = th.App.UpdatePost(post, true)
|
||||
require.Nil(t, err, "Updating a post should not error")
|
||||
|
||||
assert.Len(t, remoteClusterService.notifications, 2)
|
||||
assert.Equal(t, channel.Id, remoteClusterService.notifications[0])
|
||||
assert.Equal(t, channel.Id, remoteClusterService.notifications[1])
|
||||
})
|
||||
|
||||
t.Run("deleting a post in a shared channel performs a content sync when sync service is running on that node", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
remoteClusterService := NewMockSharedChannelService(nil)
|
||||
th.App.srv.sharedChannelService = remoteClusterService
|
||||
testCluster := &testlib.FakeClusterInterface{}
|
||||
th.Server.Cluster = testCluster
|
||||
|
||||
user := th.BasicUser
|
||||
|
||||
channel := th.CreateChannel(th.BasicTeam, WithShared(true))
|
||||
|
||||
post, err := th.App.CreatePost(&model.Post{
|
||||
UserId: user.Id,
|
||||
ChannelId: channel.Id,
|
||||
Message: "Hello folks",
|
||||
}, channel, false, true)
|
||||
require.Nil(t, err, "Creating a post should not error")
|
||||
|
||||
_, err = th.App.DeletePost(post.Id, user.Id)
|
||||
require.Nil(t, err, "Deleting a post should not error")
|
||||
|
||||
// one creation and two deletes
|
||||
assert.Len(t, remoteClusterService.notifications, 3)
|
||||
assert.Equal(t, channel.Id, remoteClusterService.notifications[0])
|
||||
assert.Equal(t, channel.Id, remoteClusterService.notifications[1])
|
||||
assert.Equal(t, channel.Id, remoteClusterService.notifications[2])
|
||||
})
|
||||
}
|
||||
|
|
|
|||
86
app/reaction_test.go
Normal file
86
app/reaction_test.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/testlib"
|
||||
)
|
||||
|
||||
func TestSharedChannelSyncForReactionActions(t *testing.T) {
|
||||
t.Run("adding a reaction in a shared channel performs a content sync when sync service is running on that node", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
sharedChannelService := NewMockSharedChannelService(nil)
|
||||
th.App.srv.sharedChannelService = sharedChannelService
|
||||
testCluster := &testlib.FakeClusterInterface{}
|
||||
th.Server.Cluster = testCluster
|
||||
|
||||
user := th.BasicUser
|
||||
|
||||
channel := th.CreateChannel(th.BasicTeam, WithShared(true))
|
||||
|
||||
post, err := th.App.CreatePost(&model.Post{
|
||||
UserId: user.Id,
|
||||
ChannelId: channel.Id,
|
||||
Message: "Hello folks",
|
||||
}, channel, false, true)
|
||||
require.Nil(t, err, "Creating a post should not error")
|
||||
|
||||
reaction := &model.Reaction{
|
||||
UserId: user.Id,
|
||||
PostId: post.Id,
|
||||
EmojiName: "+1",
|
||||
}
|
||||
|
||||
_, err = th.App.SaveReactionForPost(reaction)
|
||||
require.Nil(t, err, "Adding a reaction should not error")
|
||||
|
||||
th.TearDown() // We need to enforce teardown because reaction instrumentation happens in a goroutine
|
||||
|
||||
assert.Len(t, sharedChannelService.notifications, 2)
|
||||
assert.Equal(t, channel.Id, sharedChannelService.notifications[0])
|
||||
assert.Equal(t, channel.Id, sharedChannelService.notifications[1])
|
||||
})
|
||||
|
||||
t.Run("removing a reaction in a shared channel performs a content sync when sync service is running on that node", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
sharedChannelService := NewMockSharedChannelService(nil)
|
||||
th.App.srv.sharedChannelService = sharedChannelService
|
||||
testCluster := &testlib.FakeClusterInterface{}
|
||||
th.Server.Cluster = testCluster
|
||||
|
||||
user := th.BasicUser
|
||||
|
||||
channel := th.CreateChannel(th.BasicTeam, WithShared(true))
|
||||
|
||||
post, err := th.App.CreatePost(&model.Post{
|
||||
UserId: user.Id,
|
||||
ChannelId: channel.Id,
|
||||
Message: "Hello folks",
|
||||
}, channel, false, true)
|
||||
require.Nil(t, err, "Creating a post should not error")
|
||||
|
||||
reaction := &model.Reaction{
|
||||
UserId: user.Id,
|
||||
PostId: post.Id,
|
||||
EmojiName: "+1",
|
||||
}
|
||||
|
||||
err = th.App.DeleteReactionForPost(reaction)
|
||||
require.Nil(t, err, "Adding a reaction should not error")
|
||||
|
||||
th.TearDown() // We need to enforce teardown because reaction instrumentation happens in a goroutine
|
||||
|
||||
assert.Len(t, sharedChannelService.notifications, 2)
|
||||
assert.Equal(t, channel.Id, sharedChannelService.notifications[0])
|
||||
assert.Equal(t, channel.Id, sharedChannelService.notifications[1])
|
||||
})
|
||||
}
|
||||
87
app/remote_cluster.go
Normal file
87
app/remote_cluster.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/services/remotecluster"
|
||||
"github.com/mattermost/mattermost-server/v5/store/sqlstore"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
)
|
||||
|
||||
func (a *App) AddRemoteCluster(rc *model.RemoteCluster) (*model.RemoteCluster, *model.AppError) {
|
||||
rc, err := a.Srv().Store.RemoteCluster().Save(rc)
|
||||
if err != nil {
|
||||
if sqlstore.IsUniqueConstraintError(errors.Cause(err), []string{sqlstore.RemoteClusterSiteURLUniqueIndex}) {
|
||||
return nil, model.NewAppError("AddRemoteCluster", "api.remote_cluster.save_not_unique.app_error", nil, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return nil, model.NewAppError("AddRemoteCluster", "api.remote_cluster.save.app_error", nil, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
func (a *App) UpdateRemoteCluster(rc *model.RemoteCluster) (*model.RemoteCluster, *model.AppError) {
|
||||
rc, err := a.Srv().Store.RemoteCluster().Update(rc)
|
||||
if err != nil {
|
||||
if sqlstore.IsUniqueConstraintError(errors.Cause(err), []string{sqlstore.RemoteClusterSiteURLUniqueIndex}) {
|
||||
return nil, model.NewAppError("UpdateRemoteCluster", "api.remote_cluster.update_not_unique.app_error", nil, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return nil, model.NewAppError("UpdateRemoteCluster", "api.remote_cluster.update.app_error", nil, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteRemoteCluster(remoteClusterId string) (bool, *model.AppError) {
|
||||
deleted, err := a.Srv().Store.RemoteCluster().Delete(remoteClusterId)
|
||||
if err != nil {
|
||||
return false, model.NewAppError("DeleteRemoteCluster", "api.remote_cluster.delete.app_error", nil, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func (a *App) GetRemoteCluster(remoteClusterId string) (*model.RemoteCluster, *model.AppError) {
|
||||
rc, err := a.Srv().Store.RemoteCluster().Get(remoteClusterId)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("GetRemoteCluster", "api.remote_cluster.get.app_error", nil, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
func (a *App) GetAllRemoteClusters(filter model.RemoteClusterQueryFilter) ([]*model.RemoteCluster, *model.AppError) {
|
||||
list, err := a.Srv().Store.RemoteCluster().GetAll(filter)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("GetAllRemoteClusters", "api.remote_cluster.get.app_error", nil, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (a *App) UpdateRemoteClusterTopics(remoteClusterId string, topics string) (*model.RemoteCluster, *model.AppError) {
|
||||
rc, err := a.Srv().Store.RemoteCluster().UpdateTopics(remoteClusterId, topics)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("UpdateRemoteClusterTopics", "api.remote_cluster.save.app_error", nil, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
func (a *App) SetRemoteClusterLastPingAt(remoteClusterId string) *model.AppError {
|
||||
err := a.Srv().Store.RemoteCluster().SetLastPingAt(remoteClusterId)
|
||||
if err != nil {
|
||||
return model.NewAppError("SetRemoteClusterLastPingAt", "api.remote_cluster.save.app_error", nil, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) GetRemoteClusterService() (remotecluster.RemoteClusterServiceIFace, *model.AppError) {
|
||||
service := a.Srv().GetRemoteClusterService()
|
||||
if service == nil {
|
||||
return nil, model.NewAppError("GetRemoteClusterService", "api.remote_cluster.service_not_enabled.app_error", nil, "", http.StatusNotImplemented)
|
||||
}
|
||||
return service, nil
|
||||
}
|
||||
75
app/remote_cluster_service_mock.go
Normal file
75
app/remote_cluster_service_mock.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/services/remotecluster"
|
||||
)
|
||||
|
||||
// MockOptionRemoteClusterService a mock of the remote cluster service
|
||||
type MockOptionRemoteClusterService func(service *mockRemoteClusterService)
|
||||
|
||||
func MockOptionRemoteClusterServiceWithActive(active bool) MockOptionRemoteClusterService {
|
||||
return func(mrcs *mockRemoteClusterService) {
|
||||
mrcs.active = active
|
||||
}
|
||||
}
|
||||
|
||||
func NewMockRemoteClusterService(service remotecluster.RemoteClusterServiceIFace, options ...MockOptionRemoteClusterService) *mockRemoteClusterService {
|
||||
mrcs := &mockRemoteClusterService{service, true}
|
||||
for _, option := range options {
|
||||
option(mrcs)
|
||||
}
|
||||
return mrcs
|
||||
}
|
||||
|
||||
type mockRemoteClusterService struct {
|
||||
remotecluster.RemoteClusterServiceIFace
|
||||
active bool
|
||||
}
|
||||
|
||||
func (mrcs *mockRemoteClusterService) Shutdown() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mrcs *mockRemoteClusterService) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mrcs *mockRemoteClusterService) Active() bool {
|
||||
return mrcs.active
|
||||
}
|
||||
|
||||
func (mrcs *mockRemoteClusterService) AddTopicListener(topic string, listener remotecluster.TopicListener) string {
|
||||
return model.NewId()
|
||||
}
|
||||
|
||||
func (mrcs *mockRemoteClusterService) RemoveTopicListener(listenerId string) {
|
||||
}
|
||||
|
||||
func (mrcs *mockRemoteClusterService) AddConnectionStateListener(listener remotecluster.ConnectionStateListener) string {
|
||||
return model.NewId()
|
||||
}
|
||||
|
||||
func (mrcs *mockRemoteClusterService) RemoveConnectionStateListener(listenerId string) {
|
||||
}
|
||||
|
||||
func (mrcs *mockRemoteClusterService) SendMsg(ctx context.Context, msg model.RemoteClusterMsg, rc *model.RemoteCluster, f remotecluster.SendMsgResultFunc) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mrcs *mockRemoteClusterService) SendFile(ctx context.Context, us *model.UploadSession, fi *model.FileInfo, rc *model.RemoteCluster, rp remotecluster.ReaderProvider, f remotecluster.SendFileResultFunc) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mrcs *mockRemoteClusterService) AcceptInvitation(invite *model.RemoteClusterInvite, name string, creatorId string, teamId string, siteURL string) (*model.RemoteCluster, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (mrcs *mockRemoteClusterService) ReceiveIncomingMsg(rc *model.RemoteCluster, msg model.RemoteClusterMsg) remotecluster.Response {
|
||||
return remotecluster.Response{}
|
||||
}
|
||||
152
app/remote_cluster_test.go
Normal file
152
app/remote_cluster_test.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
)
|
||||
|
||||
func TestAddRemoteCluster(t *testing.T) {
|
||||
t.Run("adding remote cluster with duplicate site url and remote team id", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
remoteCluster := &model.RemoteCluster{
|
||||
RemoteTeamId: model.NewId(),
|
||||
DisplayName: "test",
|
||||
SiteURL: "http://localhost:8065",
|
||||
Token: "test",
|
||||
RemoteToken: "test",
|
||||
Topics: "",
|
||||
CreatorId: th.BasicUser.Id,
|
||||
}
|
||||
|
||||
_, err := th.App.AddRemoteCluster(remoteCluster)
|
||||
require.Nil(t, err, "Adding a remote cluster should not error")
|
||||
|
||||
remoteCluster.RemoteId = model.NewId()
|
||||
_, err = th.App.AddRemoteCluster(remoteCluster)
|
||||
require.Error(t, err, "Adding a duplicate remote cluster should error")
|
||||
assert.Contains(t, err.Error(), "Remote cluster has already been added.")
|
||||
})
|
||||
|
||||
t.Run("adding remote cluster with duplicate site url or remote team id is allowed", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
remoteCluster := &model.RemoteCluster{
|
||||
RemoteTeamId: model.NewId(),
|
||||
DisplayName: "test",
|
||||
SiteURL: "http://localhost:8065",
|
||||
Token: "test",
|
||||
RemoteToken: "test",
|
||||
Topics: "",
|
||||
CreatorId: th.BasicUser.Id,
|
||||
}
|
||||
|
||||
existingRemoteCluster, err := th.App.AddRemoteCluster(remoteCluster)
|
||||
require.Nil(t, err, "Adding a remote cluster should not error")
|
||||
|
||||
// Same site url but different remote team id
|
||||
remoteCluster.RemoteId = model.NewId()
|
||||
remoteCluster.RemoteTeamId = model.NewId()
|
||||
remoteCluster.SiteURL = existingRemoteCluster.SiteURL
|
||||
_, err = th.App.AddRemoteCluster(remoteCluster)
|
||||
assert.Nil(t, err, "Adding a remote cluster should not error")
|
||||
|
||||
// Same remote team id but different site url
|
||||
remoteCluster.RemoteId = model.NewId()
|
||||
remoteCluster.RemoteTeamId = existingRemoteCluster.RemoteTeamId
|
||||
remoteCluster.SiteURL = existingRemoteCluster.SiteURL + "/new"
|
||||
_, err = th.App.AddRemoteCluster(remoteCluster)
|
||||
assert.Nil(t, err, "Adding a remote cluster should not error")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateRemoteCluster(t *testing.T) {
|
||||
t.Run("update remote cluster with an already existing site url and team id", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
remoteCluster := &model.RemoteCluster{
|
||||
RemoteTeamId: model.NewId(),
|
||||
DisplayName: "test",
|
||||
SiteURL: "http://localhost:8065",
|
||||
Token: "test",
|
||||
RemoteToken: "test",
|
||||
Topics: "",
|
||||
CreatorId: th.BasicUser.Id,
|
||||
}
|
||||
|
||||
otherRemoteCluster := &model.RemoteCluster{
|
||||
RemoteTeamId: model.NewId(),
|
||||
DisplayName: "test",
|
||||
SiteURL: "http://localhost:8066",
|
||||
Token: "test",
|
||||
RemoteToken: "test",
|
||||
Topics: "",
|
||||
CreatorId: th.BasicUser.Id,
|
||||
}
|
||||
|
||||
_, err := th.App.AddRemoteCluster(remoteCluster)
|
||||
require.Nil(t, err, "Adding a remote cluster should not error")
|
||||
|
||||
savedRemoteClustered, err := th.App.AddRemoteCluster(otherRemoteCluster)
|
||||
require.Nil(t, err, "Adding a remote cluster should not error")
|
||||
|
||||
savedRemoteClustered.SiteURL = remoteCluster.SiteURL
|
||||
savedRemoteClustered.RemoteTeamId = remoteCluster.RemoteTeamId
|
||||
_, err = th.App.UpdateRemoteCluster(savedRemoteClustered)
|
||||
require.Error(t, err, "Updating remote cluster with duplicate site url should error")
|
||||
assert.Contains(t, err.Error(), "Remote cluster with the same url already exists.")
|
||||
})
|
||||
|
||||
t.Run("update remote cluster with an already existing site url or team id, is allowed", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
remoteCluster := &model.RemoteCluster{
|
||||
RemoteTeamId: model.NewId(),
|
||||
DisplayName: "test",
|
||||
SiteURL: "http://localhost:8065",
|
||||
Token: "test",
|
||||
RemoteToken: "test",
|
||||
Topics: "",
|
||||
CreatorId: th.BasicUser.Id,
|
||||
}
|
||||
|
||||
otherRemoteCluster := &model.RemoteCluster{
|
||||
RemoteTeamId: model.NewId(),
|
||||
DisplayName: "test",
|
||||
SiteURL: "http://localhost:8066",
|
||||
Token: "test",
|
||||
RemoteToken: "test",
|
||||
Topics: "",
|
||||
CreatorId: th.BasicUser.Id,
|
||||
}
|
||||
|
||||
existingRemoteCluster, err := th.App.AddRemoteCluster(remoteCluster)
|
||||
require.Nil(t, err, "Adding a remote cluster should not error")
|
||||
|
||||
anotherExistingRemoteClustered, err := th.App.AddRemoteCluster(otherRemoteCluster)
|
||||
require.Nil(t, err, "Adding a remote cluster should not error")
|
||||
|
||||
// Same site url but different remote team id
|
||||
anotherExistingRemoteClustered.SiteURL = existingRemoteCluster.SiteURL
|
||||
anotherExistingRemoteClustered.RemoteTeamId = model.NewId()
|
||||
_, err = th.App.UpdateRemoteCluster(anotherExistingRemoteClustered)
|
||||
assert.Nil(t, err, "Updating remote cluster should not error")
|
||||
|
||||
// Same remote team id but different site url
|
||||
anotherExistingRemoteClustered.SiteURL = existingRemoteCluster.SiteURL + "/new"
|
||||
anotherExistingRemoteClustered.RemoteTeamId = existingRemoteCluster.RemoteTeamId
|
||||
_, err = th.App.UpdateRemoteCluster(anotherExistingRemoteClustered)
|
||||
assert.Nil(t, err, "Updating remote cluster should not error")
|
||||
})
|
||||
}
|
||||
113
app/server.go
113
app/server.go
|
|
@ -47,8 +47,10 @@ import (
|
|||
"github.com/mattermost/mattermost-server/v5/services/cache"
|
||||
"github.com/mattermost/mattermost-server/v5/services/httpservice"
|
||||
"github.com/mattermost/mattermost-server/v5/services/imageproxy"
|
||||
"github.com/mattermost/mattermost-server/v5/services/remotecluster"
|
||||
"github.com/mattermost/mattermost-server/v5/services/searchengine"
|
||||
"github.com/mattermost/mattermost-server/v5/services/searchengine/bleveengine"
|
||||
"github.com/mattermost/mattermost-server/v5/services/sharedchannel"
|
||||
"github.com/mattermost/mattermost-server/v5/services/telemetry"
|
||||
"github.com/mattermost/mattermost-server/v5/services/timezones"
|
||||
"github.com/mattermost/mattermost-server/v5/services/tracing"
|
||||
|
|
@ -157,6 +159,9 @@ type Server struct {
|
|||
|
||||
telemetryService *telemetry.TelemetryService
|
||||
|
||||
remoteClusterService remotecluster.RemoteClusterServiceIFace
|
||||
sharedChannelService SharedChannelServiceIFace
|
||||
|
||||
phase2PermissionsMigrationComplete bool
|
||||
|
||||
HTTPService httpservice.HTTPService
|
||||
|
|
@ -808,6 +813,64 @@ func (s *Server) removeUnlicensedLogTargets(license *model.License) {
|
|||
})
|
||||
}
|
||||
|
||||
func (s *Server) startInterClusterServices(license *model.License, app *App) error {
|
||||
if license == nil {
|
||||
mlog.Debug("No license provided; Remote Cluster services disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remote Cluster service
|
||||
|
||||
// License check
|
||||
if !*license.Features.RemoteClusterService {
|
||||
mlog.Debug("License does not have Remote Cluster services enabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Config check
|
||||
if !*s.Config().ExperimentalSettings.EnableRemoteClusterService {
|
||||
mlog.Debug("Remote Cluster Service disabled via config")
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
s.remoteClusterService, err = remotecluster.NewRemoteClusterService(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = s.remoteClusterService.Start(); err != nil {
|
||||
s.remoteClusterService = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// Shared Channels service
|
||||
|
||||
// License check
|
||||
if !*license.Features.SharedChannels {
|
||||
mlog.Debug("License does not have shared channels enabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Config check
|
||||
if !*s.Config().ExperimentalSettings.EnableSharedChannels {
|
||||
mlog.Debug("Shared Channels Service disabled via config")
|
||||
return nil
|
||||
}
|
||||
|
||||
s.sharedChannelService, err = sharedchannel.NewSharedChannelService(s, app)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = s.sharedChannelService.Start(); err != nil {
|
||||
s.remoteClusterService = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) enableLoggingMetrics() {
|
||||
if s.Metrics == nil {
|
||||
return
|
||||
|
|
@ -866,6 +929,12 @@ func (s *Server) Shutdown() {
|
|||
mlog.Warn("Unable to cleanly shutdown telemetry client", mlog.Err(err))
|
||||
}
|
||||
|
||||
if s.remoteClusterService != nil {
|
||||
if err = s.remoteClusterService.Shutdown(); err != nil {
|
||||
mlog.Error("Error shutting down intercluster services", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
s.StopHTTPServer()
|
||||
s.stopLocalModeServer()
|
||||
// Push notification hub needs to be shutdown after HTTP server
|
||||
|
|
@ -1231,6 +1300,10 @@ func (s *Server) Start() error {
|
|||
}
|
||||
}
|
||||
|
||||
if err := s.startInterClusterServices(s.License(), s.WebSocketRouter.app); err != nil {
|
||||
mlog.Error("Error starting inter-cluster services", mlog.Err(err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -1799,6 +1872,46 @@ func (s *Server) SetLog(l *mlog.Logger) {
|
|||
s.Log = l
|
||||
}
|
||||
|
||||
func (s *Server) GetLogger() mlog.LoggerIFace {
|
||||
return s.Log
|
||||
}
|
||||
|
||||
// GetStore returns the server's Store. Exposing via a method
|
||||
// allows interfaces to be created with subsets of server APIs.
|
||||
func (s *Server) GetStore() store.Store {
|
||||
return s.Store
|
||||
}
|
||||
|
||||
// GetRemoteClusterService returns the `RemoteClusterService` instantiated by the server.
|
||||
// May be nil if the service is not enabled via license.
|
||||
func (s *Server) GetRemoteClusterService() remotecluster.RemoteClusterServiceIFace {
|
||||
return s.remoteClusterService
|
||||
}
|
||||
|
||||
// GetSharedChannelSyncService returns the `SharedChannelSyncService` instantiated by the server.
|
||||
// May be nil if the service is not enabled via license.
|
||||
func (s *Server) GetSharedChannelSyncService() SharedChannelServiceIFace {
|
||||
return s.sharedChannelService
|
||||
}
|
||||
|
||||
// GetMetrics returns the server's Metrics interface. Exposing via a method
|
||||
// allows interfaces to be created with subsets of server APIs.
|
||||
func (s *Server) GetMetrics() einterfaces.MetricsInterface {
|
||||
return s.Metrics
|
||||
}
|
||||
|
||||
// SetRemoteClusterService sets the `RemoteClusterService` to be used by the server.
|
||||
// For testing only.
|
||||
func (s *Server) SetRemoteClusterService(remoteClusterService remotecluster.RemoteClusterServiceIFace) {
|
||||
s.remoteClusterService = remoteClusterService
|
||||
}
|
||||
|
||||
// SetSharedChannelSyncService sets the `SharedChannelSyncService` to be used by the server.
|
||||
// For testing only.
|
||||
func (s *Server) SetSharedChannelSyncService(sharedChannelService SharedChannelServiceIFace) {
|
||||
s.sharedChannelService = sharedChannelService
|
||||
}
|
||||
|
||||
func (a *App) GenerateSupportPacket() []model.FileData {
|
||||
// If any errors we come across within this function, we will log it in a warning.txt file so that we know why certain files did not get produced if any
|
||||
var warnings []string
|
||||
|
|
|
|||
|
|
@ -67,6 +67,21 @@ func (a *App) GetCloudSession(token string) (*model.Session, *model.AppError) {
|
|||
return nil, model.NewAppError("GetCloudSession", "api.context.invalid_token.error", map[string]interface{}{"Token": token, "Error": ""}, "The provided token is invalid", http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
func (a *App) GetRemoteClusterSession(token string, remoteId string) (*model.Session, *model.AppError) {
|
||||
rc, appErr := a.GetRemoteCluster(remoteId)
|
||||
if appErr == nil && rc.Token == token {
|
||||
// Need a bare-bones session object for later checks
|
||||
session := &model.Session{
|
||||
Token: token,
|
||||
IsOAuth: false,
|
||||
}
|
||||
|
||||
session.AddProp(model.SESSION_PROP_TYPE, model.SESSION_TYPE_REMOTECLUSTER_TOKEN)
|
||||
return session, nil
|
||||
}
|
||||
return nil, model.NewAppError("GetRemoteClusterSession", "api.context.invalid_token.error", map[string]interface{}{"Token": token, "Error": ""}, "The provided token is invalid", http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
func (a *App) GetSession(token string) (*model.Session, *model.AppError) {
|
||||
metrics := a.Metrics()
|
||||
|
||||
|
|
|
|||
|
|
@ -439,3 +439,39 @@ func TestGetCloudSession(t *testing.T) {
|
|||
require.Equal(t, "api.context.invalid_token.error", err.Id)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetRemoteClusterSession(t *testing.T) {
|
||||
th := Setup(t)
|
||||
token := model.NewId()
|
||||
remoteId := model.NewId()
|
||||
|
||||
rc := model.RemoteCluster{
|
||||
RemoteId: remoteId,
|
||||
RemoteTeamId: model.NewId(),
|
||||
DisplayName: "test",
|
||||
Token: token,
|
||||
CreatorId: model.NewId(),
|
||||
}
|
||||
|
||||
_, err := th.GetSqlStore().RemoteCluster().Save(&rc)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("Valid remote token should return session", func(t *testing.T) {
|
||||
session, err := th.App.GetRemoteClusterSession(token, remoteId)
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, session)
|
||||
require.Equal(t, token, session.Token)
|
||||
})
|
||||
|
||||
t.Run("Invalid remote token should return error", func(t *testing.T) {
|
||||
session, err := th.App.GetRemoteClusterSession(model.NewId(), remoteId)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, session)
|
||||
})
|
||||
|
||||
t.Run("Invalid remote id should return error", func(t *testing.T) {
|
||||
session, err := th.App.GetRemoteClusterSession(token, model.NewId())
|
||||
require.Error(t, err)
|
||||
require.Nil(t, session)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
149
app/shared_channel.go
Normal file
149
app/shared_channel.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/store"
|
||||
)
|
||||
|
||||
func (a *App) checkChannelNotShared(channelId string) error {
|
||||
// check that channel exists.
|
||||
if _, err := a.GetChannel(channelId); err != nil {
|
||||
return fmt.Errorf("cannot share this channel: %w", err)
|
||||
}
|
||||
|
||||
// Check channel is not already shared.
|
||||
if _, err := a.GetSharedChannel(channelId); err == nil {
|
||||
var errNotFound *store.ErrNotFound
|
||||
if errors.As(err, &errNotFound) {
|
||||
return errors.New("channel is already shared.")
|
||||
}
|
||||
return fmt.Errorf("cannot find channel: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) checkChannelIsShared(channelId string) error {
|
||||
if _, err := a.GetSharedChannel(channelId); err != nil {
|
||||
var errNotFound *store.ErrNotFound
|
||||
if errors.As(err, &errNotFound) {
|
||||
return errors.New("channel is not shared.")
|
||||
}
|
||||
return fmt.Errorf("cannot find channel: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) CheckCanInviteToSharedChannel(channelId string) error {
|
||||
sc, err := a.GetSharedChannel(channelId)
|
||||
if err != nil {
|
||||
var errNotFound *store.ErrNotFound
|
||||
if errors.As(err, &errNotFound) {
|
||||
return errors.New("channel is not shared.")
|
||||
}
|
||||
return fmt.Errorf("cannot find channel: %w", err)
|
||||
}
|
||||
|
||||
if !sc.Home {
|
||||
return errors.New("channel is homed on a remote cluster.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SharedChannels
|
||||
|
||||
func (a *App) SaveSharedChannel(sc *model.SharedChannel) (*model.SharedChannel, error) {
|
||||
if err := a.checkChannelNotShared(sc.ChannelId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.Srv().Store.SharedChannel().Save(sc)
|
||||
}
|
||||
|
||||
func (a *App) GetSharedChannel(channelID string) (*model.SharedChannel, error) {
|
||||
return a.Srv().Store.SharedChannel().Get(channelID)
|
||||
}
|
||||
|
||||
func (a *App) HasSharedChannel(channelID string) (bool, error) {
|
||||
return a.Srv().Store.SharedChannel().HasChannel(channelID)
|
||||
}
|
||||
|
||||
func (a *App) GetSharedChannels(page int, perPage int, opts model.SharedChannelFilterOpts) ([]*model.SharedChannel, *model.AppError) {
|
||||
channels, err := a.Srv().Store.SharedChannel().GetAll(page*perPage, perPage, opts)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("GetSharedChannels", "app.channel.get_channels.not_found.app_error", nil, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return channels, nil
|
||||
}
|
||||
|
||||
func (a *App) GetSharedChannelsCount(opts model.SharedChannelFilterOpts) (int64, error) {
|
||||
return a.Srv().Store.SharedChannel().GetAllCount(opts)
|
||||
}
|
||||
|
||||
func (a *App) UpdateSharedChannel(sc *model.SharedChannel) (*model.SharedChannel, error) {
|
||||
return a.Srv().Store.SharedChannel().Update(sc)
|
||||
}
|
||||
|
||||
func (a *App) DeleteSharedChannel(channelID string) (bool, error) {
|
||||
return a.Srv().Store.SharedChannel().Delete(channelID)
|
||||
}
|
||||
|
||||
// SharedChannelRemotes
|
||||
|
||||
func (a *App) SaveSharedChannelRemote(remote *model.SharedChannelRemote) (*model.SharedChannelRemote, error) {
|
||||
if err := a.checkChannelIsShared(remote.ChannelId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.Srv().Store.SharedChannel().SaveRemote(remote)
|
||||
}
|
||||
|
||||
func (a *App) GetSharedChannelRemote(id string) (*model.SharedChannelRemote, error) {
|
||||
return a.Srv().Store.SharedChannel().GetRemote(id)
|
||||
}
|
||||
|
||||
func (a *App) GetSharedChannelRemoteByIds(channelID string, remoteID string) (*model.SharedChannelRemote, error) {
|
||||
return a.Srv().Store.SharedChannel().GetRemoteByIds(channelID, remoteID)
|
||||
}
|
||||
|
||||
func (a *App) GetSharedChannelRemotes(opts model.SharedChannelRemoteFilterOpts) ([]*model.SharedChannelRemote, error) {
|
||||
return a.Srv().Store.SharedChannel().GetRemotes(opts)
|
||||
}
|
||||
|
||||
// HasRemote returns whether a given channelID is present in the channel remotes or not.
|
||||
func (a *App) HasRemote(channelID string, remoteID string) (bool, error) {
|
||||
return a.Srv().Store.SharedChannel().HasRemote(channelID, remoteID)
|
||||
}
|
||||
|
||||
func (a *App) GetRemoteClusterForUser(remoteID string, userID string) (*model.RemoteCluster, *model.AppError) {
|
||||
rc, err := a.Srv().Store.SharedChannel().GetRemoteForUser(remoteID, userID)
|
||||
if err != nil {
|
||||
var nfErr *store.ErrNotFound
|
||||
switch {
|
||||
case errors.As(err, &nfErr):
|
||||
return nil, model.NewAppError("GetRemoteClusterForUser", "api.context.remote_id_invalid.app_error", nil, nfErr.Error(), http.StatusNotFound)
|
||||
default:
|
||||
return nil, model.NewAppError("GetRemoteClusterForUser", "api.context.remote_id_invalid.app_error", nil, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
func (a *App) UpdateSharedChannelRemoteNextSyncAt(id string, syncTime int64) error {
|
||||
return a.Srv().Store.SharedChannel().UpdateRemoteNextSyncAt(id, syncTime)
|
||||
}
|
||||
|
||||
func (a *App) DeleteSharedChannelRemote(id string) (bool, error) {
|
||||
return a.Srv().Store.SharedChannel().DeleteRemote(id)
|
||||
}
|
||||
|
||||
func (a *App) GetSharedChannelRemotesStatus(channelID string) ([]*model.SharedChannelRemoteStatus, error) {
|
||||
if err := a.checkChannelIsShared(channelID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.Srv().Store.SharedChannel().GetRemotesStatus(channelID)
|
||||
}
|
||||
144
app/shared_channel_notifier.go
Normal file
144
app/shared_channel_notifier.go
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/services/sharedchannel"
|
||||
"github.com/mattermost/mattermost-server/v5/shared/mlog"
|
||||
)
|
||||
|
||||
var sharedChannelEventsForSync model.StringArray = []string{
|
||||
model.WEBSOCKET_EVENT_POSTED,
|
||||
model.WEBSOCKET_EVENT_POST_EDITED,
|
||||
model.WEBSOCKET_EVENT_POST_DELETED,
|
||||
model.WEBSOCKET_EVENT_REACTION_ADDED,
|
||||
model.WEBSOCKET_EVENT_REACTION_REMOVED,
|
||||
}
|
||||
|
||||
var sharedChannelEventsForInvitation model.StringArray = []string{
|
||||
model.WEBSOCKET_EVENT_DIRECT_ADDED,
|
||||
}
|
||||
|
||||
// SharedChannelSyncHandler is called when a websocket event is received by a cluster node.
|
||||
// Only on the leader node it will notify the sync service to perform necessary updates to the remote for the given
|
||||
// shared channel.
|
||||
func (s *Server) SharedChannelSyncHandler(event *model.WebSocketEvent) {
|
||||
syncService := s.GetSharedChannelSyncService()
|
||||
if isEligibleForEvents(syncService, event, sharedChannelEventsForSync) {
|
||||
err := handleContentSync(s, syncService, event)
|
||||
if err != nil {
|
||||
mlog.Warn(
|
||||
err.Error(),
|
||||
mlog.String("event", event.EventType()),
|
||||
mlog.String("action", "content_sync"),
|
||||
)
|
||||
}
|
||||
} else if isEligibleForEvents(syncService, event, sharedChannelEventsForInvitation) {
|
||||
err := handleInvitation(s, syncService, event)
|
||||
if err != nil {
|
||||
mlog.Warn(
|
||||
err.Error(),
|
||||
mlog.String("event", event.EventType()),
|
||||
mlog.String("action", "invitation"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isEligibleForEvents(syncService SharedChannelServiceIFace, event *model.WebSocketEvent, events model.StringArray) bool {
|
||||
return syncServiceEnabled(syncService) &&
|
||||
eventHasChannel(event) &&
|
||||
events.Contains(event.EventType())
|
||||
}
|
||||
|
||||
func eventHasChannel(event *model.WebSocketEvent) bool {
|
||||
return event.GetBroadcast() != nil &&
|
||||
event.GetBroadcast().ChannelId != ""
|
||||
}
|
||||
|
||||
func syncServiceEnabled(syncService SharedChannelServiceIFace) bool {
|
||||
return syncService != nil &&
|
||||
syncService.Active()
|
||||
}
|
||||
|
||||
func handleContentSync(s *Server, syncService SharedChannelServiceIFace, event *model.WebSocketEvent) error {
|
||||
channel, err := findChannel(s, event.GetBroadcast().ChannelId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if channel != nil && channel.IsShared() {
|
||||
syncService.NotifyChannelChanged(channel.Id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleInvitation(s *Server, syncService SharedChannelServiceIFace, event *model.WebSocketEvent) error {
|
||||
channel, err := findChannel(s, event.GetBroadcast().ChannelId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if channel == nil || !channel.IsShared() {
|
||||
return nil
|
||||
}
|
||||
|
||||
creator, err := getUserFromEvent(s, event, "creator_id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// This is a termination condition, since on the other end when we are processing
|
||||
// the invite we are re-triggering a model.WEBSOCKET_EVENT_DIRECT_ADDED, which will call this handler.
|
||||
// When the creator is remote, it means that this is a DM that was not originated from the current server
|
||||
// and therefore we do not need to do anything.
|
||||
if creator == nil || creator.IsRemote() {
|
||||
return nil
|
||||
}
|
||||
|
||||
participant, err := getUserFromEvent(s, event, "teammate_id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if participant == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
rc, err := s.Store.RemoteCluster().Get(*participant.RemoteId)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, fmt.Sprintf("couldn't find remote cluster %s, for creating shared channel invitation for a DM", *participant.RemoteId))
|
||||
}
|
||||
|
||||
return syncService.SendChannelInvite(channel, creator.Id, "", rc, sharedchannel.WithDirectParticipantID(creator.Id), sharedchannel.WithDirectParticipantID(participant.Id))
|
||||
}
|
||||
|
||||
func getUserFromEvent(s *Server, event *model.WebSocketEvent, key string) (*model.User, error) {
|
||||
userID, ok := event.GetData()[key].(string)
|
||||
if !ok || userID == "" {
|
||||
return nil, fmt.Errorf("received websocket message that is eligible for sending an invitation but message does not have `%s` present", key)
|
||||
}
|
||||
|
||||
user, err := s.Store.User().Get(context.Background(), userID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "couldn't find user for creating shared channel invitation for a DM")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func findChannel(server *Server, channelId string) (*model.Channel, error) {
|
||||
channel, err := server.Store.Channel().Get(channelId, true)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "received websocket message that is eligible for shared channel sync but channel does not exist")
|
||||
}
|
||||
|
||||
return channel, nil
|
||||
}
|
||||
71
app/shared_channel_notifier_test.go
Normal file
71
app/shared_channel_notifier_test.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
)
|
||||
|
||||
func TestServerSyncSharedChannelHandler(t *testing.T) {
|
||||
t.Run("sync service inactive, it does nothing", func(t *testing.T) {
|
||||
th := SetupWithStoreMock(t)
|
||||
defer th.TearDown()
|
||||
|
||||
mockService := NewMockSharedChannelService(nil)
|
||||
mockService.active = false
|
||||
th.App.srv.sharedChannelService = mockService
|
||||
|
||||
th.App.srv.SharedChannelSyncHandler(&model.WebSocketEvent{})
|
||||
assert.Empty(t, mockService.notifications)
|
||||
})
|
||||
|
||||
t.Run("sync service active and broadcast envelope has ineligible event, it does nothing", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
mockService := NewMockSharedChannelService(nil)
|
||||
mockService.active = true
|
||||
th.App.srv.sharedChannelService = mockService
|
||||
channel := th.CreateChannel(th.BasicTeam, WithShared(true))
|
||||
|
||||
websocketEvent := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_ADDED_TO_TEAM, model.NewId(), channel.Id, "", nil)
|
||||
|
||||
th.App.srv.SharedChannelSyncHandler(websocketEvent)
|
||||
assert.Empty(t, mockService.notifications)
|
||||
})
|
||||
|
||||
t.Run("sync service active and broadcast envelope has eligible event but channel does not exist, it does nothing", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
mockService := NewMockSharedChannelService(nil)
|
||||
mockService.active = true
|
||||
th.App.srv.sharedChannelService = mockService
|
||||
|
||||
websocketEvent := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_POSTED, model.NewId(), model.NewId(), "", nil)
|
||||
|
||||
th.App.srv.SharedChannelSyncHandler(websocketEvent)
|
||||
assert.Empty(t, mockService.notifications)
|
||||
})
|
||||
|
||||
t.Run("sync service active when received eligible event, it triggers a shared channel content sync", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
mockService := NewMockSharedChannelService(nil)
|
||||
mockService.active = true
|
||||
th.App.srv.sharedChannelService = mockService
|
||||
|
||||
channel := th.CreateChannel(th.BasicTeam, WithShared(true))
|
||||
websocketEvent := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_POSTED, model.NewId(), channel.Id, "", nil)
|
||||
|
||||
th.App.srv.SharedChannelSyncHandler(websocketEvent)
|
||||
assert.Len(t, mockService.notifications, 1)
|
||||
assert.Equal(t, channel.Id, mockService.notifications[0])
|
||||
})
|
||||
}
|
||||
66
app/shared_channel_service_iface.go
Normal file
66
app/shared_channel_service_iface.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/services/sharedchannel"
|
||||
)
|
||||
|
||||
// SharedChannelServiceIFace is the interface to the shared channel service
|
||||
type SharedChannelServiceIFace interface {
|
||||
Shutdown() error
|
||||
Start() error
|
||||
NotifyChannelChanged(channelId string)
|
||||
SendChannelInvite(channel *model.Channel, userId string, description string, rc *model.RemoteCluster, options ...sharedchannel.InviteOption) error
|
||||
Active() bool
|
||||
}
|
||||
|
||||
type MockOptionSharedChannelService func(service *mockSharedChannelService)
|
||||
|
||||
func MockOptionSharedChannelServiceWithActive(active bool) MockOptionSharedChannelService {
|
||||
return func(mrcs *mockSharedChannelService) {
|
||||
mrcs.active = active
|
||||
}
|
||||
}
|
||||
|
||||
func NewMockSharedChannelService(service SharedChannelServiceIFace, options ...MockOptionSharedChannelService) *mockSharedChannelService {
|
||||
mrcs := &mockSharedChannelService{service, true, []string{}, 0}
|
||||
for _, option := range options {
|
||||
option(mrcs)
|
||||
}
|
||||
return mrcs
|
||||
}
|
||||
|
||||
type mockSharedChannelService struct {
|
||||
SharedChannelServiceIFace
|
||||
active bool
|
||||
notifications []string
|
||||
numInvitations int
|
||||
}
|
||||
|
||||
func (mrcs *mockSharedChannelService) NotifyChannelChanged(channelId string) {
|
||||
mrcs.notifications = append(mrcs.notifications, channelId)
|
||||
}
|
||||
|
||||
func (mrcs *mockSharedChannelService) Shutdown() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mrcs *mockSharedChannelService) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mrcs *mockSharedChannelService) Active() bool {
|
||||
return mrcs.active
|
||||
}
|
||||
|
||||
func (mrcs *mockSharedChannelService) SendChannelInvite(channel *model.Channel, userId string, description string, rc *model.RemoteCluster, options ...sharedchannel.InviteOption) error {
|
||||
mrcs.numInvitations += 1
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mrcs *mockSharedChannelService) NumInvitations() int {
|
||||
return mrcs.numInvitations
|
||||
}
|
||||
89
app/shared_channel_test.go
Normal file
89
app/shared_channel_test.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
)
|
||||
|
||||
func TestApp_CheckCanInviteToSharedChannel(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
|
||||
channel1 := th.CreateChannel(th.BasicTeam)
|
||||
channel2 := th.CreateChannel(th.BasicTeam)
|
||||
channel3 := th.CreateChannel(th.BasicTeam)
|
||||
|
||||
data := []struct {
|
||||
channelId string
|
||||
home bool
|
||||
name string
|
||||
remoteId string
|
||||
}{
|
||||
{channelId: channel1.Id, home: true, name: "test_home", remoteId: ""},
|
||||
{channelId: channel2.Id, home: false, name: "test_remote", remoteId: model.NewId()},
|
||||
}
|
||||
|
||||
for _, d := range data {
|
||||
sc := &model.SharedChannel{
|
||||
ChannelId: d.channelId,
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Home: d.home,
|
||||
ShareName: d.name,
|
||||
CreatorId: th.BasicUser.Id,
|
||||
RemoteId: d.remoteId,
|
||||
}
|
||||
_, err := th.App.SaveSharedChannel(sc)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
t.Run("Test checkChannelNotShared: not yet shared channel", func(t *testing.T) {
|
||||
err := th.App.checkChannelNotShared(channel3.Id)
|
||||
assert.NoError(t, err, "unshared channel should not error")
|
||||
})
|
||||
|
||||
t.Run("Test checkChannelNotShared: already shared channel", func(t *testing.T) {
|
||||
err := th.App.checkChannelNotShared(channel1.Id)
|
||||
assert.Error(t, err, "already shared channel should error")
|
||||
})
|
||||
|
||||
t.Run("Test checkChannelNotShared: invalid channel", func(t *testing.T) {
|
||||
err := th.App.checkChannelNotShared(model.NewId())
|
||||
assert.Error(t, err, "invalid channel should error")
|
||||
})
|
||||
|
||||
t.Run("Test checkChannelIsShared: not yet shared channel", func(t *testing.T) {
|
||||
err := th.App.checkChannelIsShared(channel3.Id)
|
||||
assert.Error(t, err, "unshared channel should error")
|
||||
})
|
||||
|
||||
t.Run("Test checkChannelIsShared: already shared channel", func(t *testing.T) {
|
||||
err := th.App.checkChannelIsShared(channel1.Id)
|
||||
assert.NoError(t, err, "already channel should not error")
|
||||
})
|
||||
|
||||
t.Run("Test checkChannelIsShared: invalid channel", func(t *testing.T) {
|
||||
err := th.App.checkChannelIsShared(model.NewId())
|
||||
assert.Error(t, err, "invalid channel should error")
|
||||
})
|
||||
|
||||
t.Run("Test CheckCanInviteToSharedChannel: Home shared channel", func(t *testing.T) {
|
||||
err := th.App.CheckCanInviteToSharedChannel(data[0].channelId)
|
||||
assert.NoError(t, err, "home channel should allow invites")
|
||||
})
|
||||
|
||||
t.Run("Test CheckCanInviteToSharedChannel: Remote shared channel", func(t *testing.T) {
|
||||
err := th.App.CheckCanInviteToSharedChannel(data[1].channelId)
|
||||
assert.Error(t, err, "home channel should not allow invites")
|
||||
})
|
||||
|
||||
t.Run("Test CheckCanInviteToSharedChannel: Invalid shared channel", func(t *testing.T) {
|
||||
err := th.App.CheckCanInviteToSharedChannel(model.NewId())
|
||||
assert.Error(t, err, "invalid channel should not allow invites")
|
||||
})
|
||||
}
|
||||
292
app/slashcommands/command_remote.go
Normal file
292
app/slashcommands/command_remote.go
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package slashcommands
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/app"
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/shared/i18n"
|
||||
)
|
||||
|
||||
const (
|
||||
AvailableRemoteActions = "invite, accept, remove, status"
|
||||
)
|
||||
|
||||
type RemoteProvider struct {
|
||||
}
|
||||
|
||||
const (
|
||||
CommandTriggerRemote = "remote"
|
||||
)
|
||||
|
||||
func init() {
|
||||
app.RegisterCommandProvider(&RemoteProvider{})
|
||||
}
|
||||
|
||||
func (rp *RemoteProvider) GetTrigger() string {
|
||||
return CommandTriggerRemote
|
||||
}
|
||||
|
||||
func (rp *RemoteProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
|
||||
|
||||
remote := model.NewAutocompleteData(rp.GetTrigger(), "[action]", T("api.command_remote.remote_add_remove.help", map[string]interface{}{"Actions": AvailableRemoteActions}))
|
||||
|
||||
invite := model.NewAutocompleteData("invite", "", T("api.command_remote.invite.help"))
|
||||
invite.AddNamedTextArgument("password", T("api.command_remote.invite_password.help"), T("api.command_remote.invite_password.hint"), "", true)
|
||||
invite.AddNamedTextArgument("name", T("api.command_remote.name.help"), T("api.command_remote.name.hint"), "", true)
|
||||
|
||||
accept := model.NewAutocompleteData("accept", "", T("api.command_remote.accept.help"))
|
||||
accept.AddNamedTextArgument("password", T("api.command_remote.invite_password.help"), T("api.command_remote.invite_password.hint"), "", true)
|
||||
accept.AddNamedTextArgument("name", T("api.command_remote.name.help"), T("api.command_remote.name.hint"), "", true)
|
||||
accept.AddNamedTextArgument("invite", T("api.command_remote.invitation.help"), T("api.command_remote.invitation.hint"), "", true)
|
||||
|
||||
remove := model.NewAutocompleteData("remove", "", T("api.command_remote.remove.help"))
|
||||
remove.AddNamedDynamicListArgument("remoteId", T("api.command_remote.remove_remote_id.help"), "builtin:remote", true)
|
||||
|
||||
status := model.NewAutocompleteData("status", "", T("api.command_remote.status.help"))
|
||||
|
||||
remote.AddCommand(invite)
|
||||
remote.AddCommand(accept)
|
||||
remote.AddCommand(remove)
|
||||
remote.AddCommand(status)
|
||||
|
||||
return &model.Command{
|
||||
Trigger: rp.GetTrigger(),
|
||||
AutoComplete: true,
|
||||
AutoCompleteDesc: T("api.command_remote.desc"),
|
||||
AutoCompleteHint: T("api.command_remote.hint"),
|
||||
DisplayName: T("api.command_remote.name"),
|
||||
AutocompleteData: remote,
|
||||
}
|
||||
}
|
||||
|
||||
func (rp *RemoteProvider) DoCommand(a *app.App, args *model.CommandArgs, message string) *model.CommandResponse {
|
||||
if !a.HasPermissionTo(args.UserId, model.PERMISSION_MANAGE_SHARED_CHANNELS) {
|
||||
return responsef(args.T("api.command_remote.permission_required", map[string]interface{}{"Permission": "manage_shared_channels"}))
|
||||
}
|
||||
|
||||
margs := parseNamedArgs(args.Command)
|
||||
action, ok := margs[ActionKey]
|
||||
if !ok {
|
||||
return responsef(args.T("api.command_remote.missing_command", map[string]interface{}{"Actions": AvailableRemoteActions}))
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "invite":
|
||||
return rp.doInvite(a, args, margs)
|
||||
case "accept":
|
||||
return rp.doAccept(a, args, margs)
|
||||
case "remove":
|
||||
return rp.doRemove(a, args, margs)
|
||||
case "status":
|
||||
return rp.doStatus(a, args, margs)
|
||||
}
|
||||
|
||||
return responsef(args.T("api.command_remote.unknown_action", map[string]interface{}{"Action": action}))
|
||||
}
|
||||
|
||||
func (rp *RemoteProvider) GetAutoCompleteListItems(a *app.App, commandArgs *model.CommandArgs, arg *model.AutocompleteArg, parsed, toBeParsed string) ([]model.AutocompleteListItem, error) {
|
||||
if !a.HasPermissionTo(commandArgs.UserId, model.PERMISSION_MANAGE_SHARED_CHANNELS) {
|
||||
return nil, errors.New("You require `manage_shared_channels` permission to manage remote clusters.")
|
||||
}
|
||||
|
||||
if arg.Name == "remoteId" && strings.Contains(parsed, " remove ") {
|
||||
return getRemoteClusterAutocompleteListItems(a, true)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("`%s` is not a dynamic argument", arg.Name)
|
||||
}
|
||||
|
||||
// doInvite creates and displays an encrypted invite that can be used by a remote site to establish a simple trust.
|
||||
func (rp *RemoteProvider) doInvite(a *app.App, args *model.CommandArgs, margs map[string]string) *model.CommandResponse {
|
||||
password := margs["password"]
|
||||
if password == "" {
|
||||
return responsef(args.T("api.command_remote.missing_empty", map[string]interface{}{"Arg": "password"}))
|
||||
}
|
||||
|
||||
name := margs["name"]
|
||||
if name == "" {
|
||||
return responsef(args.T("api.command_remote.missing_empty", map[string]interface{}{"Arg": "name"}))
|
||||
}
|
||||
|
||||
url := a.GetSiteURL()
|
||||
if url == "" {
|
||||
return responsef(args.T("api.command_remote.site_url_not_set"))
|
||||
}
|
||||
|
||||
rc := &model.RemoteCluster{
|
||||
DisplayName: name,
|
||||
Token: model.NewId(),
|
||||
CreatorId: args.UserId,
|
||||
}
|
||||
|
||||
rcSaved, appErr := a.AddRemoteCluster(rc)
|
||||
if appErr != nil {
|
||||
return responsef(args.T("api.command_remote.add_remote.error", map[string]interface{}{"Error": appErr.Error()}))
|
||||
}
|
||||
|
||||
// Display the encrypted invitation
|
||||
invite := &model.RemoteClusterInvite{
|
||||
RemoteId: rcSaved.RemoteId,
|
||||
RemoteTeamId: args.TeamId,
|
||||
SiteURL: url,
|
||||
Token: rcSaved.Token,
|
||||
}
|
||||
encrypted, err := invite.Encrypt(password)
|
||||
if err != nil {
|
||||
return responsef(args.T("api.command_remote.encrypt_invitation.error", map[string]interface{}{"Error": err.Error()}))
|
||||
}
|
||||
encoded := base64.URLEncoding.EncodeToString(encrypted)
|
||||
|
||||
return responsef("##### " + args.T("api.command_remote.invitation_created") + "\n" +
|
||||
args.T("api.command_remote.invite_summary", map[string]interface{}{"Command": "/remote accept", "Invitation": encoded, "SiteURL": invite.SiteURL}))
|
||||
}
|
||||
|
||||
// doAccept accepts an invitation generated by a remote site.
|
||||
func (rp *RemoteProvider) doAccept(a *app.App, args *model.CommandArgs, margs map[string]string) *model.CommandResponse {
|
||||
password := margs["password"]
|
||||
if password == "" {
|
||||
return responsef(args.T("api.command_remote.missing_empty", map[string]interface{}{"Arg": "password"}))
|
||||
}
|
||||
|
||||
name := margs["name"]
|
||||
if name == "" {
|
||||
return responsef(args.T("api.command_remote.missing_empty", map[string]interface{}{"Arg": "name"}))
|
||||
}
|
||||
|
||||
blob := margs["invite"]
|
||||
if blob == "" {
|
||||
return responsef(args.T("api.command_remote.missing_empty", map[string]interface{}{"Arg": "invite"}))
|
||||
}
|
||||
|
||||
// invite is encoded as base64 and encrypted
|
||||
decoded, err := base64.URLEncoding.DecodeString(blob)
|
||||
if err != nil {
|
||||
return responsef(args.T("api.command_remote.decode_invitation.error", map[string]interface{}{"Error": err.Error()}))
|
||||
}
|
||||
invite := &model.RemoteClusterInvite{}
|
||||
err = invite.Decrypt(decoded, password)
|
||||
if err != nil {
|
||||
return responsef(args.T("api.command_remote.incorrect_password.error", map[string]interface{}{"Error": err.Error()}))
|
||||
}
|
||||
|
||||
rcs, _ := a.GetRemoteClusterService()
|
||||
if rcs == nil {
|
||||
return responsef(args.T("api.command_remote.service_not_enabled"))
|
||||
}
|
||||
|
||||
url := a.GetSiteURL()
|
||||
if url == "" {
|
||||
return responsef(args.T("api.command_remote.site_url_not_set"))
|
||||
}
|
||||
|
||||
rc, err := rcs.AcceptInvitation(invite, name, args.UserId, args.TeamId, url)
|
||||
if err != nil {
|
||||
return responsef(args.T("api.command_remote.accept_invitation.error", map[string]interface{}{"Error": err.Error()}))
|
||||
}
|
||||
|
||||
return responsef("##### " + args.T("api.command_remote.accept_invitation", map[string]interface{}{"SiteURL": rc.SiteURL}))
|
||||
}
|
||||
|
||||
// doRemove removes a remote cluster from the database, effectively revoking the trust relationship.
|
||||
func (rp *RemoteProvider) doRemove(a *app.App, args *model.CommandArgs, margs map[string]string) *model.CommandResponse {
|
||||
id, ok := margs["remoteId"]
|
||||
if !ok {
|
||||
return responsef(args.T("api.command_remote.missing_empty", map[string]interface{}{"Arg": "remoteId"}))
|
||||
}
|
||||
|
||||
deleted, err := a.DeleteRemoteCluster(id)
|
||||
if err != nil {
|
||||
responsef(args.T("api.command_remote.remove_remote.error", map[string]interface{}{"Error": err.Error()}))
|
||||
}
|
||||
|
||||
result := "removed"
|
||||
if !deleted {
|
||||
result = "**NOT FOUND**"
|
||||
}
|
||||
return responsef("##### " + args.T("api.command_remote.cluster_removed", map[string]interface{}{"RemoteId": id, "Result": result}))
|
||||
}
|
||||
|
||||
// doStatus displays connection status for all remote clusters.
|
||||
func (rp *RemoteProvider) doStatus(a *app.App, args *model.CommandArgs, _ map[string]string) *model.CommandResponse {
|
||||
list, err := a.GetAllRemoteClusters(model.RemoteClusterQueryFilter{})
|
||||
if err != nil {
|
||||
responsef(args.T("api.command_remote.fetch_status.error", map[string]interface{}{"Error": err.Error()}))
|
||||
}
|
||||
|
||||
if len(list) == 0 {
|
||||
return responsef("** " + args.T("api.command_remote.remotes_not_found") + " **")
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, args.T("api.command_remote.remote_table_header")+"| \n")
|
||||
fmt.Fprintf(&sb, "| ---- | -------- | ---------- | :-------------: | :----: | ---------- |\n")
|
||||
|
||||
for _, rc := range list {
|
||||
accepted := ":white_check_mark:"
|
||||
if rc.SiteURL == "" {
|
||||
accepted = ":x:"
|
||||
}
|
||||
|
||||
online := ":white_check_mark:"
|
||||
if !isOnline(rc.LastPingAt) {
|
||||
online = ":skull_and_crossbones:"
|
||||
}
|
||||
|
||||
lastPing := formatTimestamp(model.GetTimeForMillis(rc.LastPingAt))
|
||||
|
||||
fmt.Fprintf(&sb, "| %s | %s | %s | %s | %s | %s |\n", rc.DisplayName, rc.SiteURL, rc.RemoteId, accepted, online, lastPing)
|
||||
}
|
||||
return responsef(sb.String())
|
||||
}
|
||||
|
||||
func isOnline(lastPing int64) bool {
|
||||
return lastPing > model.GetMillis()-model.RemoteOfflineAfterMillis
|
||||
}
|
||||
|
||||
func getRemoteClusterAutocompleteListItems(a *app.App, includeOffline bool) ([]model.AutocompleteListItem, error) {
|
||||
filter := model.RemoteClusterQueryFilter{
|
||||
ExcludeOffline: !includeOffline,
|
||||
}
|
||||
clusters, err := a.GetAllRemoteClusters(filter)
|
||||
if err != nil || len(clusters) == 0 {
|
||||
return []model.AutocompleteListItem{}, nil
|
||||
}
|
||||
|
||||
list := make([]model.AutocompleteListItem, 0, len(clusters))
|
||||
|
||||
for _, rc := range clusters {
|
||||
item := model.AutocompleteListItem{
|
||||
Item: rc.RemoteId,
|
||||
HelpText: fmt.Sprintf("%s (%s)", rc.DisplayName, rc.SiteURL)}
|
||||
list = append(list, item)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func getRemoteClusterAutocompleteListItemsNotInChannel(a *app.App, channelId string, includeOffline bool) ([]model.AutocompleteListItem, error) {
|
||||
filter := model.RemoteClusterQueryFilter{
|
||||
ExcludeOffline: !includeOffline,
|
||||
NotInChannel: channelId,
|
||||
}
|
||||
all, err := a.GetAllRemoteClusters(filter)
|
||||
if err != nil || len(all) == 0 {
|
||||
return []model.AutocompleteListItem{}, nil
|
||||
}
|
||||
|
||||
list := make([]model.AutocompleteListItem, 0, len(all))
|
||||
|
||||
for _, rc := range all {
|
||||
item := model.AutocompleteListItem{
|
||||
Item: rc.RemoteId,
|
||||
HelpText: fmt.Sprintf("%s (%s)", rc.DisplayName, rc.SiteURL)}
|
||||
list = append(list, item)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
347
app/slashcommands/command_share.go
Normal file
347
app/slashcommands/command_share.go
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package slashcommands
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/app"
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/shared/i18n"
|
||||
)
|
||||
|
||||
type ShareProvider struct {
|
||||
}
|
||||
|
||||
const (
|
||||
CommandTriggerShare = "share"
|
||||
AvailableShareActions = "share_channel, unshare_channel, invite_remove, uninvite_remote, status"
|
||||
)
|
||||
|
||||
func init() {
|
||||
app.RegisterCommandProvider(&ShareProvider{})
|
||||
}
|
||||
|
||||
func (sp *ShareProvider) GetTrigger() string {
|
||||
return CommandTriggerShare
|
||||
}
|
||||
|
||||
func (sp *ShareProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
|
||||
share := model.NewAutocompleteData(CommandTriggerShare, "[action]", T("api.command_share.available_actions", map[string]interface{}{"Actions": AvailableShareActions}))
|
||||
|
||||
shareChannel := model.NewAutocompleteData("share_channel", "", T("api.command_share.share_current"))
|
||||
shareChannel.AddNamedTextArgument("readonly", T("api.command_share.share_read_only.help"), T("api.command_share.share_read_only.hint"), "Y|N|y|n", false)
|
||||
shareChannel.AddNamedTextArgument("name", T("api.command_share.channel_name.help"), T("api.command_share.channel_name.hint"), "", false)
|
||||
shareChannel.AddNamedTextArgument("displayname", T("api.command_share.channel_display_name.help"), T("api.command_share.channel_display_name.hint"), "", false)
|
||||
shareChannel.AddNamedTextArgument("purpose", T("api.command_share.channel_purpose.help"), T("api.command_share.channel_purpose.hint"), "", false)
|
||||
shareChannel.AddNamedTextArgument("header", T("api.command_share.channel_header.help"), T("api.command_share.channel_header.hint"), "", false)
|
||||
|
||||
unshareChannel := model.NewAutocompleteData("unshare_channel", "", T("api.command_share.unshare_channel.help"))
|
||||
unshareChannel.AddNamedTextArgument("are_you_sure", T("api.command_share.unshare_confirmation.help"), T("api.command_share.unshare_confirmation.hint"), "Y|N|y|n", true)
|
||||
|
||||
inviteRemote := model.NewAutocompleteData("invite_remote", "", T("api.command_share.invite_remote.help"))
|
||||
inviteRemote.AddNamedDynamicListArgument("remoteId", T("api.command_share.remote_id.help"), "builtin:share", true)
|
||||
inviteRemote.AddNamedTextArgument("description", T("api.command_share.description_invite.help"), T("api.command_share.description_invite.hint"), "", false)
|
||||
|
||||
unInviteRemote := model.NewAutocompleteData("uninvite_remote", "", T("api.command_share.uninvite_remote.help"))
|
||||
unInviteRemote.AddNamedDynamicListArgument("remoteId", T("api.command_share.uninvite_remote_id.help"), "builtin:share", true)
|
||||
|
||||
status := model.NewAutocompleteData("status", "", T("api.command_share.channel_status.help"))
|
||||
|
||||
share.AddCommand(shareChannel)
|
||||
share.AddCommand(unshareChannel)
|
||||
share.AddCommand(inviteRemote)
|
||||
share.AddCommand(unInviteRemote)
|
||||
share.AddCommand(status)
|
||||
|
||||
return &model.Command{
|
||||
Trigger: CommandTriggerShare,
|
||||
AutoComplete: true,
|
||||
AutoCompleteDesc: T("api.command_share.desc"),
|
||||
AutoCompleteHint: T("api.command_share.hint"),
|
||||
DisplayName: T("api.command_share.name"),
|
||||
AutocompleteData: share,
|
||||
}
|
||||
}
|
||||
|
||||
func (sp *ShareProvider) GetAutoCompleteListItems(a *app.App, commandArgs *model.CommandArgs, arg *model.AutocompleteArg, parsed, toBeParsed string) ([]model.AutocompleteListItem, error) {
|
||||
switch {
|
||||
case strings.Contains(parsed, " share_channel "):
|
||||
|
||||
return sp.getAutoCompleteShareChannel(a, commandArgs, arg)
|
||||
|
||||
case strings.Contains(parsed, " invite_remote "):
|
||||
|
||||
return sp.getAutoCompleteInviteRemote(a, commandArgs, arg)
|
||||
|
||||
case strings.Contains(parsed, " uninvite_remote "):
|
||||
|
||||
return sp.getAutoCompleteUnInviteRemote(a, commandArgs, arg)
|
||||
|
||||
}
|
||||
return nil, errors.New("invalid action")
|
||||
}
|
||||
|
||||
func (sp *ShareProvider) getAutoCompleteShareChannel(a *app.App, commandArgs *model.CommandArgs, arg *model.AutocompleteArg) ([]model.AutocompleteListItem, error) {
|
||||
channel, err := a.GetChannel(commandArgs.ChannelId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var item model.AutocompleteListItem
|
||||
|
||||
switch arg.Name {
|
||||
case "name":
|
||||
item = model.AutocompleteListItem{
|
||||
Item: channel.Name,
|
||||
HelpText: channel.DisplayName,
|
||||
}
|
||||
case "displayname":
|
||||
item = model.AutocompleteListItem{
|
||||
Item: channel.DisplayName,
|
||||
HelpText: channel.Name,
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("%s not a dynamic argument", arg.Name)
|
||||
}
|
||||
return []model.AutocompleteListItem{item}, nil
|
||||
}
|
||||
|
||||
func (sp *ShareProvider) getAutoCompleteInviteRemote(a *app.App, commandArgs *model.CommandArgs, arg *model.AutocompleteArg) ([]model.AutocompleteListItem, error) {
|
||||
switch arg.Name {
|
||||
case "remoteId":
|
||||
return getRemoteClusterAutocompleteListItemsNotInChannel(a, commandArgs.ChannelId, true)
|
||||
default:
|
||||
return nil, fmt.Errorf("%s not a dynamic argument", arg.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func (sp *ShareProvider) getAutoCompleteUnInviteRemote(a *app.App, _ *model.CommandArgs, arg *model.AutocompleteArg) ([]model.AutocompleteListItem, error) {
|
||||
switch arg.Name {
|
||||
case "remoteId":
|
||||
return getRemoteClusterAutocompleteListItems(a, true)
|
||||
default:
|
||||
return nil, fmt.Errorf("%s not a dynamic argument", arg.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func (sp *ShareProvider) DoCommand(a *app.App, args *model.CommandArgs, message string) *model.CommandResponse {
|
||||
if !a.HasPermissionTo(args.UserId, model.PERMISSION_MANAGE_SHARED_CHANNELS) {
|
||||
return responsef(args.T("api.command_share.permission_required", map[string]interface{}{"Permission": "manage_shared_channels"}))
|
||||
}
|
||||
|
||||
if a.Srv().GetSharedChannelSyncService() == nil {
|
||||
return responsef(args.T("api.command_share.service_disabled"))
|
||||
}
|
||||
|
||||
if a.Srv().GetRemoteClusterService() == nil {
|
||||
return responsef(args.T("api.command_remote.service_disabled"))
|
||||
}
|
||||
|
||||
margs := parseNamedArgs(args.Command)
|
||||
action, ok := margs[ActionKey]
|
||||
if !ok {
|
||||
return responsef(args.T("api.command_share.missing_action", map[string]interface{}{"Actions": AvailableShareActions}))
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "share_channel":
|
||||
return sp.doShareChannel(a, args, margs)
|
||||
case "unshare_channel":
|
||||
return sp.doUnshareChannel(a, args, margs)
|
||||
case "invite_remote":
|
||||
return sp.doInviteRemote(a, args, margs)
|
||||
case "uninvite_remote":
|
||||
return sp.doUninviteRemote(a, args, margs)
|
||||
case "status":
|
||||
return sp.doStatus(a, args, margs)
|
||||
}
|
||||
return responsef(args.T("api.command_share.unknown_action", map[string]interface{}{"Action": action, "Actions": AvailableShareActions}))
|
||||
}
|
||||
|
||||
func (sp *ShareProvider) doShareChannel(a *app.App, args *model.CommandArgs, margs map[string]string) *model.CommandResponse {
|
||||
// check that channel exists.
|
||||
channel, errApp := a.GetChannel(args.ChannelId)
|
||||
if errApp != nil {
|
||||
return responsef(args.T("api.command_share.share_channel.error", map[string]interface{}{"Error": errApp.Error()}))
|
||||
}
|
||||
|
||||
if name := margs["name"]; name == "" {
|
||||
margs["name"] = channel.Name
|
||||
}
|
||||
if name := margs["displayname"]; name == "" {
|
||||
margs["displayname"] = channel.DisplayName
|
||||
}
|
||||
if name := margs["purpose"]; name == "" {
|
||||
margs["purpose"] = channel.Purpose
|
||||
}
|
||||
if name := margs["header"]; name == "" {
|
||||
margs["header"] = channel.Header
|
||||
}
|
||||
if _, ok := margs["readonly"]; !ok {
|
||||
margs["readonly"] = "N"
|
||||
}
|
||||
|
||||
readonly, err := parseBool(margs["readonly"])
|
||||
if err != nil {
|
||||
return responsef(args.T("api.command_share.invalid_value.error", map[string]interface{}{"Arg": "readonly", "Error": err.Error()}))
|
||||
}
|
||||
|
||||
sc := &model.SharedChannel{
|
||||
ChannelId: args.ChannelId,
|
||||
TeamId: args.TeamId,
|
||||
Home: true,
|
||||
ReadOnly: readonly,
|
||||
ShareName: margs["name"],
|
||||
ShareDisplayName: margs["displayname"],
|
||||
SharePurpose: margs["purpose"],
|
||||
ShareHeader: margs["header"],
|
||||
CreatorId: args.UserId,
|
||||
}
|
||||
|
||||
if _, err := a.SaveSharedChannel(sc); err != nil {
|
||||
return responsef(args.T("api.command_share.share_channel.error", map[string]interface{}{"Error": err.Error()}))
|
||||
}
|
||||
|
||||
notifyClientsForChannelUpdate(a, sc)
|
||||
|
||||
return responsef("##### " + args.T("api.command_share.channel_shared"))
|
||||
}
|
||||
|
||||
func (sp *ShareProvider) doUnshareChannel(a *app.App, args *model.CommandArgs, margs map[string]string) *model.CommandResponse {
|
||||
if _, ok := margs["are_you_sure"]; !ok {
|
||||
margs["are_you_sure"] = "N"
|
||||
}
|
||||
|
||||
sure, err := parseBool(margs["are_you_sure"])
|
||||
if err != nil || !sure {
|
||||
return responsef(args.T("api.command_share.shared_channel_not_deleted", map[string]interface{}{"Arg": "are_you_sure", "Expected": "Y"}))
|
||||
}
|
||||
|
||||
sc, appErr := a.GetSharedChannel(args.ChannelId)
|
||||
if appErr != nil {
|
||||
return responsef(args.T("api.command_share.shared_channel_unshare.error", map[string]interface{}{"Error": appErr.Error()}))
|
||||
}
|
||||
|
||||
deleted, err := a.DeleteSharedChannel(args.ChannelId)
|
||||
if err != nil {
|
||||
return responsef(args.T("api.command_share.shared_channel_unshare.error", map[string]interface{}{"Error": err.Error()}))
|
||||
}
|
||||
if !deleted {
|
||||
return responsef(args.T("api.command_share.not_shared_channel_unshare"))
|
||||
}
|
||||
|
||||
notifyClientsForChannelUpdate(a, sc)
|
||||
|
||||
return responsef("##### " + args.T("api.command_share.shared_channel_unavailable"))
|
||||
}
|
||||
|
||||
func (sp *ShareProvider) doInviteRemote(a *app.App, args *model.CommandArgs, margs map[string]string) (resp *model.CommandResponse) {
|
||||
remoteId, ok := margs["remoteId"]
|
||||
if !ok || remoteId == "" {
|
||||
return responsef(args.T("api.command_share.must_specify_valid_remote"))
|
||||
}
|
||||
|
||||
hasRemote, err := a.HasRemote(args.ChannelId, remoteId)
|
||||
if err != nil {
|
||||
return responsef(args.T("api.command_share.fetch_remote.error", map[string]interface{}{"Error": err.Error()}))
|
||||
}
|
||||
if hasRemote {
|
||||
return responsef(args.T("api.command_share.remote_already_invited"))
|
||||
}
|
||||
|
||||
// Check if channel is shared or not.
|
||||
hasChan, err := a.HasSharedChannel(args.ChannelId)
|
||||
if err != nil {
|
||||
return responsef(args.T("api.command_share.check_channel_exist.error", map[string]interface{}{"Error": err.Error()}))
|
||||
}
|
||||
if !hasChan {
|
||||
// If it doesn't exist, then create it.
|
||||
resp2 := sp.doShareChannel(a, args, margs)
|
||||
// We modify the outgoing response by prepending the text
|
||||
// from the shareChannel response.
|
||||
defer func() {
|
||||
resp.Text = resp2.Text + "\n" + resp.Text
|
||||
}()
|
||||
}
|
||||
|
||||
// don't allow invitation to shared channel originating from remote.
|
||||
// (also blocks cyclic invitations)
|
||||
if err := a.CheckCanInviteToSharedChannel(args.ChannelId); err != nil {
|
||||
return responsef(args.T("api.command_share.channel_invite_not_home.error"))
|
||||
}
|
||||
|
||||
rc, appErr := a.GetRemoteCluster(remoteId)
|
||||
if appErr != nil {
|
||||
return responsef(args.T("api.command_share.remote_id_invalid.error", map[string]interface{}{"Error": appErr.Error()}))
|
||||
}
|
||||
|
||||
channel, errApp := a.GetChannel(args.ChannelId)
|
||||
if errApp != nil {
|
||||
return responsef(args.T("api.command_share.channel_invite.error", map[string]interface{}{"Name": rc.DisplayName, "Error": errApp.Error()}))
|
||||
}
|
||||
// send channel invite to remote cluster
|
||||
if err := a.Srv().GetSharedChannelSyncService().SendChannelInvite(channel, args.UserId, margs["description"], rc); err != nil {
|
||||
return responsef(args.T("api.command_share.channel_invite.error", map[string]interface{}{"Name": rc.DisplayName, "Error": err.Error()}))
|
||||
}
|
||||
|
||||
return responsef("##### " + args.T("api.command_share.invitation_sent", map[string]interface{}{"Name": rc.DisplayName, "SiteURL": rc.SiteURL}))
|
||||
}
|
||||
|
||||
func (sp *ShareProvider) doUninviteRemote(a *app.App, args *model.CommandArgs, margs map[string]string) *model.CommandResponse {
|
||||
remoteId, ok := margs["remoteId"]
|
||||
if !ok || remoteId == "" {
|
||||
return responsef(args.T("api.command_share.remote_not_valid"))
|
||||
}
|
||||
|
||||
scr, err := a.GetSharedChannelRemoteByIds(args.ChannelId, remoteId)
|
||||
if err != nil || scr.ChannelId != args.ChannelId {
|
||||
return responsef(args.T("api.command_share.channel_remote_id_not_exists", map[string]interface{}{"RemoteId": remoteId}))
|
||||
}
|
||||
|
||||
deleted, err := a.DeleteSharedChannelRemote(scr.Id)
|
||||
if err != nil || !deleted {
|
||||
return responsef(args.T("api.command_share.could_not_uninvite.error", map[string]interface{}{"RemoteId": remoteId, "Error": err.Error()}))
|
||||
}
|
||||
return responsef("##### " + args.T("api.command_share.remote_uninvited", map[string]interface{}{"RemoteId": remoteId}))
|
||||
}
|
||||
|
||||
func (sp *ShareProvider) doStatus(a *app.App, args *model.CommandArgs, _ map[string]string) *model.CommandResponse {
|
||||
statuses, err := a.GetSharedChannelRemotesStatus(args.ChannelId)
|
||||
if err != nil {
|
||||
return responsef(args.T("api.command_share.fetch_remote_status.error", map[string]interface{}{"Error": err.Error()}))
|
||||
}
|
||||
if len(statuses) == 0 {
|
||||
return responsef(args.T("api.command_share.no_remote_invited"))
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
fmt.Fprintf(&sb, args.T("api.command_share.channel_status_id", map[string]interface{}{"ChannelId": statuses[0].ChannelId})+"\n\n")
|
||||
|
||||
fmt.Fprintf(&sb, args.T("api.command_share.remote_table_header")+" \n")
|
||||
fmt.Fprintf(&sb, "| ------ | ------- | ----------- | -------- | -------------- | ------ | --------- | \n")
|
||||
|
||||
for _, status := range statuses {
|
||||
online := ":white_check_mark:"
|
||||
if !isOnline(status.LastPingAt) {
|
||||
online = ":skull_and_crossbones:"
|
||||
}
|
||||
|
||||
lastSync := formatTimestamp(model.GetTimeForMillis(status.NextSyncAt))
|
||||
|
||||
fmt.Fprintf(&sb, "| %s | %s | %s | %t | %t | %s | %s |\n",
|
||||
status.DisplayName, status.SiteURL, status.Description,
|
||||
status.ReadOnly, status.IsInviteAccepted, online, lastSync)
|
||||
}
|
||||
return responsef(sb.String())
|
||||
}
|
||||
|
||||
func notifyClientsForChannelUpdate(a *app.App, sharedChannel *model.SharedChannel) {
|
||||
messageWs := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_CHANNEL_CONVERTED, sharedChannel.TeamId, "", "", nil)
|
||||
messageWs.Add("channel_id", sharedChannel.ChannelId)
|
||||
a.Publish(messageWs)
|
||||
}
|
||||
92
app/slashcommands/command_share_test.go
Normal file
92
app/slashcommands/command_share_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package slashcommands
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/testlib"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/app"
|
||||
"github.com/mattermost/mattermost-server/v5/services/remotecluster"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
)
|
||||
|
||||
func TestShareProviderDoCommand(t *testing.T) {
|
||||
t.Run("share command sends a websocket channel converted event", func(t *testing.T) {
|
||||
th := setup(t).initBasic()
|
||||
defer th.tearDown()
|
||||
|
||||
th.addPermissionToRole(model.PERMISSION_MANAGE_SHARED_CHANNELS.Id, th.BasicUser.Roles)
|
||||
|
||||
mockSyncService := app.NewMockSharedChannelService(nil)
|
||||
th.Server.SetSharedChannelSyncService(mockSyncService)
|
||||
mockRemoteCluster, err := remotecluster.NewRemoteClusterService(th.Server)
|
||||
require.NoError(t, err)
|
||||
|
||||
th.Server.SetRemoteClusterService(mockRemoteCluster)
|
||||
testCluster := &testlib.FakeClusterInterface{}
|
||||
th.Server.Cluster = testCluster
|
||||
|
||||
commandProvider := ShareProvider{}
|
||||
channel := th.CreateChannel(th.BasicTeam, WithShared(false))
|
||||
args := &model.CommandArgs{
|
||||
T: func(s string, args ...interface{}) string { return s },
|
||||
ChannelId: channel.Id,
|
||||
UserId: th.BasicUser.Id,
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Command: "/share share_channel",
|
||||
}
|
||||
|
||||
response := commandProvider.DoCommand(th.App, args, "")
|
||||
require.Equal(t, "##### "+args.T("api.command_share.channel_shared"), response.Text)
|
||||
|
||||
channelConvertedMessages := testCluster.SelectMessages(func(msg *model.ClusterMessage) bool {
|
||||
event := model.WebSocketEventFromJson(strings.NewReader(msg.Data))
|
||||
return event != nil && event.EventType() == model.WEBSOCKET_EVENT_CHANNEL_CONVERTED
|
||||
})
|
||||
assert.Len(t, channelConvertedMessages, 1)
|
||||
})
|
||||
|
||||
t.Run("unshare command sends a websocket channel converted event", func(t *testing.T) {
|
||||
th := setup(t).initBasic()
|
||||
defer th.tearDown()
|
||||
|
||||
th.addPermissionToRole(model.PERMISSION_MANAGE_SHARED_CHANNELS.Id, th.BasicUser.Roles)
|
||||
|
||||
mockSyncService := app.NewMockSharedChannelService(nil)
|
||||
th.Server.SetSharedChannelSyncService(mockSyncService)
|
||||
mockRemoteCluster, err := remotecluster.NewRemoteClusterService(th.Server)
|
||||
require.NoError(t, err)
|
||||
|
||||
th.Server.SetRemoteClusterService(mockRemoteCluster)
|
||||
testCluster := &testlib.FakeClusterInterface{}
|
||||
th.Server.Cluster = testCluster
|
||||
|
||||
commandProvider := ShareProvider{}
|
||||
channel := th.CreateChannel(th.BasicTeam, WithShared(true))
|
||||
args := &model.CommandArgs{
|
||||
T: func(s string, args ...interface{}) string { return s },
|
||||
ChannelId: channel.Id,
|
||||
UserId: th.BasicUser.Id,
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Command: "/share unshare_channel --are_you_sure Y",
|
||||
}
|
||||
|
||||
response := commandProvider.DoCommand(th.App, args, "")
|
||||
require.Equal(t, "##### "+args.T("api.command_share.shared_channel_unavailable"), response.Text)
|
||||
|
||||
channelConvertedMessages := testCluster.SelectMessages(func(msg *model.ClusterMessage) bool {
|
||||
event := model.WebSocketEventFromJson(strings.NewReader(msg.Data))
|
||||
return event != nil && event.EventType() == model.WEBSOCKET_EVENT_CHANNEL_CONVERTED
|
||||
})
|
||||
require.Len(t, channelConvertedMessages, 1)
|
||||
})
|
||||
}
|
||||
|
|
@ -226,15 +226,23 @@ func (th *TestHelper) createUserOrGuest(guest bool) *model.User {
|
|||
return user
|
||||
}
|
||||
|
||||
func (th *TestHelper) CreateChannel(team *model.Team) *model.Channel {
|
||||
return th.createChannel(team, model.CHANNEL_OPEN)
|
||||
type ChannelOption func(*model.Channel)
|
||||
|
||||
func WithShared(v bool) ChannelOption {
|
||||
return func(channel *model.Channel) {
|
||||
channel.Shared = model.NewBool(v)
|
||||
}
|
||||
}
|
||||
|
||||
func (th *TestHelper) CreateChannel(team *model.Team, options ...ChannelOption) *model.Channel {
|
||||
return th.createChannel(team, model.CHANNEL_OPEN, options...)
|
||||
}
|
||||
|
||||
func (th *TestHelper) createPrivateChannel(team *model.Team) *model.Channel {
|
||||
return th.createChannel(team, model.CHANNEL_PRIVATE)
|
||||
}
|
||||
|
||||
func (th *TestHelper) createChannel(team *model.Team, channelType string) *model.Channel {
|
||||
func (th *TestHelper) createChannel(team *model.Team, channelType string, options ...ChannelOption) *model.Channel {
|
||||
id := model.NewId()
|
||||
|
||||
channel := &model.Channel{
|
||||
|
|
@ -245,11 +253,32 @@ func (th *TestHelper) createChannel(team *model.Team, channelType string) *model
|
|||
CreatorId: th.BasicUser.Id,
|
||||
}
|
||||
|
||||
for _, option := range options {
|
||||
option(channel)
|
||||
}
|
||||
|
||||
utils.DisableDebugLogForTest()
|
||||
var err *model.AppError
|
||||
if channel, err = th.App.CreateChannel(channel, true); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if channel.IsShared() {
|
||||
id := model.NewId()
|
||||
_, err := th.App.SaveSharedChannel(&model.SharedChannel{
|
||||
ChannelId: channel.Id,
|
||||
TeamId: channel.TeamId,
|
||||
Home: false,
|
||||
ReadOnly: false,
|
||||
ShareName: "shared-" + id,
|
||||
ShareDisplayName: "shared-" + id,
|
||||
CreatorId: th.BasicUser.Id,
|
||||
RemoteId: model.NewId(),
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
utils.EnableDebugLogForTest()
|
||||
return channel
|
||||
}
|
||||
|
|
|
|||
88
app/slashcommands/util.go
Normal file
88
app/slashcommands/util.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package slashcommands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
)
|
||||
|
||||
const (
|
||||
ActionKey = "-action"
|
||||
)
|
||||
|
||||
// responsef creates an ephemeral command response using printf syntax.
|
||||
func responsef(format string, args ...interface{}) *model.CommandResponse {
|
||||
return &model.CommandResponse{
|
||||
ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL,
|
||||
Text: fmt.Sprintf(format, args...),
|
||||
Type: model.POST_DEFAULT,
|
||||
}
|
||||
}
|
||||
|
||||
// parseNamedArgs parses a command string into a map of arguments. It is assumed the
|
||||
// command string is of the form `<action> --arg1 value1 ...` Supports empty values.
|
||||
// Arg names are limited to [0-9a-zA-Z_].
|
||||
func parseNamedArgs(cmd string) map[string]string {
|
||||
m := make(map[string]string)
|
||||
|
||||
split := strings.Fields(cmd)
|
||||
|
||||
// check for optional action
|
||||
if len(split) >= 2 && !strings.HasPrefix(split[1], "--") {
|
||||
m[ActionKey] = split[1] // prefix with hyphen to avoid collision with arg named "action"
|
||||
}
|
||||
|
||||
for i := 0; i < len(split); i++ {
|
||||
if !strings.HasPrefix(split[i], "--") {
|
||||
continue
|
||||
}
|
||||
var val string
|
||||
arg := trimSpaceAndQuotes(strings.Trim(split[i], "-"))
|
||||
if i < len(split)-1 && !strings.HasPrefix(split[i+1], "--") {
|
||||
val = trimSpaceAndQuotes(split[i+1])
|
||||
}
|
||||
if arg != "" {
|
||||
m[arg] = val
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func trimSpaceAndQuotes(s string) string {
|
||||
trimmed := strings.TrimSpace(s)
|
||||
trimmed = strings.TrimPrefix(trimmed, "\"")
|
||||
trimmed = strings.TrimPrefix(trimmed, "'")
|
||||
trimmed = strings.TrimSuffix(trimmed, "\"")
|
||||
trimmed = strings.TrimSuffix(trimmed, "'")
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func parseBool(s string) (bool, error) {
|
||||
switch strings.ToLower(s) {
|
||||
case "1", "t", "true", "yes", "y":
|
||||
return true, nil
|
||||
case "0", "f", "false", "no", "n":
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("cannot parse '%s' as a boolean", s)
|
||||
}
|
||||
|
||||
func formatTimestamp(ts time.Time) string {
|
||||
if !isToday(ts) {
|
||||
return ts.Format("Jan 2 15:04:05 MST 2006")
|
||||
}
|
||||
date := ts.Format("15:04:05 MST 2006")
|
||||
return fmt.Sprintf("today %s", date)
|
||||
}
|
||||
|
||||
func isToday(ts time.Time) bool {
|
||||
now := time.Now()
|
||||
year, month, day := ts.Date()
|
||||
nowYear, nowMonth, nowDay := now.Date()
|
||||
return year == nowYear && month == nowMonth && day == nowDay
|
||||
}
|
||||
40
app/slashcommands/util_test.go
Normal file
40
app/slashcommands/util_test.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package slashcommands
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseNamedArgs(t *testing.T) {
|
||||
data := []struct {
|
||||
name string
|
||||
s string
|
||||
m map[string]string
|
||||
}{
|
||||
{"empty", "", map[string]string{}},
|
||||
{"gibberish", "ifu3ue-h29f8", map[string]string{}},
|
||||
{"action only", "remote status", map[string]string{ActionKey: "status"}},
|
||||
{"no action", "remote --arg1 val1 --arg2 val2", map[string]string{"arg1": "val1", "arg2": "val2"}},
|
||||
{"command only", "remote", map[string]string{}},
|
||||
{"trailing empty arg", "remote add --arg1 val1 --arg2", map[string]string{ActionKey: "add", "arg1": "val1", "arg2": ""}},
|
||||
{"leading empty arg", "remote add --arg1 --arg2 val2", map[string]string{ActionKey: "add", "arg1": "", "arg2": "val2"}},
|
||||
{"weird", "-- -- -- --", map[string]string{}},
|
||||
{"hyphen before action", "remote -- add", map[string]string{}},
|
||||
{"trailing hyphen", "remote add -- ", map[string]string{ActionKey: "add"}},
|
||||
{"hyphen in val", "remote add --arg1 val-1 ", map[string]string{ActionKey: "add", "arg1": "val-1"}},
|
||||
{"quote prefix and suffix", "remote add --arg1 \"val-1\"", map[string]string{ActionKey: "add", "arg1": "val-1"}},
|
||||
{"quote embedded", "remote add --arg1 O'Brien", map[string]string{ActionKey: "add", "arg1": "O'Brien"}},
|
||||
{"quote prefix, suffix, and embedded", "remote add --arg1 \"O'Brien\"", map[string]string{ActionKey: "add", "arg1": "O'Brien"}},
|
||||
{"empty quotes", "remote add --arg1 \"\"", map[string]string{ActionKey: "add", "arg1": ""}},
|
||||
}
|
||||
|
||||
for _, tt := range data {
|
||||
m := parseNamedArgs(tt.s)
|
||||
assert.NotNil(t, m)
|
||||
assert.Equal(t, tt.m, m, tt.name)
|
||||
}
|
||||
}
|
||||
|
|
@ -253,6 +253,10 @@ func (a *App) UploadData(us *model.UploadSession, rd io.Reader) (*model.FileInfo
|
|||
|
||||
info.CreatorId = us.UserId
|
||||
info.Path = us.Path
|
||||
info.RemoteId = model.NewString(us.RemoteId)
|
||||
if us.ReqFileId != "" {
|
||||
info.Id = us.ReqFileId
|
||||
}
|
||||
|
||||
// run plugins upload hook
|
||||
if err := a.runPluginsHook(info, file); err != nil {
|
||||
|
|
|
|||
|
|
@ -180,17 +180,20 @@ func (a *App) Publish(message *model.WebSocketEvent) {
|
|||
a.Srv().Publish(message)
|
||||
}
|
||||
|
||||
func (s *Server) PublishSkipClusterSend(message *model.WebSocketEvent) {
|
||||
if message.GetBroadcast().UserId != "" {
|
||||
hub := s.GetHubForUserId(message.GetBroadcast().UserId)
|
||||
func (s *Server) PublishSkipClusterSend(event *model.WebSocketEvent) {
|
||||
if event.GetBroadcast().UserId != "" {
|
||||
hub := s.GetHubForUserId(event.GetBroadcast().UserId)
|
||||
if hub != nil {
|
||||
hub.Broadcast(message)
|
||||
hub.Broadcast(event)
|
||||
}
|
||||
} else {
|
||||
for _, hub := range s.hubs {
|
||||
hub.Broadcast(message)
|
||||
hub.Broadcast(event)
|
||||
}
|
||||
}
|
||||
|
||||
// Notify shared channel sync service
|
||||
s.SharedChannelSyncHandler(event)
|
||||
}
|
||||
|
||||
func (a *App) invalidateCacheForChannel(channel *model.Channel) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
upstream app_cluster {
|
||||
server leader:8065 fail_timeout=5s max_fails=10;
|
||||
server follower:8065 fail_timeout=5s max_fails=10;
|
||||
server leader:8065 fail_timeout=10s max_fails=10;
|
||||
server follower:8065 fail_timeout=10s max_fails=10;
|
||||
server follower2:8065 fail_timeout=10s max_fails=10;
|
||||
}
|
||||
|
||||
server {
|
||||
|
|
@ -9,6 +10,7 @@ server {
|
|||
location ~ /api/v[0-9]+/(users/)?websocket$ {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_http_version 1.1;
|
||||
client_max_body_size 50M;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
|
@ -25,6 +27,7 @@ server {
|
|||
client_max_body_size 50M;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
|
|
|||
|
|
@ -205,6 +205,7 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li
|
|||
|
||||
if *license.Features.SharedChannels {
|
||||
props["ExperimentalSharedChannels"] = strconv.FormatBool(*c.ExperimentalSettings.EnableSharedChannels)
|
||||
props["ExperimentalRemoteClusterService"] = strconv.FormatBool(c.FeatureFlags.EnableRemoteClusterService && *c.ExperimentalSettings.EnableRemoteClusterService)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ services:
|
|||
- "MM_SQLSETTINGS_DATASOURCE=postgres://mmuser:mostest@postgres/mattermost_test?sslmode=disable\u0026connect_timeout=10"
|
||||
- "MM_NO_DOCKER=true"
|
||||
- "RUN_SERVER_IN_BACKGROUND=false"
|
||||
- "MM_CLUSTERSETTINGS_ENABLE=true"
|
||||
networks:
|
||||
- mm-test
|
||||
depends_on:
|
||||
|
|
@ -118,11 +119,18 @@ services:
|
|||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://leader:8065/api/v4/system/ping"]
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
timeout: 30s
|
||||
retries: 30
|
||||
start_period: 5m
|
||||
user: ${CURRENT_UID}
|
||||
command: ['make', 'run-server']
|
||||
expose:
|
||||
- "8065"
|
||||
- "8064/tcp"
|
||||
- "8064/udp"
|
||||
- "8074/tcp"
|
||||
- "8074/udp"
|
||||
- "8075"
|
||||
|
||||
follower:
|
||||
build:
|
||||
|
|
@ -133,6 +141,7 @@ services:
|
|||
- "MM_SQLSETTINGS_DATASOURCE=postgres://mmuser:mostest@postgres/mattermost_test?sslmode=disable\u0026connect_timeout=10"
|
||||
- "MM_NO_DOCKER=true"
|
||||
- "RUN_SERVER_IN_BACKGROUND=false"
|
||||
- "MM_CLUSTERSETTINGS_ENABLE=true"
|
||||
networks:
|
||||
- mm-test
|
||||
depends_on:
|
||||
|
|
@ -144,12 +153,54 @@ services:
|
|||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://follower:8065/api/v4/system/ping"]
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
timeout: 30s
|
||||
retries: 30
|
||||
start_period: 5m
|
||||
user: ${CURRENT_UID}
|
||||
command: ['make', 'run-server']
|
||||
restart: on-failure
|
||||
expose:
|
||||
- "8065"
|
||||
- "8064/tcp"
|
||||
- "8064/udp"
|
||||
- "8074/tcp"
|
||||
- "8074/udp"
|
||||
- "8075"
|
||||
|
||||
follower2:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./build/Dockerfile.buildenv
|
||||
working_dir: '/home/mattermost-server'
|
||||
environment:
|
||||
- "MM_SQLSETTINGS_DATASOURCE=postgres://mmuser:mostest@postgres/mattermost_test?sslmode=disable\u0026connect_timeout=10"
|
||||
- "MM_NO_DOCKER=true"
|
||||
- "RUN_SERVER_IN_BACKGROUND=false"
|
||||
- "MM_CLUSTERSETTINGS_ENABLE=true"
|
||||
networks:
|
||||
- mm-test
|
||||
depends_on:
|
||||
- leader
|
||||
volumes:
|
||||
- './:/home/mattermost-server'
|
||||
- './../mattermost-webapp:/home/mattermost-webapp'
|
||||
- './../enterprise:/home/enterprise'
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://follower2:8065/api/v4/system/ping"]
|
||||
interval: 5s
|
||||
timeout: 30s
|
||||
retries: 30
|
||||
start_period: 5m
|
||||
user: ${CURRENT_UID}
|
||||
command: ['make', 'run-server']
|
||||
restart: on-failure
|
||||
expose:
|
||||
- "8065"
|
||||
- "8064/tcp"
|
||||
- "8064/udp"
|
||||
- "8074/tcp"
|
||||
- "8074/udp"
|
||||
- "8075"
|
||||
|
||||
haproxy:
|
||||
image: nginx
|
||||
|
|
@ -159,8 +210,12 @@ services:
|
|||
- ./build/docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
- leader
|
||||
- follower
|
||||
leader:
|
||||
condition: service_healthy
|
||||
follower:
|
||||
condition: service_healthy
|
||||
follower2:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8065:8065"
|
||||
|
||||
|
|
@ -171,4 +226,4 @@ networks:
|
|||
driver: default
|
||||
config:
|
||||
- subnet: 192.168.254.0/24
|
||||
ip_range: 192.168.254.0/24
|
||||
ip_range: 192.168.254.0/24
|
||||
|
|
@ -66,6 +66,13 @@ type MetricsInterface interface {
|
|||
ObserveEnabledUsers(users int64)
|
||||
GetLoggerMetricsCollector() logr.MetricsCollector
|
||||
|
||||
IncrementRemoteClusterMsgSentCounter(remoteID string)
|
||||
IncrementRemoteClusterMsgReceivedCounter(remoteID string)
|
||||
IncrementRemoteClusterMsgErrorsCounter(remoteID string, timeout bool)
|
||||
ObserveRemoteClusterPingDuration(remoteID string, elapsed float64)
|
||||
ObserveRemoteClusterClockSkew(remoteID string, skew float64)
|
||||
IncrementRemoteClusterConnStateChangeCounter(remoteID string, online bool)
|
||||
|
||||
IncrementJobActive(jobType string)
|
||||
DecrementJobActive(jobType string)
|
||||
|
||||
|
|
|
|||
|
|
@ -180,6 +180,26 @@ func (_m *MetricsInterface) IncrementPostsSearchCounter() {
|
|||
_m.Called()
|
||||
}
|
||||
|
||||
// IncrementRemoteClusterConnStateChangeCounter provides a mock function with given fields: remoteID, online
|
||||
func (_m *MetricsInterface) IncrementRemoteClusterConnStateChangeCounter(remoteID string, online bool) {
|
||||
_m.Called(remoteID, online)
|
||||
}
|
||||
|
||||
// IncrementRemoteClusterMsgErrorsCounter provides a mock function with given fields: remoteID, timeout
|
||||
func (_m *MetricsInterface) IncrementRemoteClusterMsgErrorsCounter(remoteID string, timeout bool) {
|
||||
_m.Called(remoteID, timeout)
|
||||
}
|
||||
|
||||
// IncrementRemoteClusterMsgReceivedCounter provides a mock function with given fields: remoteID
|
||||
func (_m *MetricsInterface) IncrementRemoteClusterMsgReceivedCounter(remoteID string) {
|
||||
_m.Called(remoteID)
|
||||
}
|
||||
|
||||
// IncrementRemoteClusterMsgSentCounter provides a mock function with given fields: remoteID
|
||||
func (_m *MetricsInterface) IncrementRemoteClusterMsgSentCounter(remoteID string) {
|
||||
_m.Called(remoteID)
|
||||
}
|
||||
|
||||
// IncrementUserIndexCounter provides a mock function with given fields:
|
||||
func (_m *MetricsInterface) IncrementUserIndexCounter() {
|
||||
_m.Called()
|
||||
|
|
@ -255,6 +275,16 @@ func (_m *MetricsInterface) ObservePostsSearchDuration(elapsed float64) {
|
|||
_m.Called(elapsed)
|
||||
}
|
||||
|
||||
// ObserveRemoteClusterClockSkew provides a mock function with given fields: remoteID, skew
|
||||
func (_m *MetricsInterface) ObserveRemoteClusterClockSkew(remoteID string, skew float64) {
|
||||
_m.Called(remoteID, skew)
|
||||
}
|
||||
|
||||
// ObserveRemoteClusterPingDuration provides a mock function with given fields: remoteID, elapsed
|
||||
func (_m *MetricsInterface) ObserveRemoteClusterPingDuration(remoteID string, elapsed float64) {
|
||||
_m.Called(remoteID, elapsed)
|
||||
}
|
||||
|
||||
// ObserveStoreMethodDuration provides a mock function with given fields: method, success, elapsed
|
||||
func (_m *MetricsInterface) ObserveStoreMethodDuration(method string, success string, elapsed float64) {
|
||||
_m.Called(method, success, elapsed)
|
||||
|
|
|
|||
|
|
@ -387,6 +387,8 @@ github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FI
|
|||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/philhofer/fwd v1.0.0 h1:UbZqGr5Y38ApvM/V/jEljVxwocdweyH+vmYvRPBnbqQ=
|
||||
github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
|
||||
github.com/philhofer/fwd v1.1.1 h1:GdGcTjf5RNAxwS4QLsiMzJYj5KEvPJD3Abr261yRQXQ=
|
||||
github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
|
||||
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
|
||||
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 h1:49lOXmGaUpV9Fz3gd7TFZY106KVlPVa5jcYD1gaQf98=
|
||||
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA=
|
||||
|
|
@ -469,6 +471,8 @@ github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69
|
|||
github.com/tidwall/pretty v0.0.0-20180105212114-65a9db5fad51/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||
github.com/tinylib/msgp v1.1.2 h1:gWmO7n0Ys2RBEb7GPYB9Ujq8Mk5p2U08lRnmMcGy6BQ=
|
||||
github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
|
||||
github.com/tinylib/msgp v1.1.5 h1:2gXmtWueD2HefZHQe1QOy9HVzmFrLOVvsXwXBQ0ayy0=
|
||||
github.com/tinylib/msgp v1.1.5/go.mod h1:eQsjooMTnV42mHu917E26IogZ2930nFyBQdofk10Udg=
|
||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
|
||||
github.com/ttacon/chalk v0.0.0-20160626202418-22c06c80ed31 h1:OXcKh35JaYsGMRzpvFkLv/MEyPuL49CThT1pZ8aSml4=
|
||||
github.com/ttacon/chalk v0.0.0-20160626202418-22c06c80ed31/go.mod h1:onvgF043R+lC5RZ8IT9rBXDaEDnpnw/Cl+HFiw+v/7Q=
|
||||
|
|
|
|||
420
i18n/en.json
420
i18n/en.json
|
|
@ -1078,6 +1078,146 @@
|
|||
"id": "api.command_open.name",
|
||||
"translation": "open"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.accept.help",
|
||||
"translation": "Accept an invitation from a remote cluster"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.accept_invitation",
|
||||
"translation": "Invitation accepted and confirmed.\nSiteURL: {{.SiteURL}}"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.accept_invitation.error",
|
||||
"translation": "Could not accept invitation: {{.Error}}"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.add_remote.error",
|
||||
"translation": "Could not add remote cluster: {{.Error}}"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.cluster_removed",
|
||||
"translation": "Remote cluster {{.RemoteId}} {{.Result}}."
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.decode_invitation.error",
|
||||
"translation": "Could not decode invitation: {{.Error}}"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.desc",
|
||||
"translation": "Invite remote Mattermost clusters for inter-cluster communication."
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.encrypt_invitation.error",
|
||||
"translation": "Could not encrypt invitation: {{.Error}}"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.fetch_status.error",
|
||||
"translation": "Could not fetch remote clusters: {{.Error}}"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.hint",
|
||||
"translation": "[action]"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.incorrect_password.error",
|
||||
"translation": "Could not decrypt invitation. Incorrect password or corrupt invitation: {{.Error}}"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.invitation.help",
|
||||
"translation": "Invitation from remote cluster"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.invitation.hint",
|
||||
"translation": "The encrypted invitation from a remote cluster"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.invitation_created",
|
||||
"translation": "Invitation created."
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.invite.help",
|
||||
"translation": "Invite a remote cluster"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.invite_password.help",
|
||||
"translation": "Invitation password"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.invite_password.hint",
|
||||
"translation": "Password to be used to encrypt the invitation"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.invite_summary",
|
||||
"translation": "Send the following encrypted (AES256 + Base64) blob to the remote site administrator along with the password. They will use the `{{.Command}}` slash command to accept the invitation.\n\n```\n{{.Invitation}}\n```\n\n**Ensure the remote site can access your cluster via** {{.SiteURL}}"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.missing_command",
|
||||
"translation": "Missing command. Available actions: {{.Actions}}"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.missing_empty",
|
||||
"translation": "Missing or empty `{{.Arg}}`"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.name",
|
||||
"translation": "remote"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.name.help",
|
||||
"translation": "Remote cluster name"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.name.hint",
|
||||
"translation": "A display name for the remote cluster"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.permission_required",
|
||||
"translation": "You require `{{.Permission}}` permission to manage remote clusters."
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.remote_add_remove.help",
|
||||
"translation": "Add/remove remote clusters. Available actions: {{.Actions}}"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.remote_table_header",
|
||||
"translation": "| Name | SiteURL | RemoteId | Invite Accepted | Online | Last Ping |"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.remotes_not_found",
|
||||
"translation": "No remote clusters found."
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.remove.help",
|
||||
"translation": "Removes a remote cluster"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.remove_remote.error",
|
||||
"translation": "Could not remove remote cluster: {{.Error}}"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.remove_remote_id.help",
|
||||
"translation": "Id of remote cluster remove"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.service_disabled",
|
||||
"translation": "Remote Cluster Service is disabled."
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.service_not_enabled",
|
||||
"translation": "Remote Cluster service not enabled."
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.site_url_not_set",
|
||||
"translation": "SiteURL not set. Please set this via the system console."
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.status.help",
|
||||
"translation": "Displays status for all remote clusters"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remote.unknown_action",
|
||||
"translation": "Unknown action `{{.Action}}`"
|
||||
},
|
||||
{
|
||||
"id": "api.command_remove.desc",
|
||||
"translation": "Remove a member from the channel"
|
||||
|
|
@ -1142,6 +1282,214 @@
|
|||
"id": "api.command_settings.unsupported.app_error",
|
||||
"translation": "The settings command is not supported on your device."
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.available_actions",
|
||||
"translation": "Available actions: {{.Actions}}"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.channel_display_name.help",
|
||||
"translation": "Channel display name provided to remote instances"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.channel_display_name.hint",
|
||||
"translation": "[displayname] - defaults to channel displayname"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.channel_header.help",
|
||||
"translation": "Channel header provided to remote instances"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.channel_header.hint",
|
||||
"translation": "[header] - defaults to channels header"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.channel_invite.error",
|
||||
"translation": "Error inviting `{{.Name}}` to this channel: {{.Error}}"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.channel_invite_not_home.error",
|
||||
"translation": "Cannot invite remote cluster to a shared channel originating somewhere else."
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.channel_name.help",
|
||||
"translation": "Channel name provided to remote instances"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.channel_name.hint",
|
||||
"translation": "[name] - defaults to channel name"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.channel_purpose.help",
|
||||
"translation": "Channel purpose provided to remote instances"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.channel_purpose.hint",
|
||||
"translation": "[purpose] - defaults to channel purpose"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.channel_remote_id_not_exists",
|
||||
"translation": "Shared channel remote id `{{.RemoteId}}` does not exist for this channel."
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.channel_shared",
|
||||
"translation": "This channel is now shared."
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.channel_status.help",
|
||||
"translation": "Displays status for this shared channel"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.channel_status_id",
|
||||
"translation": "Status for channel Id `{{.ChannelId}}`"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.check_channel_exist.error",
|
||||
"translation": "Error while checking if shared channel exists: {{.Error}}"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.could_not_uninvite.error",
|
||||
"translation": "Could not uninvite `{{.RemoteId}}`: {{.Error}}"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.desc",
|
||||
"translation": "Shares the current channel with a remote Mattermost instance."
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.description_invite.help",
|
||||
"translation": "Description for invite"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.description_invite.hint",
|
||||
"translation": "[description] - optional"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.fetch_remote.error",
|
||||
"translation": "Error fetching remote clusters: {{.Error}}"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.fetch_remote_status.error",
|
||||
"translation": "Could not fetch status for remotes: {{.Error}}."
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.hint",
|
||||
"translation": "[action]"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.invalid_value.error",
|
||||
"translation": "Invalid value for '{{.Arg}}': {{.Error}}"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.invitation_sent",
|
||||
"translation": "Channel invitation has been sent to `{{.Name}} {{.SiteURL}}`."
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.invite_remote.help",
|
||||
"translation": "Invites a remote instance to the current shared channel"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.missing_action",
|
||||
"translation": "Missing action. Available actions: {{.Actions}}"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.must_specify_valid_remote",
|
||||
"translation": "Must specify a valid remote cluster id to invite."
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.name",
|
||||
"translation": "share"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.no_remote_invited",
|
||||
"translation": "No remotes have been invited to this shared channel."
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.not_shared_channel_unshare",
|
||||
"translation": "Cannot unshare a channel that is not shared."
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.permission_required",
|
||||
"translation": "You require `{{.Permission}}` permission to manage shared channels."
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.remote_already_invited",
|
||||
"translation": "The remote cluster has already been invited."
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.remote_id.help",
|
||||
"translation": "Id of an existing remote instance. See `remote` command to add a remote instance."
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.remote_id_invalid.error",
|
||||
"translation": "Remote cluster id is invalid: {{.Error}}"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.remote_not_valid",
|
||||
"translation": "Must specify a valid remote cluster to uninvite"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.remote_table_header",
|
||||
"translation": "| Remote | SiteURL | Description | ReadOnly | InviteAccepted | Online | Last Sync |"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.remote_uninvited",
|
||||
"translation": "Remote `{{.RemoteId}}` uninvited."
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.service_disabled",
|
||||
"translation": "Shared Channels Service is disabled.."
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.share_channel.error",
|
||||
"translation": "Cannot share this channel: {{.Error}}"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.share_current",
|
||||
"translation": "Share the current channel"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.share_read_only.help",
|
||||
"translation": "Channel will be shared in read-only mode"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.share_read_only.hint",
|
||||
"translation": "[readonly] - 'Y' or 'N'. Defaults to 'N'"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.shared_channel_not_deleted",
|
||||
"translation": "Shared channel was not deleted: `{{.Arg}}` must be `{{.Expected}}`."
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.shared_channel_unavailable",
|
||||
"translation": "This channel is no longer shared."
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.shared_channel_unshare.error",
|
||||
"translation": "Cannot unshare this channel: {{.Error}}."
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.uninvite_remote.help",
|
||||
"translation": "Uninvites a remote instance from this shared channel"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.uninvite_remote_id.help",
|
||||
"translation": "Id of remote instance to uninvite."
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.unknown_action",
|
||||
"translation": "Unknown action `{{.Action}}`. Available actions: {{.Actions}}"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.unshare_channel.help",
|
||||
"translation": "Unshares the current channel"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.unshare_confirmation.help",
|
||||
"translation": "Are you sure? This channel will be unshared and all remote instances will be uninvited"
|
||||
},
|
||||
{
|
||||
"id": "api.command_share.unshare_confirmation.hint",
|
||||
"translation": "'Y' or 'N'"
|
||||
},
|
||||
{
|
||||
"id": "api.command_shortcuts.desc",
|
||||
"translation": "Displays a list of keyboard shortcuts"
|
||||
|
|
@ -1218,6 +1566,14 @@
|
|||
"id": "api.context.invalid_url_param.app_error",
|
||||
"translation": "Invalid or missing {{.Name}} parameter in request URL."
|
||||
},
|
||||
{
|
||||
"id": "api.context.invitation_expired.error",
|
||||
"translation": "Invitation is expired."
|
||||
},
|
||||
{
|
||||
"id": "api.context.json_encoding.app_error",
|
||||
"translation": "Error encoding JSON."
|
||||
},
|
||||
{
|
||||
"id": "api.context.local_origin_required.app_error",
|
||||
"translation": "This endpoint requires a local request origin."
|
||||
|
|
@ -1230,6 +1586,18 @@
|
|||
"id": "api.context.permissions.app_error",
|
||||
"translation": "You do not have the appropriate permissions."
|
||||
},
|
||||
{
|
||||
"id": "api.context.remote_id_invalid.app_error",
|
||||
"translation": "Unable to find remote cluster id {{.RemoteId}}."
|
||||
},
|
||||
{
|
||||
"id": "api.context.remote_id_mismatch.app_error",
|
||||
"translation": "Remote cluster id mismatch."
|
||||
},
|
||||
{
|
||||
"id": "api.context.remote_id_missing.app_error",
|
||||
"translation": "Remote cluster id missing."
|
||||
},
|
||||
{
|
||||
"id": "api.context.server_busy.app_error",
|
||||
"translation": "Server is busy, non-critical services are temporarily unavailable."
|
||||
|
|
@ -2026,6 +2394,42 @@
|
|||
"id": "api.reaction.town_square_read_only",
|
||||
"translation": "Reacting to posts is not possible in read-only channels."
|
||||
},
|
||||
{
|
||||
"id": "api.remote_cluster.delete.app_error",
|
||||
"translation": "We encountered an error deleting the remote cluster."
|
||||
},
|
||||
{
|
||||
"id": "api.remote_cluster.get.app_error",
|
||||
"translation": "We encountered an error retrieving a remote cluster."
|
||||
},
|
||||
{
|
||||
"id": "api.remote_cluster.invalid_id.app_error",
|
||||
"translation": "Invalid id."
|
||||
},
|
||||
{
|
||||
"id": "api.remote_cluster.invalid_topic.app_error",
|
||||
"translation": "Invalid topic."
|
||||
},
|
||||
{
|
||||
"id": "api.remote_cluster.save.app_error",
|
||||
"translation": "We encountered an error saving the remote cluster."
|
||||
},
|
||||
{
|
||||
"id": "api.remote_cluster.save_not_unique.app_error",
|
||||
"translation": "Remote cluster has already been added."
|
||||
},
|
||||
{
|
||||
"id": "api.remote_cluster.service_not_enabled.app_error",
|
||||
"translation": "The remote cluster service is not enabled."
|
||||
},
|
||||
{
|
||||
"id": "api.remote_cluster.update.app_error",
|
||||
"translation": "We encountered an error updating the remote cluster."
|
||||
},
|
||||
{
|
||||
"id": "api.remote_cluster.update_not_unique.app_error",
|
||||
"translation": "Remote cluster with the same url already exists."
|
||||
},
|
||||
{
|
||||
"id": "api.restricted_system_admin",
|
||||
"translation": "This action is forbidden to a restricted system admin."
|
||||
|
|
@ -5558,6 +5962,10 @@
|
|||
"id": "app.session.update_device_id.app_error",
|
||||
"translation": "Unable to update the device id."
|
||||
},
|
||||
{
|
||||
"id": "app.sharedchannel.dm_channel_creation.internal_error",
|
||||
"translation": "Encountered an error while creating a direct shared channel."
|
||||
},
|
||||
{
|
||||
"id": "app.status.get.app_error",
|
||||
"translation": "Encountered an error retrieving the status."
|
||||
|
|
@ -7250,6 +7658,10 @@
|
|||
"id": "model.channel.is_valid.creator_id.app_error",
|
||||
"translation": "Invalid creator id."
|
||||
},
|
||||
{
|
||||
"id": "model.channel.is_valid.description.app_error",
|
||||
"translation": "Invalid description."
|
||||
},
|
||||
{
|
||||
"id": "model.channel.is_valid.display_name.app_error",
|
||||
"translation": "Invalid display name."
|
||||
|
|
@ -8566,6 +8978,14 @@
|
|||
"id": "searchengine.bleve.disabled.error",
|
||||
"translation": "Error purging Bleve indexes: engine is disabled"
|
||||
},
|
||||
{
|
||||
"id": "sharedchannel.cannot_deliver_post",
|
||||
"translation": "One or more posts could not be delivered to remote site {{.Remote}} because it is offline. The post(s) will be delivered when the site is online."
|
||||
},
|
||||
{
|
||||
"id": "sharedchannel.permalink.not_found",
|
||||
"translation": "This post contains permalinks to other channels which may not be visible to users in other sites."
|
||||
},
|
||||
{
|
||||
"id": "store.sql.convert_string_array",
|
||||
"translation": "FromDb: Unable to convert StringArray to *string"
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ func AuditModelTypeConv(val interface{}) (newVal interface{}, converted bool) {
|
|||
return newAuditIncomingWebhook(v), true
|
||||
case *OutgoingWebhook:
|
||||
return newAuditOutgoingWebhook(v), true
|
||||
case *RemoteCluster:
|
||||
return newRemoteCluster(v), true
|
||||
}
|
||||
return val, false
|
||||
}
|
||||
|
|
@ -667,3 +669,42 @@ func (h auditOutgoingWebhook) MarshalJSONObject(enc *gojay.Encoder) {
|
|||
func (h auditOutgoingWebhook) IsNil() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type auditRemoteCluster struct {
|
||||
RemoteId string
|
||||
RemoteTeamId string
|
||||
DisplayName string
|
||||
SiteURL string
|
||||
CreateAt int64
|
||||
LastPingAt int64
|
||||
CreatorId string
|
||||
}
|
||||
|
||||
// newRemoteCluster creates a simplified representation of RemoteCluster for output to audit log.
|
||||
func newRemoteCluster(r *RemoteCluster) auditRemoteCluster {
|
||||
var rc auditRemoteCluster
|
||||
if r != nil {
|
||||
rc.RemoteId = r.RemoteId
|
||||
rc.RemoteTeamId = r.RemoteTeamId
|
||||
rc.DisplayName = r.DisplayName
|
||||
rc.SiteURL = r.SiteURL
|
||||
rc.CreateAt = r.CreateAt
|
||||
rc.LastPingAt = r.LastPingAt
|
||||
rc.CreatorId = r.CreatorId
|
||||
}
|
||||
return rc
|
||||
}
|
||||
|
||||
func (r auditRemoteCluster) MarshalJSONObject(enc *gojay.Encoder) {
|
||||
enc.StringKey("remote_id", r.RemoteId)
|
||||
enc.StringKey("remote_team_id", r.RemoteTeamId)
|
||||
enc.StringKey("display_name", r.DisplayName)
|
||||
enc.StringKey("site_url", r.SiteURL)
|
||||
enc.Int64Key("create_at", r.CreateAt)
|
||||
enc.Int64Key("last_ping_at", r.LastPingAt)
|
||||
enc.StringKey("creator_id", r.CreatorId)
|
||||
}
|
||||
|
||||
func (r auditRemoteCluster) IsNil() bool {
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,6 +142,14 @@ type ChannelMemberCountByGroup struct {
|
|||
ChannelMemberTimezonesCount int64 `db:"-" json:"channel_member_timezones_count"`
|
||||
}
|
||||
|
||||
type ChannelOption func(channel *Channel)
|
||||
|
||||
func WithID(ID string) ChannelOption {
|
||||
return func(channel *Channel) {
|
||||
channel.Id = ID
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Channel) DeepCopy() *Channel {
|
||||
copy := *o
|
||||
if copy.SchemeId != nil {
|
||||
|
|
|
|||
|
|
@ -19,27 +19,29 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
HEADER_REQUEST_ID = "X-Request-ID"
|
||||
HEADER_VERSION_ID = "X-Version-ID"
|
||||
HEADER_CLUSTER_ID = "X-Cluster-ID"
|
||||
HEADER_ETAG_SERVER = "ETag"
|
||||
HEADER_ETAG_CLIENT = "If-None-Match"
|
||||
HEADER_FORWARDED = "X-Forwarded-For"
|
||||
HEADER_REAL_IP = "X-Real-IP"
|
||||
HEADER_FORWARDED_PROTO = "X-Forwarded-Proto"
|
||||
HEADER_TOKEN = "token"
|
||||
HEADER_CSRF_TOKEN = "X-CSRF-Token"
|
||||
HEADER_BEARER = "BEARER"
|
||||
HEADER_AUTH = "Authorization"
|
||||
HEADER_CLOUD_TOKEN = "X-Cloud-Token"
|
||||
HEADER_REQUESTED_WITH = "X-Requested-With"
|
||||
HEADER_REQUESTED_WITH_XML = "XMLHttpRequest"
|
||||
HEADER_RANGE = "Range"
|
||||
STATUS = "status"
|
||||
STATUS_OK = "OK"
|
||||
STATUS_FAIL = "FAIL"
|
||||
STATUS_UNHEALTHY = "UNHEALTHY"
|
||||
STATUS_REMOVE = "REMOVE"
|
||||
HEADER_REQUEST_ID = "X-Request-ID"
|
||||
HEADER_VERSION_ID = "X-Version-ID"
|
||||
HEADER_CLUSTER_ID = "X-Cluster-ID"
|
||||
HEADER_ETAG_SERVER = "ETag"
|
||||
HEADER_ETAG_CLIENT = "If-None-Match"
|
||||
HEADER_FORWARDED = "X-Forwarded-For"
|
||||
HEADER_REAL_IP = "X-Real-IP"
|
||||
HEADER_FORWARDED_PROTO = "X-Forwarded-Proto"
|
||||
HEADER_TOKEN = "token"
|
||||
HEADER_CSRF_TOKEN = "X-CSRF-Token"
|
||||
HEADER_BEARER = "BEARER"
|
||||
HEADER_AUTH = "Authorization"
|
||||
HEADER_CLOUD_TOKEN = "X-Cloud-Token"
|
||||
HEADER_REMOTECLUSTER_TOKEN = "X-RemoteCluster-Token"
|
||||
HEADER_REMOTECLUSTER_ID = "X-RemoteCluster-Id"
|
||||
HEADER_REQUESTED_WITH = "X-Requested-With"
|
||||
HEADER_REQUESTED_WITH_XML = "XMLHttpRequest"
|
||||
HEADER_RANGE = "Range"
|
||||
STATUS = "status"
|
||||
STATUS_OK = "OK"
|
||||
STATUS_FAIL = "FAIL"
|
||||
STATUS_UNHEALTHY = "UNHEALTHY"
|
||||
STATUS_REMOVE = "REMOVE"
|
||||
|
||||
CLIENT_DIR = "client"
|
||||
|
||||
|
|
@ -559,6 +561,14 @@ func (c *Client4) GetExportRoute(name string) string {
|
|||
return fmt.Sprintf(c.GetExportsRoute()+"/%v", name)
|
||||
}
|
||||
|
||||
func (c *Client4) GetRemoteClusterRoute() string {
|
||||
return "/remotecluster"
|
||||
}
|
||||
|
||||
func (c *Client4) GetSharedChannelsRoute() string {
|
||||
return "/sharedchannels"
|
||||
}
|
||||
|
||||
func (c *Client4) DoApiGet(url string, etag string) (*http.Response, *AppError) {
|
||||
return c.DoApiRequest(http.MethodGet, c.ApiUrl+url, "", etag)
|
||||
}
|
||||
|
|
@ -5999,3 +6009,31 @@ func (c *Client4) SendAdminUpgradeRequestEmailOnJoin() *Response {
|
|||
|
||||
return BuildResponse(r)
|
||||
}
|
||||
|
||||
func (c *Client4) GetAllSharedChannels(teamID string, page, perPage int) ([]*SharedChannel, *Response) {
|
||||
url := fmt.Sprintf("%s/%s?page=%d&per_page=%d", c.GetSharedChannelsRoute(), teamID, page, perPage)
|
||||
r, appErr := c.DoApiGet(url, "")
|
||||
if appErr != nil {
|
||||
return nil, BuildErrorResponse(r, appErr)
|
||||
}
|
||||
defer closeBody(r)
|
||||
|
||||
var channels []*SharedChannel
|
||||
json.NewDecoder(r.Body).Decode(&channels)
|
||||
|
||||
return channels, BuildResponse(r)
|
||||
}
|
||||
|
||||
func (c *Client4) GetRemoteClusterInfo(remoteID string) (RemoteClusterInfo, *Response) {
|
||||
url := fmt.Sprintf("%s/remote_info/%s", c.GetSharedChannelsRoute(), remoteID)
|
||||
r, appErr := c.DoApiGet(url, "")
|
||||
if appErr != nil {
|
||||
return RemoteClusterInfo{}, BuildErrorResponse(r, appErr)
|
||||
}
|
||||
defer closeBody(r)
|
||||
|
||||
var rci RemoteClusterInfo
|
||||
json.NewDecoder(r.Body).Decode(&rci)
|
||||
|
||||
return rci, BuildResponse(r)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -940,6 +940,7 @@ type ExperimentalSettings struct {
|
|||
CloudUserLimit *int64 `access:"experimental,write_restrictable"`
|
||||
CloudBilling *bool `access:"experimental,write_restrictable"`
|
||||
EnableSharedChannels *bool `access:"experimental"`
|
||||
EnableRemoteClusterService *bool `access:"experimental"`
|
||||
}
|
||||
|
||||
func (s *ExperimentalSettings) SetDefaults() {
|
||||
|
|
@ -979,6 +980,10 @@ func (s *ExperimentalSettings) SetDefaults() {
|
|||
if s.EnableSharedChannels == nil {
|
||||
s.EnableSharedChannels = NewBool(false)
|
||||
}
|
||||
|
||||
if s.EnableRemoteClusterService == nil {
|
||||
s.EnableRemoteClusterService = NewBool(false)
|
||||
}
|
||||
}
|
||||
|
||||
type AnalyticsSettings struct {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ type FeatureFlags struct {
|
|||
// Toggle on and off support for Collapsed Threads
|
||||
CollapsedThreads bool
|
||||
|
||||
// Enable the remote cluster service for shared channels.
|
||||
EnableRemoteClusterService bool
|
||||
|
||||
// Toggle on and off support for Custom User Statuses
|
||||
CustomUserStatuses bool
|
||||
|
||||
// AppsEnabled toggle the Apps framework functionalities both in server and client side
|
||||
AppsEnabled bool
|
||||
|
||||
|
|
@ -37,6 +43,7 @@ func (f *FeatureFlags) SetDefaults() {
|
|||
f.TestBoolFeature = false
|
||||
f.CloudDelinquentEmailJobsEnabled = false
|
||||
f.CollapsedThreads = false
|
||||
f.EnableRemoteClusterService = false
|
||||
f.FilesSearch = false
|
||||
f.AppsEnabled = false
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ type FileInfo struct {
|
|||
HasPreviewImage bool `json:"has_preview_image,omitempty"`
|
||||
MiniPreview *[]byte `json:"mini_preview"` // declared as *[]byte to avoid postgres/mysql differences in deserialization
|
||||
Content string `json:"-"`
|
||||
RemoteId *string `json:"remote_id"`
|
||||
}
|
||||
|
||||
func (fi *FileInfo) ToJson() string {
|
||||
|
|
@ -105,6 +106,10 @@ func (fi *FileInfo) PreSave() {
|
|||
if fi.UpdateAt < fi.CreateAt {
|
||||
fi.UpdateAt = fi.CreateAt
|
||||
}
|
||||
|
||||
if fi.RemoteId == nil {
|
||||
fi.RemoteId = NewString("")
|
||||
}
|
||||
}
|
||||
|
||||
func (fi *FileInfo) IsValid() *AppError {
|
||||
|
|
|
|||
|
|
@ -240,7 +240,7 @@ func (f *Features) SetDefaults() {
|
|||
}
|
||||
|
||||
if f.RemoteClusterService == nil {
|
||||
f.RemoteClusterService = f.SharedChannels
|
||||
f.RemoteClusterService = NewBool(*f.FutureFeatures)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ type Post struct {
|
|||
FileIds StringArray `json:"file_ids,omitempty"`
|
||||
PendingPostId string `json:"pending_post_id" db:"-"`
|
||||
HasReactions bool `json:"has_reactions,omitempty"`
|
||||
RemoteId *string `json:"remote_id,omitempty"`
|
||||
|
||||
// Transient data populated before sending a post to the client
|
||||
ReplyCount int64 `json:"reply_count" db:"-"`
|
||||
|
|
@ -206,6 +207,7 @@ func (o *Post) ShallowCopy(dst *Post) error {
|
|||
dst.Participants = o.Participants
|
||||
dst.LastReplyAt = o.LastReplyAt
|
||||
dst.Metadata = o.Metadata
|
||||
dst.RemoteId = o.RemoteId
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -235,6 +237,18 @@ type GetPostsSinceOptions struct {
|
|||
SkipFetchThreads bool
|
||||
CollapsedThreads bool
|
||||
CollapsedThreadsExtended bool
|
||||
SortAscending bool
|
||||
}
|
||||
|
||||
type GetPostsSinceForSyncOptions struct {
|
||||
ChannelId string
|
||||
Since int64 // inclusive
|
||||
Until int64 // inclusive
|
||||
SortDescending bool
|
||||
ExcludeRemoteId string
|
||||
IncludeDeleted bool
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
type GetPostsOptions struct {
|
||||
|
|
@ -452,6 +466,11 @@ func (o *Post) IsSystemMessage() bool {
|
|||
return len(o.Type) >= len(POST_SYSTEM_MESSAGE_PREFIX) && o.Type[:len(POST_SYSTEM_MESSAGE_PREFIX)] == POST_SYSTEM_MESSAGE_PREFIX
|
||||
}
|
||||
|
||||
// IsRemote returns true if the post originated on a remote cluster.
|
||||
func (o *Post) IsRemote() bool {
|
||||
return o.RemoteId != nil && *o.RemoteId != ""
|
||||
}
|
||||
|
||||
func (o *Post) IsJoinLeaveMessage() bool {
|
||||
return o.Type == POST_JOIN_LEAVE ||
|
||||
o.Type == POST_ADD_REMOVE ||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ func NewPostList() *PostList {
|
|||
|
||||
func (o *PostList) ToSlice() []*Post {
|
||||
var posts []*Post
|
||||
|
||||
if l := len(o.Posts); l > 0 {
|
||||
posts = make([]*Post, 0, l)
|
||||
}
|
||||
|
||||
for _, id := range o.Order {
|
||||
posts = append(posts, o.Posts[id])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,12 +11,13 @@ import (
|
|||
)
|
||||
|
||||
type Reaction struct {
|
||||
UserId string `json:"user_id"`
|
||||
PostId string `json:"post_id"`
|
||||
EmojiName string `json:"emoji_name"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
UpdateAt int64 `json:"update_at"`
|
||||
DeleteAt int64 `json:"delete_at"`
|
||||
UserId string `json:"user_id"`
|
||||
PostId string `json:"post_id"`
|
||||
EmojiName string `json:"emoji_name"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
UpdateAt int64 `json:"update_at"`
|
||||
DeleteAt int64 `json:"delete_at"`
|
||||
RemoteId *string `json:"remote_id"`
|
||||
}
|
||||
|
||||
func (o *Reaction) ToJson() string {
|
||||
|
|
@ -94,8 +95,16 @@ func (o *Reaction) PreSave() {
|
|||
}
|
||||
o.UpdateAt = GetMillis()
|
||||
o.DeleteAt = 0
|
||||
|
||||
if o.RemoteId == nil {
|
||||
o.RemoteId = NewString("")
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Reaction) PreUpdate() {
|
||||
o.UpdateAt = GetMillis()
|
||||
|
||||
if o.RemoteId == nil {
|
||||
o.RemoteId = NewString("")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
295
model/remote_cluster.go
Normal file
295
model/remote_cluster.go
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha512"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
RemoteOfflineAfterMillis = 1000 * 60 * 5 // 5 minutes
|
||||
)
|
||||
|
||||
type RemoteCluster struct {
|
||||
RemoteId string `json:"remote_id"`
|
||||
RemoteTeamId string `json:"remote_team_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
SiteURL string `json:"site_url"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
LastPingAt int64 `json:"last_ping_at"`
|
||||
Token string `json:"token"`
|
||||
RemoteToken string `json:"remote_token"`
|
||||
Topics string `json:"topics"`
|
||||
CreatorId string `json:"creator_id"`
|
||||
}
|
||||
|
||||
func (rc *RemoteCluster) PreSave() {
|
||||
if rc.RemoteId == "" {
|
||||
rc.RemoteId = NewId()
|
||||
}
|
||||
|
||||
if rc.Token == "" {
|
||||
rc.Token = NewId()
|
||||
}
|
||||
|
||||
if rc.CreateAt == 0 {
|
||||
rc.CreateAt = GetMillis()
|
||||
}
|
||||
rc.fixTopics()
|
||||
}
|
||||
|
||||
func (rc *RemoteCluster) IsValid() *AppError {
|
||||
if !IsValidId(rc.RemoteId) {
|
||||
return NewAppError("RemoteCluster.IsValid", "model.cluster.is_valid.id.app_error", nil, "id="+rc.RemoteId, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if rc.DisplayName == "" {
|
||||
return NewAppError("RemoteCluster.IsValid", "model.cluster.is_valid.name.app_error", nil, "display_name empty", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if rc.CreateAt == 0 {
|
||||
return NewAppError("RemoteCluster.IsValid", "model.cluster.is_valid.create_at.app_error", nil, "create_at=0", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if !IsValidId(rc.CreatorId) {
|
||||
return NewAppError("RemoteCluster.IsValid", "model.cluster.is_valid.id.app_error", nil, "creator_id="+rc.CreatorId, http.StatusBadRequest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rc *RemoteCluster) PreUpdate() {
|
||||
rc.fixTopics()
|
||||
}
|
||||
|
||||
func (rc *RemoteCluster) IsOnline() bool {
|
||||
return rc.LastPingAt > GetMillis()-RemoteOfflineAfterMillis
|
||||
}
|
||||
|
||||
// fixTopics ensures all topics are separated by one, and only one, space.
|
||||
func (rc *RemoteCluster) fixTopics() {
|
||||
trimmed := strings.TrimSpace(rc.Topics)
|
||||
if trimmed == "" || trimmed == "*" {
|
||||
rc.Topics = trimmed
|
||||
return
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(" ")
|
||||
|
||||
ss := strings.Split(rc.Topics, " ")
|
||||
for _, c := range ss {
|
||||
cc := strings.TrimSpace(c)
|
||||
if cc != "" {
|
||||
sb.WriteString(cc)
|
||||
sb.WriteString(" ")
|
||||
}
|
||||
}
|
||||
rc.Topics = sb.String()
|
||||
}
|
||||
|
||||
func (rc *RemoteCluster) ToJSON() (string, error) {
|
||||
b, err := json.Marshal(rc)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func (rc *RemoteCluster) ToRemoteClusterInfo() RemoteClusterInfo {
|
||||
return RemoteClusterInfo{
|
||||
DisplayName: rc.DisplayName,
|
||||
CreateAt: rc.CreateAt,
|
||||
LastPingAt: rc.LastPingAt,
|
||||
}
|
||||
}
|
||||
|
||||
func RemoteClusterFromJSON(data io.Reader) (*RemoteCluster, *AppError) {
|
||||
var rc RemoteCluster
|
||||
err := json.NewDecoder(data).Decode(&rc)
|
||||
if err != nil {
|
||||
return nil, NewAppError("RemoteClusterFromJSON", "model.utils.decode_json.app_error", nil, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
return &rc, nil
|
||||
}
|
||||
|
||||
// RemoteClusterInfo provides a subset of RemoteCluster fields suitable for sending to clients.
|
||||
type RemoteClusterInfo struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
LastPingAt int64 `json:"last_ping_at"`
|
||||
}
|
||||
|
||||
// RemoteClusterFrame wraps a `RemoteClusterMsg` with credentials specific to a remote cluster.
|
||||
type RemoteClusterFrame struct {
|
||||
RemoteId string `json:"remote_id"`
|
||||
Msg RemoteClusterMsg `json:"msg"`
|
||||
}
|
||||
|
||||
func (f *RemoteClusterFrame) IsValid() *AppError {
|
||||
if !IsValidId(f.RemoteId) {
|
||||
return NewAppError("RemoteClusterFrame.IsValid", "api.remote_cluster.invalid_id.app_error", nil, "RemoteId="+f.RemoteId, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if err := f.Msg.IsValid(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func RemoteClusterFrameFromJSON(data io.Reader) (*RemoteClusterFrame, *AppError) {
|
||||
var frame RemoteClusterFrame
|
||||
err := json.NewDecoder(data).Decode(&frame)
|
||||
if err != nil {
|
||||
return nil, NewAppError("RemoteClusterFrameFromJSON", "model.utils.decode_json.app_error", nil, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
return &frame, nil
|
||||
}
|
||||
|
||||
// RemoteClusterMsg represents a message that is sent and received between clusters.
|
||||
// These are processed and routed via the RemoteClusters service.
|
||||
type RemoteClusterMsg struct {
|
||||
Id string `json:"id"`
|
||||
Topic string `json:"topic"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
func NewRemoteClusterMsg(topic string, payload json.RawMessage) RemoteClusterMsg {
|
||||
return RemoteClusterMsg{
|
||||
Id: NewId(),
|
||||
Topic: topic,
|
||||
CreateAt: GetMillis(),
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
|
||||
func (m RemoteClusterMsg) IsValid() *AppError {
|
||||
if !IsValidId(m.Id) {
|
||||
return NewAppError("RemoteClusterMsg.IsValid", "api.remote_cluster.invalid_id.app_error", nil, "Id="+m.Id, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if m.Topic == "" {
|
||||
return NewAppError("RemoteClusterMsg.IsValid", "api.remote_cluster.invalid_topic.app_error", nil, "Topic empty", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if len(m.Payload) == 0 {
|
||||
return NewAppError("RemoteClusterMsg.IsValid", "api.context.invalid_body_param.app_error", map[string]interface{}{"Name": "PayLoad"}, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func RemoteClusterMsgFromJSON(data io.Reader) (RemoteClusterMsg, *AppError) {
|
||||
var msg RemoteClusterMsg
|
||||
err := json.NewDecoder(data).Decode(&msg)
|
||||
if err != nil {
|
||||
return RemoteClusterMsg{}, NewAppError("RemoteClusterMsgFromJSON", "model.utils.decode_json.app_error", nil, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// RemoteClusterPing represents a ping that is sent and received between clusters
|
||||
// to indicate a connection is alive. This is the payload for a `RemoteClusterMsg`.
|
||||
type RemoteClusterPing struct {
|
||||
SentAt int64 `json:"sent_at"`
|
||||
RecvAt int64 `json:"recv_at"`
|
||||
}
|
||||
|
||||
func RemoteClusterPingFromRawJSON(raw json.RawMessage) (RemoteClusterPing, *AppError) {
|
||||
var ping RemoteClusterPing
|
||||
err := json.Unmarshal(raw, &ping)
|
||||
if err != nil {
|
||||
return RemoteClusterPing{}, NewAppError("RemoteClusterPingFromRawJSON", "model.utils.decode_json.app_error", nil, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
return ping, nil
|
||||
}
|
||||
|
||||
// RemoteClusterInvite represents an invitation to establish a simple trust with a remote cluster.
|
||||
type RemoteClusterInvite struct {
|
||||
RemoteId string `json:"remote_id"`
|
||||
RemoteTeamId string `json:"remote_team_id"`
|
||||
SiteURL string `json:"site_url"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func RemoteClusterInviteFromRawJSON(raw json.RawMessage) (*RemoteClusterInvite, *AppError) {
|
||||
var invite RemoteClusterInvite
|
||||
err := json.Unmarshal(raw, &invite)
|
||||
if err != nil {
|
||||
return nil, NewAppError("RemoteClusterInviteFromRawJSON", "model.utils.decode_json.app_error", nil, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
return &invite, nil
|
||||
}
|
||||
|
||||
func (rci *RemoteClusterInvite) Encrypt(password string) ([]byte, error) {
|
||||
raw, err := json.Marshal(&rci)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// hash the pasword to 32 bytes for AES256
|
||||
key := sha512.Sum512_256([]byte(password))
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// create random nonce
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// prefix the nonce to the cyphertext so we don't need to keep track of it.
|
||||
return gcm.Seal(nonce, nonce, raw, nil), nil
|
||||
}
|
||||
|
||||
func (rci *RemoteClusterInvite) Decrypt(encrypted []byte, password string) error {
|
||||
// hash the pasword to 32 bytes for AES256
|
||||
key := sha512.Sum512_256([]byte(password))
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// nonce was prefixed to the cyphertext when encrypting so we need to extract it.
|
||||
nonceSize := gcm.NonceSize()
|
||||
nonce, cyphertext := encrypted[:nonceSize], encrypted[nonceSize:]
|
||||
|
||||
plain, err := gcm.Open(nil, nonce, cyphertext, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// try to unmarshall the decrypted JSON to this invite struct.
|
||||
return json.Unmarshal(plain, &rci)
|
||||
}
|
||||
|
||||
// RemoteClusterQueryFilter provides filter criteria for RemoteClusterStore.GetAll
|
||||
type RemoteClusterQueryFilter struct {
|
||||
ExcludeOffline bool
|
||||
InChannel string
|
||||
NotInChannel string
|
||||
Topic string
|
||||
CreatorId string
|
||||
OnlyConfirmed bool
|
||||
}
|
||||
158
model/remote_cluster_test.go
Normal file
158
model/remote_cluster_test.go
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRemoteClusterJson(t *testing.T) {
|
||||
o := RemoteCluster{RemoteId: NewId(), DisplayName: "test"}
|
||||
|
||||
json, err := o.ToJSON()
|
||||
require.NoError(t, err)
|
||||
|
||||
ro, err := RemoteClusterFromJSON(strings.NewReader(json))
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, o.RemoteId, ro.RemoteId)
|
||||
require.Equal(t, o.DisplayName, ro.DisplayName)
|
||||
}
|
||||
|
||||
func TestRemoteClusterIsValid(t *testing.T) {
|
||||
id := NewId()
|
||||
creator := NewId()
|
||||
now := GetMillis()
|
||||
data := []struct {
|
||||
name string
|
||||
rc *RemoteCluster
|
||||
valid bool
|
||||
}{
|
||||
{name: "Zero value", rc: &RemoteCluster{}, valid: false},
|
||||
{name: "Missing cluster_name", rc: &RemoteCluster{RemoteId: id}, valid: false},
|
||||
{name: "Missing host_name", rc: &RemoteCluster{RemoteId: id, DisplayName: "test cluster"}, valid: false},
|
||||
{name: "Missing create_at", rc: &RemoteCluster{RemoteId: id, DisplayName: "test cluster", SiteURL: "example.com"}, valid: false},
|
||||
{name: "Missing last_ping_at", rc: &RemoteCluster{RemoteId: id, DisplayName: "test cluster", SiteURL: "example.com", CreatorId: creator, CreateAt: now}, valid: true},
|
||||
{name: "Missing creator", rc: &RemoteCluster{RemoteId: id, DisplayName: "test cluster", SiteURL: "example.com", CreateAt: now, LastPingAt: now}, valid: false},
|
||||
{name: "RemoteCluster valid", rc: &RemoteCluster{RemoteId: id, DisplayName: "test cluster", SiteURL: "example.com", CreateAt: now, LastPingAt: now, CreatorId: creator}, valid: true},
|
||||
{name: "Include protocol", rc: &RemoteCluster{RemoteId: id, DisplayName: "test cluster", SiteURL: "http://example.com", CreateAt: now, LastPingAt: now, CreatorId: creator}, valid: true},
|
||||
{name: "Include protocol & port", rc: &RemoteCluster{RemoteId: id, DisplayName: "test cluster", SiteURL: "http://example.com:8065", CreateAt: now, LastPingAt: now, CreatorId: creator}, valid: true},
|
||||
}
|
||||
|
||||
for _, item := range data {
|
||||
err := item.rc.IsValid()
|
||||
if item.valid {
|
||||
assert.Nil(t, err, item.name)
|
||||
} else {
|
||||
assert.NotNil(t, err, item.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteClusterPreSave(t *testing.T) {
|
||||
now := GetMillis()
|
||||
|
||||
o := RemoteCluster{RemoteId: NewId(), DisplayName: "test"}
|
||||
o.PreSave()
|
||||
|
||||
require.GreaterOrEqual(t, o.CreateAt, now)
|
||||
}
|
||||
|
||||
func TestRemoteClusterMsgJson(t *testing.T) {
|
||||
o := NewRemoteClusterMsg("shared_channel", []byte("{\"hello\":\"world\"}"))
|
||||
|
||||
json, err := json.Marshal(o)
|
||||
require.NoError(t, err)
|
||||
|
||||
ro, err := RemoteClusterMsgFromJSON(strings.NewReader(string(json)))
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, o.Id, ro.Id)
|
||||
require.Equal(t, o.CreateAt, ro.CreateAt)
|
||||
require.Equal(t, o.Topic, ro.Topic)
|
||||
}
|
||||
|
||||
func TestRemoteClusterMsgIsValid(t *testing.T) {
|
||||
id := NewId()
|
||||
now := GetMillis()
|
||||
data := []struct {
|
||||
name string
|
||||
msg *RemoteClusterMsg
|
||||
valid bool
|
||||
}{
|
||||
{name: "Zero value", msg: &RemoteClusterMsg{}, valid: false},
|
||||
{name: "Missing remote id", msg: &RemoteClusterMsg{Id: id}, valid: false},
|
||||
{name: "Missing Topic", msg: &RemoteClusterMsg{Id: id}, valid: false},
|
||||
{name: "Missing Payload", msg: &RemoteClusterMsg{Id: id, CreateAt: now, Topic: "shared_channel"}, valid: false},
|
||||
{name: "RemoteClusterMsg valid", msg: &RemoteClusterMsg{Id: id, CreateAt: now, Topic: "shared_channel", Payload: []byte("{\"hello\":\"world\"}")}, valid: true},
|
||||
}
|
||||
|
||||
for _, item := range data {
|
||||
err := item.msg.IsValid()
|
||||
if item.valid {
|
||||
assert.Nil(t, err, item.name)
|
||||
} else {
|
||||
assert.NotNil(t, err, item.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixTopics(t *testing.T) {
|
||||
testData := []struct {
|
||||
topics string
|
||||
expected string
|
||||
}{
|
||||
{topics: "", expected: ""},
|
||||
{topics: " ", expected: ""},
|
||||
{topics: "share", expected: " share "},
|
||||
{topics: "share incident", expected: " share incident "},
|
||||
{topics: " share incident ", expected: " share incident "},
|
||||
{topics: " share incident ", expected: " share incident "},
|
||||
}
|
||||
|
||||
for _, tt := range testData {
|
||||
rc := &RemoteCluster{Topics: tt.topics}
|
||||
rc.fixTopics()
|
||||
assert.Equal(t, tt.expected, rc.Topics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteClusterInviteEncryption(t *testing.T) {
|
||||
testData := []struct {
|
||||
name string
|
||||
badDecrypt bool
|
||||
password string
|
||||
invite RemoteClusterInvite
|
||||
}{
|
||||
{name: "empty password", badDecrypt: false, password: "", invite: RemoteClusterInvite{RemoteId: NewId(), SiteURL: "https://example.com:8065", Token: NewId()}},
|
||||
{name: "good password", badDecrypt: false, password: "Ultra secret password!", invite: RemoteClusterInvite{RemoteId: NewId(), SiteURL: "https://example.com:8065", Token: NewId()}},
|
||||
{name: "bad decrypt", badDecrypt: true, password: "correct horse battery staple", invite: RemoteClusterInvite{RemoteId: NewId(), SiteURL: "https://example.com:8065", Token: NewId()}},
|
||||
}
|
||||
|
||||
for _, tt := range testData {
|
||||
encrypted, err := tt.invite.Encrypt(tt.password)
|
||||
require.NoError(t, err)
|
||||
|
||||
invite := RemoteClusterInvite{}
|
||||
if tt.badDecrypt {
|
||||
buf := make([]byte, len(encrypted))
|
||||
_, err = io.ReadFull(rand.Reader, buf)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = invite.Decrypt(buf, tt.password)
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
err = invite.Decrypt(encrypted, tt.password)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.invite, invite)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ const (
|
|||
SESSION_PROP_IS_BOT_VALUE = "true"
|
||||
SESSION_TYPE_USER_ACCESS_TOKEN = "UserAccessToken"
|
||||
SESSION_TYPE_CLOUD_KEY = "CloudKey"
|
||||
SESSION_TYPE_REMOTECLUSTER_TOKEN = "RemoteClusterToken"
|
||||
SESSION_PROP_IS_GUEST = "is_guest"
|
||||
SESSION_ACTIVITY_TIMEOUT = 1000 * 60 * 5 // 5 minutes
|
||||
SESSION_USER_ACCESS_TOKEN_EXPIRY = 100 * 365 // 100 years
|
||||
|
|
|
|||
267
model/shared_channel.go
Normal file
267
model/shared_channel.go
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// SharedChannel represents a channel that can be synchronized with a remote cluster.
|
||||
// If "home" is true, then the shared channel is homed locally and "SharedChannelRemote"
|
||||
// table contains the remote clusters that have been invited.
|
||||
// If "home" is false, then the shared channel is homed remotely, and "RemoteId"
|
||||
// field points to the remote cluster connection in "RemoteClusters" table.
|
||||
type SharedChannel struct {
|
||||
ChannelId string `json:"channel_id"`
|
||||
TeamId string `json:"team_id"`
|
||||
Home bool `json:"home"`
|
||||
ReadOnly bool `json:"readonly"`
|
||||
ShareName string `json:"share_name"`
|
||||
ShareDisplayName string `json:"share_displayname"`
|
||||
SharePurpose string `json:"share_purpose"`
|
||||
ShareHeader string `json:"share_header"`
|
||||
CreatorId string `json:"creator_id"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
UpdateAt int64 `json:"update_at"`
|
||||
RemoteId string `json:"remote_id,omitempty"` // if not "home"
|
||||
Type string `db:"-"`
|
||||
}
|
||||
|
||||
func (sc *SharedChannel) ToJson() string {
|
||||
b, _ := json.Marshal(sc)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func SharedChannelFromJson(data io.Reader) (*SharedChannel, error) {
|
||||
var sc *SharedChannel
|
||||
err := json.NewDecoder(data).Decode(&sc)
|
||||
return sc, err
|
||||
}
|
||||
|
||||
func (sc *SharedChannel) IsValid() *AppError {
|
||||
if !IsValidId(sc.ChannelId) {
|
||||
return NewAppError("SharedChannel.IsValid", "model.channel.is_valid.id.app_error", nil, "ChannelId="+sc.ChannelId, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if sc.Type != CHANNEL_DIRECT && !IsValidId(sc.TeamId) {
|
||||
return NewAppError("SharedChannel.IsValid", "model.channel.is_valid.id.app_error", nil, "TeamId="+sc.TeamId, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if sc.CreateAt == 0 {
|
||||
return NewAppError("SharedChannel.IsValid", "model.channel.is_valid.create_at.app_error", nil, "id="+sc.ChannelId, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if sc.UpdateAt == 0 {
|
||||
return NewAppError("SharedChannel.IsValid", "model.channel.is_valid.update_at.app_error", nil, "id="+sc.ChannelId, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if utf8.RuneCountInString(sc.ShareDisplayName) > CHANNEL_DISPLAY_NAME_MAX_RUNES {
|
||||
return NewAppError("SharedChannel.IsValid", "model.channel.is_valid.display_name.app_error", nil, "id="+sc.ChannelId, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if !IsValidChannelIdentifier(sc.ShareName) {
|
||||
return NewAppError("SharedChannel.IsValid", "model.channel.is_valid.2_or_more.app_error", nil, "id="+sc.ChannelId, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if utf8.RuneCountInString(sc.ShareHeader) > CHANNEL_HEADER_MAX_RUNES {
|
||||
return NewAppError("SharedChannel.IsValid", "model.channel.is_valid.header.app_error", nil, "id="+sc.ChannelId, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if utf8.RuneCountInString(sc.SharePurpose) > CHANNEL_PURPOSE_MAX_RUNES {
|
||||
return NewAppError("SharedChannel.IsValid", "model.channel.is_valid.purpose.app_error", nil, "id="+sc.ChannelId, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if !IsValidId(sc.CreatorId) {
|
||||
return NewAppError("SharedChannel.IsValid", "model.channel.is_valid.creator_id.app_error", nil, "CreatorId="+sc.CreatorId, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if !sc.Home {
|
||||
if !IsValidId(sc.RemoteId) {
|
||||
return NewAppError("SharedChannel.IsValid", "model.channel.is_valid.id.app_error", nil, "RemoteId="+sc.RemoteId, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sc *SharedChannel) PreSave() {
|
||||
sc.ShareName = SanitizeUnicode(sc.ShareName)
|
||||
sc.ShareDisplayName = SanitizeUnicode(sc.ShareDisplayName)
|
||||
|
||||
sc.CreateAt = GetMillis()
|
||||
sc.UpdateAt = sc.CreateAt
|
||||
}
|
||||
|
||||
func (sc *SharedChannel) PreUpdate() {
|
||||
sc.UpdateAt = GetMillis()
|
||||
sc.ShareName = SanitizeUnicode(sc.ShareName)
|
||||
sc.ShareDisplayName = SanitizeUnicode(sc.ShareDisplayName)
|
||||
}
|
||||
|
||||
// SharedChannelRemote represents a remote cluster that has been invited
|
||||
// to a shared channel.
|
||||
type SharedChannelRemote struct {
|
||||
Id string `json:"id"`
|
||||
ChannelId string `json:"channel_id"`
|
||||
Description string `json:"description"`
|
||||
CreatorId string `json:"creator_id"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
UpdateAt int64 `json:"update_at"`
|
||||
IsInviteAccepted bool `json:"is_invite_accepted"`
|
||||
IsInviteConfirmed bool `json:"is_invite_confirmed"`
|
||||
RemoteId string `json:"remote_id"`
|
||||
NextSyncAt int64 `json:"next_sync_at"`
|
||||
}
|
||||
|
||||
func (sc *SharedChannelRemote) ToJson() string {
|
||||
b, _ := json.Marshal(sc)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func SharedChannelRemoteFromJson(data io.Reader) (*SharedChannelRemote, error) {
|
||||
var sc *SharedChannelRemote
|
||||
err := json.NewDecoder(data).Decode(&sc)
|
||||
return sc, err
|
||||
}
|
||||
|
||||
func (sc *SharedChannelRemote) IsValid() *AppError {
|
||||
if !IsValidId(sc.Id) {
|
||||
return NewAppError("SharedChannelRemote.IsValid", "model.channel.is_valid.id.app_error", nil, "Id="+sc.Id, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if !IsValidId(sc.ChannelId) {
|
||||
return NewAppError("SharedChannelRemote.IsValid", "model.channel.is_valid.id.app_error", nil, "ChannelId="+sc.ChannelId, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if len(sc.Description) > 64 {
|
||||
return NewAppError("SharedChannelRemote.IsValid", "model.channel.is_valid.description.app_error", nil, "description="+sc.Description, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if sc.CreateAt == 0 {
|
||||
return NewAppError("SharedChannelRemote.IsValid", "model.channel.is_valid.create_at.app_error", nil, "id="+sc.ChannelId, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if sc.UpdateAt == 0 {
|
||||
return NewAppError("SharedChannelRemote.IsValid", "model.channel.is_valid.update_at.app_error", nil, "id="+sc.ChannelId, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if !IsValidId(sc.CreatorId) {
|
||||
return NewAppError("SharedChannelRemote.IsValid", "model.channel.is_valid.creator_id.app_error", nil, "id="+sc.CreatorId, http.StatusBadRequest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sc *SharedChannelRemote) PreSave() {
|
||||
if sc.Id == "" {
|
||||
sc.Id = NewId()
|
||||
}
|
||||
sc.CreateAt = GetMillis()
|
||||
sc.UpdateAt = sc.CreateAt
|
||||
}
|
||||
|
||||
func (sc *SharedChannelRemote) PreUpdate() {
|
||||
sc.UpdateAt = GetMillis()
|
||||
}
|
||||
|
||||
type SharedChannelRemoteStatus struct {
|
||||
ChannelId string `json:"channel_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
SiteURL string `json:"site_url"`
|
||||
LastPingAt int64 `json:"last_ping_at"`
|
||||
NextSyncAt int64 `json:"next_sync_at"`
|
||||
Description string `json:"description"`
|
||||
ReadOnly bool `json:"readonly"`
|
||||
IsInviteAccepted bool `json:"is_invite_accepted"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// SharedChannelUser stores a lastSyncAt timestamp on behalf of a remote cluster for
|
||||
// each user that has been synchronized.
|
||||
type SharedChannelUser struct {
|
||||
Id string `json:"id"`
|
||||
UserId string `json:"user_id"`
|
||||
RemoteId string `json:"remote_id"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
LastSyncAt int64 `json:"last_sync_at"`
|
||||
}
|
||||
|
||||
func (scu *SharedChannelUser) PreSave() {
|
||||
scu.Id = NewId()
|
||||
scu.CreateAt = GetMillis()
|
||||
}
|
||||
|
||||
func (scu *SharedChannelUser) IsValid() *AppError {
|
||||
if !IsValidId(scu.Id) {
|
||||
return NewAppError("SharedChannelUser.IsValid", "model.channel.is_valid.id.app_error", nil, "Id="+scu.Id, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if !IsValidId(scu.UserId) {
|
||||
return NewAppError("SharedChannelUser.IsValid", "model.channel.is_valid.id.app_error", nil, "UserId="+scu.UserId, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if !IsValidId(scu.RemoteId) {
|
||||
return NewAppError("SharedChannelUser.IsValid", "model.channel.is_valid.id.app_error", nil, "RemoteId="+scu.RemoteId, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if scu.CreateAt == 0 {
|
||||
return NewAppError("SharedChannelUser.IsValid", "model.channel.is_valid.create_at.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SharedChannelAttachment stores a lastSyncAt timestamp on behalf of a remote cluster for
|
||||
// each file attachment that has been synchronized.
|
||||
type SharedChannelAttachment struct {
|
||||
Id string `json:"id"`
|
||||
FileId string `json:"file_id"`
|
||||
RemoteId string `json:"remote_id"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
LastSyncAt int64 `json:"last_sync_at"`
|
||||
}
|
||||
|
||||
func (scf *SharedChannelAttachment) PreSave() {
|
||||
if scf.Id == "" {
|
||||
scf.Id = NewId()
|
||||
}
|
||||
if scf.CreateAt == 0 {
|
||||
scf.CreateAt = GetMillis()
|
||||
scf.LastSyncAt = scf.CreateAt
|
||||
} else {
|
||||
scf.LastSyncAt = GetMillis()
|
||||
}
|
||||
}
|
||||
|
||||
func (scf *SharedChannelAttachment) IsValid() *AppError {
|
||||
if !IsValidId(scf.Id) {
|
||||
return NewAppError("SharedChannelAttachment.IsValid", "model.channel.is_valid.id.app_error", nil, "Id="+scf.Id, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if !IsValidId(scf.FileId) {
|
||||
return NewAppError("SharedChannelAttachment.IsValid", "model.channel.is_valid.id.app_error", nil, "FileId="+scf.FileId, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if !IsValidId(scf.RemoteId) {
|
||||
return NewAppError("SharedChannelAttachment.IsValid", "model.channel.is_valid.id.app_error", nil, "RemoteId="+scf.RemoteId, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if scf.CreateAt == 0 {
|
||||
return NewAppError("SharedChannelAttachment.IsValid", "model.channel.is_valid.create_at.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type SharedChannelFilterOpts struct {
|
||||
TeamId string
|
||||
CreatorId string
|
||||
ExcludeHome bool
|
||||
ExcludeRemote bool
|
||||
}
|
||||
|
||||
type SharedChannelRemoteFilterOpts struct {
|
||||
ChannelId string
|
||||
RemoteId string
|
||||
InclUnconfirmed bool
|
||||
}
|
||||
87
model/shared_channel_test.go
Normal file
87
model/shared_channel_test.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSharedChannelJson(t *testing.T) {
|
||||
o := SharedChannel{ChannelId: NewId(), ShareName: NewId()}
|
||||
json := o.ToJson()
|
||||
ro, err := SharedChannelFromJson(strings.NewReader(json))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, o.ChannelId, ro.ChannelId)
|
||||
require.Equal(t, o.ShareName, ro.ShareName)
|
||||
}
|
||||
|
||||
func TestSharedChannelIsValid(t *testing.T) {
|
||||
id := NewId()
|
||||
now := GetMillis()
|
||||
data := []struct {
|
||||
name string
|
||||
sc *SharedChannel
|
||||
valid bool
|
||||
}{
|
||||
{name: "Zero value", sc: &SharedChannel{}, valid: false},
|
||||
{name: "Missing team_id", sc: &SharedChannel{ChannelId: id}, valid: false},
|
||||
{name: "Missing create_at", sc: &SharedChannel{ChannelId: id, TeamId: id}, valid: false},
|
||||
{name: "Missing update_at", sc: &SharedChannel{ChannelId: id, TeamId: id, CreateAt: now}, valid: false},
|
||||
{name: "Missing share_name", sc: &SharedChannel{ChannelId: id, TeamId: id, CreateAt: now, UpdateAt: now}, valid: false},
|
||||
{name: "Invalid share_name", sc: &SharedChannel{ChannelId: id, TeamId: id, CreateAt: now, UpdateAt: now,
|
||||
ShareName: "@test@"}, valid: false},
|
||||
{name: "Too long share_name", sc: &SharedChannel{ChannelId: id, TeamId: id, CreateAt: now, UpdateAt: now,
|
||||
ShareName: strings.Repeat("01234567890", 100)}, valid: false},
|
||||
{name: "Missing creator_id", sc: &SharedChannel{ChannelId: id, TeamId: id, CreateAt: now, UpdateAt: now,
|
||||
ShareName: "test"}, valid: false},
|
||||
{name: "Missing remote_id", sc: &SharedChannel{ChannelId: id, TeamId: id, CreateAt: now, UpdateAt: now,
|
||||
ShareName: "test", CreatorId: id}, valid: false},
|
||||
{name: "Valid shared channel", sc: &SharedChannel{ChannelId: id, TeamId: id, CreateAt: now, UpdateAt: now,
|
||||
ShareName: "test", CreatorId: id, RemoteId: id}, valid: true},
|
||||
}
|
||||
|
||||
for _, item := range data {
|
||||
err := item.sc.IsValid()
|
||||
if item.valid {
|
||||
assert.Nil(t, err, item.name)
|
||||
} else {
|
||||
assert.NotNil(t, err, item.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedChannelPreSave(t *testing.T) {
|
||||
now := GetMillis()
|
||||
|
||||
o := SharedChannel{ChannelId: NewId(), ShareName: "test"}
|
||||
o.PreSave()
|
||||
|
||||
require.GreaterOrEqual(t, o.CreateAt, now)
|
||||
require.GreaterOrEqual(t, o.UpdateAt, now)
|
||||
}
|
||||
|
||||
func TestSharedChannelPreUpdate(t *testing.T) {
|
||||
now := GetMillis()
|
||||
|
||||
o := SharedChannel{ChannelId: NewId(), ShareName: "test"}
|
||||
o.PreUpdate()
|
||||
|
||||
require.GreaterOrEqual(t, o.UpdateAt, now)
|
||||
}
|
||||
|
||||
func TestSharedChannelRemoteJson(t *testing.T) {
|
||||
o := SharedChannelRemote{Id: NewId(), ChannelId: NewId(), Description: "Test"}
|
||||
json := o.ToJson()
|
||||
ro, err := SharedChannelRemoteFromJson(strings.NewReader(json))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, o.Id, ro.Id)
|
||||
require.Equal(t, o.ChannelId, ro.ChannelId)
|
||||
require.Equal(t, o.Description, ro.Description)
|
||||
}
|
||||
|
|
@ -42,6 +42,10 @@ type UploadSession struct {
|
|||
// The amount of received data in bytes. If equal to FileSize it means the
|
||||
// upload has finished.
|
||||
FileOffset int64 `json:"file_offset"`
|
||||
// Id of remote cluster if uploading for shared channel
|
||||
RemoteId string `json:"remote_id"`
|
||||
// Requested file id if uploading for shared channel
|
||||
ReqFileId string `json:"req_file_id"`
|
||||
}
|
||||
|
||||
// ToJson serializes the UploadSession into JSON and returns it as string.
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ type User struct {
|
|||
Timezone StringMap `json:"timezone"`
|
||||
MfaActive bool `json:"mfa_active,omitempty"`
|
||||
MfaSecret string `json:"mfa_secret,omitempty"`
|
||||
RemoteId *string `json:"remote_id,omitempty"`
|
||||
LastActivityAt int64 `db:"-" json:"last_activity_at,omitempty"`
|
||||
IsBot bool `db:"-" json:"is_bot,omitempty"`
|
||||
BotDescription string `db:"-" json:"bot_description,omitempty"`
|
||||
|
|
@ -124,6 +125,7 @@ type UserPatch struct {
|
|||
NotifyProps StringMap `json:"notify_props,omitempty"`
|
||||
Locale *string `json:"locale"`
|
||||
Timezone StringMap `json:"timezone"`
|
||||
RemoteId *string `json:"remote_id"`
|
||||
}
|
||||
|
||||
//msgp:ignore UserAuth
|
||||
|
|
@ -512,6 +514,10 @@ func (u *User) Patch(patch *UserPatch) {
|
|||
if patch.Timezone != nil {
|
||||
u.Timezone = patch.Timezone
|
||||
}
|
||||
|
||||
if patch.RemoteId != nil {
|
||||
u.RemoteId = patch.RemoteId
|
||||
}
|
||||
}
|
||||
|
||||
// ToJson convert a User to a json string
|
||||
|
|
@ -734,6 +740,11 @@ func (u *User) GetPreferredTimezone() string {
|
|||
return GetPreferredTimezone(u.Timezone)
|
||||
}
|
||||
|
||||
// IsRemote returns true if the user belongs to a remote cluster (has RemoteId).
|
||||
func (u *User) IsRemote() bool {
|
||||
return u.RemoteId != nil && *u.RemoteId != ""
|
||||
}
|
||||
|
||||
func (u *User) ToPatch() *UserPatch {
|
||||
return &UserPatch{
|
||||
Username: &u.Username, Password: &u.Password,
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ func (z *User) DecodeMsg(dc *msgp.Reader) (err error) {
|
|||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
if zb0001 != 31 {
|
||||
err = msgp.ArrayError{Wanted: 31, Got: zb0001}
|
||||
if zb0001 != 32 {
|
||||
err = msgp.ArrayError{Wanted: 32, Got: zb0001}
|
||||
return
|
||||
}
|
||||
z.Id, err = dc.ReadString()
|
||||
|
|
@ -158,6 +158,23 @@ func (z *User) DecodeMsg(dc *msgp.Reader) (err error) {
|
|||
err = msgp.WrapError(err, "MfaSecret")
|
||||
return
|
||||
}
|
||||
if dc.IsNil() {
|
||||
err = dc.ReadNil()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "RemoteId")
|
||||
return
|
||||
}
|
||||
z.RemoteId = nil
|
||||
} else {
|
||||
if z.RemoteId == nil {
|
||||
z.RemoteId = new(string)
|
||||
}
|
||||
*z.RemoteId, err = dc.ReadString()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "RemoteId")
|
||||
return
|
||||
}
|
||||
}
|
||||
z.LastActivityAt, err = dc.ReadInt64()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "LastActivityAt")
|
||||
|
|
@ -193,8 +210,8 @@ func (z *User) DecodeMsg(dc *msgp.Reader) (err error) {
|
|||
|
||||
// EncodeMsg implements msgp.Encodable
|
||||
func (z *User) EncodeMsg(en *msgp.Writer) (err error) {
|
||||
// array header, size 31
|
||||
err = en.Append(0xdc, 0x0, 0x1f)
|
||||
// array header, size 32
|
||||
err = en.Append(0xdc, 0x0, 0x20)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
|
@ -330,6 +347,18 @@ func (z *User) EncodeMsg(en *msgp.Writer) (err error) {
|
|||
err = msgp.WrapError(err, "MfaSecret")
|
||||
return
|
||||
}
|
||||
if z.RemoteId == nil {
|
||||
err = en.WriteNil()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
err = en.WriteString(*z.RemoteId)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "RemoteId")
|
||||
return
|
||||
}
|
||||
}
|
||||
err = en.WriteInt64(z.LastActivityAt)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "LastActivityAt")
|
||||
|
|
@ -366,8 +395,8 @@ func (z *User) EncodeMsg(en *msgp.Writer) (err error) {
|
|||
// MarshalMsg implements msgp.Marshaler
|
||||
func (z *User) MarshalMsg(b []byte) (o []byte, err error) {
|
||||
o = msgp.Require(b, z.Msgsize())
|
||||
// array header, size 31
|
||||
o = append(o, 0xdc, 0x0, 0x1f)
|
||||
// array header, size 32
|
||||
o = append(o, 0xdc, 0x0, 0x20)
|
||||
o = msgp.AppendString(o, z.Id)
|
||||
o = msgp.AppendInt64(o, z.CreateAt)
|
||||
o = msgp.AppendInt64(o, z.UpdateAt)
|
||||
|
|
@ -409,6 +438,11 @@ func (z *User) MarshalMsg(b []byte) (o []byte, err error) {
|
|||
}
|
||||
o = msgp.AppendBool(o, z.MfaActive)
|
||||
o = msgp.AppendString(o, z.MfaSecret)
|
||||
if z.RemoteId == nil {
|
||||
o = msgp.AppendNil(o)
|
||||
} else {
|
||||
o = msgp.AppendString(o, *z.RemoteId)
|
||||
}
|
||||
o = msgp.AppendInt64(o, z.LastActivityAt)
|
||||
o = msgp.AppendBool(o, z.IsBot)
|
||||
o = msgp.AppendString(o, z.BotDescription)
|
||||
|
|
@ -426,8 +460,8 @@ func (z *User) UnmarshalMsg(bts []byte) (o []byte, err error) {
|
|||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
if zb0001 != 31 {
|
||||
err = msgp.ArrayError{Wanted: 31, Got: zb0001}
|
||||
if zb0001 != 32 {
|
||||
err = msgp.ArrayError{Wanted: 32, Got: zb0001}
|
||||
return
|
||||
}
|
||||
z.Id, bts, err = msgp.ReadStringBytes(bts)
|
||||
|
|
@ -566,6 +600,22 @@ func (z *User) UnmarshalMsg(bts []byte) (o []byte, err error) {
|
|||
err = msgp.WrapError(err, "MfaSecret")
|
||||
return
|
||||
}
|
||||
if msgp.IsNil(bts) {
|
||||
bts, err = msgp.ReadNilBytes(bts)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
z.RemoteId = nil
|
||||
} else {
|
||||
if z.RemoteId == nil {
|
||||
z.RemoteId = new(string)
|
||||
}
|
||||
*z.RemoteId, bts, err = msgp.ReadStringBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "RemoteId")
|
||||
return
|
||||
}
|
||||
}
|
||||
z.LastActivityAt, bts, err = msgp.ReadInt64Bytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "LastActivityAt")
|
||||
|
|
@ -608,7 +658,13 @@ func (z *User) Msgsize() (s int) {
|
|||
} else {
|
||||
s += msgp.StringPrefixSize + len(*z.AuthData)
|
||||
}
|
||||
s += msgp.StringPrefixSize + len(z.AuthService) + msgp.StringPrefixSize + len(z.Email) + msgp.BoolSize + msgp.StringPrefixSize + len(z.Nickname) + msgp.StringPrefixSize + len(z.FirstName) + msgp.StringPrefixSize + len(z.LastName) + msgp.StringPrefixSize + len(z.Position) + msgp.StringPrefixSize + len(z.Roles) + msgp.BoolSize + z.Props.Msgsize() + z.NotifyProps.Msgsize() + msgp.Int64Size + msgp.Int64Size + msgp.IntSize + msgp.StringPrefixSize + len(z.Locale) + z.Timezone.Msgsize() + msgp.BoolSize + msgp.StringPrefixSize + len(z.MfaSecret) + msgp.Int64Size + msgp.BoolSize + msgp.StringPrefixSize + len(z.BotDescription) + msgp.Int64Size + msgp.StringPrefixSize + len(z.TermsOfServiceId) + msgp.Int64Size
|
||||
s += msgp.StringPrefixSize + len(z.AuthService) + msgp.StringPrefixSize + len(z.Email) + msgp.BoolSize + msgp.StringPrefixSize + len(z.Nickname) + msgp.StringPrefixSize + len(z.FirstName) + msgp.StringPrefixSize + len(z.LastName) + msgp.StringPrefixSize + len(z.Position) + msgp.StringPrefixSize + len(z.Roles) + msgp.BoolSize + z.Props.Msgsize() + z.NotifyProps.Msgsize() + msgp.Int64Size + msgp.Int64Size + msgp.IntSize + msgp.StringPrefixSize + len(z.Locale) + z.Timezone.Msgsize() + msgp.BoolSize + msgp.StringPrefixSize + len(z.MfaSecret)
|
||||
if z.RemoteId == nil {
|
||||
s += msgp.NilSize
|
||||
} else {
|
||||
s += msgp.StringPrefixSize + len(*z.RemoteId)
|
||||
}
|
||||
s += msgp.Int64Size + msgp.BoolSize + msgp.StringPrefixSize + len(z.BotDescription) + msgp.Int64Size + msgp.StringPrefixSize + len(z.TermsOfServiceId) + msgp.Int64Size
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -195,6 +195,11 @@ func GetMillisForTime(thisTime time.Time) int64 {
|
|||
return thisTime.UnixNano() / int64(time.Millisecond)
|
||||
}
|
||||
|
||||
// GetTimeForMillis is a convenience method to get time.Time for milliseconds since epoch.
|
||||
func GetTimeForMillis(millis int64) time.Time {
|
||||
return time.Unix(0, millis*int64(time.Millisecond))
|
||||
}
|
||||
|
||||
// PadDateStringZeros is a convenience method to pad 2 digit date parts with zeros to meet ISO 8601 format
|
||||
func PadDateStringZeros(dateString string) string {
|
||||
parts := strings.Split(dateString, "-")
|
||||
|
|
|
|||
|
|
@ -40,6 +40,14 @@ func TestGetMillisForTime(t *testing.T) {
|
|||
require.Equalf(t, thisTimeMillis, result, "millis are not the same: %d and %d", thisTimeMillis, result)
|
||||
}
|
||||
|
||||
func TestGetTimeForMillis(t *testing.T) {
|
||||
thisTimeMillis := int64(1471219200000)
|
||||
thisTime := time.Date(2016, time.August, 15, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
result := GetTimeForMillis(thisTimeMillis)
|
||||
require.True(t, thisTime.Equal(result))
|
||||
}
|
||||
|
||||
func TestPadDateStringZeros(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
Name string
|
||||
|
|
|
|||
24
services/remotecluster/error.go
Normal file
24
services/remotecluster/error.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package remotecluster
|
||||
|
||||
import "fmt"
|
||||
|
||||
type BufferFullError struct {
|
||||
capacity int
|
||||
}
|
||||
|
||||
func NewBufferFullError(capacity int) BufferFullError {
|
||||
return BufferFullError{
|
||||
capacity: capacity,
|
||||
}
|
||||
}
|
||||
|
||||
func (e BufferFullError) Capacity() int {
|
||||
return e.capacity
|
||||
}
|
||||
|
||||
func (e BufferFullError) Error() string {
|
||||
return fmt.Sprintf("buffer capacity (%d) exceeded", e.capacity)
|
||||
}
|
||||
82
services/remotecluster/invitation.go
Normal file
82
services/remotecluster/invitation.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package remotecluster
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
)
|
||||
|
||||
// AcceptInvitation is called when accepting an invitation to connect with a remote cluster.
|
||||
func (rcs *Service) AcceptInvitation(invite *model.RemoteClusterInvite, name string, creatorId string, teamId string, siteURL string) (*model.RemoteCluster, error) {
|
||||
rc := &model.RemoteCluster{
|
||||
RemoteId: invite.RemoteId,
|
||||
RemoteTeamId: invite.RemoteTeamId,
|
||||
DisplayName: name,
|
||||
Token: model.NewId(),
|
||||
RemoteToken: invite.Token,
|
||||
SiteURL: invite.SiteURL,
|
||||
CreatorId: creatorId,
|
||||
}
|
||||
|
||||
rcSaved, err := rcs.server.GetStore().RemoteCluster().Save(rc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// confirm the invitation with the originating site
|
||||
frame, err := makeConfirmFrame(rcSaved, teamId, siteURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/%s", rcSaved.SiteURL, ConfirmInviteURL)
|
||||
|
||||
resp, err := rcs.sendFrameToRemote(PingTimeout, rc, frame, url)
|
||||
if err != nil {
|
||||
rcs.server.GetStore().RemoteCluster().Delete(rcSaved.RemoteId)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response Response
|
||||
err = json.Unmarshal(resp, &response)
|
||||
if err != nil {
|
||||
rcs.server.GetStore().RemoteCluster().Delete(rcSaved.RemoteId)
|
||||
return nil, fmt.Errorf("invalid response from remote server: %w", err)
|
||||
}
|
||||
|
||||
if !response.IsSuccess() {
|
||||
rcs.server.GetStore().RemoteCluster().Delete(rcSaved.RemoteId)
|
||||
return nil, errors.New(response.Err)
|
||||
}
|
||||
|
||||
// issue the first ping right away. The goroutine will exit when ping completes or PingTimeout exceeded.
|
||||
go rcs.pingRemote(rcSaved)
|
||||
|
||||
return rcSaved, nil
|
||||
}
|
||||
|
||||
func makeConfirmFrame(rc *model.RemoteCluster, teamId string, siteURL string) (*model.RemoteClusterFrame, error) {
|
||||
confirm := model.RemoteClusterInvite{
|
||||
RemoteId: rc.RemoteId,
|
||||
RemoteTeamId: teamId,
|
||||
SiteURL: siteURL,
|
||||
Token: rc.Token,
|
||||
}
|
||||
confirmRaw, err := json.Marshal(confirm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
msg := model.NewRemoteClusterMsg(InvitationTopic, confirmRaw)
|
||||
|
||||
frame := &model.RemoteClusterFrame{
|
||||
RemoteId: rc.RemoteId,
|
||||
Msg: msg,
|
||||
}
|
||||
return frame, nil
|
||||
}
|
||||
104
services/remotecluster/mocks_test.go
Normal file
104
services/remotecluster/mocks_test.go
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package remotecluster
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap/zapcore"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/einterfaces"
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/plugin/plugintest/mock"
|
||||
"github.com/mattermost/mattermost-server/v5/shared/mlog"
|
||||
"github.com/mattermost/mattermost-server/v5/store"
|
||||
"github.com/mattermost/mattermost-server/v5/store/storetest/mocks"
|
||||
)
|
||||
|
||||
type mockServer struct {
|
||||
remotes []*model.RemoteCluster
|
||||
logger *mockLogger
|
||||
}
|
||||
|
||||
func newMockServer(t *testing.T, remotes []*model.RemoteCluster) *mockServer {
|
||||
return &mockServer{
|
||||
remotes: remotes,
|
||||
logger: &mockLogger{t: t},
|
||||
}
|
||||
}
|
||||
|
||||
func (ms *mockServer) Config() *model.Config { return nil }
|
||||
func (ms *mockServer) GetMetrics() einterfaces.MetricsInterface { return nil }
|
||||
func (ms *mockServer) IsLeader() bool { return true }
|
||||
func (ms *mockServer) AddClusterLeaderChangedListener(listener func()) string { return model.NewId() }
|
||||
func (ms *mockServer) RemoveClusterLeaderChangedListener(id string) {}
|
||||
func (ms *mockServer) GetLogger() mlog.LoggerIFace {
|
||||
return ms.logger
|
||||
}
|
||||
func (ms *mockServer) GetStore() store.Store {
|
||||
anyFilter := mock.MatchedBy(func(filter model.RemoteClusterQueryFilter) bool {
|
||||
return true
|
||||
})
|
||||
|
||||
remoteClusterStoreMock := &mocks.RemoteClusterStore{}
|
||||
remoteClusterStoreMock.On("GetByTopic", "share").Return(ms.remotes, nil)
|
||||
remoteClusterStoreMock.On("GetAll", anyFilter).Return(ms.remotes, nil)
|
||||
|
||||
storeMock := &mocks.Store{}
|
||||
storeMock.On("RemoteCluster").Return(remoteClusterStoreMock)
|
||||
return storeMock
|
||||
}
|
||||
|
||||
type mockLogger struct {
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
func (ml *mockLogger) IsLevelEnabled(level mlog.LogLevel) bool {
|
||||
return true
|
||||
}
|
||||
func (ml *mockLogger) Debug(s string, flds ...mlog.Field) {
|
||||
ml.t.Log("debug", s, fieldsToStrings(flds))
|
||||
}
|
||||
func (ml *mockLogger) Info(s string, flds ...mlog.Field) {
|
||||
ml.t.Log("info", s, fieldsToStrings(flds))
|
||||
}
|
||||
func (ml *mockLogger) Warn(s string, flds ...mlog.Field) {
|
||||
ml.t.Log("warn", s, fieldsToStrings(flds))
|
||||
}
|
||||
func (ml *mockLogger) Error(s string, flds ...mlog.Field) {
|
||||
ml.t.Log("error", s, fieldsToStrings(flds))
|
||||
}
|
||||
func (ml *mockLogger) Critical(s string, flds ...mlog.Field) {
|
||||
ml.t.Log("crit", s, fieldsToStrings(flds))
|
||||
}
|
||||
func (ml *mockLogger) Log(level mlog.LogLevel, s string, flds ...mlog.Field) {
|
||||
ml.t.Log(level.Name, s, fieldsToStrings(flds))
|
||||
}
|
||||
func (ml *mockLogger) LogM(levels []mlog.LogLevel, s string, flds ...mlog.Field) {
|
||||
ml.t.Log(levelsToString(levels), s, fieldsToStrings(flds))
|
||||
}
|
||||
|
||||
func levelsToString(levels []mlog.LogLevel) string {
|
||||
sb := strings.Builder{}
|
||||
for _, l := range levels {
|
||||
sb.WriteString(l.Name)
|
||||
sb.WriteString(",")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func fieldsToStrings(fields []mlog.Field) []string {
|
||||
encoder := zapcore.NewMapObjectEncoder()
|
||||
for _, zapField := range fields {
|
||||
zapField.AddTo(encoder)
|
||||
}
|
||||
|
||||
var result []string
|
||||
for k, v := range encoder.Fields {
|
||||
result = append(result, fmt.Sprintf("%s:%v", k, v))
|
||||
}
|
||||
return result
|
||||
}
|
||||
174
services/remotecluster/ping.go
Normal file
174
services/remotecluster/ping.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package remotecluster
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/shared/mlog"
|
||||
)
|
||||
|
||||
// pingLoop periodically sends a ping to all remote clusters.
|
||||
func (rcs *Service) pingLoop(done <-chan struct{}) {
|
||||
pingChan := make(chan *model.RemoteCluster, MaxConcurrentSends*2)
|
||||
|
||||
// create a thread pool to send pings concurrently to remotes.
|
||||
for i := 0; i < MaxConcurrentSends; i++ {
|
||||
go rcs.pingEmitter(pingChan, done)
|
||||
}
|
||||
|
||||
go rcs.pingGenerator(pingChan, done)
|
||||
}
|
||||
|
||||
func (rcs *Service) pingGenerator(pingChan chan *model.RemoteCluster, done <-chan struct{}) {
|
||||
defer close(pingChan)
|
||||
|
||||
for {
|
||||
start := time.Now()
|
||||
|
||||
// get all remotes, including any previously offline.
|
||||
remotes, err := rcs.server.GetStore().RemoteCluster().GetAll(model.RemoteClusterQueryFilter{})
|
||||
if err != nil {
|
||||
rcs.server.GetLogger().Log(mlog.LvlRemoteClusterServiceError, "Ping remote cluster failed (could not get list of remotes)", mlog.Err(err))
|
||||
select {
|
||||
case <-time.After(PingFreq):
|
||||
continue
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for _, rc := range remotes {
|
||||
if rc.SiteURL != "" { // filter out unconfirmed invites
|
||||
pingChan <- rc
|
||||
}
|
||||
}
|
||||
|
||||
// try to maintain frequency
|
||||
elapsed := time.Since(start)
|
||||
if elapsed < PingFreq {
|
||||
sleep := time.Until(start.Add(PingFreq))
|
||||
select {
|
||||
case <-time.After(sleep):
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pingEmitter pulls Remotes from the ping queue (pingChan) and pings them.
|
||||
// Pinging a remote cannot take longer than PingTimeoutMillis.
|
||||
func (rcs *Service) pingEmitter(pingChan <-chan *model.RemoteCluster, done <-chan struct{}) {
|
||||
for {
|
||||
select {
|
||||
case rc := <-pingChan:
|
||||
if rc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
online := rc.IsOnline()
|
||||
|
||||
if err := rcs.pingRemote(rc); err != nil {
|
||||
rcs.server.GetLogger().Log(mlog.LvlRemoteClusterServiceWarn, "Remote cluster ping failed",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.String("remoteId", rc.RemoteId),
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
|
||||
if online != rc.IsOnline() {
|
||||
if metrics := rcs.server.GetMetrics(); metrics != nil {
|
||||
metrics.IncrementRemoteClusterConnStateChangeCounter(rc.RemoteId, rc.IsOnline())
|
||||
}
|
||||
rcs.fireConnectionStateChgEvent(rc)
|
||||
}
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pingRemote make a synchronous ping to a remote cluster. Return is error if ping is
|
||||
// unsuccessful and nil on success.
|
||||
func (rcs *Service) pingRemote(rc *model.RemoteCluster) error {
|
||||
frame, err := makePingFrame(rc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
url := fmt.Sprintf("%s/%s", rc.SiteURL, PingURL)
|
||||
|
||||
resp, err := rcs.sendFrameToRemote(PingTimeout, rc, frame, url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ping := model.RemoteClusterPing{}
|
||||
err = json.Unmarshal(resp, &ping)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := rcs.server.GetStore().RemoteCluster().SetLastPingAt(rc.RemoteId); err != nil {
|
||||
rcs.server.GetLogger().Log(mlog.LvlRemoteClusterServiceError, "Failed to update LastPingAt for remote cluster",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.String("remoteId", rc.RemoteId),
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
rc.LastPingAt = model.GetMillis()
|
||||
|
||||
if metrics := rcs.server.GetMetrics(); metrics != nil {
|
||||
sentAt := time.Unix(0, ping.SentAt*int64(time.Millisecond))
|
||||
elapsed := time.Since(sentAt).Seconds()
|
||||
metrics.ObserveRemoteClusterPingDuration(rc.RemoteId, elapsed)
|
||||
|
||||
// we approximate clock skew between remotes.
|
||||
skew := elapsed/2 - float64(ping.RecvAt-ping.SentAt)/1000
|
||||
metrics.ObserveRemoteClusterClockSkew(rc.RemoteId, skew)
|
||||
}
|
||||
|
||||
rcs.server.GetLogger().Log(mlog.LvlRemoteClusterServiceDebug, "Remote cluster ping",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.String("remoteId", rc.RemoteId),
|
||||
mlog.Int64("SentAt", ping.SentAt),
|
||||
mlog.Int64("RecvAt", ping.RecvAt),
|
||||
mlog.Int64("Diff", ping.RecvAt-ping.SentAt),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func makePingFrame(rc *model.RemoteCluster) (*model.RemoteClusterFrame, error) {
|
||||
ping := model.RemoteClusterPing{
|
||||
SentAt: model.GetMillis(),
|
||||
}
|
||||
pingRaw, err := json.Marshal(ping)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
msg := model.NewRemoteClusterMsg(PingTopic, pingRaw)
|
||||
|
||||
frame := &model.RemoteClusterFrame{
|
||||
RemoteId: rc.RemoteId,
|
||||
Msg: msg,
|
||||
}
|
||||
return frame, nil
|
||||
}
|
||||
|
||||
func (rcs *Service) fireConnectionStateChgEvent(rc *model.RemoteCluster) {
|
||||
rcs.mux.RLock()
|
||||
listeners := make([]ConnectionStateListener, 0, len(rcs.connectionStateListeners))
|
||||
for _, l := range rcs.connectionStateListeners {
|
||||
listeners = append(listeners, l)
|
||||
}
|
||||
rcs.mux.RUnlock()
|
||||
|
||||
for _, l := range listeners {
|
||||
l(rc, rc.IsOnline())
|
||||
}
|
||||
}
|
||||
133
services/remotecluster/ping_test.go
Normal file
133
services/remotecluster/ping_test.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package remotecluster
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/wiggin77/merror"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
)
|
||||
|
||||
const (
|
||||
Recent = 60000
|
||||
)
|
||||
|
||||
func TestPing(t *testing.T) {
|
||||
disablePing = false
|
||||
|
||||
t.Run("No error", func(t *testing.T) {
|
||||
var countWebReq int32
|
||||
merr := merror.New()
|
||||
|
||||
wg := &sync.WaitGroup{}
|
||||
wg.Add(NumRemotes)
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer wg.Done()
|
||||
defer w.WriteHeader(200)
|
||||
atomic.AddInt32(&countWebReq, 1)
|
||||
|
||||
frame, err := model.RemoteClusterFrameFromJSON(r.Body)
|
||||
if err != nil {
|
||||
merr.Append(err)
|
||||
return
|
||||
}
|
||||
if len(frame.Msg.Payload) == 0 {
|
||||
merr.Append(fmt.Errorf("Payload should not be empty; remote_id=%s", frame.RemoteId))
|
||||
return
|
||||
}
|
||||
|
||||
ping, err := model.RemoteClusterPingFromRawJSON(frame.Msg.Payload)
|
||||
if err != nil {
|
||||
merr.Append(err)
|
||||
return
|
||||
}
|
||||
if !checkRecent(ping.SentAt, Recent) {
|
||||
merr.Append(fmt.Errorf("timestamp out of range, got %d", ping.SentAt))
|
||||
return
|
||||
}
|
||||
if ping.RecvAt != 0 {
|
||||
merr.Append(fmt.Errorf("timestamp should be 0, got %d", ping.RecvAt))
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
mockServer := newMockServer(t, makeRemoteClusters(NumRemotes, ts.URL))
|
||||
service, err := NewRemoteClusterService(mockServer)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = service.Start()
|
||||
require.NoError(t, err)
|
||||
defer service.Shutdown()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
assert.NoError(t, merr.ErrorOrNil())
|
||||
|
||||
assert.Equal(t, int32(NumRemotes), atomic.LoadInt32(&countWebReq))
|
||||
t.Log(fmt.Sprintf("%d web requests counted; %d expected",
|
||||
atomic.LoadInt32(&countWebReq), NumRemotes))
|
||||
})
|
||||
|
||||
t.Run("HTTP errors", func(t *testing.T) {
|
||||
var countWebReq int32
|
||||
merr := merror.New()
|
||||
|
||||
wg := &sync.WaitGroup{}
|
||||
wg.Add(NumRemotes)
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer wg.Done()
|
||||
atomic.AddInt32(&countWebReq, 1)
|
||||
|
||||
frame, err := model.RemoteClusterFrameFromJSON(r.Body)
|
||||
if err != nil {
|
||||
merr.Append(err)
|
||||
}
|
||||
ping, err := model.RemoteClusterPingFromRawJSON(frame.Msg.Payload)
|
||||
if err != nil {
|
||||
merr.Append(err)
|
||||
}
|
||||
if !checkRecent(ping.SentAt, Recent) {
|
||||
merr.Append(fmt.Errorf("timestamp out of range, got %d", ping.SentAt))
|
||||
}
|
||||
if ping.RecvAt != 0 {
|
||||
merr.Append(fmt.Errorf("timestamp should be 0, got %d", ping.RecvAt))
|
||||
}
|
||||
w.WriteHeader(500)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
mockServer := newMockServer(t, makeRemoteClusters(NumRemotes, ts.URL))
|
||||
service, err := NewRemoteClusterService(mockServer)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = service.Start()
|
||||
require.NoError(t, err)
|
||||
defer service.Shutdown()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
assert.Nil(t, merr.ErrorOrNil())
|
||||
|
||||
assert.Equal(t, int32(NumRemotes), atomic.LoadInt32(&countWebReq))
|
||||
t.Log(fmt.Sprintf("%d web requests counted; %d expected",
|
||||
atomic.LoadInt32(&countWebReq), NumRemotes))
|
||||
})
|
||||
}
|
||||
|
||||
func checkRecent(millis int64, within int64) bool {
|
||||
now := model.GetMillis()
|
||||
return millis > now-within && millis < now+within
|
||||
}
|
||||
53
services/remotecluster/recv.go
Normal file
53
services/remotecluster/recv.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package remotecluster
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/shared/mlog"
|
||||
)
|
||||
|
||||
// ReceiveIncomingMsg is called by the Rest API layer, or websocket layer (future), when a Remote Cluster
|
||||
// message is received. Here we route the message to any topic listeners.
|
||||
// `rc` and `msg` cannot be nil.
|
||||
func (rcs *Service) ReceiveIncomingMsg(rc *model.RemoteCluster, msg model.RemoteClusterMsg) Response {
|
||||
rcs.mux.RLock()
|
||||
defer rcs.mux.RUnlock()
|
||||
|
||||
if metrics := rcs.server.GetMetrics(); metrics != nil {
|
||||
metrics.IncrementRemoteClusterMsgReceivedCounter(rc.RemoteId)
|
||||
}
|
||||
|
||||
rcSanitized := *rc
|
||||
rcSanitized.Token = ""
|
||||
rcSanitized.RemoteToken = ""
|
||||
|
||||
var response Response
|
||||
response.Status = ResponseStatusOK
|
||||
|
||||
listeners := rcs.getTopicListeners(msg.Topic)
|
||||
|
||||
for _, l := range listeners {
|
||||
if err := callback(l, msg, &rcSanitized, &response); err != nil {
|
||||
rcs.server.GetLogger().Log(mlog.LvlRemoteClusterServiceError, "Error from remote cluster message listener",
|
||||
mlog.String("msgId", msg.Id), mlog.String("topic", msg.Topic), mlog.String("remote", rc.DisplayName), mlog.Err(err))
|
||||
|
||||
response.Status = ResponseStatusFail
|
||||
response.Err = err.Error()
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func callback(listener TopicListener, msg model.RemoteClusterMsg, rc *model.RemoteCluster, resp *Response) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("%v", r)
|
||||
}
|
||||
}()
|
||||
err = listener(msg, rc, resp)
|
||||
return
|
||||
}
|
||||
30
services/remotecluster/response.go
Normal file
30
services/remotecluster/response.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package remotecluster
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// Response represents the bytes replied from a remote server when a message is sent.
|
||||
type Response struct {
|
||||
Status string `json:"status"`
|
||||
Err string `json:"err"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
// IsSuccess returns true if the response status indicates success.
|
||||
func (r *Response) IsSuccess() bool {
|
||||
return r.Status == ResponseStatusOK
|
||||
}
|
||||
|
||||
// SetPayload serializes an arbitrary struct as a RawMessage.
|
||||
func (r *Response) SetPayload(v interface{}) error {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Payload = raw
|
||||
return nil
|
||||
}
|
||||
56
services/remotecluster/send.go
Normal file
56
services/remotecluster/send.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package remotecluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"hash/fnv"
|
||||
)
|
||||
|
||||
// enqueueTask adds a task to one of the send channels based on remoteId.
|
||||
//
|
||||
// There are a number of send channels (`MaxConcurrentSends`) to allow for sending to multiple
|
||||
// remotes concurrently, while preserving message order for each remote.
|
||||
func (rcs *Service) enqueueTask(ctx context.Context, remoteId string, task interface{}) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
h := hash(remoteId)
|
||||
idx := h % uint32(len(rcs.send))
|
||||
|
||||
select {
|
||||
case rcs.send[idx] <- task:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return NewBufferFullError(cap(rcs.send))
|
||||
}
|
||||
}
|
||||
|
||||
func hash(s string) uint32 {
|
||||
h := fnv.New32a()
|
||||
h.Write([]byte(s))
|
||||
return h.Sum32()
|
||||
}
|
||||
|
||||
// sendLoop is called by each goroutine created for the send pool and waits for sendTask's until the
|
||||
// done channel is signalled.
|
||||
//
|
||||
// Each goroutine in the pool is assigned a specific channel, and tasks are placed in the
|
||||
// channel corresponding to the remoteId.
|
||||
func (rcs *Service) sendLoop(idx int, done chan struct{}) {
|
||||
for {
|
||||
select {
|
||||
case t := <-rcs.send[idx]:
|
||||
switch task := t.(type) {
|
||||
case sendMsgTask:
|
||||
rcs.sendMsg(task)
|
||||
case sendFileTask:
|
||||
rcs.sendFile(task)
|
||||
}
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
200
services/remotecluster/send_test.go
Normal file
200
services/remotecluster/send_test.go
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package remotecluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/wiggin77/merror"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
)
|
||||
|
||||
const (
|
||||
TestTopics = " share incident "
|
||||
TestTopic = "share"
|
||||
NumRemotes = 50
|
||||
NoteContent = "Woot!!"
|
||||
)
|
||||
|
||||
type testPayload struct {
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
func TestBroadcastMsg(t *testing.T) {
|
||||
msgId := model.NewId()
|
||||
disablePing = true
|
||||
|
||||
t.Run("No error", func(t *testing.T) {
|
||||
var countCallbacks int32
|
||||
var countWebReq int32
|
||||
merr := merror.New()
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
w.WriteHeader(200)
|
||||
var resp Response
|
||||
b, errMarshall := json.Marshal(&resp)
|
||||
if errMarshall != nil {
|
||||
merr.Append(errMarshall)
|
||||
return
|
||||
}
|
||||
w.Write(b)
|
||||
}()
|
||||
|
||||
atomic.AddInt32(&countWebReq, 1)
|
||||
|
||||
frame, appErr := model.RemoteClusterFrameFromJSON(r.Body)
|
||||
if appErr != nil {
|
||||
merr.Append(appErr)
|
||||
return
|
||||
}
|
||||
if len(frame.Msg.Payload) == 0 {
|
||||
merr.Append(fmt.Errorf("webrequest missing Msg.Payload"))
|
||||
}
|
||||
if msgId != frame.Msg.Id {
|
||||
merr.Append(fmt.Errorf("webrequest msgId expected %s, got %s", msgId, frame.Msg.Id))
|
||||
return
|
||||
}
|
||||
|
||||
note := testPayload{}
|
||||
err := json.Unmarshal(frame.Msg.Payload, ¬e)
|
||||
if err != nil {
|
||||
merr.Append(err)
|
||||
return
|
||||
}
|
||||
if note.Note != NoteContent {
|
||||
merr.Append(fmt.Errorf("webrequest payload expected %s, got %s", NoteContent, note.Note))
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
mockServer := newMockServer(t, makeRemoteClusters(NumRemotes, ts.URL))
|
||||
service, err := NewRemoteClusterService(mockServer)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = service.Start()
|
||||
require.NoError(t, err)
|
||||
defer service.Shutdown()
|
||||
|
||||
wg := &sync.WaitGroup{}
|
||||
wg.Add(NumRemotes)
|
||||
|
||||
msg := makeRemoteClusterMsg(msgId, NoteContent)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*15)
|
||||
defer cancel()
|
||||
|
||||
err = service.BroadcastMsg(ctx, msg, func(msg model.RemoteClusterMsg, remote *model.RemoteCluster, resp *Response, err error) {
|
||||
defer wg.Done()
|
||||
atomic.AddInt32(&countCallbacks, 1)
|
||||
|
||||
if err != nil {
|
||||
merr.Append(err)
|
||||
}
|
||||
if msgId != msg.Id {
|
||||
merr.Append(fmt.Errorf("result callback msgId expected %s, got %s", msgId, msg.Id))
|
||||
}
|
||||
|
||||
var note testPayload
|
||||
err2 := json.Unmarshal(msg.Payload, ¬e)
|
||||
if err2 != nil {
|
||||
merr.Append(fmt.Errorf("unmarshal payload error: %w", err2))
|
||||
return
|
||||
}
|
||||
if note.Note != NoteContent {
|
||||
merr.Append(fmt.Errorf("compare payload failed: expected '%s', got '%s'", NoteContent, note))
|
||||
}
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
wg.Wait()
|
||||
|
||||
assert.NoError(t, merr.ErrorOrNil())
|
||||
|
||||
assert.Equal(t, int32(NumRemotes), atomic.LoadInt32(&countCallbacks))
|
||||
assert.Equal(t, int32(NumRemotes), atomic.LoadInt32(&countWebReq))
|
||||
t.Log(fmt.Sprintf("%d callbacks counted; %d web requests counted; %d expected",
|
||||
atomic.LoadInt32(&countCallbacks), atomic.LoadInt32(&countWebReq), NumRemotes))
|
||||
})
|
||||
|
||||
t.Run("HTTP error", func(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(500)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
mockServer := newMockServer(t, makeRemoteClusters(NumRemotes, ts.URL))
|
||||
service, err := NewRemoteClusterService(mockServer)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = service.Start()
|
||||
require.NoError(t, err)
|
||||
defer service.Shutdown()
|
||||
|
||||
msg := makeRemoteClusterMsg(msgId, NoteContent)
|
||||
var countCallbacks int32
|
||||
var countErrors int32
|
||||
wg := &sync.WaitGroup{}
|
||||
wg.Add(NumRemotes)
|
||||
|
||||
err = service.BroadcastMsg(context.Background(), msg, func(msg model.RemoteClusterMsg, remote *model.RemoteCluster, resp *Response, err error) {
|
||||
defer wg.Done()
|
||||
atomic.AddInt32(&countCallbacks, 1)
|
||||
if err != nil {
|
||||
atomic.AddInt32(&countErrors, 1)
|
||||
}
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
wg.Wait()
|
||||
|
||||
assert.Equal(t, int32(NumRemotes), atomic.LoadInt32(&countCallbacks))
|
||||
assert.Equal(t, int32(NumRemotes), atomic.LoadInt32(&countErrors))
|
||||
})
|
||||
}
|
||||
|
||||
func makeRemoteClusters(num int, siteURL string) []*model.RemoteCluster {
|
||||
var remotes []*model.RemoteCluster
|
||||
for i := 0; i < num; i++ {
|
||||
rc := makeRemoteCluster(fmt.Sprintf("test cluster %d", i+1), siteURL, TestTopics)
|
||||
remotes = append(remotes, rc)
|
||||
}
|
||||
return remotes
|
||||
}
|
||||
|
||||
func makeRemoteCluster(name string, siteURL string, topics string) *model.RemoteCluster {
|
||||
return &model.RemoteCluster{
|
||||
RemoteId: model.NewId(),
|
||||
DisplayName: name,
|
||||
SiteURL: siteURL,
|
||||
Token: model.NewId(),
|
||||
Topics: topics,
|
||||
CreateAt: model.GetMillis(),
|
||||
LastPingAt: model.GetMillis(),
|
||||
CreatorId: model.NewId(),
|
||||
}
|
||||
}
|
||||
|
||||
func makeRemoteClusterMsg(id string, note string) model.RemoteClusterMsg {
|
||||
payload := testPayload{Note: note}
|
||||
raw, _ := json.Marshal(payload)
|
||||
|
||||
return model.RemoteClusterMsg{
|
||||
Id: id,
|
||||
Topic: TestTopic,
|
||||
CreateAt: model.GetMillis(),
|
||||
Payload: raw}
|
||||
}
|
||||
147
services/remotecluster/sendfile.go
Normal file
147
services/remotecluster/sendfile.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package remotecluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/shared/filestore"
|
||||
"github.com/mattermost/mattermost-server/v5/shared/mlog"
|
||||
)
|
||||
|
||||
type SendFileResultFunc func(us *model.UploadSession, rc *model.RemoteCluster, resp *Response, err error)
|
||||
|
||||
type sendFileTask struct {
|
||||
rc *model.RemoteCluster
|
||||
us *model.UploadSession
|
||||
fi *model.FileInfo
|
||||
rp ReaderProvider
|
||||
f SendFileResultFunc
|
||||
}
|
||||
|
||||
type ReaderProvider interface {
|
||||
FileReader(path string) (filestore.ReadCloseSeeker, *model.AppError)
|
||||
}
|
||||
|
||||
// SendFile asynchronously sends a file to a remote cluster.
|
||||
//
|
||||
// `ctx` determines behaviour when the outbound queue is full. A timeout or deadline context will return a
|
||||
// BufferFullError if the file cannot be enqueued before the timeout. A background context will block indefinitely.
|
||||
//
|
||||
// Nil or error return indicates success or failure of file enqueue only.
|
||||
//
|
||||
// An optional callback can be provided that receives the response from the remote cluster. The `err` provided to the
|
||||
// callback is regarding file delivery only. The `resp` contains the decoded bytes returned from the remote.
|
||||
// If a callback is provided it should return quickly.
|
||||
func (rcs *Service) SendFile(ctx context.Context, us *model.UploadSession, fi *model.FileInfo, rc *model.RemoteCluster, rp ReaderProvider, f SendFileResultFunc) error {
|
||||
task := sendFileTask{
|
||||
rc: rc,
|
||||
us: us,
|
||||
fi: fi,
|
||||
rp: rp,
|
||||
f: f,
|
||||
}
|
||||
return rcs.enqueueTask(ctx, rc.RemoteId, task)
|
||||
}
|
||||
|
||||
// sendFile is called when a sendFileTask is popped from the send channel.
|
||||
func (rcs *Service) sendFile(task sendFileTask) {
|
||||
// Ensure a panic from the callback does not exit the goroutine.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
rcs.server.GetLogger().Log(mlog.LvlRemoteClusterServiceError, "Remote Cluster sendFile panic",
|
||||
mlog.String("remote", task.rc.DisplayName),
|
||||
mlog.String("uploadId", task.us.Id),
|
||||
mlog.Any("panic", r),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
fi, err := rcs.sendFileToRemote(SendTimeout, task)
|
||||
var response Response
|
||||
|
||||
if err != nil {
|
||||
rcs.server.GetLogger().Log(mlog.LvlRemoteClusterServiceError, "Remote Cluster send file failed",
|
||||
mlog.String("remote", task.rc.DisplayName),
|
||||
mlog.String("uploadId", task.us.Id),
|
||||
mlog.Err(err),
|
||||
)
|
||||
response.Status = ResponseStatusFail
|
||||
response.Err = err.Error()
|
||||
} else {
|
||||
rcs.server.GetLogger().Log(mlog.LvlRemoteClusterServiceDebug, "Remote Cluster file sent successfully",
|
||||
mlog.String("remote", task.rc.DisplayName),
|
||||
mlog.String("uploadId", task.us.Id),
|
||||
)
|
||||
response.Status = ResponseStatusOK
|
||||
response.SetPayload(fi)
|
||||
}
|
||||
|
||||
// If callback provided then call it with the results.
|
||||
if task.f != nil {
|
||||
task.f(task.us, task.rc, &response, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (rcs *Service) sendFileToRemote(timeout time.Duration, task sendFileTask) (*model.FileInfo, error) {
|
||||
rcs.server.GetLogger().Log(mlog.LvlRemoteClusterServiceDebug, "sending file to remote...",
|
||||
mlog.String("remote", task.rc.DisplayName),
|
||||
mlog.String("uploadId", task.us.Id),
|
||||
mlog.String("file_path", task.us.Path),
|
||||
)
|
||||
|
||||
r, appErr := task.rp.FileReader(task.fi.Path) // get Reader for the file
|
||||
if appErr != nil {
|
||||
return nil, fmt.Errorf("error opening file while sending file to remote %s: %w", task.rc.RemoteId, appErr)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
u, err := url.Parse(task.rc.SiteURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid siteURL while sending file to remote %s: %w", task.rc.RemoteId, err)
|
||||
}
|
||||
u.Path = path.Join(u.Path, model.API_URL_SUFFIX, "remotecluster", "upload", task.us.Id)
|
||||
|
||||
req, err := http.NewRequest("POST", u.String(), r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set(model.HEADER_REMOTECLUSTER_ID, task.rc.RemoteId)
|
||||
req.Header.Set(model.HEADER_REMOTECLUSTER_TOKEN, task.rc.RemoteToken)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
resp, err := rcs.httpClient.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected response: %d - %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
// body should be a FileInfo
|
||||
var fi model.FileInfo
|
||||
if err := json.Unmarshal(body, &fi); err != nil {
|
||||
return nil, fmt.Errorf("unexpected response body: %w", err)
|
||||
}
|
||||
|
||||
return &fi, nil
|
||||
}
|
||||
180
services/remotecluster/sendmsg.go
Normal file
180
services/remotecluster/sendmsg.go
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package remotecluster
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/wiggin77/merror"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/shared/mlog"
|
||||
)
|
||||
|
||||
type SendMsgResultFunc func(msg model.RemoteClusterMsg, rc *model.RemoteCluster, resp *Response, err error)
|
||||
|
||||
type sendMsgTask struct {
|
||||
rc *model.RemoteCluster
|
||||
msg model.RemoteClusterMsg
|
||||
f SendMsgResultFunc
|
||||
}
|
||||
|
||||
// BroadcastMsg asynchronously sends a message to all remote clusters interested in the message's topic.
|
||||
//
|
||||
// `ctx` determines behaviour when the outbound queue is full. A timeout or deadline context will return a
|
||||
// BufferFullError if the message cannot be enqueued before the timeout. A background context will block indefinitely.
|
||||
//
|
||||
// An optional callback can be provided that receives the success or fail result of sending to each remote cluster.
|
||||
// Success or fail is regarding message delivery only. If a callback is provided it should return quickly.
|
||||
func (rcs *Service) BroadcastMsg(ctx context.Context, msg model.RemoteClusterMsg, f SendMsgResultFunc) error {
|
||||
// get list of interested remotes.
|
||||
filter := model.RemoteClusterQueryFilter{
|
||||
Topic: msg.Topic,
|
||||
}
|
||||
list, err := rcs.server.GetStore().RemoteCluster().GetAll(filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
errs := merror.New()
|
||||
|
||||
for _, rc := range list {
|
||||
if err := rcs.SendMsg(ctx, msg, rc, f); err != nil {
|
||||
errs.Append(err)
|
||||
}
|
||||
}
|
||||
return errs.ErrorOrNil()
|
||||
}
|
||||
|
||||
// SendMsg asynchronously sends a message to a remote cluster.
|
||||
//
|
||||
// `ctx` determines behaviour when the outbound queue is full. A timeout or deadline context will return a
|
||||
// BufferFullError if the message cannot be enqueued before the timeout. A background context will block indefinitely.
|
||||
//
|
||||
// Nil or error return indicates success or failure of message enqueue only.
|
||||
//
|
||||
// An optional callback can be provided that receives the response from the remote cluster. The `err` provided to the
|
||||
// callback is regarding response decoding only. The `resp` contains the decoded bytes returned from the remote.
|
||||
// If a callback is provided it should return quickly.
|
||||
func (rcs *Service) SendMsg(ctx context.Context, msg model.RemoteClusterMsg, rc *model.RemoteCluster, f SendMsgResultFunc) error {
|
||||
task := sendMsgTask{
|
||||
rc: rc,
|
||||
msg: msg,
|
||||
f: f,
|
||||
}
|
||||
return rcs.enqueueTask(ctx, rc.RemoteId, task)
|
||||
}
|
||||
|
||||
// sendMsg is called when a sendMsgTask is popped from the send channel.
|
||||
func (rcs *Service) sendMsg(task sendMsgTask) {
|
||||
var errResp error
|
||||
var response Response
|
||||
|
||||
// Ensure a panic from the callback does not exit the pool goroutine.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
rcs.server.GetLogger().Log(mlog.LvlRemoteClusterServiceError, "Remote Cluster sendMsg panic",
|
||||
mlog.String("remote", task.rc.DisplayName), mlog.String("msgId", task.msg.Id), mlog.Any("panic", r))
|
||||
}
|
||||
|
||||
if errResp != nil {
|
||||
response.Err = errResp.Error()
|
||||
}
|
||||
|
||||
// If callback provided then call it with the results.
|
||||
if task.f != nil {
|
||||
task.f(task.msg, task.rc, &response, errResp)
|
||||
}
|
||||
}()
|
||||
|
||||
frame := &model.RemoteClusterFrame{
|
||||
RemoteId: task.rc.RemoteId,
|
||||
Msg: task.msg,
|
||||
}
|
||||
|
||||
u, err := url.Parse(task.rc.SiteURL)
|
||||
if err != nil {
|
||||
rcs.server.GetLogger().Log(mlog.LvlRemoteClusterServiceError, "Invalid siteURL while sending message to remote",
|
||||
mlog.String("remote", task.rc.DisplayName),
|
||||
mlog.String("msgId", task.msg.Id),
|
||||
mlog.Err(err),
|
||||
)
|
||||
errResp = err
|
||||
return
|
||||
}
|
||||
u.Path = path.Join(u.Path, SendMsgURL)
|
||||
|
||||
respJSON, err := rcs.sendFrameToRemote(SendTimeout, task.rc, frame, u.String())
|
||||
|
||||
if err != nil {
|
||||
rcs.server.GetLogger().Log(mlog.LvlRemoteClusterServiceError, "Remote Cluster send message failed",
|
||||
mlog.String("remote", task.rc.DisplayName),
|
||||
mlog.String("msgId", task.msg.Id),
|
||||
mlog.Err(err),
|
||||
)
|
||||
errResp = err
|
||||
} else {
|
||||
rcs.server.GetLogger().Log(mlog.LvlRemoteClusterServiceDebug, "Remote Cluster message sent successfully",
|
||||
mlog.String("remote", task.rc.DisplayName),
|
||||
mlog.String("msgId", task.msg.Id),
|
||||
)
|
||||
|
||||
if err = json.Unmarshal(respJSON, &response); err != nil {
|
||||
rcs.server.GetLogger().Error("Invalid response sending message to remote cluster",
|
||||
mlog.String("remote", task.rc.DisplayName),
|
||||
mlog.Err(err),
|
||||
)
|
||||
errResp = err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (rcs *Service) sendFrameToRemote(timeout time.Duration, rc *model.RemoteCluster, frame *model.RemoteClusterFrame, url string) ([]byte, error) {
|
||||
body, err := json.Marshal(frame)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set(model.HEADER_REMOTECLUSTER_ID, rc.RemoteId)
|
||||
req.Header.Set(model.HEADER_REMOTECLUSTER_TOKEN, rc.RemoteToken)
|
||||
|
||||
resp, err := rcs.httpClient.Do(req.WithContext(ctx))
|
||||
if metrics := rcs.server.GetMetrics(); metrics != nil {
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
metrics.IncrementRemoteClusterMsgErrorsCounter(frame.RemoteId, os.IsTimeout(err))
|
||||
} else {
|
||||
metrics.IncrementRemoteClusterMsgSentCounter(frame.RemoteId)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err = ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return body, fmt.Errorf("unexpected response: %d - %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
261
services/remotecluster/service.go
Normal file
261
services/remotecluster/service.go
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package remotecluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/einterfaces"
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/shared/mlog"
|
||||
"github.com/mattermost/mattermost-server/v5/store"
|
||||
)
|
||||
|
||||
const (
|
||||
SendChanBuffer = 50
|
||||
RecvChanBuffer = 50
|
||||
ResultsChanBuffer = 50
|
||||
ResultQueueDrainTimeoutMillis = 10000
|
||||
MaxConcurrentSends = 10
|
||||
SendMsgURL = "api/v4/remotecluster/msg"
|
||||
SendTimeout = time.Minute
|
||||
SendFileTimeout = time.Minute * 5
|
||||
PingURL = "api/v4/remotecluster/ping"
|
||||
PingFreq = time.Minute
|
||||
PingTimeout = time.Second * 15
|
||||
ConfirmInviteURL = "api/v4/remotecluster/confirm_invite"
|
||||
InvitationTopic = "invitation"
|
||||
PingTopic = "ping"
|
||||
ResponseStatusOK = model.STATUS_OK
|
||||
ResponseStatusFail = model.STATUS_FAIL
|
||||
InviteExpiresAfter = time.Hour * 48
|
||||
)
|
||||
|
||||
var (
|
||||
disablePing bool // override for testing
|
||||
)
|
||||
|
||||
type ServerIface interface {
|
||||
Config() *model.Config
|
||||
IsLeader() bool
|
||||
AddClusterLeaderChangedListener(listener func()) string
|
||||
RemoveClusterLeaderChangedListener(id string)
|
||||
GetStore() store.Store
|
||||
GetLogger() mlog.LoggerIFace
|
||||
GetMetrics() einterfaces.MetricsInterface
|
||||
}
|
||||
|
||||
// RemoteClusterServiceIFace is used to allow mocking where a remote cluster service is used (for testing).
|
||||
// Unfortunately it lives here because the shared channel service, app layer, and server interface all need it.
|
||||
// Putting it in app layer means shared channel service must import app package.
|
||||
type RemoteClusterServiceIFace interface {
|
||||
Shutdown() error
|
||||
Start() error
|
||||
Active() bool
|
||||
AddTopicListener(topic string, listener TopicListener) string
|
||||
RemoveTopicListener(listenerId string)
|
||||
AddConnectionStateListener(listener ConnectionStateListener) string
|
||||
RemoveConnectionStateListener(listenerId string)
|
||||
SendMsg(ctx context.Context, msg model.RemoteClusterMsg, rc *model.RemoteCluster, f SendMsgResultFunc) error
|
||||
SendFile(ctx context.Context, us *model.UploadSession, fi *model.FileInfo, rc *model.RemoteCluster, rp ReaderProvider, f SendFileResultFunc) error
|
||||
AcceptInvitation(invite *model.RemoteClusterInvite, name string, creatorId string, teamId string, siteURL string) (*model.RemoteCluster, error)
|
||||
ReceiveIncomingMsg(rc *model.RemoteCluster, msg model.RemoteClusterMsg) Response
|
||||
}
|
||||
|
||||
// TopicListener is a callback signature used to listen for incoming messages for
|
||||
// a specific topic.
|
||||
type TopicListener func(msg model.RemoteClusterMsg, rc *model.RemoteCluster, resp *Response) error
|
||||
|
||||
// ConnectionStateListener is used to listen to remote cluster connection state changes.
|
||||
type ConnectionStateListener func(rc *model.RemoteCluster, online bool)
|
||||
|
||||
// Service provides inter-cluster communication via topic based messages.
|
||||
type Service struct {
|
||||
server ServerIface
|
||||
httpClient *http.Client
|
||||
send []chan interface{}
|
||||
|
||||
// everything below guarded by `mux`
|
||||
mux sync.RWMutex
|
||||
active bool
|
||||
leaderListenerId string
|
||||
topicListeners map[string]map[string]TopicListener // maps topic id to a map of listenerid->listener
|
||||
connectionStateListeners map[string]ConnectionStateListener // maps listener id to listener
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// NewRemoteClusterService creates a RemoteClusterService instance.
|
||||
func NewRemoteClusterService(server ServerIface) (*Service, error) {
|
||||
transport := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
DualStack: true,
|
||||
}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 200,
|
||||
MaxIdleConnsPerHost: 2,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
DisableCompression: false,
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: SendTimeout,
|
||||
}
|
||||
|
||||
service := &Service{
|
||||
server: server,
|
||||
httpClient: client,
|
||||
topicListeners: make(map[string]map[string]TopicListener),
|
||||
connectionStateListeners: make(map[string]ConnectionStateListener),
|
||||
}
|
||||
|
||||
service.send = make([]chan interface{}, MaxConcurrentSends)
|
||||
for i := range service.send {
|
||||
service.send[i] = make(chan interface{}, SendChanBuffer)
|
||||
}
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// Start is called by the server on server start-up.
|
||||
func (rcs *Service) Start() error {
|
||||
rcs.mux.Lock()
|
||||
rcs.leaderListenerId = rcs.server.AddClusterLeaderChangedListener(rcs.onClusterLeaderChange)
|
||||
rcs.mux.Unlock()
|
||||
|
||||
rcs.onClusterLeaderChange()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown is called by the server on server shutdown.
|
||||
func (rcs *Service) Shutdown() error {
|
||||
rcs.server.RemoveClusterLeaderChangedListener(rcs.leaderListenerId)
|
||||
rcs.pause()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Active returns true if this instance of the remote cluster service is active.
|
||||
// The active instance is responsible for pinging and sending messages to remotes.
|
||||
func (rcs *Service) Active() bool {
|
||||
rcs.mux.Lock()
|
||||
defer rcs.mux.Unlock()
|
||||
return rcs.active
|
||||
}
|
||||
|
||||
// AddTopicListener registers a callback
|
||||
func (rcs *Service) AddTopicListener(topic string, listener TopicListener) string {
|
||||
rcs.mux.Lock()
|
||||
defer rcs.mux.Unlock()
|
||||
|
||||
id := model.NewId()
|
||||
|
||||
listeners, ok := rcs.topicListeners[topic]
|
||||
if !ok || listeners == nil {
|
||||
rcs.topicListeners[topic] = make(map[string]TopicListener)
|
||||
}
|
||||
rcs.topicListeners[topic][id] = listener
|
||||
return id
|
||||
}
|
||||
|
||||
func (rcs *Service) RemoveTopicListener(listenerId string) {
|
||||
rcs.mux.Lock()
|
||||
defer rcs.mux.Unlock()
|
||||
|
||||
for topic, listeners := range rcs.topicListeners {
|
||||
if _, ok := listeners[listenerId]; ok {
|
||||
delete(listeners, listenerId)
|
||||
if len(listeners) == 0 {
|
||||
delete(rcs.topicListeners, topic)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (rcs *Service) getTopicListeners(topic string) []TopicListener {
|
||||
rcs.mux.RLock()
|
||||
defer rcs.mux.RUnlock()
|
||||
|
||||
listeners, ok := rcs.topicListeners[topic]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
listenersCopy := make([]TopicListener, 0, len(listeners))
|
||||
for _, l := range listeners {
|
||||
listenersCopy = append(listenersCopy, l)
|
||||
}
|
||||
return listenersCopy
|
||||
}
|
||||
|
||||
func (rcs *Service) AddConnectionStateListener(listener ConnectionStateListener) string {
|
||||
id := model.NewId()
|
||||
|
||||
rcs.mux.Lock()
|
||||
defer rcs.mux.Unlock()
|
||||
|
||||
rcs.connectionStateListeners[id] = listener
|
||||
return id
|
||||
}
|
||||
|
||||
func (rcs *Service) RemoveConnectionStateListener(listenerId string) {
|
||||
rcs.mux.Lock()
|
||||
defer rcs.mux.Unlock()
|
||||
delete(rcs.connectionStateListeners, listenerId)
|
||||
}
|
||||
|
||||
// onClusterLeaderChange is called whenever the cluster leader may have changed.
|
||||
func (rcs *Service) onClusterLeaderChange() {
|
||||
if rcs.server.IsLeader() {
|
||||
rcs.resume()
|
||||
} else {
|
||||
rcs.pause()
|
||||
}
|
||||
}
|
||||
|
||||
func (rcs *Service) resume() {
|
||||
rcs.mux.Lock()
|
||||
defer rcs.mux.Unlock()
|
||||
|
||||
if rcs.active {
|
||||
return // already active
|
||||
}
|
||||
rcs.active = true
|
||||
rcs.done = make(chan struct{})
|
||||
|
||||
if !disablePing {
|
||||
rcs.pingLoop(rcs.done)
|
||||
}
|
||||
|
||||
// create thread pool for concurrent message sending.
|
||||
for i := range rcs.send {
|
||||
go rcs.sendLoop(i, rcs.done)
|
||||
}
|
||||
|
||||
rcs.server.GetLogger().Debug("Remote Cluster Service active")
|
||||
}
|
||||
|
||||
func (rcs *Service) pause() {
|
||||
rcs.mux.Lock()
|
||||
defer rcs.mux.Unlock()
|
||||
|
||||
if !rcs.active {
|
||||
return // already inactive
|
||||
}
|
||||
rcs.active = false
|
||||
close(rcs.done)
|
||||
rcs.done = nil
|
||||
|
||||
rcs.server.GetLogger().Debug("Remote Cluster Service inactive")
|
||||
}
|
||||
71
services/remotecluster/service_test.go
Normal file
71
services/remotecluster/service_test.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package remotecluster
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
)
|
||||
|
||||
func TestService_AddTopicListener(t *testing.T) {
|
||||
var count int32
|
||||
|
||||
l1 := func(msg model.RemoteClusterMsg, rc *model.RemoteCluster, resp *Response) error {
|
||||
atomic.AddInt32(&count, 1)
|
||||
return nil
|
||||
}
|
||||
l2 := func(msg model.RemoteClusterMsg, rc *model.RemoteCluster, resp *Response) error {
|
||||
atomic.AddInt32(&count, 1)
|
||||
return nil
|
||||
}
|
||||
l3 := func(msg model.RemoteClusterMsg, rc *model.RemoteCluster, resp *Response) error {
|
||||
atomic.AddInt32(&count, 1)
|
||||
return nil
|
||||
}
|
||||
|
||||
mockServer := newMockServer(t, makeRemoteClusters(NumRemotes, ""))
|
||||
service, err := NewRemoteClusterService(mockServer)
|
||||
require.NoError(t, err)
|
||||
|
||||
l1id := service.AddTopicListener("test", l1)
|
||||
l2id := service.AddTopicListener("test", l2)
|
||||
l3id := service.AddTopicListener("different", l3)
|
||||
|
||||
listeners := service.getTopicListeners("test")
|
||||
assert.Len(t, listeners, 2)
|
||||
|
||||
rc := &model.RemoteCluster{}
|
||||
msg1 := model.RemoteClusterMsg{Topic: "test"}
|
||||
msg2 := model.RemoteClusterMsg{Topic: "different"}
|
||||
|
||||
service.ReceiveIncomingMsg(rc, msg1)
|
||||
assert.Equal(t, int32(2), atomic.LoadInt32(&count))
|
||||
|
||||
service.ReceiveIncomingMsg(rc, msg2)
|
||||
assert.Equal(t, int32(3), atomic.LoadInt32(&count))
|
||||
|
||||
service.RemoveTopicListener(l1id)
|
||||
service.ReceiveIncomingMsg(rc, msg1)
|
||||
assert.Equal(t, int32(4), atomic.LoadInt32(&count))
|
||||
|
||||
service.RemoveTopicListener(l2id)
|
||||
service.ReceiveIncomingMsg(rc, msg1)
|
||||
assert.Equal(t, int32(4), atomic.LoadInt32(&count))
|
||||
|
||||
service.ReceiveIncomingMsg(rc, msg2)
|
||||
assert.Equal(t, int32(5), atomic.LoadInt32(&count))
|
||||
|
||||
service.RemoveTopicListener(l3id)
|
||||
service.ReceiveIncomingMsg(rc, msg1)
|
||||
service.ReceiveIncomingMsg(rc, msg2)
|
||||
assert.Equal(t, int32(5), atomic.LoadInt32(&count))
|
||||
|
||||
listeners = service.getTopicListeners("test")
|
||||
assert.Empty(t, listeners)
|
||||
}
|
||||
183
services/sharedchannel/attachment.go
Normal file
183
services/sharedchannel/attachment.go
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
// 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/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/services/remotecluster"
|
||||
"github.com/mattermost/mattermost-server/v5/shared/mlog"
|
||||
)
|
||||
|
||||
// postToAttachments returns the file attachments for a post that need to be synchronized.
|
||||
func (scs *Service) postToAttachments(post *model.Post, rc *model.RemoteCluster) ([]*model.FileInfo, error) {
|
||||
infos := make([]*model.FileInfo, 0)
|
||||
|
||||
fis, err := scs.server.GetStore().FileInfo().GetForPost(post.Id, false, true, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get file info for attachment: %w", err)
|
||||
}
|
||||
|
||||
for _, fi := range fis {
|
||||
if scs.shouldSyncAttachment(fi, rc) {
|
||||
infos = append(infos, fi)
|
||||
}
|
||||
}
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
// 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.GetLogger().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.GetLogger().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.GetLogger().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.GetLogger().Log(mlog.LvlSharedChannelServiceError, "error saving SharedChannelAttachment",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.String("uploadId", usResp.Id),
|
||||
mlog.Err(err2),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
scs.server.GetLogger().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(&us)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
response.SetPayload(usSaved)
|
||||
return nil
|
||||
}
|
||||
220
services/sharedchannel/channelinvite.go
Normal file
220
services/sharedchannel/channelinvite.go
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
// 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/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/services/remotecluster"
|
||||
"github.com/mattermost/mattermost-server/v5/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 string `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, description 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,
|
||||
Description: description,
|
||||
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.GetLogger().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(channel)
|
||||
return fmt.Errorf("cannot create shared channel (channel_id=%s): %w", invite.ChannelId, err)
|
||||
}
|
||||
|
||||
sharedChannelRemote := &model.SharedChannelRemote{
|
||||
Id: model.NewId(),
|
||||
ChannelId: channel.Id,
|
||||
Description: invite.DisplayName,
|
||||
CreatorId: channel.CreatorId,
|
||||
IsInviteAccepted: true,
|
||||
IsInviteConfirmed: true,
|
||||
RemoteId: rc.RemoteId,
|
||||
}
|
||||
|
||||
if _, err := scs.server.GetStore().SharedChannel().SaveRemote(sharedChannelRemote); err != nil {
|
||||
scs.app.PermanentDeleteChannel(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.CHANNEL_DIRECT {
|
||||
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(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(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
|
||||
}
|
||||
197
services/sharedchannel/channelinvite_test.go
Normal file
197
services/sharedchannel/channelinvite_test.go
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
// 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/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/plugin/plugintest/mock"
|
||||
"github.com/mattermost/mattermost-server/v5/shared/mlog"
|
||||
"github.com/mattermost/mattermost-server/v5/store/storetest/mocks"
|
||||
)
|
||||
|
||||
type mockLogger struct {
|
||||
mlog.LoggerIFace
|
||||
}
|
||||
|
||||
func (ml *mockLogger) Log(level mlog.LogLevel, s string, flds ...mlog.Field) {}
|
||||
|
||||
func TestOnReceiveChannelInvite(t *testing.T) {
|
||||
t.Run("when msg payload is empty, it does nothing", func(t *testing.T) {
|
||||
mockServer := &MockServerIface{}
|
||||
mockLogger := &mockLogger{}
|
||||
mockServer.On("GetLogger").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 := &mockLogger{}
|
||||
mockServer.On("GetLogger").Return(mockLogger)
|
||||
mockApp := &MockAppIface{}
|
||||
scs := &Service{
|
||||
server: mockServer,
|
||||
app: mockApp,
|
||||
}
|
||||
|
||||
mockStore := &mocks.Store{}
|
||||
remoteCluster := &model.RemoteCluster{DisplayName: "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.PERMISSION_CREATE_POST.Id]
|
||||
createReactionPermission := model.ChannelModeratedPermissionsMap[model.PERMISSION_ADD_REACTION.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", 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 := &mockLogger{}
|
||||
mockServer.On("GetLogger").Return(mockLogger)
|
||||
mockApp := &MockAppIface{}
|
||||
scs := &Service{
|
||||
server: mockServer,
|
||||
app: mockApp,
|
||||
}
|
||||
|
||||
mockStore := &mocks.Store{}
|
||||
remoteCluster := &model.RemoteCluster{DisplayName: "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{}
|
||||
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", 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 := &mockLogger{}
|
||||
mockServer.On("GetLogger").Return(mockLogger)
|
||||
mockApp := &MockAppIface{}
|
||||
scs := &Service{
|
||||
server: mockServer,
|
||||
app: mockApp,
|
||||
}
|
||||
|
||||
mockStore := &mocks.Store{}
|
||||
remoteCluster := &model.RemoteCluster{DisplayName: "test", CreatorId: model.NewId()}
|
||||
invitation := channelInviteMsg{
|
||||
ChannelId: model.NewId(),
|
||||
TeamId: model.NewId(),
|
||||
ReadOnly: false,
|
||||
Type: model.CHANNEL_DIRECT,
|
||||
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", 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)
|
||||
})
|
||||
}
|
||||
83
services/sharedchannel/getpostssince.go
Normal file
83
services/sharedchannel/getpostssince.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sharedchannel
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/shared/mlog"
|
||||
)
|
||||
|
||||
type sinceResult struct {
|
||||
posts []*model.Post
|
||||
hasMore bool
|
||||
nextSince int64
|
||||
}
|
||||
|
||||
// getPostsSince fetches posts that need to be synchronized with a remote cluster.
|
||||
// There is a soft cap on the number of posts that will be synchronized in a single pass (MaxPostsPerSync).
|
||||
//
|
||||
// There is a special case where multiple posts have the same UpdateAt value. It is vital that this method
|
||||
// include all posts within that millisecond so that subsequent calls can use an incremented `since`. If this
|
||||
// method were to be called repeatedly with the same `since` value the same records would be returned each time
|
||||
// and the sync would never move forward.
|
||||
//
|
||||
// A boolean is also returned to indicate if there are more posts to be synchronized (true) or not (false).
|
||||
func (scs *Service) getPostsSince(channelId string, rc *model.RemoteCluster, since int64) (sinceResult, error) {
|
||||
opts := model.GetPostsSinceForSyncOptions{
|
||||
ChannelId: channelId,
|
||||
Since: since,
|
||||
IncludeDeleted: true,
|
||||
Limit: MaxPostsPerSync + 1, // ask for 1 more than needed to peek at first post in next batch
|
||||
}
|
||||
posts, err := scs.server.GetStore().Post().GetPostsSinceForSync(opts, true)
|
||||
if err != nil {
|
||||
return sinceResult{}, err
|
||||
}
|
||||
|
||||
if len(posts) == 0 {
|
||||
return sinceResult{nextSince: since}, nil
|
||||
}
|
||||
|
||||
var hasMore bool
|
||||
if len(posts) > MaxPostsPerSync {
|
||||
hasMore = true
|
||||
peekUpdateAt := posts[len(posts)-1].UpdateAt
|
||||
posts = posts[:MaxPostsPerSync] // trim the peeked at record
|
||||
|
||||
// If the last post to be synchronized has the same Update value as the first post in the next batch
|
||||
// then we need to grab the rest of the posts for that millisecond to ensure the next call can have an
|
||||
// incremented `since`.
|
||||
if peekUpdateAt == posts[len(posts)-1].UpdateAt {
|
||||
opts.Since = peekUpdateAt
|
||||
opts.Until = opts.Since
|
||||
opts.Limit = 1000
|
||||
opts.Offset = countPostsAtMillisecond(posts, peekUpdateAt)
|
||||
|
||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceDebug, "getPostsSince handling updateAt collision",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.Int64("update_at", peekUpdateAt),
|
||||
mlog.Int("offset", opts.Offset),
|
||||
)
|
||||
|
||||
morePosts, err := scs.server.GetStore().Post().GetPostsSinceForSync(opts, true)
|
||||
if err != nil {
|
||||
return sinceResult{}, err
|
||||
}
|
||||
posts = append(posts, morePosts...)
|
||||
}
|
||||
}
|
||||
return sinceResult{posts: posts, hasMore: hasMore, nextSince: posts[len(posts)-1].UpdateAt + 1}, nil
|
||||
}
|
||||
|
||||
func countPostsAtMillisecond(posts []*model.Post, milli int64) int {
|
||||
// walk backward through the slice until we find a post with UpdateAt that differs from milli.
|
||||
var count int
|
||||
for i := len(posts) - 1; i >= 0; i-- {
|
||||
if posts[i].UpdateAt != milli {
|
||||
return count
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
338
services/sharedchannel/mock_AppIface_test.go
Normal file
338
services/sharedchannel/mock_AppIface_test.go
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
// Code generated by mockery v1.0.0. DO NOT EDIT.
|
||||
|
||||
// Regenerate this file using `make sharedchannel-mocks`.
|
||||
|
||||
package sharedchannel
|
||||
|
||||
import (
|
||||
filestore "github.com/mattermost/mattermost-server/v5/shared/filestore"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
|
||||
model "github.com/mattermost/mattermost-server/v5/model"
|
||||
)
|
||||
|
||||
// MockAppIface is an autogenerated mock type for the AppIface type
|
||||
type MockAppIface struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// AddUserToChannel provides a mock function with given fields: user, channel
|
||||
func (_m *MockAppIface) AddUserToChannel(user *model.User, channel *model.Channel) (*model.ChannelMember, *model.AppError) {
|
||||
ret := _m.Called(user, channel)
|
||||
|
||||
var r0 *model.ChannelMember
|
||||
if rf, ok := ret.Get(0).(func(*model.User, *model.Channel) *model.ChannelMember); ok {
|
||||
r0 = rf(user, channel)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.ChannelMember)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(1).(func(*model.User, *model.Channel) *model.AppError); ok {
|
||||
r1 = rf(user, channel)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// AddUserToTeamByTeamId provides a mock function with given fields: teamId, user
|
||||
func (_m *MockAppIface) AddUserToTeamByTeamId(teamId string, user *model.User) *model.AppError {
|
||||
ret := _m.Called(teamId, user)
|
||||
|
||||
var r0 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(string, *model.User) *model.AppError); ok {
|
||||
r0 = rf(teamId, user)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// CreateChannelWithUser provides a mock function with given fields: channel, userId
|
||||
func (_m *MockAppIface) CreateChannelWithUser(channel *model.Channel, userId string) (*model.Channel, *model.AppError) {
|
||||
ret := _m.Called(channel, userId)
|
||||
|
||||
var r0 *model.Channel
|
||||
if rf, ok := ret.Get(0).(func(*model.Channel, string) *model.Channel); ok {
|
||||
r0 = rf(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(*model.Channel, string) *model.AppError); ok {
|
||||
r1 = rf(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: post, channel, triggerWebhooks, setOnline
|
||||
func (_m *MockAppIface) CreatePost(post *model.Post, channel *model.Channel, triggerWebhooks bool, setOnline bool) (*model.Post, *model.AppError) {
|
||||
ret := _m.Called(post, channel, triggerWebhooks, setOnline)
|
||||
|
||||
var r0 *model.Post
|
||||
if rf, ok := ret.Get(0).(func(*model.Post, *model.Channel, bool, bool) *model.Post); ok {
|
||||
r0 = rf(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(*model.Post, *model.Channel, bool, bool) *model.AppError); ok {
|
||||
r1 = rf(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: us
|
||||
func (_m *MockAppIface) CreateUploadSession(us *model.UploadSession) (*model.UploadSession, *model.AppError) {
|
||||
ret := _m.Called(us)
|
||||
|
||||
var r0 *model.UploadSession
|
||||
if rf, ok := ret.Get(0).(func(*model.UploadSession) *model.UploadSession); ok {
|
||||
r0 = rf(us)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.UploadSession)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(1).(func(*model.UploadSession) *model.AppError); ok {
|
||||
r1 = rf(us)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// DeletePost provides a mock function with given fields: postID, deleteByID
|
||||
func (_m *MockAppIface) DeletePost(postID string, deleteByID string) (*model.Post, *model.AppError) {
|
||||
ret := _m.Called(postID, deleteByID)
|
||||
|
||||
var r0 *model.Post
|
||||
if rf, ok := ret.Get(0).(func(string, string) *model.Post); ok {
|
||||
r0 = rf(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(string, string) *model.AppError); ok {
|
||||
r1 = rf(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: reaction
|
||||
func (_m *MockAppIface) DeleteReactionForPost(reaction *model.Reaction) *model.AppError {
|
||||
ret := _m.Called(reaction)
|
||||
|
||||
var r0 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(*model.Reaction) *model.AppError); ok {
|
||||
r0 = rf(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: userId, otherUserId, channelOptions
|
||||
func (_m *MockAppIface) GetOrCreateDirectChannel(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, userId, otherUserId)
|
||||
_ca = append(_ca, _va...)
|
||||
ret := _m.Called(_ca...)
|
||||
|
||||
var r0 *model.Channel
|
||||
if rf, ok := ret.Get(0).(func(string, string, ...model.ChannelOption) *model.Channel); ok {
|
||||
r0 = rf(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(string, string, ...model.ChannelOption) *model.AppError); ok {
|
||||
r1 = rf(userId, otherUserId, channelOptions...)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// PatchChannelModerationsForChannel provides a mock function with given fields: channel, channelModerationsPatch
|
||||
func (_m *MockAppIface) PatchChannelModerationsForChannel(channel *model.Channel, channelModerationsPatch []*model.ChannelModerationPatch) ([]*model.ChannelModeration, *model.AppError) {
|
||||
ret := _m.Called(channel, channelModerationsPatch)
|
||||
|
||||
var r0 []*model.ChannelModeration
|
||||
if rf, ok := ret.Get(0).(func(*model.Channel, []*model.ChannelModerationPatch) []*model.ChannelModeration); ok {
|
||||
r0 = rf(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(*model.Channel, []*model.ChannelModerationPatch) *model.AppError); ok {
|
||||
r1 = rf(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: channel
|
||||
func (_m *MockAppIface) PermanentDeleteChannel(channel *model.Channel) *model.AppError {
|
||||
ret := _m.Called(channel)
|
||||
|
||||
var r0 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(*model.Channel) *model.AppError); ok {
|
||||
r0 = rf(channel)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// SaveReactionForPost provides a mock function with given fields: reaction
|
||||
func (_m *MockAppIface) SaveReactionForPost(reaction *model.Reaction) (*model.Reaction, *model.AppError) {
|
||||
ret := _m.Called(reaction)
|
||||
|
||||
var r0 *model.Reaction
|
||||
if rf, ok := ret.Get(0).(func(*model.Reaction) *model.Reaction); ok {
|
||||
r0 = rf(reaction)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.Reaction)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(1).(func(*model.Reaction) *model.AppError); ok {
|
||||
r1 = rf(reaction)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// SendEphemeralPost provides a mock function with given fields: userId, post
|
||||
func (_m *MockAppIface) SendEphemeralPost(userId string, post *model.Post) *model.Post {
|
||||
ret := _m.Called(userId, post)
|
||||
|
||||
var r0 *model.Post
|
||||
if rf, ok := ret.Get(0).(func(string, *model.Post) *model.Post); ok {
|
||||
r0 = rf(userId, post)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.Post)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// UpdatePost provides a mock function with given fields: post, safeUpdate
|
||||
func (_m *MockAppIface) UpdatePost(post *model.Post, safeUpdate bool) (*model.Post, *model.AppError) {
|
||||
ret := _m.Called(post, safeUpdate)
|
||||
|
||||
var r0 *model.Post
|
||||
if rf, ok := ret.Get(0).(func(*model.Post, bool) *model.Post); ok {
|
||||
r0 = rf(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(*model.Post, bool) *model.AppError); ok {
|
||||
r1 = rf(post, safeUpdate)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
118
services/sharedchannel/mock_ServerIface_test.go
Normal file
118
services/sharedchannel/mock_ServerIface_test.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// Code generated by mockery v1.0.0. DO NOT EDIT.
|
||||
|
||||
// Regenerate this file using `make sharedchannel-mocks`.
|
||||
|
||||
package sharedchannel
|
||||
|
||||
import (
|
||||
mlog "github.com/mattermost/mattermost-server/v5/shared/mlog"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
|
||||
model "github.com/mattermost/mattermost-server/v5/model"
|
||||
|
||||
remotecluster "github.com/mattermost/mattermost-server/v5/services/remotecluster"
|
||||
|
||||
store "github.com/mattermost/mattermost-server/v5/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
|
||||
}
|
||||
|
||||
// GetLogger provides a mock function with given fields:
|
||||
func (_m *MockServerIface) GetLogger() mlog.LoggerIFace {
|
||||
ret := _m.Called()
|
||||
|
||||
var r0 mlog.LoggerIFace
|
||||
if rf, ok := ret.Get(0).(func() mlog.LoggerIFace); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(mlog.LoggerIFace)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// RemoveClusterLeaderChangedListener provides a mock function with given fields: id
|
||||
func (_m *MockServerIface) RemoveClusterLeaderChangedListener(id string) {
|
||||
_m.Called(id)
|
||||
}
|
||||
216
services/sharedchannel/msg.go
Normal file
216
services/sharedchannel/msg.go
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sharedchannel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/shared/mlog"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
ChannelId string `json:"channel_id"`
|
||||
PostId string `json:"post_id"`
|
||||
Post *model.Post `json:"post"`
|
||||
Users []*model.User `json:"users"`
|
||||
Reactions []*model.Reaction `json:"reactions"`
|
||||
Attachments []*model.FileInfo `json:"-"`
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
type userCache map[string]struct{}
|
||||
|
||||
func (u userCache) Has(id string) bool {
|
||||
_, ok := u[id]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (u userCache) Add(id string) {
|
||||
u[id] = struct{}{}
|
||||
}
|
||||
|
||||
// postsToSyncMessages takes a slice of posts and converts to a `RemoteClusterMsg` which can be
|
||||
// sent to a remote cluster.
|
||||
func (scs *Service) postsToSyncMessages(posts []*model.Post, rc *model.RemoteCluster, nextSyncAt int64) ([]syncMsg, error) {
|
||||
syncMessages := make([]syncMsg, 0, len(posts))
|
||||
|
||||
uCache := make(userCache)
|
||||
|
||||
for _, p := range posts {
|
||||
if p.IsSystemMessage() { // don't sync system messages
|
||||
continue
|
||||
}
|
||||
|
||||
// any reactions originating from the remote cluster are filtered out
|
||||
reactions, err := scs.server.GetStore().Reaction().GetForPostSince(p.Id, nextSyncAt, rc.RemoteId, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
postSync := p
|
||||
|
||||
// Don't resend an existing post where only the reactions changed.
|
||||
// Posts we must send:
|
||||
// - new posts (EditAt == 0)
|
||||
// - edited posts (EditAt >= nextSyncAt)
|
||||
// - deleted posts (DeleteAt > 0)
|
||||
if p.EditAt > 0 && p.EditAt < nextSyncAt && p.DeleteAt == 0 {
|
||||
postSync = nil
|
||||
}
|
||||
|
||||
// Don't send a deleted post if it is just the original copy from an edit.
|
||||
if p.DeleteAt > 0 && p.OriginalId != "" {
|
||||
postSync = nil
|
||||
}
|
||||
|
||||
// don't sync a post back to the remote it came from.
|
||||
if p.RemoteId != nil && *p.RemoteId == rc.RemoteId {
|
||||
postSync = nil
|
||||
}
|
||||
|
||||
var attachments []*model.FileInfo
|
||||
if postSync != nil {
|
||||
// parse out all permalinks in the message.
|
||||
postSync.Message = scs.processPermalinkToRemote(postSync)
|
||||
|
||||
// get any file attachments
|
||||
attachments, err = scs.postToAttachments(postSync, rc)
|
||||
if err != nil {
|
||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceError, "Could not fetch attachments for post",
|
||||
mlog.String("post_id", postSync.Id),
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// any users originating from the remote cluster are filtered out
|
||||
users := scs.usersForPost(postSync, reactions, rc, uCache)
|
||||
|
||||
// if everything was filtered out then don't send an empty message.
|
||||
if postSync == nil && len(reactions) == 0 && len(users) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
sm := syncMsg{
|
||||
ChannelId: p.ChannelId,
|
||||
PostId: p.Id,
|
||||
Post: postSync,
|
||||
Users: users,
|
||||
Reactions: reactions,
|
||||
Attachments: attachments,
|
||||
}
|
||||
syncMessages = append(syncMessages, sm)
|
||||
}
|
||||
return syncMessages, nil
|
||||
}
|
||||
|
||||
// usersForPost provides a list of Users associated with the post that need to be synchronized.
|
||||
// The user cache ensures the same user is not synchronized redundantly if they appear in multiple
|
||||
// posts for this sync batch.
|
||||
func (scs *Service) usersForPost(post *model.Post, reactions []*model.Reaction, rc *model.RemoteCluster, uCache userCache) []*model.User {
|
||||
userIds := make([]string, 0)
|
||||
|
||||
if post != nil && !uCache.Has(post.UserId) {
|
||||
userIds = append(userIds, post.UserId)
|
||||
uCache.Add(post.UserId)
|
||||
}
|
||||
|
||||
for _, r := range reactions {
|
||||
if !uCache.Has(r.UserId) {
|
||||
userIds = append(userIds, r.UserId)
|
||||
uCache.Add(r.UserId)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: extract @mentions to local users and sync those as well?
|
||||
|
||||
users := make([]*model.User, 0)
|
||||
|
||||
for _, id := range userIds {
|
||||
user, err := scs.server.GetStore().User().Get(context.Background(), id)
|
||||
if err == nil {
|
||||
if sync, err2 := scs.shouldUserSync(user, rc); err2 != nil {
|
||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceError, "Could not find user for post",
|
||||
mlog.String("user_id", id),
|
||||
mlog.Err(err2))
|
||||
continue
|
||||
} else if sync {
|
||||
users = append(users, sanitizeUserForSync(user))
|
||||
}
|
||||
} else {
|
||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceError, "Error checking if user should sync",
|
||||
mlog.String("user_id", id),
|
||||
mlog.Err(err))
|
||||
}
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
func sanitizeUserForSync(user *model.User) *model.User {
|
||||
user.Password = model.NewId()
|
||||
user.AuthData = nil
|
||||
user.AuthService = ""
|
||||
user.Roles = "system_user"
|
||||
user.AllowMarketing = false
|
||||
user.Props = model.StringMap{}
|
||||
user.NotifyProps = model.StringMap{}
|
||||
user.LastPasswordUpdate = 0
|
||||
user.LastPictureUpdate = 0
|
||||
user.FailedAttempts = 0
|
||||
user.MfaActive = false
|
||||
user.MfaSecret = ""
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
// shouldUserSync determines if a user needs to be synchronized.
|
||||
// User should be synchronized if it has no entry in the SharedChannelUsers table,
|
||||
// or there is an entry but the LastSyncAt is less than user.UpdateAt
|
||||
func (scs *Service) shouldUserSync(user *model.User, rc *model.RemoteCluster) (bool, error) {
|
||||
// don't sync users with the remote they originated from.
|
||||
if user.RemoteId != nil && *user.RemoteId == rc.RemoteId {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
scu, err := scs.server.GetStore().SharedChannel().GetUser(user.Id, rc.RemoteId)
|
||||
if err != nil {
|
||||
if _, ok := err.(errNotFound); !ok {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// user not in the SharedChannelUsers table, so we must add them.
|
||||
scu = &model.SharedChannelUser{
|
||||
UserId: user.Id,
|
||||
RemoteId: rc.RemoteId,
|
||||
}
|
||||
if _, err = scs.server.GetStore().SharedChannel().SaveUser(scu); err != nil {
|
||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceError, "Error adding user to shared channel users",
|
||||
mlog.String("remote_id", rc.RemoteId),
|
||||
mlog.String("user_id", user.Id),
|
||||
)
|
||||
}
|
||||
} else if scu.LastSyncAt >= user.UpdateAt {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
81
services/sharedchannel/permalink.go
Normal file
81
services/sharedchannel/permalink.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// 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/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/shared/i18n"
|
||||
"github.com/mattermost/mattermost-server/v5/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:]
|
||||
postList, err := scs.server.GetStore().Post().Get(context.Background(), postID, true, false, false, "")
|
||||
if err != nil {
|
||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceWarn, "Unable to get post during replacing permalinks", mlog.Err(err))
|
||||
return msg
|
||||
}
|
||||
if len(postList.Order) == 0 {
|
||||
scs.server.GetLogger().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.GetLogger().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)
|
||||
})
|
||||
}
|
||||
110
services/sharedchannel/permalink_test.go
Normal file
110
services/sharedchannel/permalink_test.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
// 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/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/plugin/plugintest/mock"
|
||||
"github.com/mattermost/mattermost-server/v5/store/storetest/mocks"
|
||||
"github.com/mattermost/mattermost-server/v5/utils"
|
||||
)
|
||||
|
||||
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", true, false, false, "").Return(pl, nil)
|
||||
|
||||
mockStore.On("Post").Return(&mockPostStore)
|
||||
|
||||
mockServer := scs.server.(*MockServerIface)
|
||||
mockServer.On("GetStore").Return(mockStore)
|
||||
|
||||
mockApp := scs.app.(*MockAppIface)
|
||||
mockApp.On("SendEphemeralPost", "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)
|
||||
})
|
||||
}
|
||||
10
services/sharedchannel/response.go
Normal file
10
services/sharedchannel/response.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sharedchannel
|
||||
|
||||
type SyncResponse struct {
|
||||
LastSyncAt int64 `json:"last_sync_at"`
|
||||
PostErrors []string `json:"post_errors"`
|
||||
UsersSyncd []string `json:"users_syncd"`
|
||||
}
|
||||
239
services/sharedchannel/service.go
Normal file
239
services/sharedchannel/service.go
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
// 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/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/services/remotecluster"
|
||||
"github.com/mattermost/mattermost-server/v5/shared/filestore"
|
||||
"github.com/mattermost/mattermost-server/v5/shared/mlog"
|
||||
"github.com/mattermost/mattermost-server/v5/store"
|
||||
)
|
||||
|
||||
const (
|
||||
TopicSync = "sharedchannel_sync"
|
||||
TopicChannelInvite = "sharedchannel_invite"
|
||||
TopicUploadCreate = "sharedchannel_upload"
|
||||
MaxRetries = 3
|
||||
MaxPostsPerSync = 12 // a bit more than one typical screenfull of posts
|
||||
NotifyRemoteOfflineThreshold = time.Second * 10
|
||||
NotifyMinimumDelay = time.Second * 2
|
||||
)
|
||||
|
||||
// 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
|
||||
GetLogger() mlog.LoggerIFace
|
||||
GetRemoteClusterService() remotecluster.RemoteClusterServiceIFace
|
||||
}
|
||||
|
||||
type AppIface interface {
|
||||
SendEphemeralPost(userId string, post *model.Post) *model.Post
|
||||
CreateChannelWithUser(channel *model.Channel, userId string) (*model.Channel, *model.AppError)
|
||||
GetOrCreateDirectChannel(userId, otherUserId string, channelOptions ...model.ChannelOption) (*model.Channel, *model.AppError)
|
||||
AddUserToChannel(user *model.User, channel *model.Channel) (*model.ChannelMember, *model.AppError)
|
||||
AddUserToTeamByTeamId(teamId string, user *model.User) *model.AppError
|
||||
PermanentDeleteChannel(channel *model.Channel) *model.AppError
|
||||
CreatePost(post *model.Post, channel *model.Channel, triggerWebhooks bool, setOnline bool) (savedPost *model.Post, err *model.AppError)
|
||||
UpdatePost(post *model.Post, safeUpdate bool) (*model.Post, *model.AppError)
|
||||
DeletePost(postID, deleteByID string) (*model.Post, *model.AppError)
|
||||
SaveReactionForPost(reaction *model.Reaction) (*model.Reaction, *model.AppError)
|
||||
DeleteReactionForPost(reaction *model.Reaction) *model.AppError
|
||||
PatchChannelModerationsForChannel(channel *model.Channel, channelModerationsPatch []*model.ChannelModerationPatch) ([]*model.ChannelModeration, *model.AppError)
|
||||
CreateUploadSession(us *model.UploadSession) (*model.UploadSession, *model.AppError)
|
||||
FileReader(path string) (filestore.ReadCloseSeeker, *model.AppError)
|
||||
}
|
||||
|
||||
// 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 interface{})
|
||||
}
|
||||
|
||||
// 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(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.GetLogger().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.GetLogger().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.PERMISSION_CREATE_POST.Id]
|
||||
createReactionPermission := model.ChannelModeratedPermissionsMap[model.PERMISSION_ADD_REACTION.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(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.GetLogger().Log(mlog.LvlSharedChannelServiceDebug, "Remote cluster connection status changed",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.String("remoteId", rc.RemoteId),
|
||||
mlog.Bool("online", online),
|
||||
)
|
||||
}
|
||||
296
services/sharedchannel/sync_recv.go
Normal file
296
services/sharedchannel/sync_recv.go
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sharedchannel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/services/remotecluster"
|
||||
"github.com/mattermost/mattermost-server/v5/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.GetLogger().IsLevelEnabled(mlog.LvlSharedChannelServiceMessagesInbound) {
|
||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceMessagesInbound, "inbound message",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.String("msg", string(msg.Payload)),
|
||||
)
|
||||
}
|
||||
|
||||
var syncMessages []syncMsg
|
||||
|
||||
if err := json.Unmarshal(msg.Payload, &syncMessages); err != nil {
|
||||
return fmt.Errorf("invalid sync message: %w", err)
|
||||
}
|
||||
|
||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceDebug, "Batch of sync messages received",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.Int("sync_msg_count", len(syncMessages)),
|
||||
)
|
||||
|
||||
return scs.processSyncMessages(syncMessages, rc, response)
|
||||
}
|
||||
|
||||
func (scs *Service) processSyncMessages(syncMessages []syncMsg, rc *model.RemoteCluster, response *remotecluster.Response) error {
|
||||
var channel *model.Channel
|
||||
var team *model.Team
|
||||
|
||||
postErrors := make([]string, 0)
|
||||
usersSyncd := make([]string, 0)
|
||||
var lastSyncAt int64
|
||||
var err error
|
||||
|
||||
for _, sm := range syncMessages {
|
||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceDebug, "Sync msg received",
|
||||
mlog.String("post_id", sm.PostId),
|
||||
mlog.String("channel_id", sm.ChannelId),
|
||||
mlog.Int("reaction_count", len(sm.Reactions)),
|
||||
mlog.Int("user_count", len(sm.Users)),
|
||||
mlog.Bool("has_post", sm.Post != nil),
|
||||
)
|
||||
|
||||
if channel == nil {
|
||||
if channel, err = scs.server.GetStore().Channel().Get(sm.ChannelId, true); err != nil {
|
||||
// if the channel doesn't exist then none of these sync messages are going to work.
|
||||
return fmt.Errorf("channel not found processing sync messages: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// add/update users before posts
|
||||
for _, user := range sm.Users {
|
||||
if userSaved, err := scs.upsertSyncUser(user, channel, rc); err != nil {
|
||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceError, "Error upserting sync user",
|
||||
mlog.String("post_id", sm.PostId),
|
||||
mlog.String("channel_id", sm.ChannelId),
|
||||
mlog.String("user_id", user.Id),
|
||||
mlog.Err(err))
|
||||
} else {
|
||||
usersSyncd = append(usersSyncd, userSaved.Id)
|
||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceDebug, "User upserted via sync",
|
||||
mlog.String("post_id", sm.PostId),
|
||||
mlog.String("channel_id", sm.ChannelId),
|
||||
mlog.String("user_id", user.Id),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if sm.Post != nil {
|
||||
if sm.ChannelId != sm.Post.ChannelId {
|
||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceError, "ChannelId mismatch",
|
||||
mlog.String("sm.ChannelId", sm.ChannelId),
|
||||
mlog.String("sm.Post.ChannelId", sm.Post.ChannelId),
|
||||
mlog.String("PostId", sm.Post.Id),
|
||||
)
|
||||
postErrors = append(postErrors, sm.Post.Id)
|
||||
continue
|
||||
}
|
||||
|
||||
if channel.Type != model.CHANNEL_DIRECT && team == nil {
|
||||
var err2 error
|
||||
team, err2 = scs.server.GetStore().Channel().GetTeamForChannel(sm.ChannelId)
|
||||
if err2 != nil {
|
||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceError, "Error getting Team for Channel",
|
||||
mlog.String("ChannelId", sm.Post.ChannelId),
|
||||
mlog.String("PostId", sm.Post.Id),
|
||||
mlog.Err(err2),
|
||||
)
|
||||
postErrors = append(postErrors, sm.Post.Id)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// process perma-links for remote
|
||||
if team != nil {
|
||||
sm.Post.Message = scs.processPermalinkFromRemote(sm.Post, team)
|
||||
}
|
||||
|
||||
// add/update post (may be nil if only reactions changed)
|
||||
rpost, err := scs.upsertSyncPost(sm.Post, channel, rc)
|
||||
if err != nil {
|
||||
postErrors = append(postErrors, sm.Post.Id)
|
||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceError, "Error upserting sync post",
|
||||
mlog.String("post_id", sm.Post.Id),
|
||||
mlog.String("channel_id", sm.Post.ChannelId),
|
||||
mlog.Err(err),
|
||||
)
|
||||
} else if lastSyncAt < rpost.UpdateAt {
|
||||
lastSyncAt = rpost.UpdateAt
|
||||
}
|
||||
}
|
||||
|
||||
// add/remove reactions
|
||||
for _, reaction := range sm.Reactions {
|
||||
if _, err := scs.upsertSyncReaction(reaction, rc); err != nil {
|
||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceError, "Error upserting sync reaction",
|
||||
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.GetLogger().Log(mlog.LvlSharedChannelServiceDebug, "Reaction upserted via sync",
|
||||
mlog.String("user_id", reaction.UserId),
|
||||
mlog.String("post_id", reaction.PostId),
|
||||
mlog.String("emoji", reaction.EmojiName),
|
||||
mlog.Int64("delete_at", reaction.DeleteAt),
|
||||
)
|
||||
|
||||
if lastSyncAt < reaction.UpdateAt {
|
||||
lastSyncAt = reaction.UpdateAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
syncResp := SyncResponse{
|
||||
LastSyncAt: lastSyncAt, // might be zero
|
||||
PostErrors: postErrors, // might be empty
|
||||
UsersSyncd: usersSyncd, // might be empty
|
||||
}
|
||||
|
||||
response.SetPayload(syncResp)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (scs *Service) upsertSyncUser(user *model.User, channel *model.Channel, rc *model.RemoteCluster) (*model.User, error) {
|
||||
var err error
|
||||
var userSaved *model.User
|
||||
|
||||
user.RemoteId = model.NewString(rc.RemoteId)
|
||||
|
||||
// does the user already exist?
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if euser == nil {
|
||||
if userSaved, err = scs.server.GetStore().User().Save(user); err != nil {
|
||||
if e, ok := err.(errInvalidInput); ok {
|
||||
_, field, value := e.InvalidInputInfo()
|
||||
if field == "email" || field == "username" {
|
||||
// username or email collision
|
||||
// TODO: handle collision by modifying username/email (MM-32133)
|
||||
return nil, fmt.Errorf("collision inserting sync user (%s=%s): %w", field, value, err)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("error inserting sync user: %w", err)
|
||||
}
|
||||
} else {
|
||||
patch := &model.UserPatch{
|
||||
Nickname: &user.Nickname,
|
||||
FirstName: &user.FirstName,
|
||||
LastName: &user.LastName,
|
||||
Position: &user.Position,
|
||||
Locale: &user.Locale,
|
||||
Timezone: user.Timezone,
|
||||
RemoteId: user.RemoteId,
|
||||
}
|
||||
euser.Patch(patch)
|
||||
userUpdated, err := scs.server.GetStore().User().Update(euser, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error updating sync user: %w", err)
|
||||
}
|
||||
userSaved = userUpdated.New
|
||||
}
|
||||
|
||||
// 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 already
|
||||
// added and exit quickly.
|
||||
if err := scs.app.AddUserToTeamByTeamId(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(userSaved, channel); err != nil {
|
||||
return nil, fmt.Errorf("error adding sync user to ChannelMembers: %w", err)
|
||||
}
|
||||
return userSaved, nil
|
||||
}
|
||||
|
||||
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(post, channel, true, true)
|
||||
scs.server.GetLogger().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(post.Id, post.UserId)
|
||||
scs.server.GetLogger().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(post, false)
|
||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceDebug, "Updated sync post",
|
||||
mlog.String("post_id", post.Id),
|
||||
mlog.String("channel_id", post.ChannelId),
|
||||
)
|
||||
} else {
|
||||
// nothing to update
|
||||
scs.server.GetLogger().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(reaction)
|
||||
} else {
|
||||
appErr = scs.app.DeleteReactionForPost(reaction)
|
||||
}
|
||||
|
||||
var err error
|
||||
if appErr != nil {
|
||||
err = errors.New(appErr.Error())
|
||||
}
|
||||
return savedReaction, err
|
||||
}
|
||||
512
services/sharedchannel/sync_send.go
Normal file
512
services/sharedchannel/sync_send.go
Normal file
|
|
@ -0,0 +1,512 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sharedchannel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/services/remotecluster"
|
||||
"github.com/mattermost/mattermost-server/v5/shared/i18n"
|
||||
"github.com/mattermost/mattermost-server/v5/shared/mlog"
|
||||
)
|
||||
|
||||
type syncTask struct {
|
||||
id string
|
||||
channelId string
|
||||
remoteId string
|
||||
AddedAt time.Time
|
||||
retryCount int
|
||||
retryPost *model.Post
|
||||
schedule time.Time
|
||||
}
|
||||
|
||||
func newSyncTask(channelId string, remoteId string, retryPost *model.Post) syncTask {
|
||||
var postId string
|
||||
if retryPost != nil {
|
||||
postId = retryPost.Id
|
||||
}
|
||||
|
||||
return syncTask{
|
||||
id: channelId + remoteId + postId, // combination of ids to avoid duplicates
|
||||
channelId: channelId,
|
||||
remoteId: remoteId, // empty means update all remote clusters
|
||||
retryPost: retryPost,
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.GetLogger().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.GetLogger().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.updateForRemote(rtask, rc); err != nil {
|
||||
// retry...
|
||||
if rtask.incRetry() {
|
||||
scs.addTask(rtask)
|
||||
} else {
|
||||
scs.server.GetLogger().Error("Failed to synchronize shared channel for remote cluster",
|
||||
mlog.String("channelId", rtask.channelId),
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.String("remoteId", rtask.remoteId),
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateForRemote 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.
|
||||
func (scs *Service) updateForRemote(task syncTask, rc *model.RemoteCluster) error {
|
||||
rcs := scs.server.GetRemoteClusterService()
|
||||
if rcs == nil {
|
||||
return fmt.Errorf("cannot update remote cluster for channel id %s; Remote Cluster Service not enabled", task.channelId)
|
||||
}
|
||||
|
||||
scr, err := scs.server.GetStore().SharedChannel().GetRemoteByIds(task.channelId, rc.RemoteId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var posts []*model.Post
|
||||
var repeat bool
|
||||
nextSince := scr.NextSyncAt
|
||||
|
||||
if task.retryPost != nil {
|
||||
posts = []*model.Post{task.retryPost}
|
||||
} else {
|
||||
result, err2 := scs.getPostsSince(task.channelId, rc, scr.NextSyncAt)
|
||||
if err2 != nil {
|
||||
return err2
|
||||
}
|
||||
posts = result.posts
|
||||
repeat = result.hasMore
|
||||
nextSince = result.nextSince
|
||||
}
|
||||
|
||||
if len(posts) == 0 {
|
||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceDebug, "sync task found zero posts; skipping sync",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.String("channel_id", task.channelId),
|
||||
mlog.Int64("lastSyncAt", scr.NextSyncAt),
|
||||
mlog.Int64("nextSince", nextSince),
|
||||
mlog.Bool("repeat", repeat),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceDebug, "sync task found posts to sync",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.String("channel_id", task.channelId),
|
||||
mlog.Int64("lastSyncAt", scr.NextSyncAt),
|
||||
mlog.Int64("nextSince", nextSince),
|
||||
mlog.Int("count", len(posts)),
|
||||
mlog.Bool("repeat", repeat),
|
||||
)
|
||||
|
||||
if !rc.IsOnline() {
|
||||
scs.notifyRemoteOffline(posts, rc)
|
||||
return nil
|
||||
}
|
||||
|
||||
syncMessages, err := scs.postsToSyncMessages(posts, rc, scr.NextSyncAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(syncMessages) == 0 {
|
||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceDebug, "sync task, all messages filtered out; skipping sync",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.String("channel_id", task.channelId),
|
||||
mlog.Bool("repeat", repeat),
|
||||
)
|
||||
|
||||
// All posts were filtered out, meaning no need to send them. Fast forward SharedChannelRemote's NextSyncAt.
|
||||
scs.updateNextSyncForRemote(scr.Id, rc, nextSince)
|
||||
|
||||
// everything was filtered out, nothing to send.
|
||||
if repeat {
|
||||
scs.addTask(newSyncTask(task.channelId, task.remoteId, nil))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
scs.sendAttachments(syncMessages, rc)
|
||||
|
||||
b, err := json.Marshal(syncMessages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg := model.NewRemoteClusterMsg(TopicSync, b)
|
||||
|
||||
if scs.server.GetLogger().IsLevelEnabled(mlog.LvlSharedChannelServiceMessagesOutbound) {
|
||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceMessagesOutbound, "outbound message",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.Int64("NextSyncAt", scr.NextSyncAt),
|
||||
mlog.String("msg", string(b)),
|
||||
)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), remotecluster.SendTimeout)
|
||||
defer cancel()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
|
||||
err = rcs.SendMsg(ctx, msg, rc, func(msg model.RemoteClusterMsg, rc *model.RemoteCluster, resp *remotecluster.Response, err error) {
|
||||
defer wg.Done()
|
||||
if err != nil {
|
||||
return // this means the response could not be parsed; already logged
|
||||
}
|
||||
|
||||
var syncResp SyncResponse
|
||||
if err2 := json.Unmarshal(resp.Payload, &syncResp); err2 != nil {
|
||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceError, "invalid sync response after update shared channel",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.Err(err2),
|
||||
)
|
||||
}
|
||||
|
||||
// Any Post(s) that failed to save on remote side are included in an array of post ids in the Response payload.
|
||||
// Handle each error by retrying the post a fixed number of times before giving up.
|
||||
for _, p := range syncResp.PostErrors {
|
||||
scs.handlePostError(p, task, rc)
|
||||
}
|
||||
|
||||
// update NextSyncAt for all the users that were synchronized
|
||||
scs.updateSyncUsers(syncResp.UsersSyncd, rc, nextSince)
|
||||
})
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if err == nil {
|
||||
// Optimistically update SharedChannelRemote's NextSyncAt; if any posts failed they will be retried.
|
||||
scs.updateNextSyncForRemote(scr.Id, rc, nextSince)
|
||||
}
|
||||
|
||||
if repeat {
|
||||
scs.addTask(newSyncTask(task.channelId, task.remoteId, nil))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (scs *Service) sendAttachments(syncMessages []syncMsg, rc *model.RemoteCluster) {
|
||||
for _, sm := range syncMessages {
|
||||
for _, fi := range sm.Attachments {
|
||||
if err := scs.sendAttachmentForRemote(fi, sm.Post, rc); err != nil {
|
||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceError, "error syncing attachment for post",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.String("post_id", sm.Post.Id),
|
||||
mlog.String("file_id", fi.Id),
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (scs *Service) handlePostError(postId string, task syncTask, rc *model.RemoteCluster) {
|
||||
if task.retryPost != nil && task.retryPost.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.GetLogger().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.GetLogger().Log(mlog.LvlSharedChannelServiceError, "error fetching post for sync retry",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.String("post_id", postId),
|
||||
)
|
||||
return
|
||||
}
|
||||
scs.addTask(newSyncTask(task.channelId, task.remoteId, post))
|
||||
}
|
||||
|
||||
// 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]interface{}{"Remote": rc.DisplayName}),
|
||||
CreateAt: post.CreateAt + 1,
|
||||
}
|
||||
scs.app.SendEphemeralPost(post.UserId, ephemeral)
|
||||
|
||||
notified[post.UserId] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (scs *Service) updateNextSyncForRemote(scrId string, rc *model.RemoteCluster, nextSyncAt int64) {
|
||||
if nextSyncAt == 0 {
|
||||
return
|
||||
}
|
||||
if err := scs.server.GetStore().SharedChannel().UpdateRemoteNextSyncAt(scrId, nextSyncAt); err != nil {
|
||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceError, "error updating NextSyncAt for shared channel remote",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.Err(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceDebug, "updated NextSyncAt for remote",
|
||||
mlog.String("remote_id", rc.RemoteId),
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.Int64("next_update_at", nextSyncAt),
|
||||
)
|
||||
}
|
||||
|
||||
func (scs *Service) updateSyncUsers(userIds []string, rc *model.RemoteCluster, lastSyncAt int64) {
|
||||
for _, uid := range userIds {
|
||||
scu, err := scs.server.GetStore().SharedChannel().GetUser(uid, rc.RemoteId)
|
||||
if err != nil {
|
||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceError, "error getting user for lastSyncAt update",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.String("user_id", uid),
|
||||
mlog.Err(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := scs.server.GetStore().SharedChannel().UpdateUserLastSyncAt(scu.Id, lastSyncAt); err != nil {
|
||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceError, "error updating lastSyncAt for user",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.String("user_id", uid),
|
||||
mlog.Err(err),
|
||||
)
|
||||
} else {
|
||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceDebug, "updated lastSyncAt for user",
|
||||
mlog.String("remote", rc.DisplayName),
|
||||
mlog.String("user_id", scu.UserId),
|
||||
mlog.Int64("last_update_at", lastSyncAt),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.DEFAULT_LOCALE
|
||||
}
|
||||
return i18n.GetUserTranslations(locale)
|
||||
}
|
||||
|
|
@ -85,7 +85,7 @@ type Actions struct {
|
|||
UpdateActive func(*model.User, bool) (*model.User, *model.AppError)
|
||||
AddUserToChannel func(*model.User, *model.Channel) (*model.ChannelMember, *model.AppError)
|
||||
JoinUserToTeam func(*model.Team, *model.User, string) *model.AppError
|
||||
CreateDirectChannel func(string, string) (*model.Channel, *model.AppError)
|
||||
CreateDirectChannel func(string, string, ...model.ChannelOption) (*model.Channel, *model.AppError)
|
||||
CreateGroupChannel func([]string) (*model.Channel, *model.AppError)
|
||||
CreateChannel func(*model.Channel, bool) (*model.Channel, *model.AppError)
|
||||
DoUploadFile func(time.Time, string, string, string, string, []byte) (*model.FileInfo, *model.AppError)
|
||||
|
|
|
|||
|
|
@ -734,6 +734,7 @@ func (ts *TelemetryService) trackConfig() {
|
|||
"cloud_billing": *cfg.ExperimentalSettings.CloudBilling,
|
||||
"cloud_user_limit": *cfg.ExperimentalSettings.CloudUserLimit,
|
||||
"enable_shared_channels": *cfg.ExperimentalSettings.EnableSharedChannels,
|
||||
"enable_remote_cluster_service": *cfg.ExperimentalSettings.EnableRemoteClusterService && cfg.FeatureFlags.EnableRemoteClusterService,
|
||||
})
|
||||
|
||||
ts.sendTelemetry(TrackConfigAnalytics, map[string]interface{}{
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ func defaultLog(level, msg string, fields ...Field) {
|
|||
}
|
||||
}
|
||||
|
||||
func defaultIsLevelEnabled(level LogLevel) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func defaultDebugLog(msg string, fields ...Field) {
|
||||
defaultLog("debug", msg, fields...)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ func InitGlobalLogger(logger *Logger) {
|
|||
glob := *logger
|
||||
glob.zap = glob.zap.WithOptions(zap.AddCallerSkip(1))
|
||||
globalLogger = &glob
|
||||
IsLevelEnabled = globalLogger.IsLevelEnabled
|
||||
Debug = globalLogger.Debug
|
||||
Info = globalLogger.Info
|
||||
Warn = globalLogger.Warn
|
||||
|
|
@ -59,6 +60,7 @@ func RedirectStdLog(logger *Logger) {
|
|||
log.SetOutput(logWriterFunc(writer))
|
||||
}
|
||||
|
||||
type IsLevelEnabledFunc func(LogLevel) bool
|
||||
type LogFunc func(string, ...Field)
|
||||
type LogFuncCustom func(LogLevel, string, ...Field)
|
||||
type LogFuncCustomMulti func([]LogLevel, string, ...Field)
|
||||
|
|
@ -79,6 +81,7 @@ func GloballyEnableDebugLogForTest() {
|
|||
globalLogger.consoleLevel.SetLevel(zapcore.DebugLevel)
|
||||
}
|
||||
|
||||
var IsLevelEnabled IsLevelEnabledFunc = defaultIsLevelEnabled
|
||||
var Debug LogFunc = defaultDebugLog
|
||||
var Info LogFunc = defaultInfoLog
|
||||
var Warn LogFunc = defaultWarnLog
|
||||
|
|
|
|||
|
|
@ -30,6 +30,18 @@ var (
|
|||
// used by the TCP log target
|
||||
LvlTcpLogTarget = LogLevel{ID: 120, Name: "TcpLogTarget"}
|
||||
|
||||
// used by Remote Cluster Service
|
||||
LvlRemoteClusterServiceDebug = LogLevel{ID: 130, Name: "RemoteClusterServiceDebug"}
|
||||
LvlRemoteClusterServiceError = LogLevel{ID: 131, Name: "RemoteClusterServiceError"}
|
||||
LvlRemoteClusterServiceWarn = LogLevel{ID: 132, Name: "RemoteClusterServiceWarn"}
|
||||
|
||||
// used by Shared Channel Sync Service
|
||||
LvlSharedChannelServiceDebug = LogLevel{ID: 200, Name: "SharedChannelServiceDebug"}
|
||||
LvlSharedChannelServiceError = LogLevel{ID: 201, Name: "SharedChannelServiceError"}
|
||||
LvlSharedChannelServiceWarn = LogLevel{ID: 202, Name: "SharedChannelServiceWarn"}
|
||||
LvlSharedChannelServiceMessagesInbound = LogLevel{ID: 203, Name: "SharedChannelServiceMsgInbound"}
|
||||
LvlSharedChannelServiceMessagesOutbound = LogLevel{ID: 204, Name: "SharedChannelServiceMsgOutbound"}
|
||||
|
||||
// add more here ...
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,17 @@ var NamedErr = zap.NamedError
|
|||
var Bool = zap.Bool
|
||||
var Duration = zap.Duration
|
||||
|
||||
type LoggerIFace interface {
|
||||
IsLevelEnabled(LogLevel) bool
|
||||
Debug(string, ...Field)
|
||||
Info(string, ...Field)
|
||||
Warn(string, ...Field)
|
||||
Error(string, ...Field)
|
||||
Critical(string, ...Field)
|
||||
Log(LogLevel, string, ...Field)
|
||||
LogM([]LogLevel, string, ...Field)
|
||||
}
|
||||
|
||||
type TargetInfo logr.TargetInfo
|
||||
|
||||
type LoggerConfiguration struct {
|
||||
|
|
@ -207,6 +218,10 @@ func (l *Logger) Sugar() *SugarLogger {
|
|||
}
|
||||
}
|
||||
|
||||
func (l *Logger) IsLevelEnabled(level LogLevel) bool {
|
||||
return isLevelEnabled(l.getLogger(), logr.Level(level))
|
||||
}
|
||||
|
||||
func (l *Logger) Debug(message string, fields ...Field) {
|
||||
l.zap.Debug(message, fields...)
|
||||
if isLevelEnabled(l.getLogger(), logr.Debug) {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue