snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,156 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package awsmeter
import (
"context"
"encoding/json"
"os"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds"
"github.com/aws/aws-sdk-go-v2/feature/ec2/imds"
"github.com/aws/aws-sdk-go-v2/service/marketplacemetering"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
type AwsMeter struct {
store store.Store
service *AWSMeterService
config *model.Config
}
type MeteringService interface {
MeterUsage(ctx context.Context, input *marketplacemetering.MeterUsageInput, optFns ...func(*marketplacemetering.Options)) (*marketplacemetering.MeterUsageOutput, error)
}
type AWSMeterService struct {
AwsDryRun bool
AwsProductCode string
AwsMeteringSvc MeteringService
}
type AWSMeterReport struct {
Dimension string `json:"dimension"`
Value int32 `json:"value"`
Timestamp time.Time `json:"timestamp"`
}
func (o *AWSMeterReport) ToJSON() string {
b, _ := json.Marshal(o)
return string(b)
}
func New(ctx context.Context, store store.Store, config *model.Config) *AwsMeter {
svc := &AWSMeterService{
AwsDryRun: false,
AwsProductCode: "12345", //TODO
}
service, err := newAWSMarketplaceMeteringService(ctx)
if err != nil {
mlog.Debug("Could not create AWS metering service", mlog.String("error", err.Error()))
return nil
}
svc.AwsMeteringSvc = service
return &AwsMeter{
store: store,
service: svc,
config: config,
}
}
func newAWSMarketplaceMeteringService(ctx context.Context) (*marketplacemetering.Client, error) {
region := os.Getenv("AWS_REGION")
imdsClient := imds.New(imds.Options{})
credsProvider := ec2rolecreds.New(func(o *ec2rolecreds.Options) { o.Client = imdsClient })
creds := aws.NewCredentialsCache(credsProvider)
_, err := creds.Retrieve(ctx)
if err != nil {
return nil, errors.Wrap(err, "cannot obtain credentials")
}
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion(region),
config.WithCredentialsProvider(creds),
)
if err != nil {
return nil, errors.Wrap(err, "failed to load default config with EC2 credentials provider")
}
return marketplacemetering.NewFromConfig(cfg), nil
}
// a report entry is for all metrics
func (awsm *AwsMeter) GetUserCategoryUsage(dimensions []string, startTime time.Time, endTime time.Time) []*AWSMeterReport {
reports := make([]*AWSMeterReport, 0)
for _, dimension := range dimensions {
var userCount int32
var err error
switch dimension {
case model.AwsMeteringDimensionUsageHrs:
userCount, err = awsm.store.User().AnalyticsActiveCountForPeriod(model.GetMillisForTime(startTime), model.GetMillisForTime(endTime), model.UserCountOptions{})
if err != nil {
mlog.Warn("Failed to obtain usage data", mlog.String("dimension", dimension), mlog.Time("start", startTime), mlog.Int("count", userCount), mlog.Err(err))
continue
}
default:
mlog.Debug("Dimension does not exist!", mlog.String("dimension", dimension))
continue
}
report := &AWSMeterReport{
Dimension: dimension,
Value: userCount,
Timestamp: startTime,
}
reports = append(reports, report)
}
return reports
}
func (awsm *AwsMeter) ReportUserCategoryUsage(ctx context.Context, reports []*AWSMeterReport) error {
for _, report := range reports {
err := sendReportToMeteringService(ctx, awsm.service, report)
if err != nil {
return err
}
}
return nil
}
func sendReportToMeteringService(ctx context.Context, ams *AWSMeterService, report *AWSMeterReport) error {
params := &marketplacemetering.MeterUsageInput{
DryRun: aws.Bool(ams.AwsDryRun),
ProductCode: aws.String(ams.AwsProductCode),
UsageDimension: aws.String(report.Dimension),
UsageQuantity: aws.Int32(report.Value),
Timestamp: aws.Time(report.Timestamp),
}
resp, err := ams.AwsMeteringSvc.MeterUsage(ctx, params)
if err != nil {
return errors.Wrap(err, "Invalid metering service id.")
}
if resp.MeteringRecordId == nil {
return errors.Wrap(err, "Invalid metering service id.")
}
mlog.Debug("Sent record to AWS metering service", mlog.String("dimension", report.Dimension), mlog.Int("value", report.Value), mlog.String("timestamp", report.Timestamp.String()))
return nil
}

View File

@@ -0,0 +1,146 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package awsmeter
import (
"context"
"errors"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/service/marketplacemetering"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
)
type mockMarketplaceMeteringClient struct {
MeteringService
}
func (m *mockMarketplaceMeteringClient) MeterUsage(ctx context.Context, input *marketplacemetering.MeterUsageInput, optFns ...func(*marketplacemetering.Options)) (*marketplacemetering.MeterUsageOutput, error) {
return &marketplacemetering.MeterUsageOutput{
MeteringRecordId: new("1"),
}, nil
}
type mockMarketplaceMeteringClientWithError struct {
MeteringService
}
func (m *mockMarketplaceMeteringClientWithError) MeterUsage(ctx context.Context, input *marketplacemetering.MeterUsageInput, optFns ...func(*marketplacemetering.Options)) (*marketplacemetering.MeterUsageOutput, error) {
return nil, errors.New("error")
}
func TestAwsMeterUsage(t *testing.T) {
startTime := time.Now()
endTime := time.Now()
dimensions := []string{model.AwsMeteringDimensionUsageHrs}
userStoreMock := mocks.UserStore{}
userStoreMock.On("AnalyticsActiveCountForPeriod", model.GetMillisForTime(startTime), model.GetMillisForTime(endTime), mock.AnythingOfType("model.UserCountOptions")).Return(int32(2), nil)
storeMock := mocks.Store{}
storeMock.On("User").Return(&userStoreMock)
reports := make([]*AWSMeterReport, 1)
reports[0] = &AWSMeterReport{
Dimension: model.AwsMeteringDimensionUsageHrs,
Value: 2,
Timestamp: startTime,
}
// Define a mock struct to be used in your unit tests of myFunc.
svc := &AWSMeterService{
AwsDryRun: false,
AwsProductCode: "12345",
AwsMeteringSvc: &mockMarketplaceMeteringClient{},
}
config := &model.Config{}
config.SetDefaults()
awsmeter := &AwsMeter{
store: &storeMock,
service: svc,
config: config,
}
t.Run("Send report for one usage category", func(t *testing.T) {
resultReports := awsmeter.GetUserCategoryUsage(dimensions, startTime, endTime)
require.NotNil(t, resultReports)
assert.Equal(t, 1, len(resultReports))
assert.Equal(t, reports[0].Dimension, resultReports[0].Dimension)
assert.Equal(t, reports[0].Value, resultReports[0].Value)
assert.Equal(t, reports[0].Timestamp, resultReports[0].Timestamp)
err := awsmeter.ReportUserCategoryUsage(context.Background(), resultReports)
require.NoError(t, err)
})
t.Run("Error in AWS service call", func(t *testing.T) {
awsmeter.service.AwsMeteringSvc = &mockMarketplaceMeteringClientWithError{}
resultReports := awsmeter.GetUserCategoryUsage(dimensions, startTime, endTime)
require.NotNil(t, resultReports)
assert.Equal(t, 1, len(resultReports))
err := awsmeter.ReportUserCategoryUsage(context.Background(), resultReports)
require.Error(t, err)
})
t.Run("Invalid dimension", func(t *testing.T) {
awsmeter.service.AwsMeteringSvc = &mockMarketplaceMeteringClient{}
dimensions = []string{"invalid dimension"}
resultReports := awsmeter.GetUserCategoryUsage(dimensions, startTime, endTime)
require.NotNil(t, resultReports)
assert.Equal(t, 0, len(resultReports))
err := awsmeter.ReportUserCategoryUsage(context.Background(), resultReports)
require.NoError(t, err)
})
}
func TestAwsMeterUsageWithDBError(t *testing.T) {
startTime := time.Now()
endTime := time.Now()
dimensions := []string{model.AwsMeteringDimensionUsageHrs}
userStoreMock := mocks.UserStore{}
userStoreMock.On("AnalyticsActiveCountForPeriod", model.GetMillisForTime(startTime), model.GetMillisForTime(endTime), mock.AnythingOfType("model.UserCountOptions")).Return(int32(0), errors.New("error"))
storeMock := mocks.Store{}
storeMock.On("User").Return(&userStoreMock)
reports := make([]*AWSMeterReport, 1)
reports[0] = &AWSMeterReport{
Dimension: model.AwsMeteringDimensionUsageHrs,
Value: 2,
Timestamp: startTime,
}
// Define a mock struct to be used in your unit tests of myFunc.
svc := &AWSMeterService{
AwsDryRun: false,
AwsProductCode: "12345",
AwsMeteringSvc: &mockMarketplaceMeteringClient{},
}
config := &model.Config{}
config.SetDefaults()
awsmeter := &AwsMeter{
store: &storeMock,
service: svc,
config: config,
}
t.Run("Error in DB query", func(t *testing.T) {
resultReports := awsmeter.GetUserCategoryUsage(dimensions, startTime, endTime)
require.NotNil(t, resultReports)
assert.Equal(t, 0, len(resultReports))
err := awsmeter.ReportUserCategoryUsage(context.Background(), resultReports)
require.NoError(t, err)
})
}

View File

@@ -0,0 +1,10 @@
with-expecter: false
packages:
github.com/mattermost/mattermost/server/v8/platform/services/cache:
config:
all: true
dir: "platform/services/cache/mocks"
filename: "{{.InterfaceName}}.go"
mockname: "{{.InterfaceName}}"
outpkg: mocks
note: "Regenerate this file using `make cache-mocks`."

68
server/platform/services/cache/cache.go vendored Normal file
View File

@@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package cache
import (
"errors"
"time"
"github.com/mattermost/mattermost/server/public/model"
)
// ErrKeyNotFound is the error when the given key is not found
var ErrKeyNotFound = errors.New("key not found")
// Cache is a representation of a cache store that aims to replace cache.Cache
type Cache interface {
// Purge is used to completely clear the cache.
Purge() error
// SetWithDefaultExpiry adds the given key and value to the store with the default expiry. If
// the key already exists, it will overwrite the previous value
SetWithDefaultExpiry(key string, value any) error
// SetWithExpiry adds the given key and value to the cache with the given expiry. If the key
// already exists, it will overwrite the previous value
SetWithExpiry(key string, value any, ttl time.Duration) error
// Get the content stored in the cache for the given key, and decode it into the value interface.
// Returns ErrKeyNotFound if the key is missing from the cache
Get(key string, value any) error
// GetMulti returns values for multiple keys in a single operation.
// Returns ErrKeyNotFound if the key is missing from the cache.
GetMulti(keys []string, values []any) []error
// Remove deletes the value for a given key.
Remove(key string) error
// RemoveMulti deletes multiple keys in a single operation.
RemoveMulti(keys []string) error
// Scan allows incremental iteration over the entire key-space
// in a performant manner. It provides a callback that consumers
// can use to process the keys. If the callback returns an error,
// the scan stops, returning the same error.
Scan(f func([]string) error) error
// GetInvalidateClusterEvent returns the cluster event configured when this cache was created.
GetInvalidateClusterEvent() model.ClusterEvent
// Name returns the name of the cache
Name() string
}
// ExternalCache is a super-set of the Cache interface with
// a couple of more methods that allows for more efficient cache updates.
// This can be achieved because the cache is external and an update
// is visible to all nodes.
type ExternalCache interface {
Cache
// Increment will increment the
// number stored at that key by the value.
Increment(key string, val int) error
// Decrement will decrement the
// number stored at that key by the value.
Decrement(key string, val int) error
}

237
server/platform/services/cache/lru.go vendored Normal file
View File

@@ -0,0 +1,237 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package cache
import (
"container/list"
"sync"
"time"
"github.com/tinylib/msgp/msgp"
"github.com/vmihailenco/msgpack/v5"
"github.com/mattermost/mattermost/server/public/model"
)
// LRU is a thread-safe fixed size LRU cache.
type LRU struct {
lock sync.RWMutex
size int
len int
currentGeneration int64
evictList *list.List
items map[string]*list.Element
defaultExpiry time.Duration
name string
invalidateClusterEvent model.ClusterEvent
}
// entry is used to hold a value in the evictList.
type entry struct {
key string
value []byte
expires time.Time
generation int64
}
// NewLRU creates an LRU of the given size.
func NewLRU(opts *CacheOptions) Cache {
return &LRU{
name: opts.Name,
size: opts.Size,
evictList: list.New(),
items: make(map[string]*list.Element, opts.Size),
defaultExpiry: opts.DefaultExpiry,
invalidateClusterEvent: opts.InvalidateClusterEvent,
}
}
// Purge is used to completely clear the cache.
func (l *LRU) Purge() error {
l.lock.Lock()
defer l.lock.Unlock()
l.len = 0
l.currentGeneration++
return nil
}
// SetWithDefaultExpiry adds the given key and value to the store with the default expiry. If
// the key already exists, it will overwrite the previous value
func (l *LRU) SetWithDefaultExpiry(key string, value any) error {
return l.SetWithExpiry(key, value, l.defaultExpiry)
}
// SetWithExpiry adds the given key and value to the cache with the given expiry. If the key
// already exists, it will overwrite the previous value
func (l *LRU) SetWithExpiry(key string, value any, ttl time.Duration) error {
return l.set(key, value, ttl)
}
// Get the content stored in the cache for the given key, and decode it into the value interface.
// return ErrKeyNotFound if the key is missing from the cache
func (l *LRU) Get(key string, value any) error {
return l.get(key, value)
}
func (l *LRU) GetMulti(keys []string, values []any) []error {
errs := make([]error, 0, len(values))
for i, key := range keys {
errs = append(errs, l.get(key, values[i]))
}
return errs
}
// Remove deletes the value for a key.
func (l *LRU) Remove(key string) error {
l.lock.Lock()
defer l.lock.Unlock()
if ent, ok := l.items[key]; ok {
l.removeElement(ent)
}
return nil
}
func (l *LRU) RemoveMulti(keys []string) error {
l.lock.Lock()
defer l.lock.Unlock()
// Note, this is a copy of l.Remove. But we want to avoid
// fine-grained locking for every single removal. Therefore,
// we copy a bit of code for simplicity.
for _, key := range keys {
if ent, ok := l.items[key]; ok {
l.removeElement(ent)
}
}
return nil
}
// Scan passes the whole slice of keys to the callback in LRU mode.
// We don't need this callback style for LRU, but since we share
// the same interface with Redis, we maintain parity.
func (l *LRU) Scan(f func([]string) error) error {
l.lock.RLock()
keys := make([]string, l.len)
i := 0
for ent := l.evictList.Back(); ent != nil; ent = ent.Prev() {
e := ent.Value.(*entry)
if e.generation == l.currentGeneration {
keys[i] = e.key
i++
}
}
l.lock.RUnlock()
return f(keys)
}
// Len returns the number of items in the cache.
func (l *LRU) Len() (int, error) {
l.lock.RLock()
defer l.lock.RUnlock()
return l.len, nil
}
// GetInvalidateClusterEvent returns the cluster event configured when this cache was created.
func (l *LRU) GetInvalidateClusterEvent() model.ClusterEvent {
return l.invalidateClusterEvent
}
// Name returns the name of the cache
func (l *LRU) Name() string {
return l.name
}
func (l *LRU) set(key string, value any, ttl time.Duration) error {
var expires time.Time
if ttl > 0 {
expires = time.Now().Add(ttl)
}
var buf []byte
var err error
// We use a fast path for hot structs.
if msgpVal, ok := value.(msgp.Marshaler); ok {
buf, err = msgpVal.MarshalMsg(nil)
} else {
// Slow path for other structs.
buf, err = msgpack.Marshal(value)
}
if err != nil {
return err
}
l.lock.Lock()
defer l.lock.Unlock()
// Check for existing item, ignoring expiry since we'd update anyway.
if ent, ok := l.items[key]; ok {
l.evictList.MoveToFront(ent)
e := ent.Value.(*entry)
e.value = buf
e.expires = expires
if e.generation != l.currentGeneration {
e.generation = l.currentGeneration
l.len++
}
return nil
}
// Add new item
ent := &entry{key, buf, expires, l.currentGeneration}
entry := l.evictList.PushFront(ent)
l.items[key] = entry
l.len++
if l.evictList.Len() > l.size {
l.removeElement(l.evictList.Back())
}
return nil
}
func (l *LRU) get(key string, value any) error {
val, err := l.getItem(key)
if err != nil {
return err
}
// We use a fast path for hot structs.
if msgpVal, ok := value.(msgp.Unmarshaler); ok {
_, err := msgpVal.UnmarshalMsg(val)
return err
}
// Slow path for other structs.
return msgpack.Unmarshal(val, value)
}
func (l *LRU) getItem(key string) ([]byte, error) {
l.lock.Lock()
defer l.lock.Unlock()
ent, ok := l.items[key]
if !ok {
return nil, ErrKeyNotFound
}
e := ent.Value.(*entry)
if e.generation != l.currentGeneration || (!e.expires.IsZero() && time.Now().After(e.expires)) {
l.removeElement(ent)
return nil, ErrKeyNotFound
}
l.evictList.MoveToFront(ent)
return e.value, nil
}
func (l *LRU) removeElement(e *list.Element) {
l.evictList.Remove(e)
kv := e.Value.(*entry)
if kv.generation == l.currentGeneration {
l.len--
}
delete(l.items, kv.key)
}

View File

@@ -0,0 +1,162 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package cache
import (
"errors"
"fmt"
"math"
"time"
"github.com/cespare/xxhash/v2"
"github.com/mattermost/mattermost/server/public/model"
)
// LRUStriped keeps LRU caches in buckets in order to lower mutex contention.
// This is achieved by hashing the input key to map it to a dedicated bucket.
// Each bucket (an LRU cache) has its own lock that helps distributing the lock
// contention on multiple threads/cores, leading to less wait times.
//
// LRUStriped implements the Cache interface with the same behavior as LRU.
//
// Note that, because of it's distributed nature, the fixed size cannot be strictly respected
// and you may have a tiny bit more space for keys than you defined through LRUOptions.
// Bucket size is computed as follows: (size / nbuckets) + (size % nbuckets)
//
// Because of this size limit per bucket, and because of the nature of the data, you
// may have buckets filled unevenly, and because of this, keys will be evicted from the entire
// cache where a simple LRU wouldn't have. Example:
//
// Two buckets B1 and B2, of max size 2 each, meaning, theoretically, a max size of 4:
// - Say you have a set of 3 keys, they could fill an entire LRU cache.
// - But if all those keys are assigned to a single bucket B1, the first key will be evicted from B1
// - B2 will remain empty, even though there was enough memory allocated
//
// With 4 buckets and random UUIDs as keys, the amount of false evictions is around 5%.
//
// By default, the number of buckets equals the number of cpus returned from runtime.NumCPU.
//
// This struct is lock-free and intended to be used without lock.
type LRUStriped struct {
buckets []*LRU
name string
invalidateClusterEvent model.ClusterEvent
}
func (L LRUStriped) hashkeyMapHash(key string) uint64 {
return xxhash.Sum64String(key)
}
func (L LRUStriped) keyBucket(key string) *LRU {
return L.buckets[L.hashkeyMapHash(key)%uint64(len(L.buckets))]
}
// Purge loops through each LRU cache for purging. Since LRUStriped doesn't use any lock,
// each LRU bucket is purged after another one, which means that keys could still
// be present after a call to Purge.
func (L LRUStriped) Purge() error {
for _, lru := range L.buckets {
lru.Purge() // errors from purging LRU can be ignored as they always return nil
}
return nil
}
// SetWithDefaultExpiry does the same as LRU.SetWithDefaultExpiry
func (L LRUStriped) SetWithDefaultExpiry(key string, value any) error {
return L.keyBucket(key).SetWithDefaultExpiry(key, value)
}
// SetWithExpiry does the same as LRU.SetWithExpiry
func (L LRUStriped) SetWithExpiry(key string, value any, ttl time.Duration) error {
return L.keyBucket(key).SetWithExpiry(key, value, ttl)
}
// Get does the same as LRU.Get
func (L LRUStriped) Get(key string, value any) error {
return L.keyBucket(key).Get(key, value)
}
func (L LRUStriped) GetMulti(keys []string, values []any) []error {
errs := make([]error, 0, len(values))
for i, key := range keys {
errs = append(errs, L.keyBucket(key).Get(key, values[i]))
}
return errs
}
// Remove does the same as LRU.Remove
func (L LRUStriped) Remove(key string) error {
return L.keyBucket(key).Remove(key)
}
// RemoveMulti does the same as LRU.RemoveMulti
func (L LRUStriped) RemoveMulti(keys []string) error {
var err error
for _, key := range keys {
err = errors.Join(err, L.keyBucket(key).Remove(key))
}
return err
}
// Scan is basically a copy of Keys in LRU mode.
// See comment in LRU.Scan.
func (L LRUStriped) Scan(f func([]string) error) error {
for _, lru := range L.buckets {
lru.Scan(f)
}
return nil
}
// Len does the same as LRU.Len. As for LRUStriped.Keys, this call cannot be precise.
func (L LRUStriped) Len() (int, error) {
var size int
for _, lru := range L.buckets {
s, _ := lru.Len() // Len never returns any error
size += s
}
return size, nil
}
// GetInvalidateClusterEvent does the same as LRU.GetInvalidateClusterEvent
func (L LRUStriped) GetInvalidateClusterEvent() model.ClusterEvent {
return L.invalidateClusterEvent
}
// Name does the same as LRU.Name
func (L LRUStriped) Name() string {
return L.name
}
// NewLRUStriped creates a striped LRU cache using the special CacheOptions.StripedBuckets value.
// See LRUStriped and CacheOptions for more details.
//
// Not that in order to prevent false eviction, this LRU cache adds 10% (computation is rounded up) of the
// requested size to the total cache size.
func NewLRUStriped(opts *CacheOptions) (Cache, error) {
if opts.StripedBuckets == 0 {
return nil, fmt.Errorf("number of buckets is mandatory")
}
if opts.Size < opts.StripedBuckets {
return nil, fmt.Errorf("cache size must at least be equal to the number of buckets")
}
// add 10% to the total size, before splitting
opts.Size += int(math.Ceil(float64(opts.Size) * 10.0 / 100.0))
// now this is the size for each bucket
opts.Size = (opts.Size / opts.StripedBuckets) + (opts.Size % opts.StripedBuckets)
buckets := make([]*LRU, opts.StripedBuckets)
stripedBuckets := opts.StripedBuckets
for i := range stripedBuckets {
buckets[i] = NewLRU(opts).(*LRU)
}
return LRUStriped{
buckets: buckets,
invalidateClusterEvent: opts.InvalidateClusterEvent,
name: opts.Name,
}, nil
}

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package cache_test
import (
"fmt"
"runtime"
"sync"
"testing"
"github.com/cespare/xxhash/v2"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/v8/platform/services/cache"
)
const (
m = 500_000
)
func BenchmarkLRUStriped(b *testing.B) {
opts := cache.CacheOptions{
Name: "",
Size: 128,
DefaultExpiry: 0,
InvalidateClusterEvent: "",
StripedBuckets: runtime.NumCPU() - 1,
}
cache, err := cache.NewLRUStriped(&opts)
require.NoError(b, err)
// prepare keys and initial cache values and set routine
keys := make([]string, 0, m)
// bucketKeys is to demonstrate that splitted locks is working correctly
// by assigning one sequence of key for each bucket.
bucketKeys := make([][]string, opts.StripedBuckets)
for i := range m {
key := fmt.Sprintf("%d-key-%d", i, i)
keys = append(keys, key)
bucketKey := xxhash.Sum64String(key) % uint64(opts.StripedBuckets)
bucketKeys[bucketKey] = append(bucketKeys[bucketKey], key)
}
size := opts.Size
for i := range size {
cache.SetWithDefaultExpiry(keys[i], "preflight")
}
wgGet := &sync.WaitGroup{}
wgSet := &sync.WaitGroup{}
// need buffered chan because if the set routine finished before we write into the chan,
// we're left without any consumer, making any write to the chan waiting forever.
stopSet := make(chan bool, 1)
set := func() {
defer wgSet.Done()
for i := range m {
select {
case <-stopSet:
return
default:
_ = cache.SetWithDefaultExpiry(keys[i], "ignored")
}
}
}
get := func(bucket int) {
defer wgGet.Done()
var out string
for i := range m {
_ = cache.Get(bucketKeys[bucket][i%opts.Size], &out)
}
}
for b.Loop() {
b.StopTimer()
wgSet.Add(1)
go set()
stripedBuckets := opts.StripedBuckets
for j := range stripedBuckets {
wgGet.Add(1)
go get(j)
}
b.StartTimer()
wgGet.Wait()
// Cleanup
b.StopTimer()
stopSet <- true
wgSet.Wait()
b.StartTimer()
}
}

View File

@@ -0,0 +1,136 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package cache
import (
"fmt"
"hash/maphash"
"testing"
"github.com/cespare/xxhash/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func makeLRUPredictableTestData(num int) [][2]string {
kv := make([][2]string, num)
for i := range kv {
kv[i] = [2]string{
fmt.Sprintf("%d-key-%d", i, i),
fmt.Sprintf("%d-val-%d", i, i),
}
}
return kv
}
func TestNewLRUStriped(t *testing.T) {
scache, err := NewLRUStriped(&CacheOptions{StripedBuckets: 3, Size: 20})
require.NoError(t, err)
cache := scache.(LRUStriped)
require.Len(t, cache.buckets, 3)
assert.Equal(t, 8, cache.buckets[0].size)
assert.Equal(t, 8, cache.buckets[1].size)
assert.Equal(t, 8, cache.buckets[2].size)
}
func TestLRUStripedKeyDistribution(t *testing.T) {
dataset := makeLRUPredictableTestData(100)
scache, err := NewLRUStriped(&CacheOptions{
StripedBuckets: 4,
Size: len(dataset),
DefaultExpiry: 0,
})
require.NoError(t, err)
cache := scache.(LRUStriped)
for _, kv := range dataset {
require.NoError(t, cache.SetWithDefaultExpiry(kv[0], kv[1]))
var out string
require.NoError(t, cache.Get(kv[0], &out))
require.Equal(t, kv[1], out)
}
require.Len(t, cache.buckets, 4)
acc := 0
for i := range 4 {
clen, err := cache.buckets[i].Len()
acc += clen
assert.NoError(t, err)
assert.GreaterOrEqual(t, clen, len(dataset)/2/4, "at least 50%/nbuckets of all keys in each bucket")
}
// because of the limited size of each bucket and the nature of our data,
// we may have around 10% of our keys evicted in this scenario. removing 1% because we cannot predict
// accurately what is happening with random data.
assert.GreaterOrEqual(t, acc, len(dataset)-(len(dataset)*1.0/100.0))
}
func TestLRUStriped_Size(t *testing.T) {
scache, err := NewLRUStriped(&CacheOptions{StripedBuckets: 2, Size: 128})
require.NoError(t, err)
cache := scache.(LRUStriped)
acc := 0
for _, bucket := range cache.buckets {
acc += bucket.size
}
assert.Equal(t, 128+13+1, acc) // +10% +modulo padding
}
func TestLRUStriped_HashKey(t *testing.T) {
scache, err := NewLRUStriped(&CacheOptions{StripedBuckets: 2, Size: 128})
require.NoError(t, err)
cache := scache.(LRUStriped)
first := cache.hashkeyMapHash("key")
cache.hashkeyMapHash("other_key_to_ensure_that_result_it’s_not_dependent_on_previous_input")
second := cache.hashkeyMapHash("key")
require.Equal(t, first, second)
}
func TestLRUStriped_Get(t *testing.T) {
cache, err := NewLRUStriped(&CacheOptions{
StripedBuckets: 4,
Size: 128,
DefaultExpiry: 0,
})
require.NoError(t, err)
var out string
require.Equal(t, ErrKeyNotFound, cache.Get("key", &out))
require.Zero(t, out)
require.NoError(t, cache.SetWithDefaultExpiry("key", "value"))
require.NoError(t, cache.Get("key", &out))
require.Equal(t, "value", out)
}
var hashSink uint64
func BenchmarkSum64(b *testing.B) {
cases := []string{
"1",
"22",
"333",
model.NewId(),
model.NewId() + model.NewId(),
}
for _, case_ := range cases {
b.Run(fmt.Sprintf("maphash_string_len_%d", len(case_)), func(b *testing.B) {
seed := maphash.MakeSeed()
for b.Loop() {
var h maphash.Hash
h.SetSeed(seed)
h.WriteString(case_) // documentation and code says it never fails
hashSink = h.Sum64()
}
})
b.Run(fmt.Sprintf("xxhash_string_len_%d", len(case_)), func(b *testing.B) {
for b.Loop() {
hashSink = xxhash.Sum64String(case_)
}
})
}
}

View File

@@ -0,0 +1,661 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package cache
import (
"fmt"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestLRU(t *testing.T) {
l := NewLRU(&CacheOptions{
Size: 128,
DefaultExpiry: 0,
InvalidateClusterEvent: "",
})
for i := range 256 {
err := l.SetWithDefaultExpiry(fmt.Sprintf("%d", i), i)
require.NoError(t, err)
}
lru, ok := l.(*LRU)
require.True(t, ok)
size := lru.len
require.Equalf(t, size, 128, "bad len: %v", size)
l.Scan(func(keys []string) error {
for i, k := range keys {
var v int
err := l.Get(k, &v)
require.NoError(t, err, "bad key: %v", k)
require.Equalf(t, fmt.Sprintf("%d", v), k, "bad key: %v", k)
require.Equalf(t, i+128, v, "bad value: %v", k)
}
return nil
})
for i := range 128 {
var v int
err := l.Get(fmt.Sprintf("%d", i), &v)
require.Equal(t, ErrKeyNotFound, err, "should be evicted %v: %v", i, err)
}
for i := 128; i < 256; i++ {
var v int
err := l.Get(fmt.Sprintf("%d", i), &v)
require.NoError(t, err, "should not be evicted %v: %v", i, err)
}
var v1, v2 int
var values = []any{&v1, &v2}
errs := l.GetMulti([]string{"128", "129"}, values)
for _, err := range errs {
require.NoError(t, err)
}
err := l.RemoveMulti([]string{"128", "129"})
require.NoError(t, err)
errs = l.GetMulti([]string{"128", "129"}, values)
for i, err := range errs {
require.Equal(t, ErrKeyNotFound, err, "should be deleted %v: %v", i, err)
}
for i := 128; i < 192; i++ {
l.Remove(fmt.Sprintf("%d", i))
var v int
err = l.Get(fmt.Sprintf("%d", i), &v)
require.Equal(t, ErrKeyNotFound, err, "should be deleted %v: %v", i, err)
}
var v int
err = l.Get("192", &v) // expect 192 to be last key in l.Keys()
require.NoError(t, err, "should exist")
require.Equalf(t, 192, v, "bad value: %v", v)
l.Scan(func(keys []string) error {
for i, k := range keys {
require.Falsef(t, i < 63 && k != fmt.Sprintf("%d", i+193), "out of order key: %v", k)
require.Falsef(t, i == 63 && k != "192", "out of order key: %v", k)
}
return nil
})
l.Purge()
size = lru.len
require.Equalf(t, size, 0, "bad len: %v", size)
err = l.Get("200", &v)
require.Equal(t, err, ErrKeyNotFound, "should contain nothing")
err = l.SetWithDefaultExpiry("201", 301)
require.NoError(t, err)
err = l.Get("201", &v)
require.NoError(t, err)
require.Equal(t, 301, v)
}
func TestLRUExpire(t *testing.T) {
l := NewLRU(&CacheOptions{
Size: 128,
DefaultExpiry: 1 * time.Second,
InvalidateClusterEvent: "",
})
l.SetWithDefaultExpiry("1", 1)
l.SetWithExpiry("3", 3, 0*time.Second)
time.Sleep(time.Second * 2)
var r1 int
err := l.Get("1", &r1)
require.Equal(t, err, ErrKeyNotFound, "should not exist")
var r2 int
err2 := l.Get("3", &r2)
require.NoError(t, err2, "should exist")
require.Equal(t, 3, r2)
}
func TestLRUMarshalUnMarshal(t *testing.T) {
l := NewLRU(&CacheOptions{
Size: 1,
DefaultExpiry: 0,
InvalidateClusterEvent: "",
})
value1 := map[string]any{
"key1": 1,
"key2": "value2",
}
err := l.SetWithDefaultExpiry("test", value1)
require.NoError(t, err)
var value2 map[string]any
err = l.Get("test", &value2)
require.NoError(t, err)
assert.EqualValues(t, 1, value2["key1"])
v2, ok := value2["key2"].(string)
require.True(t, ok, "unable to cast value")
assert.Equal(t, "value2", v2)
post := model.Post{
Id: "id",
CreateAt: 11111,
UpdateAt: 11111,
DeleteAt: 11111,
EditAt: 111111,
IsPinned: true,
UserId: "UserId",
ChannelId: "ChannelId",
RootId: "RootId",
OriginalId: "OriginalId",
Message: "OriginalId",
MessageSource: "MessageSource",
Type: "Type",
Props: map[string]any{
"key": "val",
},
Hashtags: "Hashtags",
Filenames: []string{"item1", "item2"},
FileIds: []string{"item1", "item2"},
PendingPostId: "PendingPostId",
HasReactions: true,
ReplyCount: 11111,
Metadata: &model.PostMetadata{
Embeds: []*model.PostEmbed{
{
Type: "Type",
URL: "URL",
Data: "some data",
},
{
Type: "Type 2",
URL: "URL 2",
Data: "some data 2",
},
},
Emojis: []*model.Emoji{
{
Id: "id",
Name: "name",
},
},
Files: nil,
Images: map[string]*model.PostImage{
"key": {
Width: 1,
Height: 1,
Format: "format",
FrameCount: 1,
},
"key2": {
Width: 999,
Height: 888,
Format: "format 2",
FrameCount: 1000,
},
},
Reactions: []*model.Reaction{
{
UserId: "user_id",
PostId: "post_id",
EmojiName: "emoji_name",
CreateAt: 111,
},
},
},
}
err = l.SetWithDefaultExpiry("post", post.Clone())
require.NoError(t, err)
var p model.Post
err = l.Get("post", &p)
require.NoError(t, err)
require.Equal(t, post.Clone(), p.Clone())
session := &model.Session{
Id: "ty7ia14yuty5bmpt8wmz6da1fw",
Token: "79c3iq6nzpycmkkawudanqhg5c",
CreateAt: 1595445296960,
ExpiresAt: 1598296496960,
LastActivityAt: 1595445296960,
UserId: "rpgh1q5ra38y9xjn9z8fjctezr",
Roles: "system_admin system_user",
IsOAuth: false,
ExpiredNotify: false,
Props: map[string]string{
"csrf": "33zb7h7rk3rfffztojn5pxbkxe",
"isMobile": "false",
"isSaml": "false",
"is_guest": "false",
"os": "",
"platform": "Windows",
},
}
err = l.SetWithDefaultExpiry("session", session)
require.NoError(t, err)
var s = &model.Session{}
err = l.Get("session", s)
require.NoError(t, err)
require.Equal(t, session, s)
user := &model.User{
Id: "id",
CreateAt: 11111,
UpdateAt: 11111,
DeleteAt: 11111,
Username: "username",
Password: "password",
AuthService: "AuthService",
AuthData: nil,
Email: "Email",
EmailVerified: true,
Nickname: "Nickname",
FirstName: "FirstName",
LastName: "LastName",
Position: "Position",
Roles: "Roles",
AllowMarketing: true,
Props: map[string]string{
"key0": "value0",
},
NotifyProps: map[string]string{
"key0": "value0",
},
LastPasswordUpdate: 111111,
LastPictureUpdate: 111111,
FailedAttempts: 111111,
Locale: "Locale",
MfaActive: true,
MfaSecret: "MfaSecret",
LastActivityAt: 111111,
IsBot: true,
TermsOfServiceId: "TermsOfServiceId",
TermsOfServiceCreateAt: 111111,
}
err = l.SetWithDefaultExpiry("user", user)
require.NoError(t, err)
var u model.User
err = l.Get("user", &u)
require.NoError(t, err)
// msgp returns an empty map instead of a nil map.
// This does not make an actual difference in terms of functionality.
u.Timezone = nil
require.Equal(t, user, &u)
tt := make(model.UserMap)
tt["1"] = &u
err = l.SetWithDefaultExpiry("mm", tt)
require.NoError(t, err)
var out model.UserMap
err = l.Get("mm", &out)
require.NoError(t, err)
out["1"].Timezone = nil
require.Equal(t, tt, out)
}
func BenchmarkLRU(b *testing.B) {
value1 := "simplestring"
b.Run("simple=new", func(b *testing.B) {
for b.Loop() {
l2 := NewLRU(&CacheOptions{
Size: 1,
DefaultExpiry: 0,
InvalidateClusterEvent: "",
})
err := l2.SetWithDefaultExpiry("test", value1)
require.NoError(b, err)
var val string
err = l2.Get("test", &val)
require.NoError(b, err)
}
})
type obj struct {
Field1 int
Field2 string
Field3 struct {
Field4 int
Field5 string
}
Field6 map[string]string
}
value2 := obj{
1,
"field2",
struct {
Field4 int
Field5 string
}{
6,
"field5 is a looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong string",
},
map[string]string{
"key0": "value0",
"key1": "value value1",
"key2": "value value value2",
"key3": "value value value value3",
"key4": "value value value value value4",
"key5": "value value value value value value5",
"key6": "value value value value value value value6",
"key7": "value value value value value value value value7",
"key8": "value value value value value value value value value8",
"key9": "value value value value value value value value value value9",
},
}
b.Run("complex=new", func(b *testing.B) {
for b.Loop() {
l2 := NewLRU(&CacheOptions{
Size: 1,
DefaultExpiry: 0,
InvalidateClusterEvent: "",
})
err := l2.SetWithDefaultExpiry("test", value2)
require.NoError(b, err)
var val obj
err = l2.Get("test", &val)
require.NoError(b, err)
}
})
user := &model.User{
Id: "id",
CreateAt: 11111,
UpdateAt: 11111,
DeleteAt: 11111,
Username: "username",
Password: "password",
AuthService: "AuthService",
AuthData: nil,
Email: "Email",
EmailVerified: true,
Nickname: "Nickname",
FirstName: "FirstName",
LastName: "LastName",
Position: "Position",
Roles: "Roles",
AllowMarketing: true,
Props: map[string]string{
"key0": "value0",
"key1": "value value1",
"key2": "value value value2",
"key3": "value value value value3",
"key4": "value value value value value4",
"key5": "value value value value value value5",
"key6": "value value value value value value value6",
"key7": "value value value value value value value value7",
"key8": "value value value value value value value value value8",
"key9": "value value value value value value value value value value9",
},
NotifyProps: map[string]string{
"key0": "value0",
"key1": "value value1",
"key2": "value value value2",
"key3": "value value value value3",
"key4": "value value value value value4",
"key5": "value value value value value value5",
"key6": "value value value value value value value6",
"key7": "value value value value value value value value7",
"key8": "value value value value value value value value value8",
"key9": "value value value value value value value value value value9",
},
LastPasswordUpdate: 111111,
LastPictureUpdate: 111111,
FailedAttempts: 111111,
Locale: "Locale",
Timezone: map[string]string{
"key0": "value0",
"key1": "value value1",
"key2": "value value value2",
"key3": "value value value value3",
"key4": "value value value value value4",
"key5": "value value value value value value5",
"key6": "value value value value value value value6",
"key7": "value value value value value value value value7",
"key8": "value value value value value value value value value8",
"key9": "value value value value value value value value value value9",
},
MfaActive: true,
MfaSecret: "MfaSecret",
LastActivityAt: 111111,
IsBot: true,
BotDescription: "field5 is a looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong string",
BotLastIconUpdate: 111111,
TermsOfServiceId: "TermsOfServiceId",
TermsOfServiceCreateAt: 111111,
}
b.Run("User=new", func(b *testing.B) {
for b.Loop() {
l2 := NewLRU(&CacheOptions{
Size: 1,
DefaultExpiry: 0,
InvalidateClusterEvent: "",
})
err := l2.SetWithDefaultExpiry("test", user)
require.NoError(b, err)
var val model.User
err = l2.Get("test", &val)
require.NoError(b, err)
}
})
uMap := map[string]*model.User{
"id1": {
Id: "id1",
CreateAt: 1111,
UpdateAt: 1112,
Username: "user1",
Password: "pass",
},
"id2": {
Id: "id2",
CreateAt: 1113,
UpdateAt: 1114,
Username: "user2",
Password: "pass2",
},
}
b.Run("UserMap=new", func(b *testing.B) {
for b.Loop() {
l2 := NewLRU(&CacheOptions{
Size: 1,
DefaultExpiry: 0,
InvalidateClusterEvent: "",
})
err := l2.SetWithDefaultExpiry("test", model.UserMap(uMap))
require.NoError(b, err)
var val map[string]*model.User
err = l2.Get("test", &val)
require.NoError(b, err)
}
})
post := &model.Post{
Id: "id",
CreateAt: 11111,
UpdateAt: 11111,
DeleteAt: 11111,
EditAt: 111111,
IsPinned: true,
UserId: "UserId",
ChannelId: "ChannelId",
RootId: "RootId",
OriginalId: "OriginalId",
Message: "OriginalId",
MessageSource: "MessageSource",
Type: "Type",
Props: map[string]any{
"key": "val",
},
Hashtags: "Hashtags",
Filenames: []string{"item1", "item2"},
FileIds: []string{"item1", "item2"},
PendingPostId: "PendingPostId",
HasReactions: true,
// Transient data populated before sending a post to the client
ReplyCount: 11111,
Metadata: &model.PostMetadata{
Embeds: []*model.PostEmbed{
{
Type: "Type",
URL: "URL",
Data: "some data",
},
{
Type: "Type 2",
URL: "URL 2",
Data: "some data 2",
},
},
Emojis: []*model.Emoji{
{
Id: "id",
Name: "name",
},
},
Files: nil,
Images: map[string]*model.PostImage{
"key": {
Width: 1,
Height: 1,
Format: "format",
FrameCount: 1,
},
"key2": {
Width: 999,
Height: 888,
Format: "format 2",
FrameCount: 1000,
},
},
Reactions: []*model.Reaction{},
},
}
b.Run("Post=new", func(b *testing.B) {
for b.Loop() {
l2 := NewLRU(&CacheOptions{
Size: 1,
DefaultExpiry: 0,
InvalidateClusterEvent: "",
})
err := l2.SetWithDefaultExpiry("test", post)
require.NoError(b, err)
var val model.Post
err = l2.Get("test", &val)
require.NoError(b, err)
}
})
status := model.Status{
UserId: "UserId",
Status: "Status",
Manual: true,
LastActivityAt: 111111,
ActiveChannel: "ActiveChannel",
}
b.Run("Status=new", func(b *testing.B) {
for b.Loop() {
l2 := NewLRU(&CacheOptions{
Size: 1,
DefaultExpiry: 0,
InvalidateClusterEvent: "",
})
err := l2.SetWithDefaultExpiry("test", status)
require.NoError(b, err)
var val *model.Status
err = l2.Get("test", &val)
require.NoError(b, err)
}
})
session := model.Session{
Id: "ty7ia14yuty5bmpt8wmz6da1fw",
Token: "79c3iq6nzpycmkkawudanqhg5c",
CreateAt: 1595445296960,
ExpiresAt: 1598296496960,
LastActivityAt: 1595445296960,
UserId: "rpgh1q5ra38y9xjn9z8fjctezr",
Roles: "system_admin system_user",
IsOAuth: false,
ExpiredNotify: false,
Props: map[string]string{
"csrf": "33zb7h7rk3rfffztojn5pxbkxe",
"isMobile": "false",
"isSaml": "false",
"is_guest": "false",
"os": "",
"platform": "Windows",
},
}
b.Run("Session=new", func(b *testing.B) {
for b.Loop() {
l2 := NewLRU(&CacheOptions{
Size: 1,
DefaultExpiry: 0,
InvalidateClusterEvent: "",
})
err := l2.SetWithDefaultExpiry("test", &session)
require.NoError(b, err)
var val *model.Session
err = l2.Get("test", &val)
require.NoError(b, err)
}
})
}
func TestLRURace(t *testing.T) {
l2 := NewLRU(&CacheOptions{
Size: 1,
DefaultExpiry: 0,
InvalidateClusterEvent: "",
})
var wg sync.WaitGroup
l2.SetWithDefaultExpiry("test", "value1")
wg.Add(2)
go func() {
defer wg.Done()
value1 := "simplestring"
err := l2.SetWithDefaultExpiry("test", value1)
require.NoError(t, err)
}()
go func() {
defer wg.Done()
var val string
err := l2.Get("test", &val)
require.NoError(t, err)
}()
wg.Wait()
}

View File

@@ -0,0 +1,213 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Regenerate this file using `make cache-mocks`.
package mocks
import (
time "time"
model "github.com/mattermost/mattermost/server/public/model"
mock "github.com/stretchr/testify/mock"
)
// Cache is an autogenerated mock type for the Cache type
type Cache struct {
mock.Mock
}
// Get provides a mock function with given fields: key, value
func (_m *Cache) Get(key string, value interface{}) error {
ret := _m.Called(key, value)
if len(ret) == 0 {
panic("no return value specified for Get")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, interface{}) error); ok {
r0 = rf(key, value)
} else {
r0 = ret.Error(0)
}
return r0
}
// GetInvalidateClusterEvent provides a mock function with no fields
func (_m *Cache) GetInvalidateClusterEvent() model.ClusterEvent {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetInvalidateClusterEvent")
}
var r0 model.ClusterEvent
if rf, ok := ret.Get(0).(func() model.ClusterEvent); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(model.ClusterEvent)
}
return r0
}
// GetMulti provides a mock function with given fields: keys, values
func (_m *Cache) GetMulti(keys []string, values []interface{}) []error {
ret := _m.Called(keys, values)
if len(ret) == 0 {
panic("no return value specified for GetMulti")
}
var r0 []error
if rf, ok := ret.Get(0).(func([]string, []interface{}) []error); ok {
r0 = rf(keys, values)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]error)
}
}
return r0
}
// Name provides a mock function with no fields
func (_m *Cache) Name() string {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Name")
}
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// Purge provides a mock function with no fields
func (_m *Cache) Purge() error {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Purge")
}
var r0 error
if rf, ok := ret.Get(0).(func() error); ok {
r0 = rf()
} else {
r0 = ret.Error(0)
}
return r0
}
// Remove provides a mock function with given fields: key
func (_m *Cache) Remove(key string) error {
ret := _m.Called(key)
if len(ret) == 0 {
panic("no return value specified for Remove")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(key)
} else {
r0 = ret.Error(0)
}
return r0
}
// RemoveMulti provides a mock function with given fields: keys
func (_m *Cache) RemoveMulti(keys []string) error {
ret := _m.Called(keys)
if len(ret) == 0 {
panic("no return value specified for RemoveMulti")
}
var r0 error
if rf, ok := ret.Get(0).(func([]string) error); ok {
r0 = rf(keys)
} else {
r0 = ret.Error(0)
}
return r0
}
// Scan provides a mock function with given fields: f
func (_m *Cache) Scan(f func([]string) error) error {
ret := _m.Called(f)
if len(ret) == 0 {
panic("no return value specified for Scan")
}
var r0 error
if rf, ok := ret.Get(0).(func(func([]string) error) error); ok {
r0 = rf(f)
} else {
r0 = ret.Error(0)
}
return r0
}
// SetWithDefaultExpiry provides a mock function with given fields: key, value
func (_m *Cache) SetWithDefaultExpiry(key string, value interface{}) error {
ret := _m.Called(key, value)
if len(ret) == 0 {
panic("no return value specified for SetWithDefaultExpiry")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, interface{}) error); ok {
r0 = rf(key, value)
} else {
r0 = ret.Error(0)
}
return r0
}
// SetWithExpiry provides a mock function with given fields: key, value, ttl
func (_m *Cache) SetWithExpiry(key string, value interface{}, ttl time.Duration) error {
ret := _m.Called(key, value, ttl)
if len(ret) == 0 {
panic("no return value specified for SetWithExpiry")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, interface{}, time.Duration) error); ok {
r0 = rf(key, value, ttl)
} else {
r0 = ret.Error(0)
}
return r0
}
// NewCache creates a new instance of Cache. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewCache(t interface {
mock.TestingT
Cleanup(func())
}) *Cache {
mock := &Cache{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,249 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Regenerate this file using `make cache-mocks`.
package mocks
import (
time "time"
model "github.com/mattermost/mattermost/server/public/model"
mock "github.com/stretchr/testify/mock"
)
// ExternalCache is an autogenerated mock type for the ExternalCache type
type ExternalCache struct {
mock.Mock
}
// Decrement provides a mock function with given fields: key, val
func (_m *ExternalCache) Decrement(key string, val int) error {
ret := _m.Called(key, val)
if len(ret) == 0 {
panic("no return value specified for Decrement")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, int) error); ok {
r0 = rf(key, val)
} else {
r0 = ret.Error(0)
}
return r0
}
// Get provides a mock function with given fields: key, value
func (_m *ExternalCache) Get(key string, value interface{}) error {
ret := _m.Called(key, value)
if len(ret) == 0 {
panic("no return value specified for Get")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, interface{}) error); ok {
r0 = rf(key, value)
} else {
r0 = ret.Error(0)
}
return r0
}
// GetInvalidateClusterEvent provides a mock function with no fields
func (_m *ExternalCache) GetInvalidateClusterEvent() model.ClusterEvent {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetInvalidateClusterEvent")
}
var r0 model.ClusterEvent
if rf, ok := ret.Get(0).(func() model.ClusterEvent); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(model.ClusterEvent)
}
return r0
}
// GetMulti provides a mock function with given fields: keys, values
func (_m *ExternalCache) GetMulti(keys []string, values []interface{}) []error {
ret := _m.Called(keys, values)
if len(ret) == 0 {
panic("no return value specified for GetMulti")
}
var r0 []error
if rf, ok := ret.Get(0).(func([]string, []interface{}) []error); ok {
r0 = rf(keys, values)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]error)
}
}
return r0
}
// Increment provides a mock function with given fields: key, val
func (_m *ExternalCache) Increment(key string, val int) error {
ret := _m.Called(key, val)
if len(ret) == 0 {
panic("no return value specified for Increment")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, int) error); ok {
r0 = rf(key, val)
} else {
r0 = ret.Error(0)
}
return r0
}
// Name provides a mock function with no fields
func (_m *ExternalCache) Name() string {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Name")
}
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// Purge provides a mock function with no fields
func (_m *ExternalCache) Purge() error {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Purge")
}
var r0 error
if rf, ok := ret.Get(0).(func() error); ok {
r0 = rf()
} else {
r0 = ret.Error(0)
}
return r0
}
// Remove provides a mock function with given fields: key
func (_m *ExternalCache) Remove(key string) error {
ret := _m.Called(key)
if len(ret) == 0 {
panic("no return value specified for Remove")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(key)
} else {
r0 = ret.Error(0)
}
return r0
}
// RemoveMulti provides a mock function with given fields: keys
func (_m *ExternalCache) RemoveMulti(keys []string) error {
ret := _m.Called(keys)
if len(ret) == 0 {
panic("no return value specified for RemoveMulti")
}
var r0 error
if rf, ok := ret.Get(0).(func([]string) error); ok {
r0 = rf(keys)
} else {
r0 = ret.Error(0)
}
return r0
}
// Scan provides a mock function with given fields: f
func (_m *ExternalCache) Scan(f func([]string) error) error {
ret := _m.Called(f)
if len(ret) == 0 {
panic("no return value specified for Scan")
}
var r0 error
if rf, ok := ret.Get(0).(func(func([]string) error) error); ok {
r0 = rf(f)
} else {
r0 = ret.Error(0)
}
return r0
}
// SetWithDefaultExpiry provides a mock function with given fields: key, value
func (_m *ExternalCache) SetWithDefaultExpiry(key string, value interface{}) error {
ret := _m.Called(key, value)
if len(ret) == 0 {
panic("no return value specified for SetWithDefaultExpiry")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, interface{}) error); ok {
r0 = rf(key, value)
} else {
r0 = ret.Error(0)
}
return r0
}
// SetWithExpiry provides a mock function with given fields: key, value, ttl
func (_m *ExternalCache) SetWithExpiry(key string, value interface{}, ttl time.Duration) error {
ret := _m.Called(key, value, ttl)
if len(ret) == 0 {
panic("no return value specified for SetWithExpiry")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, interface{}, time.Duration) error); ok {
r0 = rf(key, value, ttl)
} else {
r0 = ret.Error(0)
}
return r0
}
// NewExternalCache creates a new instance of ExternalCache. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewExternalCache(t interface {
mock.TestingT
Cleanup(func())
}) *ExternalCache {
mock := &ExternalCache{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,129 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Regenerate this file using `make cache-mocks`.
package mocks
import (
einterfaces "github.com/mattermost/mattermost/server/v8/einterfaces"
cache "github.com/mattermost/mattermost/server/v8/platform/services/cache"
mock "github.com/stretchr/testify/mock"
)
// Provider is an autogenerated mock type for the Provider type
type Provider struct {
mock.Mock
}
// Close provides a mock function with no fields
func (_m *Provider) Close() error {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Close")
}
var r0 error
if rf, ok := ret.Get(0).(func() error); ok {
r0 = rf()
} else {
r0 = ret.Error(0)
}
return r0
}
// Connect provides a mock function with no fields
func (_m *Provider) Connect() (string, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Connect")
}
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func() (string, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// NewCache provides a mock function with given fields: opts
func (_m *Provider) NewCache(opts *cache.CacheOptions) (cache.Cache, error) {
ret := _m.Called(opts)
if len(ret) == 0 {
panic("no return value specified for NewCache")
}
var r0 cache.Cache
var r1 error
if rf, ok := ret.Get(0).(func(*cache.CacheOptions) (cache.Cache, error)); ok {
return rf(opts)
}
if rf, ok := ret.Get(0).(func(*cache.CacheOptions) cache.Cache); ok {
r0 = rf(opts)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(cache.Cache)
}
}
if rf, ok := ret.Get(1).(func(*cache.CacheOptions) error); ok {
r1 = rf(opts)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// SetMetrics provides a mock function with given fields: metrics
func (_m *Provider) SetMetrics(metrics einterfaces.MetricsInterface) {
_m.Called(metrics)
}
// Type provides a mock function with no fields
func (_m *Provider) Type() string {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Type")
}
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// NewProvider creates a new instance of Provider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewProvider(t interface {
mock.TestingT
Cleanup(func())
}) *Provider {
mock := &Provider{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,146 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package cache
import (
"context"
"fmt"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/einterfaces"
"github.com/redis/rueidis"
)
// CacheOptions contains options for initializing a cache
type CacheOptions struct {
Size int
DefaultExpiry time.Duration
Name string
InvalidateClusterEvent model.ClusterEvent
Striped bool
// StripedBuckets is used only by LRUStriped and shouldn't be greater than the number
// of CPUs available on the machine running this cache.
StripedBuckets int
}
// Provider is a provider for Cache
type Provider interface {
// NewCache creates a new cache with given options.
NewCache(opts *CacheOptions) (Cache, error)
// Connect opens a new connection to the cache using specific provider parameters.
// The returned string contains the status of the response from the cache backend.
Connect() (string, error)
// SetMetrics
SetMetrics(metrics einterfaces.MetricsInterface)
// Close releases any resources used by the cache provider.
Close() error
// Type returns what type of cache it generates.
Type() string
}
type cacheProvider struct {
}
// NewProvider creates a new CacheProvider
func NewProvider() Provider {
return &cacheProvider{}
}
// NewCache creates a new cache with given opts
func (c *cacheProvider) NewCache(opts *CacheOptions) (Cache, error) {
if opts.Striped {
return NewLRUStriped(opts)
}
return NewLRU(opts), nil
}
// Connect opens a new connection to the cache using specific provider parameters.
func (c *cacheProvider) Connect() (string, error) {
return "OK", nil
}
func (c *cacheProvider) SetMetrics(metrics einterfaces.MetricsInterface) {
}
// Close releases any resources used by the cache provider.
func (c *cacheProvider) Close() error {
return nil
}
func (c *cacheProvider) Type() string {
return model.CacheTypeLRU
}
type redisProvider struct {
client rueidis.Client
cachePrefix string
metrics einterfaces.MetricsInterface
}
type RedisOptions struct {
RedisAddr string
RedisPassword string
RedisDB int
RedisCachePrefix string
DisableCache bool
}
// NewProvider creates a new CacheProvider
func NewRedisProvider(opts *RedisOptions) (Provider, error) {
client, err := rueidis.NewClient(rueidis.ClientOption{
InitAddress: []string{opts.RedisAddr},
Password: opts.RedisPassword,
SelectDB: opts.RedisDB,
ForceSingleClient: true,
CacheSizeEachConn: 32 * (1 << 20), // 32MiB local cache size
DisableCache: opts.DisableCache,
// This is used to collect more commands before flushing to Redis.
// This increases latency at the cost of lower CPU usage at Redis.
// It's a tradeoff we are willing to make because Redis is only
// meant to be used at very high scales. The docs suggest 20us,
// but going as high as 250us doesn't make any material difference.
MaxFlushDelay: 250 * time.Microsecond,
DisableRetry: true,
// The default is 10s, which is a bit too high
ConnWriteTimeout: 5 * time.Second,
})
if err != nil {
return nil, err
}
return &redisProvider{client: client, cachePrefix: opts.RedisCachePrefix}, nil
}
// NewCache creates a new cache with given opts
func (r *redisProvider) NewCache(opts *CacheOptions) (Cache, error) {
if r.cachePrefix != "" {
opts.Name = r.cachePrefix + ":" + opts.Name
}
rr, err := NewRedis(opts, r.client)
rr.metrics = r.metrics
return rr, err
}
// Connect opens a new connection to the cache using specific provider parameters.
func (r *redisProvider) Connect() (string, error) {
res, err := r.client.Do(context.Background(), r.client.B().Ping().Build()).ToString()
if err != nil {
return "", fmt.Errorf("unable to establish connection with redis: %v", err)
}
return res, nil
}
func (r *redisProvider) SetMetrics(metrics einterfaces.MetricsInterface) {
r.metrics = metrics
}
func (r *redisProvider) Type() string {
return model.CacheTypeRedis
}
// Close releases any resources used by the cache provider.
func (r *redisProvider) Close() error {
r.client.Close()
return nil
}

View File

@@ -0,0 +1,173 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package cache
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestNewCache(t *testing.T) {
t.Run("with only size option given", func(t *testing.T) {
p := NewProvider()
size := 1
c, err := p.NewCache(&CacheOptions{
Size: size,
DefaultExpiry: 0,
})
require.NoError(t, err)
err = c.SetWithDefaultExpiry("key1", "val1")
require.NoError(t, err)
err = c.SetWithDefaultExpiry("key2", "val2")
require.NoError(t, err)
err = c.SetWithDefaultExpiry("key3", "val3")
require.NoError(t, err)
})
t.Run("with only size option given", func(t *testing.T) {
p := NewProvider()
size := 1
c, err := p.NewCache(&CacheOptions{
Size: size,
DefaultExpiry: 0,
})
require.NoError(t, err)
err = c.SetWithDefaultExpiry("key1", "val1")
require.NoError(t, err)
err = c.SetWithDefaultExpiry("key2", "val2")
require.NoError(t, err)
err = c.SetWithDefaultExpiry("key3", "val3")
require.NoError(t, err)
})
t.Run("with all options specified", func(t *testing.T) {
p := NewProvider()
size := 1
expiry := 1 * time.Second
event := model.ClusterEvent("clusterEvent")
c, err := p.NewCache(&CacheOptions{
Size: size,
Name: "name",
DefaultExpiry: expiry,
InvalidateClusterEvent: event,
})
require.NoError(t, err)
require.Equal(t, event, c.GetInvalidateClusterEvent())
err = c.SetWithDefaultExpiry("key1", "val1")
require.NoError(t, err)
err = c.SetWithDefaultExpiry("key2", "val2")
require.NoError(t, err)
err = c.SetWithDefaultExpiry("key3", "val3")
require.NoError(t, err)
time.Sleep(expiry + 1*time.Second)
var v string
err = c.Get("key1", &v)
require.Equal(t, ErrKeyNotFound, err)
err = c.Get("key2", &v)
require.Equal(t, ErrKeyNotFound, err)
err = c.Get("key3", &v)
require.Equal(t, ErrKeyNotFound, err)
})
}
func TestNewCache_Striped(t *testing.T) {
t.Run("with only size option given", func(t *testing.T) {
p := NewProvider()
size := 1
c, err := p.NewCache(&CacheOptions{
Size: size,
Striped: true,
StripedBuckets: 1,
DefaultExpiry: 0,
})
require.NoError(t, err)
err = c.SetWithDefaultExpiry("key1", "val1")
require.NoError(t, err)
err = c.SetWithDefaultExpiry("key2", "val2")
require.NoError(t, err)
err = c.SetWithDefaultExpiry("key3", "val3")
require.NoError(t, err)
})
t.Run("with only size option given", func(t *testing.T) {
p := NewProvider()
size := 1
c, err := p.NewCache(&CacheOptions{
Size: size,
Striped: true,
StripedBuckets: 1,
DefaultExpiry: 0,
})
require.NoError(t, err)
err = c.SetWithDefaultExpiry("key1", "val1")
require.NoError(t, err)
err = c.SetWithDefaultExpiry("key2", "val2")
require.NoError(t, err)
err = c.SetWithDefaultExpiry("key3", "val3")
require.NoError(t, err)
})
t.Run("with all options specified", func(t *testing.T) {
p := NewProvider()
size := 1
expiry := 1 * time.Second
event := model.ClusterEvent("clusterEvent")
c, err := p.NewCache(&CacheOptions{
Size: size,
Name: "name",
DefaultExpiry: expiry,
InvalidateClusterEvent: event,
Striped: true,
StripedBuckets: 1,
})
require.NoError(t, err)
require.Equal(t, event, c.GetInvalidateClusterEvent())
err = c.SetWithDefaultExpiry("key1", "val1")
require.NoError(t, err)
err = c.SetWithDefaultExpiry("key2", "val2")
require.NoError(t, err)
err = c.SetWithDefaultExpiry("key3", "val3")
require.NoError(t, err)
time.Sleep(expiry + 1*time.Second)
var v string
err = c.Get("key1", &v)
require.Equal(t, ErrKeyNotFound, err)
err = c.Get("key2", &v)
require.Equal(t, ErrKeyNotFound, err)
err = c.Get("key3", &v)
require.Equal(t, ErrKeyNotFound, err)
})
}
func TestConnectClose(t *testing.T) {
p := NewProvider()
_, err := p.Connect()
require.NoError(t, err)
err = p.Close()
require.NoError(t, err)
}

341
server/platform/services/cache/redis.go vendored Normal file
View File

@@ -0,0 +1,341 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package cache
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/einterfaces"
"github.com/redis/rueidis"
"github.com/tinylib/msgp/msgp"
"github.com/vmihailenco/msgpack/v5"
)
const clientSideTTL = 5 * time.Minute
type Redis struct {
name string
client rueidis.Client
defaultExpiry time.Duration
metrics einterfaces.MetricsInterface
}
func NewRedis(opts *CacheOptions, client rueidis.Client) (*Redis, error) {
if opts.Name == "" {
return nil, errors.New("no name specified for cache")
}
return &Redis{
name: opts.Name,
defaultExpiry: opts.DefaultExpiry,
client: client,
}, nil
}
func (r *Redis) Purge() error {
return r.Scan(r.RemoveMulti)
}
// SetWithDefaultExpiry adds the given key and value to the store with the default expiry. If
// the key already exists, it will overwrite the previous value
func (r *Redis) SetWithDefaultExpiry(key string, value any) error {
return r.SetWithExpiry(key, value, r.defaultExpiry)
}
// SetWithExpiry adds the given key and value to the cache with the given expiry. If the key
// already exists, it will overwrite the previous value
func (r *Redis) SetWithExpiry(key string, value any, ttl time.Duration) error {
now := time.Now()
defer func() {
if r.metrics != nil {
elapsed := time.Since(now).Seconds()
r.metrics.ObserveRedisEndpointDuration(r.name, "Set", elapsed)
}
}()
var valueString string
if intVal, ok := value.(int64); ok {
valueString = strconv.Itoa(int(intVal))
} else {
var buf []byte
var err error
// We use a fast path for hot structs.
if msgpVal, ok := value.(msgp.Marshaler); ok {
buf, err = msgpVal.MarshalMsg(nil)
} else {
// Slow path for other structs.
buf, err = msgpack.Marshal(value)
}
if err != nil {
return err
}
valueString = rueidis.BinaryString(buf)
}
setValue := r.client.B().Set().
Key(r.name + ":" + key).
Value(valueString)
if ttl > 0 {
setValue.Ex(ttl)
}
return r.client.Do(context.Background(), setValue.Build()).Error()
}
// Increment increments the value of the key by the value.
func (r *Redis) Increment(key string, val int) error {
now := time.Now()
defer func() {
if r.metrics != nil {
elapsed := time.Since(now).Seconds()
r.metrics.ObserveRedisEndpointDuration(r.name, "Incr", elapsed)
}
}()
return r.client.Do(context.Background(),
r.client.B().Incrby().
Key(r.name+":"+key).
Increment(int64(val)).
Build(),
).Error()
}
// Decrement decrements the value of the key by the value.
func (r *Redis) Decrement(key string, val int) error {
now := time.Now()
defer func() {
if r.metrics != nil {
elapsed := time.Since(now).Seconds()
r.metrics.ObserveRedisEndpointDuration(r.name, "Decr", elapsed)
}
}()
return r.client.Do(context.Background(),
r.client.B().Decrby().
Key(r.name+":"+key).
Decrement(int64(val)).
Build(),
).Error()
}
// Get the content stored in the cache for the given key, and decode it into the value interface.
// Return ErrKeyNotFound if the key is missing from the cache
func (r *Redis) Get(key string, value any) error {
now := time.Now()
defer func() {
if r.metrics != nil {
elapsed := time.Since(now).Seconds()
r.metrics.ObserveRedisEndpointDuration(r.name, "Get", elapsed)
}
}()
resp := r.client.DoCache(context.Background(),
r.client.B().Get().
Key(r.name+":"+key).
Cache(),
clientSideTTL,
)
var intVal int64
var bytesVal []byte
var err error
vPtr, ok := value.(*int64)
if ok {
intVal, err = resp.AsInt64()
} else {
bytesVal, err = resp.AsBytes()
}
if err != nil {
if rueidis.IsRedisNil(err) {
return ErrKeyNotFound
}
return err
}
if ok {
*vPtr = intVal
return nil
}
// We use a fast path for hot structs.
if msgpVal, ok := value.(msgp.Unmarshaler); ok {
_, err := msgpVal.UnmarshalMsg(bytesVal)
return err
}
// Slow path for other structs.
return msgpack.Unmarshal(bytesVal, value)
}
// GetMulti uses the MGET primitive to fetch multiple keys in a single operation.
func (r *Redis) GetMulti(keys []string, values []any) []error {
now := time.Now()
defer func() {
if r.metrics != nil {
elapsed := time.Since(now).Seconds()
r.metrics.ObserveRedisEndpointDuration(r.name, "GetMulti", elapsed)
}
}()
errs := make([]error, len(keys))
newKeys := sliceMapper(keys, func(elem string) string {
return r.name + ":" + elem
})
vals, err := r.client.DoCache(context.Background(),
r.client.B().Mget().
Key(newKeys...).
Cache(),
clientSideTTL,
).ToArray()
if err != nil {
for i := range errs {
errs[i] = err
}
return errs
}
if len(vals) != len(keys) {
for i := range errs {
errs[i] = fmt.Errorf("length of returned vals %d, does not match length of keys %d", len(vals), len(keys))
}
return errs
}
for i, resp := range vals {
if resp.IsNil() {
errs[i] = ErrKeyNotFound
continue
}
var intVal int64
var bytesVal []byte
var err error
vPtr, ok := values[i].(*int64)
if ok {
intVal, err = resp.AsInt64()
} else {
bytesVal, err = resp.AsBytes()
}
if err != nil {
errs[i] = err
continue
}
if ok {
*vPtr = intVal
errs[i] = nil
continue
}
// We use a fast path for hot structs.
if msgpVal, ok := values[i].(msgp.Unmarshaler); ok {
_, err := msgpVal.UnmarshalMsg(bytesVal)
errs[i] = err
continue
}
// Slow path for other structs.
errs[i] = msgpack.Unmarshal(bytesVal, values[i])
}
return errs
}
// Remove deletes the value for a given key.
func (r *Redis) Remove(key string) error {
now := time.Now()
defer func() {
if r.metrics != nil {
elapsed := time.Since(now).Seconds()
r.metrics.ObserveRedisEndpointDuration(r.name, "Remove", elapsed)
}
}()
return r.client.Do(context.Background(),
r.client.B().Del().
Key(r.name+":"+key).
Build(),
).Error()
}
func (r *Redis) RemoveMulti(keys []string) error {
now := time.Now()
defer func() {
if r.metrics != nil {
elapsed := time.Since(now).Seconds()
r.metrics.ObserveRedisEndpointDuration(r.name, "RemoveMulti", elapsed)
}
}()
if len(keys) == 0 {
return nil
}
newKeys := sliceMapper(keys, func(elem string) string {
return r.name + ":" + elem
})
return r.client.Do(context.Background(),
r.client.B().Del().
Key(newKeys...).
Build(),
).Error()
}
func (r *Redis) Scan(f func([]string) error) error {
now := time.Now()
defer func() {
if r.metrics != nil {
elapsed := time.Since(now).Seconds()
r.metrics.ObserveRedisEndpointDuration(r.name, "Scan", elapsed)
}
}()
var scan rueidis.ScanEntry
var err error
for more := true; more; more = scan.Cursor != 0 {
scan, err = r.client.Do(context.Background(),
r.client.B().Scan().
Cursor(scan.Cursor).
Match(r.name+":*").
Count(100).
Build()).AsScanEntry()
if err != nil {
return err
}
removed := sliceMapper(scan.Elements, func(elem string) string {
return strings.TrimPrefix(elem, r.name+":")
})
err = f(removed)
if err != nil {
return err
}
}
return nil
}
// GetInvalidateClusterEvent returns the cluster event configured when this cache was created.
func (r *Redis) GetInvalidateClusterEvent() model.ClusterEvent {
return model.ClusterEventNone
}
func (r *Redis) Name() string {
return r.name
}
func sliceMapper[S ~[]E, E, R any](slice S, mapper func(E) R) []R {
newSlice := make([]R, len(slice))
for i, v := range slice {
newSlice[i] = mapper(v)
}
return newSlice
}

View File

@@ -0,0 +1,120 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package docextractor
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/mholt/archives"
"github.com/mattermost/mattermost/server/v8/channels/utils"
)
type archiveExtractor struct {
SubExtractor Extractor
}
func (ae *archiveExtractor) Name() string {
return "archiveExtractor"
}
func (ae *archiveExtractor) Match(filename string) bool {
_, _, err := archives.Identify(context.Background(), filename, nil)
return err == nil
}
// getExtAlsoTarGz returns the extension of the given file name, special casing .tar.gz.
func getExtAlsoTarGz(name string) string {
if strings.HasSuffix(name, ".tar.gz") {
return ".tar.gz"
}
return filepath.Ext(name)
}
func (ae *archiveExtractor) Extract(ctx context.Context, name string, r io.ReadSeeker, maxFileSize int64) (string, error) {
// MM-65701: Skip 7zip files due to OOM vulnerability in bodgit/sevenzip library
match, _ := (archives.SevenZip{}).Match(context.Background(), name, r)
_, _ = r.Seek(0, io.SeekStart) // Reset reader position after Match reads from stream
if match.ByName || match.ByStream {
return "", nil
}
ext := getExtAlsoTarGz(name)
// Create a temporary file, using `*` control the random component while preserving the extension.
f, err := os.CreateTemp("", "archiver-*"+ext)
if err != nil {
return "", fmt.Errorf("error creating temporary file: %v", err)
}
defer os.Remove(f.Name())
_, err = io.Copy(f, r)
f.Close()
if err != nil {
return "", fmt.Errorf("error copying data into temporary file: %v", err)
}
var text strings.Builder
fsys, err := archives.FileSystem(context.Background(), f.Name(), nil)
if err != nil {
return "", fmt.Errorf("error creating file system: %w", err)
}
err = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
text.WriteString(path + " ")
if ae.SubExtractor != nil {
filename := filepath.Base(path)
filename = strings.ReplaceAll(filename, "-", " ")
filename = strings.ReplaceAll(filename, ".", " ")
filename = strings.ReplaceAll(filename, ",", " ")
file, err := fsys.Open(path)
if err != nil {
return err
}
defer file.Close()
// Limit the size of decompressed archive entries to prevent
// memory exhaustion from zip bombs or other malicious archives.
var reader io.Reader = file
if maxFileSize > 0 {
reader = utils.NewLimitedReaderWithError(file, maxFileSize)
}
data, err := io.ReadAll(reader)
if err != nil {
return fmt.Errorf("error reading archive entry: %w", err)
}
subtext, extractErr := ae.SubExtractor.Extract(ctx, filename, bytes.NewReader(data), maxFileSize)
if extractErr == nil {
text.WriteString(subtext + " ")
} else if errors.Is(extractErr, context.Canceled) || errors.Is(extractErr, context.DeadlineExceeded) {
return fmt.Errorf("error extracting archive entry: %w", extractErr)
}
}
return nil
})
if err != nil {
return "", err
}
return text.String(), nil
}

View File

@@ -0,0 +1,117 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package docextractor
import (
"archive/zip"
"bytes"
"context"
"io"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestArchiveExtractorSkips7zip(t *testing.T) {
ae := &archiveExtractor{}
t.Run("7zip file with .7z extension returns empty string", func(t *testing.T) {
// Valid 7zip header (minimal)
sevenZipData := []byte{0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c, 0x00, 0x00}
result, err := ae.Extract(context.Background(), "test.7z", bytes.NewReader(sevenZipData), 0)
require.NoError(t, err)
assert.Empty(t, result)
})
t.Run("7zip content with wrong extension is still blocked", func(t *testing.T) {
// 7zip content disguised with .zip extension - should still be blocked via stream detection
sevenZipData := []byte{0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c, 0x00, 0x00}
result, err := ae.Extract(context.Background(), "malicious.zip", bytes.NewReader(sevenZipData), 0)
require.NoError(t, err)
assert.Empty(t, result)
})
t.Run("7zip at offset with .7z extension is blocked via filename", func(t *testing.T) {
junkPrefix := []byte{0x00, 0x00, 0x00, 0x00}
sevenZipSig := []byte{0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c, 0x00, 0x00}
dataWithOffset := append(junkPrefix, sevenZipSig...)
result, err := ae.Extract(context.Background(), "test.7z", bytes.NewReader(dataWithOffset), 0)
require.NoError(t, err)
assert.Empty(t, result)
})
t.Run("7zip at offset with wrong extension fails safely", func(t *testing.T) {
// Edge case: 7zip content at offset with non-.7z extension.
// Our check won't catch it (ByName=false, ByStream=false), but archives.FileSystem
// also won't identify it as 7zip, so it fails to extract rather than triggering OOM.
junkPrefix := []byte{0x00, 0x00, 0x00, 0x00}
sevenZipSig := []byte{0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c, 0x00, 0x00}
dataWithOffset := append(junkPrefix, sevenZipSig...)
_, err := ae.Extract(context.Background(), "malicious.zip", bytes.NewReader(dataWithOffset), 0)
assert.Error(t, err) // fails to extract as any valid archive format
})
}
// contextErrorExtractor fails every extraction with the given context error.
type contextErrorExtractor struct {
err error
}
func (ce *contextErrorExtractor) Name() string {
return "contextErrorExtractor"
}
func (ce *contextErrorExtractor) Match(filename string) bool {
return true
}
func (ce *contextErrorExtractor) Extract(_ context.Context, _ string, _ io.ReadSeeker, _ int64) (string, error) {
return "", ce.err
}
func TestArchiveExtractorErrorOmitsEntryName(t *testing.T) {
// The entry name is transformed before reaching the nested extraction error
// (separators become spaces), so assert on the stem token as well: it
// survives that transformation and would appear in either leaky error.
const entryName = "confidential-customer-list.txt"
const entryStem = "confidential"
var archive bytes.Buffer
zw := zip.NewWriter(&archive)
entry, err := zw.Create(entryName)
require.NoError(t, err)
_, err = entry.Write([]byte(strings.Repeat("a", 1024)))
require.NoError(t, err)
require.NoError(t, zw.Close())
requireNoEntryName := func(t *testing.T, err error) {
t.Helper()
require.Error(t, err)
assert.NotContains(t, err.Error(), entryName)
assert.NotContains(t, err.Error(), entryStem)
}
t.Run("entry read failure", func(t *testing.T) {
ae := &archiveExtractor{SubExtractor: &plainExtractor{}}
// A maxFileSize below the entry size fails the entry read.
_, err := ae.Extract(context.Background(), "archive.zip", bytes.NewReader(archive.Bytes()), 8)
requireNoEntryName(t, err)
})
for name, contextErr := range map[string]error{
"cancelled": context.Canceled,
"deadline exceeded": context.DeadlineExceeded,
} {
t.Run("nested extraction "+name, func(t *testing.T) {
ae := &archiveExtractor{SubExtractor: &contextErrorExtractor{err: contextErr}}
_, err := ae.Extract(context.Background(), "archive.zip", bytes.NewReader(archive.Bytes()), 0)
requireNoEntryName(t, err)
assert.ErrorIs(t, err, contextErr)
})
}
}

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package docextractor
import (
"context"
"errors"
"io"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
type combineExtractor struct {
logger mlog.LoggerIFace
SubExtractors []Extractor
}
func (ce *combineExtractor) Name() string {
return "combineExtractor"
}
func (ce *combineExtractor) Add(extractor Extractor) {
ce.SubExtractors = append(ce.SubExtractors, extractor)
}
func (ce *combineExtractor) Match(filename string) bool {
for _, extractor := range ce.SubExtractors {
if extractor.Match(filename) {
return true
}
}
return false
}
func (ce *combineExtractor) Extract(ctx context.Context, filename string, r io.ReadSeeker, maxFileSize int64) (string, error) {
for _, extractor := range ce.SubExtractors {
if extractor.Match(filename) {
r.Seek(0, io.SeekStart)
text, err := extractor.Extract(ctx, filename, r, maxFileSize)
if err != nil {
// Context errors must not be retried: the caller explicitly
// cancelled or timed out, so stop immediately.
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return "", err
}
ce.logger.Warn("Unable to extract file content", mlog.String("extractor", extractor.Name()), mlog.Err(err))
continue
}
return text, nil
}
}
return "", nil
}

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package docextractor
import (
"runtime"
"sync"
)
// maxConcurrentExtractions caps how many docconv extractions may run at once,
// including detached goroutines that continue after a per-extraction timeout.
var maxConcurrentExtractions = runtime.NumCPU()
var (
extractionSlotsMu sync.Mutex
extractionSlots = make(chan struct{}, maxConcurrentExtractions)
)
func tryAcquireExtractionSlot() bool {
select {
case extractionSlots <- struct{}{}:
return true
default:
return false
}
}
func releaseExtractionSlot() {
<-extractionSlots
}
// resetExtractionConcurrencyForTest reinitializes the extraction slot semaphore.
// It is only intended for use from tests in this package.
func resetExtractionConcurrencyForTest(limit int) {
extractionSlotsMu.Lock()
defer extractionSlotsMu.Unlock()
maxConcurrentExtractions = limit
extractionSlots = make(chan struct{}, limit)
}

View File

@@ -0,0 +1,149 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package docextractor
import (
"context"
"errors"
"fmt"
"io"
"time"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
// ExtractSettings defines the features enabled/disable during the document text extraction.
type ExtractSettings struct {
// Ctx is the parent context for the extraction. Cancellation propagates
// to context-aware extractors (e.g. the Go-native PDF extractor stops at
// the next page boundary). If nil, context.Background() is used.
Ctx context.Context
ArchiveRecursion bool
MaxFileSize int64
MMPreviewURL string
MMPreviewSecret string
// Timeout bounds how long a caller waits for a single extraction. When
// set, a child context with this deadline is derived from Ctx and passed
// to the extractor. Context-aware extractors (pdfExtractor) honour it
// and stop at the next page boundary; others run to completion on a
// detached goroutine but no longer hold the caller's worker slot. A
// global cap on concurrent extractions (including those detached
// goroutines) prevents sustained uploads from accumulating unbounded work.
Timeout time.Duration
// ReaderCloser, when set, transfers ownership of closing the input reader
// to this package. It is closed only after extraction has actually
// finished reading. This matters with Timeout set: on timeout the caller
// returns while the converter may still be reading on a detached
// goroutine, so the caller must NOT close the reader itself or it would
// race with (and close the file out from under) that goroutine.
ReaderCloser io.Closer
}
// Extract extract the text from a document using the system default extractors
func Extract(logger mlog.LoggerIFace, filename string, r io.ReadSeeker, settings ExtractSettings) (string, error) {
return ExtractWithExtraExtractors(logger, filename, r, settings, []Extractor{})
}
// ExtractWithExtraExtractors extract the text from a document using the provided extractors beside the system default extractors.
func ExtractWithExtraExtractors(logger mlog.LoggerIFace, filename string, r io.ReadSeeker, settings ExtractSettings, extraExtractors []Extractor) (string, error) {
enabledExtractors := &combineExtractor{
logger: logger,
}
for _, extraExtractor := range extraExtractors {
enabledExtractors.Add(extraExtractor)
}
enabledExtractors.Add(&documentExtractor{})
enabledExtractors.Add(&pdfExtractor{})
if settings.ArchiveRecursion {
enabledExtractors.Add(&archiveExtractor{SubExtractor: enabledExtractors})
} else {
enabledExtractors.Add(&archiveExtractor{})
}
if settings.MMPreviewURL != "" {
enabledExtractors.Add(newMMPreviewExtractor(settings.MMPreviewURL, settings.MMPreviewSecret, pdfExtractor{}))
}
enabledExtractors.Add(&plainExtractor{})
if enabledExtractors.Match(filename) {
return extractWithTimeout(enabledExtractors, filename, r, settings)
}
// No extractor matched, so nothing will read r; close it here since
// extractWithTimeout (which otherwise owns the close) is never reached.
if settings.ReaderCloser != nil {
settings.ReaderCloser.Close()
}
return "", nil
}
// extractWithTimeout runs the extraction under a context derived from
// settings.Ctx. When settings.Timeout > 0 a deadline is added; on expiry the
// caller is released and the extraction runs on a detached goroutine.
// Context-aware extractors (pdfExtractor) honour cancellation at page
// boundaries and stop promptly; others run to completion in the background.
// A global concurrency cap applies to every in-flight extraction, including
// detached goroutines, so timed-out work cannot accumulate beyond the limit.
func extractWithTimeout(e Extractor, filename string, r io.ReadSeeker, settings ExtractSettings) (string, error) {
if !tryAcquireExtractionSlot() {
if settings.ReaderCloser != nil {
settings.ReaderCloser.Close()
}
return "", fmt.Errorf("document text extraction capacity exhausted (%d concurrent extractions)", maxConcurrentExtractions)
}
ctx := settings.Ctx
if ctx == nil {
ctx = context.Background()
}
if settings.Timeout <= 0 {
defer releaseExtractionSlot()
if settings.ReaderCloser != nil {
defer settings.ReaderCloser.Close()
}
return e.Extract(ctx, filename, r, settings.MaxFileSize)
}
ctx, cancel := context.WithTimeout(ctx, settings.Timeout)
defer cancel()
type extractResult struct {
text string
err error
}
resultCh := make(chan extractResult, 1)
go func() {
defer releaseExtractionSlot()
// This goroutine owns the reader for the lifetime of the extraction.
// After the timeout fires the caller returns, but the converter may
// still be reading r here, so the reader is closed only once this
// goroutine is done with it - never by the caller.
if settings.ReaderCloser != nil {
defer settings.ReaderCloser.Close()
}
// This goroutine is detached, so an unrecovered panic in an extractor
// would crash the whole server. Convert it into an error instead.
// resultCh is buffered (cap 1), so this send never blocks even if the
// caller already timed out and stopped receiving.
defer func() {
if rec := recover(); rec != nil {
resultCh <- extractResult{err: fmt.Errorf("panic during document text extraction: %v", rec)}
}
}()
text, err := e.Extract(ctx, filename, r, settings.MaxFileSize)
resultCh <- extractResult{text: text, err: err}
}()
select {
case res := <-resultCh:
return res.text, res.err
case <-ctx.Done():
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return "", fmt.Errorf("document text extraction timed out after %s", settings.Timeout)
}
return "", fmt.Errorf("document text extraction cancelled: %w", ctx.Err())
}
}

View File

@@ -0,0 +1,608 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package docextractor
import (
"bytes"
"context"
"errors"
"io"
"runtime"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
)
func TestExtract(t *testing.T) {
logger := mlog.CreateConsoleTestLogger(t)
testCases := []struct {
Name string
TestFileName string
Settings ExtractSettings
Contains []string
NotContains []string
ExpectError bool
}{
{
"Plain text file",
"test-markdown-basics.md",
ExtractSettings{},
[]string{"followed", "separated", "Basic"},
[]string{},
false,
},
{
"Plain small text file",
"test-hashtags.md",
ExtractSettings{},
[]string{"should", "render", "strings"},
[]string{},
false,
},
{
"Zip file without recursion",
"Fake_Team_Import.zip",
ExtractSettings{},
[]string{"users", "channels", "general"},
[]string{"purpose", "announcements"},
false,
},
{
"Zip file with recursion",
"Fake_Team_Import.zip",
ExtractSettings{ArchiveRecursion: true},
[]string{"users", "channels", "general", "purpose", "announcements"},
[]string{},
false,
},
{
"Rar file without recursion",
"Fake_Team_Import.rar",
ExtractSettings{},
[]string{"users", "channels", "general"},
[]string{"purpose", "announcements"},
false,
},
{
"Rar file with recursion",
"Fake_Team_Import.rar",
ExtractSettings{ArchiveRecursion: true},
[]string{"users", "channels", "general", "purpose", "announcements"},
[]string{},
false,
},
{
"Tar.gz file without recursion",
"Fake_Team_Import.tar.gz",
ExtractSettings{},
[]string{"users", "channels", "general"},
[]string{"purpose", "announcements"},
false,
},
{
"Tar.gz file with recursion",
"Fake_Team_Import.tar.gz",
ExtractSettings{ArchiveRecursion: true},
[]string{"users", "channels", "general", "purpose", "announcements"},
[]string{},
false,
},
{
"Pdf file",
"sample-doc.pdf",
ExtractSettings{},
[]string{"simple", "document", "contains"},
[]string{},
false,
},
{
"Docx file",
"sample-doc.docx",
ExtractSettings{},
[]string{"simple", "document", "contains"},
[]string{},
false,
},
{
"Odt file",
"sample-doc.odt",
ExtractSettings{},
[]string{"simple", "document", "contains"},
[]string{},
false,
},
{
"Pptx file",
"sample-doc.pptx",
ExtractSettings{},
[]string{"simple", "document", "contains"},
[]string{},
false,
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
data, err := testutils.ReadTestFile(tc.TestFileName)
require.NoError(t, err)
text, err := Extract(logger, tc.TestFileName, bytes.NewReader(data), tc.Settings)
if tc.ExpectError {
require.Error(t, err)
} else {
require.NoError(t, err)
for _, expectedString := range tc.Contains {
assert.Contains(t, text, expectedString)
}
for _, notExpectedString := range tc.NotContains {
assert.NotContains(t, text, notExpectedString)
}
}
})
}
t.Run("Unsupported binary file", func(t *testing.T) {
data, err := testutils.ReadTestFile("testjpg.jpg")
require.NoError(t, err)
text, err := Extract(logger, "testjpg.jpg", bytes.NewReader(data), ExtractSettings{})
require.NoError(t, err)
require.Equal(t, "", text)
})
t.Run("Wrong docx extension", func(t *testing.T) {
data, err := testutils.ReadTestFile("sample-doc.pdf")
require.NoError(t, err)
text, err := Extract(logger, "sample-doc.docx", bytes.NewReader(data), ExtractSettings{})
require.NoError(t, err)
require.Equal(t, "", text)
})
}
type customTestPdfExtractor struct{}
func (te *customTestPdfExtractor) Name() string {
return "customTestPdfExtractor"
}
func (te *customTestPdfExtractor) Match(filename string) bool {
return strings.HasSuffix(filename, ".pdf")
}
func (te *customTestPdfExtractor) Extract(_ context.Context, filename string, r io.ReadSeeker, _ int64) (string, error) {
return "this is a text generated content", nil
}
type failingExtractor struct{}
func (te *failingExtractor) Name() string {
return "failingExtractor"
}
func (te *failingExtractor) Match(filename string) bool {
return true
}
func (te *failingExtractor) Extract(_ context.Context, filename string, r io.ReadSeeker, _ int64) (string, error) {
return "", errors.New("this always fail")
}
func TestExtractWithExtraExtractors(t *testing.T) {
logger := mlog.CreateConsoleTestLogger(t)
t.Run("override existing extractor", func(t *testing.T) {
data, err := testutils.ReadTestFile("sample-doc.pdf")
require.NoError(t, err)
text, err := ExtractWithExtraExtractors(logger, "sample-doc.pdf", bytes.NewReader(data), ExtractSettings{}, []Extractor{&customTestPdfExtractor{}})
require.NoError(t, err)
require.Equal(t, text, "this is a text generated content")
})
t.Run("failing extractor", func(t *testing.T) {
data, err := testutils.ReadTestFile("sample-doc.pdf")
require.NoError(t, err)
text, err := ExtractWithExtraExtractors(logger, "sample-doc.pdf", bytes.NewReader(data), ExtractSettings{}, []Extractor{&failingExtractor{}})
require.NoError(t, err)
assert.Contains(t, text, "simple")
assert.Contains(t, text, "document")
assert.Contains(t, text, "contains")
})
t.Run("cancelled context aborts extraction", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
text, err := ExtractWithExtraExtractors(
logger,
"file.txt",
bytes.NewReader([]byte("hello world")),
ExtractSettings{
Ctx: ctx,
Timeout: time.Second,
},
[]Extractor{&slowExtractor{delay: 10 * time.Second}})
require.Error(t, err)
require.Empty(t, text)
require.Contains(t, err.Error(), "cancelled")
})
// Without context propagation a cancelled context would reach documentExtractor
// (which uses docconv and ignores the context), extract successfully, and return
// no error, silently swallowing the cancellation.
t.Run("cancelled context propagates through combineExtractor", func(t *testing.T) {
data, err := testutils.ReadTestFile("sample-doc.pdf")
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
cancel()
// Inject pdfExtractor first so it runs before the system documentExtractor.
text, err := ExtractWithExtraExtractors(
logger,
"sample-doc.pdf",
bytes.NewReader(data),
ExtractSettings{Ctx: ctx}, []Extractor{&pdfExtractor{}})
require.ErrorIs(t, err, context.Canceled)
require.Empty(t, text)
})
}
type slowExtractor struct {
delay time.Duration
done chan struct{}
}
func (se *slowExtractor) Name() string { return "slowExtractor" }
func (se *slowExtractor) Match(filename string) bool { return true }
func (se *slowExtractor) Extract(_ context.Context, filename string, r io.ReadSeeker, _ int64) (string, error) {
defer func() {
if se.done != nil {
close(se.done)
}
}()
time.Sleep(se.delay)
return "done", nil
}
func TestExtractTimeout(t *testing.T) {
logger := mlog.CreateConsoleTestLogger(t)
data := []byte("hello world")
t.Run("aborts a slow extraction once the timeout elapses", func(t *testing.T) {
extractDone := make(chan struct{})
start := time.Now()
text, err := ExtractWithExtraExtractors(logger, "file.txt", bytes.NewReader(data), ExtractSettings{Timeout: 50 * time.Millisecond}, []Extractor{&slowExtractor{delay: 500 * time.Millisecond, done: extractDone}})
elapsed := time.Since(start)
require.Error(t, err)
require.Empty(t, text)
assert.Contains(t, err.Error(), "timed out")
assert.Less(t, elapsed, 200*time.Millisecond, "should return shortly after the timeout, not wait for the extraction")
select {
case <-extractDone:
case <-time.After(2 * time.Second):
require.FailNow(t, "detached extraction did not finish within the deadline")
}
})
t.Run("returns the result when extraction finishes within the timeout", func(t *testing.T) {
text, err := ExtractWithExtraExtractors(logger, "file.txt", bytes.NewReader(data), ExtractSettings{Timeout: 5 * time.Second}, []Extractor{&slowExtractor{delay: 10 * time.Millisecond}})
require.NoError(t, err)
require.Equal(t, "done", text)
})
t.Run("a zero timeout disables the bound", func(t *testing.T) {
text, err := ExtractWithExtraExtractors(logger, "file.txt", bytes.NewReader(data), ExtractSettings{Timeout: 0}, []Extractor{&slowExtractor{delay: 10 * time.Millisecond}})
require.NoError(t, err)
require.Equal(t, "done", text)
})
t.Run("a panic in the detached extraction is converted to an error", func(t *testing.T) {
text, err := ExtractWithExtraExtractors(logger, "file.txt", bytes.NewReader(data), ExtractSettings{Timeout: time.Second}, []Extractor{&panickingExtractor{}})
require.Error(t, err)
require.Empty(t, text)
require.Contains(t, err.Error(), "panic")
})
}
type panickingExtractor struct{}
func (pe *panickingExtractor) Name() string { return "panickingExtractor" }
func (pe *panickingExtractor) Match(filename string) bool { return true }
func (pe *panickingExtractor) Extract(_ context.Context, filename string, r io.ReadSeeker, _ int64) (string, error) {
panic("boom")
}
type recordingCloser struct {
closed atomic.Bool
}
func (c *recordingCloser) Close() error {
c.closed.Store(true)
return nil
}
// blockingExtractor blocks inside Extract until release is closed, simulating a
// converter that is still using the reader after an extraction timeout fires.
type blockingExtractor struct {
started chan struct{}
release chan struct{}
done chan struct{}
}
func (be *blockingExtractor) Name() string { return "blockingExtractor" }
func (be *blockingExtractor) Match(filename string) bool { return true }
func (be *blockingExtractor) Extract(_ context.Context, filename string, r io.ReadSeeker, _ int64) (string, error) {
close(be.started)
<-be.release
if be.done != nil {
close(be.done)
}
return "done", nil
}
// waitForExtractionSlotsIdle polls until every extraction slot has been
// released. be.done only signals that the extractor finished; the detached
// goroutine may still be running defers that release its slot.
func waitForExtractionSlotsIdle(t *testing.T, limit int) {
t.Helper()
require.Eventually(t, func() bool {
acquired := 0
ok := true
for range limit {
if !tryAcquireExtractionSlot() {
ok = false
break
}
acquired++
}
for range acquired {
releaseExtractionSlot()
}
return ok
}, 2*time.Second, 10*time.Millisecond, "extraction slots did not become idle")
}
func TestExtractReaderCloserOwnership(t *testing.T) {
logger := mlog.CreateConsoleTestLogger(t)
t.Run("reader is closed only after the detached extraction finishes on timeout", func(t *testing.T) {
closer := &recordingCloser{}
be := &blockingExtractor{started: make(chan struct{}), release: make(chan struct{})}
settings := ExtractSettings{Timeout: 50 * time.Millisecond, ReaderCloser: closer}
_, err := ExtractWithExtraExtractors(logger, "file.txt", bytes.NewReader([]byte("hi")), settings, []Extractor{be})
require.Error(t, err)
require.Contains(t, err.Error(), "timed out")
// Wait (with a deadline) for the detached extraction to start so the
// test fails fast instead of hanging if it never runs.
select {
case <-be.started:
case <-time.After(2 * time.Second):
require.FailNow(t, "extraction did not start within the deadline")
}
// The extraction goroutine is still running, so closing the reader now
// would race with it; it must stay open.
require.False(t, closer.closed.Load(), "reader must not be closed while the extraction goroutine is still running")
close(be.release)
require.Eventually(t, closer.closed.Load, 2*time.Second, 5*time.Millisecond, "reader should be closed once the extraction goroutine finishes")
})
t.Run("reader is closed on the synchronous path", func(t *testing.T) {
closer := &recordingCloser{}
_, err := ExtractWithExtraExtractors(logger, "file.txt", bytes.NewReader([]byte("hi")), ExtractSettings{ReaderCloser: closer}, []Extractor{&slowExtractor{delay: 0}})
require.NoError(t, err)
require.True(t, closer.closed.Load(), "reader should be closed after synchronous extraction")
})
}
func TestExtractWithTimeout(t *testing.T) {
content, err := testutils.ReadTestFile("sample-doc.pdf")
require.NoError(t, err)
t.Run("no-timeout path propagates cancelled context", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
text, err := extractWithTimeout(
&pdfExtractor{},
"sample-doc.pdf",
bytes.NewReader(content),
ExtractSettings{Ctx: ctx})
require.ErrorIs(t, err, context.Canceled)
require.Empty(t, text)
})
// context.WithTimeout is derived from the (already cancelled) ExtractSettings.Ctx,
// so ctx.Done() fires immediately and the select returns a cancellation error
// before extraction can complete.
t.Run("goroutine/select path returns cancellation error", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
text, err := extractWithTimeout(&pdfExtractor{},
"sample-doc.pdf",
bytes.NewReader(content),
ExtractSettings{
Ctx: ctx,
Timeout: time.Second,
})
require.Error(t, err)
require.Empty(t, text)
// Error comes from either the select's ctx.Done() branch ("cancelled") or
// directly from pdfExtractor propagating the cancelled context ("canceled").
require.Contains(t, strings.ToLower(err.Error()), "cancel")
})
}
func TestDocumentMaxFileSize(t *testing.T) {
logger := mlog.CreateConsoleTestLogger(t)
data, err := testutils.ReadTestFile("sample-doc.docx")
require.NoError(t, err)
t.Run("a generous limit extracts the document content", func(t *testing.T) {
text, err := Extract(logger, "sample-doc.docx", bytes.NewReader(data), ExtractSettings{MaxFileSize: 10 * 1024 * 1024})
require.NoError(t, err)
assert.Contains(t, text, "simple")
})
t.Run("a tiny limit prevents the document content from being extracted", func(t *testing.T) {
text, err := Extract(logger, "sample-doc.docx", bytes.NewReader(data), ExtractSettings{MaxFileSize: 16})
require.NoError(t, err)
assert.NotContains(t, text, "simple")
})
}
func TestArchiveMaxFileSize(t *testing.T) {
t.Parallel()
tests := []struct {
name string
file string
recursion bool
limit int64
expectContains []string
expectMissing []string
}{
{
name: "Zip with recursion and large limit extracts fully",
file: "Fake_Team_Import.zip",
recursion: true,
limit: 10 * 1024 * 1024,
expectContains: []string{"purpose", "announcements"},
},
{
name: "Zip with recursion and tiny limit rejects oversized entries",
file: "Fake_Team_Import.zip",
recursion: true,
limit: 1,
expectMissing: []string{"purpose", "announcements"},
},
{
name: "Zip with recursion and zero limit means unlimited",
file: "Fake_Team_Import.zip",
recursion: true,
limit: 0,
expectContains: []string{"purpose", "announcements"},
},
{
name: "Zip without recursion lists paths regardless of limit",
file: "Fake_Team_Import.zip",
recursion: false,
limit: 1,
expectContains: []string{"channels"},
},
{
name: "Tar.gz with recursion and tiny limit rejects oversized entries",
file: "Fake_Team_Import.tar.gz",
recursion: true,
limit: 1,
expectMissing: []string{"purpose", "announcements"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
data, err := testutils.ReadTestFile(tc.file)
require.NoError(t, err)
settings := ExtractSettings{ArchiveRecursion: tc.recursion, MaxFileSize: tc.limit}
text, err := Extract(mlog.CreateConsoleTestLogger(t), tc.file, bytes.NewReader(data), settings)
require.NoError(t, err)
for _, s := range tc.expectContains {
assert.Contains(t, text, s)
}
for _, s := range tc.expectMissing {
assert.NotContains(t, text, s)
}
})
}
}
func TestExtractConcurrency(t *testing.T) {
t.Skip("Skipped due to flakiness — tracked in https://mattermost.atlassian.net/browse/MM-70109")
logger := mlog.CreateConsoleTestLogger(t)
resetExtractionConcurrencyForTest(1)
t.Cleanup(func() {
waitForExtractionSlotsIdle(t, 1)
resetExtractionConcurrencyForTest(runtime.NumCPU())
})
data := []byte("hello world")
t.Run("rejects new extractions while the concurrency limit is reached", func(t *testing.T) {
be := &blockingExtractor{started: make(chan struct{}), release: make(chan struct{}), done: make(chan struct{})}
settings := ExtractSettings{Timeout: 50 * time.Millisecond, ReaderCloser: &recordingCloser{}}
go func() {
_, _ = ExtractWithExtraExtractors(logger, "file.txt", bytes.NewReader(data), settings, []Extractor{be})
}()
select {
case <-be.started:
case <-time.After(2 * time.Second):
require.FailNow(t, "first extraction did not start within the deadline")
}
text, err := ExtractWithExtraExtractors(logger, "file.txt", bytes.NewReader(data), ExtractSettings{Timeout: time.Second}, []Extractor{&slowExtractor{delay: 0}})
require.Error(t, err)
require.Empty(t, text)
require.Contains(t, err.Error(), "capacity exhausted")
close(be.release)
select {
case <-be.done:
case <-time.After(2 * time.Second):
require.FailNow(t, "detached extraction did not finish within the deadline")
}
waitForExtractionSlotsIdle(t, 1)
})
t.Run("a timed-out extraction keeps its slot until the detached goroutine finishes", func(t *testing.T) {
be := &blockingExtractor{started: make(chan struct{}), release: make(chan struct{}), done: make(chan struct{})}
settings := ExtractSettings{Timeout: 50 * time.Millisecond, ReaderCloser: &recordingCloser{}}
_, err := ExtractWithExtraExtractors(logger, "file.txt", bytes.NewReader(data), settings, []Extractor{be})
require.Error(t, err)
require.Contains(t, err.Error(), "timed out")
select {
case <-be.started:
case <-time.After(2 * time.Second):
require.FailNow(t, "detached extraction did not start within the deadline")
}
_, err = ExtractWithExtraExtractors(logger, "file.txt", bytes.NewReader(data), ExtractSettings{Timeout: time.Second}, []Extractor{&slowExtractor{delay: 0}})
require.Error(t, err)
require.Contains(t, err.Error(), "capacity exhausted")
close(be.release)
select {
case <-be.done:
case <-time.After(2 * time.Second):
require.FailNow(t, "detached extraction did not finish within the deadline")
}
waitForExtractionSlotsIdle(t, 1)
})
}

View File

@@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package docextractor
import (
"context"
"errors"
"io"
"path"
"strings"
"code.sajari.com/docconv/v2"
"github.com/mattermost/mattermost/server/v8/channels/utils"
)
type documentExtractor struct{}
var doconvConverterByExtensions = map[string]func(io.Reader) (string, map[string]string, error){
"doc": docconv.ConvertDoc,
"docx": docconv.ConvertDocx,
"pptx": docconv.ConvertPptx,
"odt": docconv.ConvertODT,
"html": func(r io.Reader) (string, map[string]string, error) { return docconv.ConvertHTML(r, true) },
// Temporarily disabled to avoid crashes on malicious .pages files
// "pages": docconv.ConvertPages,
"rtf": docconv.ConvertRTF,
"pdf": docconv.ConvertPDF,
}
func (de *documentExtractor) Name() string {
return "documentExtractor"
}
func (de *documentExtractor) Match(filename string) bool {
extension := strings.TrimPrefix(path.Ext(filename), ".")
_, ok := doconvConverterByExtensions[extension]
return ok
}
func (de *documentExtractor) Extract(_ context.Context, filename string, r io.ReadSeeker, maxFileSize int64) (out string, outErr error) {
defer func() {
if r := recover(); r != nil {
out = ""
outErr = errors.New("error extracting document text")
}
}()
extension := strings.TrimPrefix(path.Ext(filename), ".")
converter, ok := doconvConverterByExtensions[extension]
if !ok {
return "", errors.New("unknown converter")
}
// Bound how much data the converter is allowed to read so a small upload
// cannot expand into an unbounded amount of in-memory work.
var reader io.Reader = r
if maxFileSize > 0 {
reader = utils.NewLimitedReaderWithError(r, maxFileSize)
}
text, _, err := converter(reader)
if err != nil {
return "", err
}
return text, nil
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package docextractor
import (
"context"
"io"
)
// Extractors define the interface needed to extract file content
type Extractor interface {
Match(filename string) bool
Extract(ctx context.Context, filename string, file io.ReadSeeker, maxFileSize int64) (string, error)
Name() string
}

View File

@@ -0,0 +1,90 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package docextractor
// MMPreview is a micro-service to convert from any libreoffice supported
// format into a PDF file, and then we use the regular pdf extractor to convert
// it into plain text.
import (
"bytes"
"context"
"io"
"mime/multipart"
"net/http"
"path"
"strings"
"github.com/pkg/errors"
)
type mmPreviewExtractor struct {
url string
secret string
pdfExtractor pdfExtractor
}
var mmpreviewSupportedExtensions = map[string]bool{
"ppt": true,
"odp": true,
"xls": true,
"xlsx": true,
"ods": true,
}
func newMMPreviewExtractor(url string, secret string, pdfExtractor pdfExtractor) *mmPreviewExtractor {
return &mmPreviewExtractor{url: url, secret: secret, pdfExtractor: pdfExtractor}
}
func (mpe *mmPreviewExtractor) Name() string {
return "mmPreviewExtractor"
}
func (mpe *mmPreviewExtractor) Match(filename string) bool {
extension := strings.TrimPrefix(path.Ext(filename), ".")
return mmpreviewSupportedExtensions[extension]
}
func (mpe *mmPreviewExtractor) Extract(ctx context.Context, filename string, file io.ReadSeeker, maxFileSize int64) (string, error) {
b, w, err := createMultipartFormData("file", filename, file)
if err != nil {
return "", errors.Wrap(err, "Unable to generate file preview using mmpreview.")
}
req, err := http.NewRequestWithContext(ctx, "POST", mpe.url+"/toPDF", &b)
if err != nil {
return "", errors.Wrap(err, "Unable to generate file preview using mmpreview.")
}
req.Header.Set("Content-Type", w.FormDataContentType())
if mpe.secret != "" {
req.Header.Add("Authentication", mpe.secret)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", errors.Wrap(err, "Unable to generate file preview using mmpreview.")
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", errors.New("Unable to generate file preview using mmpreview (The server has replied with an error)")
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", errors.Wrap(err, "unable to read the response from mmpreview")
}
return mpe.pdfExtractor.Extract(ctx, filename, bytes.NewReader(data), maxFileSize)
}
func createMultipartFormData(fieldName, fileName string, fileData io.ReadSeeker) (bytes.Buffer, *multipart.Writer, error) {
var b bytes.Buffer
var err error
w := multipart.NewWriter(&b)
var fw io.Writer
if fw, err = w.CreateFormFile(fieldName, fileName); err != nil {
return b, nil, err
}
if _, err = io.Copy(fw, fileData); err != nil {
return b, nil, err
}
w.Close()
return b, w, nil
}

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package docextractor
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"path"
"strings"
"github.com/mattermost/pdf"
"github.com/mattermost/mattermost/server/v8/channels/utils"
)
type pdfExtractor struct{}
func (pe *pdfExtractor) Name() string {
return "pdfExtractor"
}
func (pe *pdfExtractor) Match(filename string) bool {
supportedExtensions := map[string]bool{
"pdf": true,
}
extension := strings.TrimPrefix(path.Ext(filename), ".")
return supportedExtensions[extension]
}
func (pe *pdfExtractor) Extract(ctx context.Context, filename string, r io.ReadSeeker, maxFileSize int64) (out string, outErr error) {
defer func() {
if r := recover(); r != nil {
out = ""
outErr = errors.New("error extracting pdf text")
}
}()
f, err := os.CreateTemp(os.TempDir(), "pdflib")
if err != nil {
return "", fmt.Errorf("error creating temporary file: %v", err)
}
defer f.Close()
defer os.Remove(f.Name())
// Bound how much data is copied to disk so a small upload cannot expand
// into an unbounded amount of temporary storage.
var src io.Reader = r
if maxFileSize > 0 {
src = utils.NewLimitedReaderWithError(r, maxFileSize)
}
size, err := io.Copy(f, src)
if err != nil {
return "", fmt.Errorf("error copying data into temporary file: %v", err)
}
reader, err := pdf.NewReader(f, size)
if err != nil {
return "", err
}
var buf bytes.Buffer
b, err := reader.GetPlainText(ctx)
if err != nil {
return "", err
}
buf.ReadFrom(b)
return buf.String(), nil
}

View File

@@ -0,0 +1,92 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package docextractor
import (
"bytes"
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
)
func TestPdfEmptyFile(t *testing.T) {
extractor := pdfExtractor{}
_, err := extractor.Extract(context.Background(), "test.pdf", bytes.NewReader([]byte{}), 0)
require.Error(t, err)
}
func TestPdfFile(t *testing.T) {
extractor := pdfExtractor{}
contentText := "\nThis is a simple document that contains some text."
content, err := testutils.ReadTestFile("sample-doc.pdf")
require.NoError(t, err)
extractedText, err := extractor.Extract(context.Background(), "sample-doc.pdf", bytes.NewReader(content), 0)
require.NoError(t, err)
require.Equal(t, contentText, extractedText)
}
func TestPdfDeeplyNestedObjects(t *testing.T) {
// Test for MM-63434
var buf bytes.Buffer
buf.WriteString("%PDF-1.0\n")
for range 10_000 {
buf.WriteString("0\n0\nobj\n")
}
buf.WriteString("startxref\n0\n%%EOF\n")
extractor := pdfExtractor{}
text, err := extractor.Extract(context.Background(), "excessive-nests.pdf", bytes.NewReader(buf.Bytes()), 0)
require.Error(t, err)
require.Empty(t, text)
}
func TestWrongPdfFile(t *testing.T) {
extractor := pdfExtractor{}
content, err := testutils.ReadTestFile("sample-doc.docx")
require.NoError(t, err)
_, err = extractor.Extract(context.Background(), "sample-doc.pdf", bytes.NewReader(content), 0)
require.Error(t, err)
}
func TestPdfCancelledContext(t *testing.T) {
extractor := pdfExtractor{}
content, err := testutils.ReadTestFile("sample-doc.pdf")
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err = extractor.Extract(ctx, "sample-doc.pdf", bytes.NewReader(content), 0)
require.ErrorIs(t, err, context.Canceled)
}
func TestPdfMaxFileSize(t *testing.T) {
extractor := pdfExtractor{}
content, err := testutils.ReadTestFile("sample-doc.pdf")
require.NoError(t, err)
require.Greater(t, len(content), 16, "fixture must be larger than the tight limit under test")
t.Run("a zero limit means unlimited and extracts the content", func(t *testing.T) {
text, err := extractor.Extract(context.Background(), "sample-doc.pdf", bytes.NewReader(content), 0)
require.NoError(t, err)
require.Contains(t, text, "simple")
})
t.Run("a generous limit extracts the content", func(t *testing.T) {
text, err := extractor.Extract(context.Background(), "sample-doc.pdf", bytes.NewReader(content), 10*1024*1024)
require.NoError(t, err)
require.Contains(t, text, "simple")
})
t.Run("a tight limit prevents extraction", func(t *testing.T) {
// The reader errors once it reads past the limit, so io.Copy to the
// temp file fails and no text is extracted.
text, err := extractor.Extract(context.Background(), "sample-doc.pdf", bytes.NewReader(content), 16)
require.Error(t, err)
require.Empty(t, text)
})
}

View File

@@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package docextractor
import (
"context"
"io"
"unicode"
"unicode/utf8"
)
type plainExtractor struct{}
func (pe *plainExtractor) Name() string {
return "plainExtractor"
}
func (pe *plainExtractor) Match(filename string) bool {
return true
}
func (pe *plainExtractor) Extract(_ context.Context, filename string, r io.ReadSeeker, _ int64) (string, error) {
// This detects any visible character plus any whitespace
validRanges := append(unicode.GraphicRanges, unicode.White_Space)
runes := make([]byte, 1024)
total, err := r.Read(runes)
if err != nil && err != io.EOF {
return "", err
}
if total == 0 {
return "", nil
}
count := 0
for {
c, size := utf8.DecodeRune(runes[count:])
if !unicode.In(c, validRanges...) {
return "", nil
}
if size == 0 {
break
}
count += size
// subtract the max rune size to prevent accidentally splitted runes at the end of first 1024 bytes
if count > total-utf8.UTFMax {
break
}
}
text, _ := io.ReadAll(r)
return string(runes[0:total]) + string(text), nil
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package docextractor
import (
"bytes"
"context"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestPlainEmptyFile(t *testing.T) {
extractor := plainExtractor{}
extractedText, err := extractor.Extract(context.Background(), "test.txt", bytes.NewReader([]byte{}), 0)
require.NoError(t, err)
require.Equal(t, "", extractedText)
}
func TestPlainTextSmallFile(t *testing.T) {
extractor := plainExtractor{}
content := strings.Repeat("test \n", 5)
extractedText, err := extractor.Extract(context.Background(), "test.txt", bytes.NewReader([]byte(content)), 0)
require.NoError(t, err)
require.Equal(t, content, extractedText)
}
func TestPlainBigFile(t *testing.T) {
extractor := plainExtractor{}
content := strings.Repeat("test \n", 1000)
extractedText, err := extractor.Extract(context.Background(), "test.txt", bytes.NewReader([]byte(content)), 0)
require.NoError(t, err)
require.Equal(t, content, extractedText)
}
func TestSmallBinaryFile(t *testing.T) {
extractor := plainExtractor{}
notUTF8Char := byte(0x7)
content := bytes.Repeat([]byte{notUTF8Char}, 1000)
extractedText, err := extractor.Extract(context.Background(), "test.bin", bytes.NewReader(content), 0)
require.NoError(t, err)
require.Equal(t, "", extractedText)
}
func TestBigBinaryFile(t *testing.T) {
extractor := plainExtractor{}
notUTF8Char := byte(0x7)
content := bytes.Repeat([]byte{notUTF8Char}, 10000)
extractedText, err := extractor.Extract(context.Background(), "test.bin", bytes.NewReader(content), 0)
require.NoError(t, err)
require.Equal(t, "", extractedText)
}

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package imageproxy
type Error struct {
error
}

View File

@@ -0,0 +1,178 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package imageproxy
import (
"errors"
"io"
"net/http"
"net/url"
"reflect"
"strings"
"sync"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/configservice"
"github.com/mattermost/mattermost/server/public/shared/httpservice"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
var ErrNotEnabled = Error{errors.New("imageproxy.ImageProxy: image proxy not enabled")}
// An ImageProxy is the public interface for Mattermost's image proxy. An instance of ImageProxy should be created
// using MakeImageProxy which requires a configService and an HTTPService provided by the server.
type ImageProxy struct {
ConfigService configservice.ConfigService
configListenerID string
HTTPService httpservice.HTTPService
Logger *mlog.Logger
siteURL *url.URL
lock sync.RWMutex
backend ImageProxyBackend
}
// An ImageProxyBackend provides the functionality for different types of image proxies. An ImageProxy will construct
// the required backend depending on the ImageProxySettings provided by the ConfigService.
type ImageProxyBackend interface {
// GetImage provides a proxied image in response to an HTTP request.
GetImage(w http.ResponseWriter, r *http.Request, imageURL string)
// GetImageDirect returns a proxied image along with its content type.
GetImageDirect(imageURL string) (io.ReadCloser, string, error)
}
func MakeImageProxy(configService configservice.ConfigService, httpService httpservice.HTTPService, logger *mlog.Logger) *ImageProxy {
proxy := &ImageProxy{
ConfigService: configService,
HTTPService: httpService,
Logger: logger,
}
// We deliberately ignore the error because it's from config.json.
// The function returns a nil pointer in case of error, and we handle it when it's used.
siteURL, _ := url.Parse(*configService.Config().ServiceSettings.SiteURL)
proxy.siteURL = siteURL
proxy.configListenerID = proxy.ConfigService.AddConfigListener(proxy.OnConfigChange)
config := proxy.ConfigService.Config()
proxy.backend = proxy.makeBackend(config.ImageProxySettings)
return proxy
}
func (proxy *ImageProxy) makeBackend(proxySettings model.ImageProxySettings) ImageProxyBackend {
if !*proxySettings.Enable {
return nil
}
switch *proxySettings.ImageProxyType {
case model.ImageProxyTypeLocal:
return makeLocalBackend(proxy)
default:
return nil
}
}
func (proxy *ImageProxy) Close() {
proxy.lock.Lock()
defer proxy.lock.Unlock()
proxy.ConfigService.RemoveConfigListener(proxy.configListenerID)
}
func (proxy *ImageProxy) OnConfigChange(oldConfig, newConfig *model.Config) {
if *oldConfig.ServiceSettings.SiteURL != *newConfig.ServiceSettings.SiteURL ||
!reflect.DeepEqual(oldConfig.ImageProxySettings, newConfig.ImageProxySettings) {
proxy.lock.Lock()
defer proxy.lock.Unlock()
siteURL, _ := url.Parse(*newConfig.ServiceSettings.SiteURL)
proxy.siteURL = siteURL
proxy.backend = proxy.makeBackend(newConfig.ImageProxySettings)
}
}
// GetImage takes an HTTP request for an image and requests that image using the image proxy.
func (proxy *ImageProxy) GetImage(w http.ResponseWriter, r *http.Request, imageURL string) {
proxy.lock.RLock()
defer proxy.lock.RUnlock()
if proxy.backend == nil {
w.WriteHeader(http.StatusNotImplemented)
return
}
proxy.backend.GetImage(w, r, imageURL)
}
// GetImageDirect takes the URL of an image and returns the image along with its content type.
func (proxy *ImageProxy) GetImageDirect(imageURL string) (io.ReadCloser, string, error) {
proxy.lock.RLock()
defer proxy.lock.RUnlock()
if proxy.backend == nil {
return nil, "", ErrNotEnabled
}
return proxy.backend.GetImageDirect(imageURL)
}
// GetProxiedImageURL takes the URL of an image and returns a URL that can be used to view that image through the
// image proxy.
func (proxy *ImageProxy) GetProxiedImageURL(imageURL string) string {
if imageURL == "" || proxy.siteURL == nil || strings.HasPrefix(strings.ToLower(imageURL), "data:image/") {
return imageURL
}
// Parse url, return siteURL in case of failure.
// Also if the URL is opaque.
parsedURL, err := url.Parse(imageURL)
if err != nil || parsedURL.Opaque != "" {
return proxy.siteURL.String()
}
// If host is same as siteURL host, return.
if parsedURL.Host == proxy.siteURL.Host {
return parsedURL.String()
}
// Handle protocol-relative URLs.
if parsedURL.Scheme == "" {
parsedURL.Scheme = proxy.siteURL.Scheme
}
// If it's a relative URL, fill up the hostname and return.
if parsedURL.Host == "" {
parsedURL.Host = proxy.siteURL.Host
return parsedURL.String()
}
return proxy.siteURL.String() + "/api/v4/image?url=" + url.QueryEscape(parsedURL.String())
}
// GetUnproxiedImageURL takes the URL of an image on the image proxy and returns the original URL of the image.
func (proxy *ImageProxy) GetUnproxiedImageURL(proxiedURL string) string {
return getUnproxiedImageURL(proxiedURL, *proxy.ConfigService.Config().ServiceSettings.SiteURL)
}
func getUnproxiedImageURL(proxiedURL, siteURL string) string {
if !strings.HasPrefix(proxiedURL, siteURL+"/api/v4/image?url=") {
return proxiedURL
}
parsed, err := url.Parse(proxiedURL)
if err != nil {
return proxiedURL
}
u := parsed.Query()["url"]
if len(u) == 0 {
return proxiedURL
}
return u[0]
}

View File

@@ -0,0 +1,135 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package imageproxy
import (
"net/url"
"testing"
"github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetProxiedImageURL(t *testing.T) {
siteURL := "https://mattermost.example.com"
parsedURL, err := url.Parse(siteURL)
require.NoError(t, err)
imageURL := "https://mattermost.com/wp-content/uploads/2022/02/logoHorizontal.png"
proxiedURL := "https://mattermost.example.com/api/v4/image?url=https%3A%2F%2Fmattermost.com%2Fwp-content%2Fuploads%2F2022%2F02%2FlogoHorizontal.png"
proxy := ImageProxy{siteURL: parsedURL}
for _, test := range []struct {
Name string
Input string
Expected string
}{
{
Name: "should proxy an image",
Input: imageURL,
Expected: proxiedURL,
},
{
Name: "should not proxy a relative image",
Input: "/static/logo.png",
Expected: "https://mattermost.example.com/static/logo.png",
},
{
Name: "should bypass opaque URLs",
Input: "http:xyz123?query",
Expected: siteURL,
},
{
Name: "should not proxy an image on the Mattermost server",
Input: "https://mattermost.example.com/static/logo.png",
Expected: "https://mattermost.example.com/static/logo.png",
},
{
Name: "should not proxy an image that has already been proxied",
Input: proxiedURL,
Expected: proxiedURL,
},
{
Name: "should not bypass protocol relative URLs",
Input: "//mattermost.com/static/logo.png",
Expected: "https://mattermost.example.com/api/v4/image?url=https%3A%2F%2Fmattermost.com%2Fstatic%2Flogo.png",
},
{
Name: "should not bypass if the host prefix is same",
Input: "https://mattermost.example.com.anothersite.com/static/logo.png",
Expected: "https://mattermost.example.com/api/v4/image?url=https%3A%2F%2Fmattermost.example.com.anothersite.com%2Fstatic%2Flogo.png",
},
{
Name: "should not bypass for user auth URLs",
Input: "https://mattermost.example.com@anothersite.com/static/logo.png",
Expected: "https://mattermost.example.com/api/v4/image?url=https%3A%2F%2Fmattermost.example.com%40anothersite.com%2Fstatic%2Flogo.png",
},
{
Name: "should not proxy embedded image",
Input: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",
Expected: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",
},
} {
t.Run(test.Name, func(t *testing.T) {
assert.Equal(t, test.Expected, proxy.GetProxiedImageURL(test.Input))
})
}
}
func TestGetUnproxiedImageURL(t *testing.T) {
siteURL := "https://mattermost.example.com"
imageURL := "https://mattermost.com/wp-content/uploads/2022/02/logoHorizontal.png"
proxiedURL := "https://mattermost.example.com/api/v4/image?url=https%3A%2F%2Fmattermost.com%2Fwp-content%2Fuploads%2F2022%2F02%2FlogoHorizontal.png"
for _, test := range []struct {
Name string
Input string
Expected string
}{
{
Name: "should remove proxy",
Input: proxiedURL,
Expected: imageURL,
},
{
Name: "should not remove proxy from a relative image",
Input: "/static/logo.png",
Expected: "/static/logo.png",
},
{
Name: "should not remove proxy from an image on the Mattermost server",
Input: "https://mattermost.example.com/static/logo.png",
Expected: "https://mattermost.example.com/static/logo.png",
},
{
Name: "should not remove proxy from a non-proxied image",
Input: imageURL,
Expected: imageURL,
},
} {
t.Run(test.Name, func(t *testing.T) {
assert.Equal(t, test.Expected, getUnproxiedImageURL(test.Input, siteURL))
})
}
}
func TestOnConfigChange(t *testing.T) {
t.Run("for local proxy, should update site URL when that changes", func(t *testing.T) {
proxy := makeTestLocalProxy()
require.Equal(t, "https://mattermost.example.com", proxy.siteURL.String())
require.Equal(t, "https://mattermost.example.com", proxy.backend.(*LocalBackend).baseURL.String())
newConfig := proxy.ConfigService.Config().Clone()
newConfig.ServiceSettings.SiteURL = new("https://new.example.com")
proxy.ConfigService.(*testutils.StaticConfigService).UpdateConfig(newConfig)
require.Equal(t, "https://new.example.com", proxy.siteURL.String())
require.Equal(t, "https://new.example.com", proxy.backend.(*LocalBackend).baseURL.String())
})
}

View File

@@ -0,0 +1,400 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package imageproxy
import (
"bufio"
"errors"
"fmt"
"io"
"mime"
"net"
"net/http"
"net/http/httptest"
"net/url"
"path"
"path/filepath"
"regexp"
"strings"
"time"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
var imageContentTypes = []string{
"image/bmp", "image/cgm", "image/g3fax", "image/gif", "image/ief", "image/jp2",
"image/jpeg", "image/jpg", "image/pict", "image/png", "image/prs.btif",
"image/tiff", "image/vnd.adobe.photoshop", "image/vnd.djvu", "image/vnd.dwg",
"image/vnd.dxf", "image/vnd.fastbidsheet", "image/vnd.fpx", "image/vnd.fst",
"image/vnd.fujixerox.edmics-mmr", "image/vnd.fujixerox.edmics-rlc",
"image/vnd.microsoft.icon", "image/vnd.ms-modi", "image/vnd.net-fpx", "image/vnd.wap.wbmp",
"image/vnd.xiff", "image/webp", "image/x-cmu-raster", "image/x-cmx", "image/x-icon",
"image/x-macpaint", "image/x-pcx", "image/x-pict", "image/x-portable-anymap",
"image/x-portable-bitmap", "image/x-portable-graymap", "image/x-portable-pixmap",
"image/x-quicktime", "image/x-rgb", "image/x-xbitmap", "image/x-xpixmap", "image/x-xwindowdump",
}
var msgNotAllowed = "requested URL is not allowed"
var ErrLocalRequestFailed = Error{errors.New("imageproxy.LocalBackend: failed to request proxied image")}
var ErrImageTooLarge = Error{errors.New("imageproxy.LocalBackend: image exceeds maximum allowed size")}
// maxImageSize caps how many bytes of a remote response GetImageDirect will
// buffer into its in-memory recorder (var, not const, so tests can override it).
// GetImage streams to a real connection instead of buffering, so it's unaffected.
var maxImageSize int64 = 1024 * 1024 * 50 // 50 MiB, matching app.MaxMetadataImageSize
type LocalBackend struct {
client *http.Client
baseURL *url.URL
}
// URLError reports a malformed URL error.
type URLError struct {
Message string
URL *url.URL
}
func (e URLError) Error() string {
return fmt.Sprintf("malformed URL %q: %s", e.URL, e.Message)
}
func makeLocalBackend(proxy *ImageProxy) *LocalBackend {
baseURL := proxy.siteURL
if baseURL == nil {
mlog.Warn("Failed to set base URL for image proxy. Relative image links may not work.")
}
client := proxy.HTTPService.MakeClient(false)
return &LocalBackend{
client: client,
baseURL: baseURL,
}
}
type contentTypeRecorder struct {
http.ResponseWriter
filename string
}
func (rec *contentTypeRecorder) WriteHeader(code int) {
hdr := rec.ResponseWriter.Header()
contentType := hdr.Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(contentType)
// The error is caused by a malformed input and there's not much use logging it.
// Therefore, even in the error case we set it to attachment mode to be safe.
if err != nil || mediaType == "image/svg+xml" {
hdr.Set("Content-Disposition", fmt.Sprintf("attachment;filename=%q", rec.filename))
}
rec.ResponseWriter.WriteHeader(code)
}
func (backend *LocalBackend) GetImage(w http.ResponseWriter, r *http.Request, imageURL string) {
// The interface to the proxy only exposes a ServeHTTP method, so fake a request to it
req, err := http.NewRequest(http.MethodGet, "/"+imageURL, nil)
if err != nil {
// http.NewRequest should only return an error on an invalid URL
mlog.Debug("Failed to create request for proxied image", mlog.String("url", imageURL), mlog.Err(err))
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte{})
return
}
u, err := url.Parse(imageURL)
if err != nil {
mlog.Debug("Failed to parse URL for proxied image", mlog.String("url", imageURL), mlog.Err(err))
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte{})
return
}
w.Header().Set("X-Frame-Options", "deny")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Security-Policy", "default-src 'none'; img-src data:; style-src 'unsafe-inline'")
rec := contentTypeRecorder{w, filepath.Base(u.Path)}
backend.ServeImage(&rec, req, 0)
}
func (backend *LocalBackend) GetImageDirect(imageURL string) (io.ReadCloser, string, error) {
// The interface to the proxy only exposes a ServeHTTP method, so fake a request to it
req, err := http.NewRequest(http.MethodGet, "/"+imageURL, nil)
if err != nil {
return nil, "", Error{err}
}
recorder := httptest.NewRecorder()
if truncated := backend.ServeImage(recorder, req, maxImageSize); truncated {
// Log only the host, not the full URL: the path/query may carry
// sensitive tokens or signed parameters that shouldn't hit the logs.
fields := []mlog.Field{mlog.Int("max_bytes", maxImageSize)}
if parsed, parseErr := url.Parse(imageURL); parseErr == nil {
fields = append(fields, mlog.String("host", parsed.Host))
}
mlog.Warn("Discarding proxied image that exceeded max size for direct fetch", fields...)
return nil, "", ErrImageTooLarge
}
if recorder.Code != http.StatusOK {
return nil, "", ErrLocalRequestFailed
}
return io.NopCloser(recorder.Body), recorder.Header().Get("Content-Type"), nil
}
// ServeImage fetches the remote image referenced by req and writes it to w.
// maxBytes limits how many bytes of the response body are copied to w; pass 0
// to disable the limit. The return value reports whether the body was truncated
// (always false when maxBytes is 0).
func (backend *LocalBackend) ServeImage(w http.ResponseWriter, req *http.Request, maxBytes int64) (truncated bool) {
proxyReq, err := newProxyRequest(req, backend.baseURL)
if err != nil {
http.Error(w, fmt.Sprintf("invalid request URL: %v", err), http.StatusBadRequest)
return false
}
actualReq, err := http.NewRequest("GET", proxyReq.String(), nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return false
}
actualReq.Header.Set("Accept", strings.Join(imageContentTypes, ", "))
resp, err := backend.client.Do(actualReq)
if err != nil {
mlog.Warn("error fetching remote image", mlog.Err(err))
statusCode := http.StatusInternalServerError
if e, ok := err.(net.Error); ok && e.Timeout() {
statusCode = http.StatusGatewayTimeout
}
http.Error(w, fmt.Sprintf("error fetching remote image: %v", err), statusCode)
return false
}
// close the original resp.Body, even if we wrap it in a NopCloser below
defer resp.Body.Close()
copyHeader(w.Header(), resp.Header, "Cache-Control", "Last-Modified", "Expires", "Etag", "Link")
// Wrap the body in a bufio.Reader so we can peek at bytes for
// content-type detection without consuming the stream.
b := bufio.NewReaderSize(resp.Body, contentPeekSize)
resp.Body = io.NopCloser(b)
if isSVGContent(b) {
http.Error(w, msgNotAllowed, http.StatusForbidden)
return false
}
contentType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type"))
if contentType == "" || contentType == "application/octet-stream" || contentType == "binary/octet-stream" {
contentType = peekContentType(b)
}
if resp.ContentLength != 0 && !contentTypeMatches(imageContentTypes, contentType) {
http.Error(w, msgNotAllowed, http.StatusForbidden)
return false
}
if should304(req, resp) {
w.WriteHeader(http.StatusNotModified)
return false
}
w.Header().Set("Content-Type", contentType)
copyHeader(w.Header(), resp.Header, "Content-Length")
// Enable CORS for 3rd party applications
w.Header().Set("Access-Control-Allow-Origin", "*")
// Add a Content-Security-Policy to prevent stored-XSS attacks via SVG files
w.Header().Set("Content-Security-Policy", "script-src 'none'")
// Disable Content-Type sniffing
w.Header().Set("X-Content-Type-Options", "nosniff")
// Block potential XSS attacks especially in legacy browsers which do not support CSP
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.WriteHeader(resp.StatusCode)
var body io.Reader = resp.Body
if maxBytes > 0 {
// Read one byte past the limit so a source that has more data than
// maxBytes can be distinguished from one that has exactly maxBytes.
body = io.LimitReader(resp.Body, maxBytes+1)
}
n, err := io.Copy(w, body)
if err != nil {
mlog.Warn("error copying response", mlog.Err(err))
}
return maxBytes > 0 && n > maxBytes
}
// copyHeader copies header values from src to dst, adding to any existing
// values with the same header name. If keys is not empty, only those header
// keys will be copied.
func copyHeader(dst, src http.Header, keys ...string) {
if len(keys) == 0 {
for k := range src {
keys = append(keys, k)
}
}
for _, key := range keys {
k := http.CanonicalHeaderKey(key)
for _, v := range src[k] {
dst.Add(k, v)
}
}
}
func should304(req *http.Request, resp *http.Response) bool {
etag := resp.Header.Get("Etag")
if etag != "" && etag == req.Header.Get("If-None-Match") {
return true
}
lastModified, err := time.Parse(time.RFC1123, resp.Header.Get("Last-Modified"))
if err != nil {
return false
}
ifModSince, err := time.Parse(time.RFC1123, req.Header.Get("If-Modified-Since"))
if err != nil {
return false
}
if lastModified.Before(ifModSince) || lastModified.Equal(ifModSince) {
return true
}
return false
}
// peekContentType peeks at the first 512 bytes of p, and attempts to detect
// the content type. Returns empty string if error occurs.
func peekContentType(p *bufio.Reader) string {
byt, err := p.Peek(512)
if err != nil && err != bufio.ErrBufferFull && err != io.EOF {
return ""
}
return http.DetectContentType(byt)
}
// contentPeekSize is the number of bytes read ahead for content inspection.
// It must match the bufio.Reader buffer size created in ServeImage.
const contentPeekSize = 8192
// isSVGContent peeks at the first contentPeekSize bytes of p and reports whether
// they contain SVG markers. UTF-16 encoded content (identified by a BOM) is
// decoded to ASCII before scanning.
func isSVGContent(p *bufio.Reader) bool {
byt, err := p.Peek(contentPeekSize)
if err != nil && err != bufio.ErrBufferFull && err != io.EOF {
return false
}
if len(byt) == 0 {
return false
}
// UseBOM selects endianness from a BOM when present (0xFF 0xFE → LE,
// 0xFE 0xFF → BE), defaulting to LE otherwise.
enc := unicode.UTF16(unicode.LittleEndian, unicode.UseBOM)
if decoded, _, decodeErr := transform.Bytes(enc.NewDecoder(), byt); decodeErr == nil {
lower := strings.ToLower(string(decoded))
if strings.Contains(lower, "<svg") ||
(strings.Contains(lower, "<?xml") && strings.Contains(lower, "<svg")) {
return true
}
}
// Raw-byte scan for UTF-8 / ASCII content; interleaved-NUL patterns cover BOM-less UTF-16 BE.
rawLower := strings.ToLower(string(byt))
return strings.Contains(rawLower, "<svg") ||
(strings.Contains(rawLower, "<?xml") && strings.Contains(rawLower, "<svg")) ||
strings.Contains(rawLower, "<\x00s\x00v\x00g\x00") || // BOM-less UTF-16 LE
strings.Contains(rawLower, "\x00<\x00s\x00v\x00g") // BOM-less UTF-16 BE
}
// contentTypeMatches returns whether contentType matches one of the allowed patterns.
func contentTypeMatches(patterns []string, contentType string) bool {
if len(patterns) == 0 {
return true
}
for _, pattern := range patterns {
if ok, err := path.Match(pattern, contentType); ok && err == nil {
return true
}
}
return false
}
// proxyRequest is an imageproxy request which includes a remote URL of an image to
// proxy.
type proxyRequest struct {
URL *url.URL // URL of the image to proxy
Original *http.Request // The original HTTP request
}
// String returns the request URL as a string, with r.Options encoded in the
// URL fragment.
func (r proxyRequest) String() string {
return r.URL.String()
}
func newProxyRequest(r *http.Request, baseURL *url.URL) (*proxyRequest, error) {
var err error
req := &proxyRequest{Original: r}
path := r.URL.EscapedPath()[1:] // strip leading slash
req.URL, err = parseURL(path)
if err != nil || !req.URL.IsAbs() {
// first segment should be options
parts := strings.SplitN(path, "/", 2)
if len(parts) != 2 {
return nil, URLError{"too few path segments", r.URL}
}
var err error
req.URL, err = parseURL(parts[1])
if err != nil {
return nil, URLError{fmt.Sprintf("unable to parse remote URL: %v", err), r.URL}
}
}
if baseURL != nil {
req.URL = baseURL.ResolveReference(req.URL)
}
if !req.URL.IsAbs() {
return nil, URLError{"must provide absolute remote URL", r.URL}
}
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
return nil, URLError{"remote URL must have http or https scheme", r.URL}
}
// query string is always part of the remote URL
req.URL.RawQuery = r.URL.RawQuery
return req, nil
}
var reCleanedURL = regexp.MustCompile(`^(https?):/+([^/])`)
// parseURL parses s as a URL, handling URLs that have been munged by
// path.Clean or a webserver that collapses multiple slashes.
func parseURL(s string) (*url.URL, error) {
s = reCleanedURL.ReplaceAllString(s, "$1://$2")
return url.Parse(s)
}

View File

@@ -0,0 +1,527 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package imageproxy
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/httpservice"
"github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
)
func makeTestLocalProxy() *ImageProxy {
configService := &testutils.StaticConfigService{
Cfg: &model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: new("https://mattermost.example.com"),
AllowedUntrustedInternalConnections: new("127.0.0.1"),
},
ImageProxySettings: model.ImageProxySettings{
Enable: new(true),
ImageProxyType: model.NewPointer(model.ImageProxyTypeLocal),
},
},
}
return MakeImageProxy(configService, httpservice.MakeHTTPService(configService), nil)
}
func TestLocalBackend_GetImage(t *testing.T) {
t.Run("image", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "max-age=2592000, private")
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Content-Length", "10")
w.WriteHeader(http.StatusOK)
w.Write([]byte("1111111111"))
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
recorder := httptest.NewRecorder()
request, _ := http.NewRequest(http.MethodGet, "", nil)
proxy.GetImage(recorder, request, mock.URL+"/image.png")
resp := recorder.Result()
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "max-age=2592000, private", resp.Header.Get("Cache-Control"))
assert.Equal(t, "10", resp.Header.Get("Content-Length"))
respBody, _ := io.ReadAll(resp.Body)
assert.Equal(t, []byte("1111111111"), respBody)
})
t.Run("not an image", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotAcceptable)
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
recorder := httptest.NewRecorder()
request, _ := http.NewRequest(http.MethodGet, "", nil)
proxy.GetImage(recorder, request, mock.URL+"/file.pdf")
resp := recorder.Result()
require.Equal(t, http.StatusNotAcceptable, resp.StatusCode)
})
t.Run("not an image, but remote server ignores accept header", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "max-age=2592000, private")
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Length", "10")
w.WriteHeader(http.StatusOK)
w.Write([]byte("1111111111"))
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
recorder := httptest.NewRecorder()
request, _ := http.NewRequest(http.MethodGet, "", nil)
proxy.GetImage(recorder, request, mock.URL+"/file.pdf")
resp := recorder.Result()
require.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("not found", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
recorder := httptest.NewRecorder()
request, _ := http.NewRequest(http.MethodGet, "", nil)
proxy.GetImage(recorder, request, mock.URL+"/image.png")
resp := recorder.Result()
require.Equal(t, http.StatusNotFound, resp.StatusCode)
})
t.Run("other server error", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
recorder := httptest.NewRecorder()
request, _ := http.NewRequest(http.MethodGet, "", nil)
proxy.GetImage(recorder, request, mock.URL+"/image.png")
resp := recorder.Result()
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
})
t.Run("timeout", func(t *testing.T) {
wait := make(chan bool, 1)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-wait
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
// Modify the timeout to be much shorter than the default 30 seconds
proxy.backend.(*LocalBackend).client.Timeout = time.Millisecond
recorder := httptest.NewRecorder()
request, _ := http.NewRequest(http.MethodGet, "", nil)
proxy.GetImage(recorder, request, mock.URL+"/image.png")
resp := recorder.Result()
require.Equal(t, http.StatusGatewayTimeout, resp.StatusCode)
wait <- true
})
t.Run("unsupported SVG content type", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "max-age=2592000, private")
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Content-Length", "10")
w.WriteHeader(http.StatusOK)
w.Write([]byte("1111111111"))
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
recorder := httptest.NewRecorder()
request, err := http.NewRequest(http.MethodGet, "", nil)
require.NoError(t, err)
proxy.GetImage(recorder, request, mock.URL+"/test.svg")
resp := recorder.Result()
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("SVG body with image/png content type", func(t *testing.T) {
body := []byte(`<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>`)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.WriteHeader(http.StatusOK)
w.Write(body)
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
recorder := httptest.NewRecorder()
request, err := http.NewRequest(http.MethodGet, "", nil)
require.NoError(t, err)
proxy.GetImage(recorder, request, mock.URL+"/image.png")
resp := recorder.Result()
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("XML-based SVG with image/png content type", func(t *testing.T) {
body := []byte(`<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>`)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.WriteHeader(http.StatusOK)
w.Write(body)
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
recorder := httptest.NewRecorder()
request, err := http.NewRequest(http.MethodGet, "", nil)
require.NoError(t, err)
proxy.GetImage(recorder, request, mock.URL+"/image.png")
resp := recorder.Result()
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("UTF-16 LE BOM SVG with image/png content type", func(t *testing.T) {
// Build a UTF-16 LE payload with BOM: 0xFF 0xFE followed by each ASCII
// character of the SVG tag as a two-byte little-endian code unit.
svgASCII := `<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>`
body := []byte{0xFF, 0xFE} // UTF-16 LE BOM
for _, c := range svgASCII {
body = append(body, byte(c), 0x00)
}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.WriteHeader(http.StatusOK)
w.Write(body)
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
recorder := httptest.NewRecorder()
request, err := http.NewRequest(http.MethodGet, "", nil)
require.NoError(t, err)
proxy.GetImage(recorder, request, mock.URL+"/image.png")
resp := recorder.Result()
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("UTF-16 BE BOM SVG with image/png content type", func(t *testing.T) {
// Build a UTF-16 BE payload with BOM: 0xFE 0xFF followed by each ASCII
// character as a two-byte big-endian code unit.
svgASCII := `<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>`
body := []byte{0xFE, 0xFF} // UTF-16 BE BOM
for _, c := range svgASCII {
body = append(body, 0x00, byte(c))
}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.WriteHeader(http.StatusOK)
w.Write(body)
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
recorder := httptest.NewRecorder()
request, err := http.NewRequest(http.MethodGet, "", nil)
require.NoError(t, err)
proxy.GetImage(recorder, request, mock.URL+"/image.png")
resp := recorder.Result()
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("SVG body with leading whitespace prefix", func(t *testing.T) {
prefix := bytes.Repeat([]byte(" "), 600)
body := append(prefix, []byte(`<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>`)...)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.WriteHeader(http.StatusOK)
w.Write(body)
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
recorder := httptest.NewRecorder()
request, err := http.NewRequest(http.MethodGet, "", nil)
require.NoError(t, err)
proxy.GetImage(recorder, request, mock.URL+"/image.png")
resp := recorder.Result()
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("Redirect", func(t *testing.T) {
var mock *httptest.Server
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/image.png":
w.Header().Set("Location", mock.URL+"/image2.png")
w.WriteHeader(http.StatusMovedPermanently)
case "/image2.png":
w.Header().Set("Cache-Control", "max-age=2592000, private")
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Content-Length", "10")
w.WriteHeader(http.StatusOK)
w.Write([]byte("1111111111"))
}
})
mock = httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
recorder := httptest.NewRecorder()
request, _ := http.NewRequest(http.MethodGet, "", nil)
proxy.GetImage(recorder, request, mock.URL+"/image.png")
resp := recorder.Result()
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "10", resp.Header.Get("Content-Length"))
})
}
func TestLocalBackend_GetImageDirect(t *testing.T) {
t.Run("image", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "max-age=2592000, private")
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Content-Length", "10")
w.WriteHeader(http.StatusOK)
w.Write([]byte("1111111111"))
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
body, contentType, err := proxy.GetImageDirect(mock.URL + "/image.png")
assert.NoError(t, err)
assert.Equal(t, "image/png", contentType)
respBody, _ := io.ReadAll(body)
assert.Equal(t, []byte("1111111111"), respBody)
})
t.Run("not an image", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotAcceptable)
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
body, contentType, err := proxy.GetImageDirect(mock.URL + "/file.pdf")
assert.Error(t, err)
assert.Equal(t, "", contentType)
assert.Equal(t, ErrLocalRequestFailed, err)
assert.Nil(t, body)
})
t.Run("not an image, but remote server ignores accept header", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "max-age=2592000, private")
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Length", "10")
w.WriteHeader(http.StatusOK)
w.Write([]byte("1111111111"))
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
body, contentType, err := proxy.GetImageDirect(mock.URL + "/file.pdf")
assert.Error(t, err)
assert.Equal(t, "", contentType)
assert.Equal(t, ErrLocalRequestFailed, err)
assert.Nil(t, body)
})
t.Run("not found", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
body, contentType, err := proxy.GetImageDirect(mock.URL + "/image.png")
assert.Error(t, err)
assert.Equal(t, "", contentType)
assert.Equal(t, ErrLocalRequestFailed, err)
assert.Nil(t, body)
})
t.Run("other server error", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
body, contentType, err := proxy.GetImageDirect(mock.URL + "/image.png")
assert.Error(t, err)
assert.Equal(t, "", contentType)
assert.Equal(t, ErrLocalRequestFailed, err)
assert.Nil(t, body)
})
t.Run("timeout", func(t *testing.T) {
wait := make(chan bool, 1)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-wait
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
// Modify the timeout to be much shorter than the default 30 seconds
proxy.backend.(*LocalBackend).client.Timeout = time.Millisecond
body, contentType, err := proxy.GetImageDirect(mock.URL + "/image.png")
assert.Error(t, err)
assert.Equal(t, "", contentType)
assert.Equal(t, ErrLocalRequestFailed, err)
assert.Nil(t, body)
wait <- true
})
t.Run("image exceeds max size", func(t *testing.T) {
originalMaxImageSize := maxImageSize
maxImageSize = 10
defer func() { maxImageSize = originalMaxImageSize }()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.WriteHeader(http.StatusOK)
w.Write(bytes.Repeat([]byte("1"), int(maxImageSize)+1))
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
body, contentType, err := proxy.GetImageDirect(mock.URL + "/image.png")
assert.Error(t, err)
assert.Equal(t, ErrImageTooLarge, err)
assert.Equal(t, "", contentType)
assert.Nil(t, body)
})
t.Run("image within max size is unaffected", func(t *testing.T) {
originalMaxImageSize := maxImageSize
maxImageSize = 10
defer func() { maxImageSize = originalMaxImageSize }()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.WriteHeader(http.StatusOK)
w.Write(bytes.Repeat([]byte("1"), int(maxImageSize)))
})
mock := httptest.NewServer(handler)
defer mock.Close()
proxy := makeTestLocalProxy()
body, contentType, err := proxy.GetImageDirect(mock.URL + "/image.png")
assert.NoError(t, err)
assert.Equal(t, "image/png", contentType)
respBody, _ := io.ReadAll(body)
assert.Equal(t, bytes.Repeat([]byte("1"), int(maxImageSize)), respBody)
})
}

View File

@@ -0,0 +1,130 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package marketplace
import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/httpservice"
)
// Client is the programmatic interface to the marketplace server API.
type Client struct {
address string
httpClient *http.Client
}
// NewClient creates a client to the marketplace server at the given address.
func NewClient(address string, httpService httpservice.HTTPService) (*Client, error) {
var httpClient *http.Client
addressURL, err := url.Parse(address)
if err != nil {
return nil, errors.Wrap(err, "failed to parse marketplace address")
}
if addressURL.Hostname() == "localhost" || addressURL.Hostname() == "127.0.0.1" {
httpClient = httpService.MakeClient(true)
} else {
httpClient = httpService.MakeClient(false)
}
return &Client{
address: address,
httpClient: httpClient,
}, nil
}
// GetPlugins fetches the list of plugins from the configured server.
func (c *Client) GetPlugins(request *model.MarketplacePluginFilter) ([]*model.BaseMarketplacePlugin, error) {
u, err := url.Parse(c.buildURL("/api/v1/plugins"))
if err != nil {
return nil, err
}
request.ApplyToURL(u)
resp, err := c.doGet(u.String())
if err != nil {
return nil, err
}
defer closeBody(resp)
switch resp.StatusCode {
case http.StatusOK:
return model.BaseMarketplacePluginsFromReader(resp.Body)
default:
return nil, errors.Errorf("failed with status code %d", resp.StatusCode)
}
}
func (c *Client) GetPlugin(filter *model.MarketplacePluginFilter, pluginVersion string) (*model.BaseMarketplacePlugin, error) {
filter.ReturnAllVersions = true
if filter.PluginId == "" {
return nil, errors.New("missing pluginID")
}
if pluginVersion == "" {
return nil, errors.New("missing pluginVersion")
}
plugins, err := c.GetPlugins(filter)
if err != nil {
return nil, err
}
for _, plugin := range plugins {
if plugin.Manifest.Version == pluginVersion {
return plugin, nil
}
}
return nil, errors.New("plugin not found")
}
func (c *Client) GetLatestPlugin(filter *model.MarketplacePluginFilter) (*model.BaseMarketplacePlugin, error) {
filter.ReturnAllVersions = false
if filter.PluginId == "" {
return nil, errors.New("no pluginID provided")
}
plugins, err := c.GetPlugins(filter)
if err != nil {
return nil, err
}
if len(plugins) == 0 {
return nil, errors.New("plugin not found")
}
if len(plugins) > 1 {
return nil, errors.Errorf("unexpectedly more then one plugin was returned from the marketplace")
}
return plugins[0], nil
}
// closeBody ensures the Body of an http.Response is properly closed.
func closeBody(r *http.Response) {
if r.Body != nil {
_, _ = io.Copy(io.Discard, r.Body)
_ = r.Body.Close()
}
}
func (c *Client) buildURL(urlPath string, args ...any) string {
if len(args) > 0 {
urlPath = fmt.Sprintf(urlPath, args...)
}
return fmt.Sprintf("%s/%s", strings.TrimRight(c.address, "/"), strings.TrimLeft(urlPath, "/"))
}
func (c *Client) doGet(u string) (*http.Response, error) {
return c.httpClient.Get(u)
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package marketplace
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestBuildURL(t *testing.T) {
config := &Client{}
testCases := map[string]struct {
base string
path string
expected string
}{
"Base url with trailing slash and path with leading slash": {
base: "https://api.integrations.mattermost.com/",
path: "/api/v1/plugins",
expected: "https://api.integrations.mattermost.com/api/v1/plugins",
},
"Base url without trailing slash and path with leading slash": {
base: "https://api.integrations.mattermost.com",
path: "/api/v1/plugins",
expected: "https://api.integrations.mattermost.com/api/v1/plugins",
},
"Base url without trailing slash and path without leading slash": {
base: "https://api.integrations.mattermost.com",
path: "api/v1/plugins",
expected: "https://api.integrations.mattermost.com/api/v1/plugins",
},
}
for name, tt := range testCases {
t.Run(name, func(t *testing.T) {
config.address = tt.base
actual := config.buildURL(tt.path)
assert.Equal(t, tt.expected, actual)
})
}
}

View File

@@ -0,0 +1,36 @@
## Remote Cluster Service
Package `remotecluster` implements Mattermost's "Secured Connections" feature, which enables communication between different Mattermost clusters. Specifically, this package provides:
### Service Management:
- Manages inter-cluster communication via topic-based messages
- Handles connection state (active/inactive) based on cluster leadership
- Maintains concurrent send channels (MaxConcurrentSends = 10) for parallel message processing
- Implements periodic health checks (pings) to monitor remote cluster connectivity
### Message Handling:
- Sends messages using a pool of goroutines to handle concurrent sends while preserving message order per remote
- Uses hash-based routing to ensure messages for the same remote ID go to the same channel
- Supports different types of sends: messages, files, and profile images
- Implements topic-based message routing with listener callbacks
### Connection Management:
- Handles invitation confirmations between clusters
- Maintains HTTP client connections with proper timeouts and transport settings
- Supports connection state listeners for monitoring remote cluster availability
- Implements ping mechanism to verify remote cluster health
### Core Features:
- Topic-based message routing
- File transfer capabilities
- Profile image synchronization
- Invitation system for establishing connections
- Health monitoring via pings
- Concurrent message processing
- Connection state management
This package is designed to be thread-safe and handles leadership changes in clustered environments, only running active operations on the leader node.

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package remotecluster
import (
"errors"
"fmt"
)
var (
RemoteClusterAlreadyConfirmedError = errors.New("the remote cluster has already been confirmed")
)
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)
}

View File

@@ -0,0 +1,97 @@
// 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/public/model"
)
// AcceptInvitation is called when accepting an invitation to connect with a remote cluster.
func (rcs *Service) AcceptInvitation(invite *model.RemoteClusterInvite, name string, displayName string, creatorId string, siteURL string, defaultTeamId string) (*model.RemoteCluster, error) {
// Generate new token for RemoteToken only if invite version is 2 or greater
var remoteToken string
if invite.Version >= 2 {
remoteToken = model.NewId() // Generate new token for v2+ protocol
} else {
remoteToken = invite.Token // Use the token from the invite for backwards compatibility
}
rc := &model.RemoteCluster{
RemoteId: invite.RemoteId,
Name: name,
DisplayName: displayName,
DefaultTeamId: defaultTeamId,
Token: model.NewId(),
RemoteToken: remoteToken,
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, siteURL)
if err != nil {
return nil, err
}
url := fmt.Sprintf("%s/%s", rcSaved.SiteURL, ConfirmInviteURL)
// for the invite confirm message, we need to use the token that
// the originating server sent in the invite instead of the one
// we're storing as a refresh
rc.RemoteToken = invite.Token
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.PingNow(rcSaved)
return rcSaved, nil
}
func makeConfirmFrame(rc *model.RemoteCluster, siteURL string) (*model.RemoteClusterFrame, error) {
confirm := model.RemoteClusterInvite{
RemoteId: rc.RemoteId,
SiteURL: siteURL,
Token: rc.Token,
RefreshedToken: rc.RemoteToken,
Version: 3,
}
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
}

View File

@@ -0,0 +1,135 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package remotecluster
import (
"slices"
"sync"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
"github.com/mattermost/mattermost/server/v8/einterfaces"
)
type mockServer struct {
remotes []*model.RemoteCluster
logger *mlog.Logger
user *model.User
customStore store.Store // if set, GetStore() returns this instead of creating default
}
func newMockServer(t *testing.T, remotes []*model.RemoteCluster) *mockServer {
logger := mlog.CreateConsoleTestLogger(t)
return &mockServer{
remotes: remotes,
logger: logger,
}
}
// newMockServerWithStore creates a mockServer with a custom store for fine-grained test control.
// Use this when you need to mock specific store behaviors that aren't covered by the default.
func newMockServerWithStore(t *testing.T, customStore store.Store) *mockServer {
logger := mlog.CreateConsoleTestLogger(t)
return &mockServer{
logger: logger,
customStore: customStore,
}
}
func (ms *mockServer) SetUser(user *model.User) {
ms.user = user
}
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) Log() *mlog.Logger {
return ms.logger
}
func (ms *mockServer) GetStore() store.Store {
// If a custom store was provided, use it (for unit tests with fine-grained control)
if ms.customStore != nil {
return ms.customStore
}
// Otherwise, return the default pre-configured store (for integration-style tests)
anyQueryFilter := mock.MatchedBy(func(filter model.RemoteClusterQueryFilter) bool {
return true
})
anyUserId := mock.AnythingOfType("string")
anyId := mock.AnythingOfType("string")
remoteClusterStoreMock := &mocks.RemoteClusterStore{}
remoteClusterStoreMock.On("GetByTopic", "share").Return(ms.remotes, nil)
remoteClusterStoreMock.On("GetAll", 0, 999999, anyQueryFilter).Return(ms.remotes, nil)
remoteClusterStoreMock.On("SetLastPingAt", anyId).Return(nil)
userStoreMock := &mocks.UserStore{}
userStoreMock.On("Get", mock.Anything, anyUserId).Return(ms.user, nil)
storeMock := &mocks.Store{}
storeMock.On("RemoteCluster").Return(remoteClusterStoreMock)
storeMock.On("User").Return(userStoreMock)
return storeMock
}
type mockApp struct {
offlinePluginIDs []string
mux sync.Mutex
totalPingCount int
totalPingErrors int
pingCounts map[string]int
}
func newMockApp(_ *testing.T, offlinePluginIDs []string) *mockApp {
return &mockApp{
offlinePluginIDs: offlinePluginIDs,
pingCounts: make(map[string]int),
}
}
func (ma *mockApp) OnSharedChannelsPing(rc *model.RemoteCluster) bool {
ma.mux.Lock()
defer ma.mux.Unlock()
if slices.Contains(ma.offlinePluginIDs, rc.PluginID) {
ma.totalPingErrors++
return false
}
ma.totalPingCount++
count := ma.pingCounts[rc.PluginID]
ma.pingCounts[rc.PluginID] = count + 1
return true
}
func (ma *mockApp) GetTotalPingCount() int {
ma.mux.Lock()
defer ma.mux.Unlock()
return ma.totalPingCount
}
func (ma *mockApp) GetTotalPingErrorCount() int {
ma.mux.Lock()
defer ma.mux.Unlock()
return ma.totalPingErrors
}
func (ma *mockApp) GetPingCount(pluginID string) int {
ma.mux.Lock()
defer ma.mux.Unlock()
return ma.pingCounts[pluginID]
}

View File

@@ -0,0 +1,210 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package remotecluster
import (
"encoding/json"
"errors"
"fmt"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
// PingNow emits a ping immediately without waiting for next ping loop.
func (rcs *Service) PingNow(rc *model.RemoteCluster) {
online := rc.IsOnline()
pingErr := rcs.pingRemote(rc)
if pingErr != nil {
rcs.server.Log().LogM(mlog.MlvlRemoteClusterServiceWarn, "Remote cluster ping failed",
mlog.String("remote", rc.DisplayName),
mlog.String("remote_id", rc.RemoteId),
mlog.String("plugin_id", rc.PluginID),
mlog.Err(pingErr),
)
}
pingSucceeded := pingErr == nil
hasPendingSync := false
if pingSucceeded {
// Consume the pending-sync-failure marker (if any). LoadAndDelete clears it so the
// map does not accumulate entries for remotes that have recovered.
_, hasPendingSync = rcs.syncFailedSinceLastPing.LoadAndDelete(rc.RemoteId)
}
// The connection-state-change counter tracks genuine online/offline transitions, so
// only bump it when IsOnline() actually flipped. The event itself must still fire on
// the pending-sync recovery path (online→online) to drive ForceSyncForRemote.
stateChanged := online != rc.IsOnline()
if stateChanged || (pingSucceeded && hasPendingSync) {
if stateChanged {
if metrics := rcs.server.GetMetrics(); metrics != nil {
metrics.IncrementRemoteClusterConnStateChangeCounter(rc.RemoteId, rc.IsOnline())
}
}
rcs.fireConnectionStateChgEvent(rc)
}
}
// pingAllNow emits a ping to all remotes immediately without waiting for next ping loop.
func (rcs *Service) pingAllNow(filter model.RemoteClusterQueryFilter) {
// get all remotes, including any previously offline.
remotes, err := rcs.server.GetStore().RemoteCluster().GetAll(0, 999999, filter)
if err != nil {
rcs.server.Log().LogM(mlog.MlvlRemoteClusterServiceError, "Ping all remote clusters failed (could not get list of remotes)", mlog.Err(err))
return
}
for _, rc := range remotes {
// filter out unconfirmed invites so we don't ping them without permission
if rc.IsConfirmed() {
rcs.PingNow(rc)
}
}
}
// 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 range MaxConcurrentSends {
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 {
pingFreq := rcs.GetPingFreq()
start := time.Now()
// ping all remotes, including any previously offline.
rcs.pingAllNow(model.RemoteClusterQueryFilter{})
// 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
}
rcs.PingNow(rc)
case <-done:
return
}
}
}
var ErrPluginPingFail = errors.New("plugin ping failed")
// 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 {
ping := model.RemoteClusterPing{}
if rc.IsPlugin() {
ping.SentAt = model.GetMillis()
if ok := rcs.app.OnSharedChannelsPing(rc); !ok {
return ErrPluginPingFail
}
ping.RecvAt = model.GetMillis()
} else {
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
}
rc.LastPingAt = model.GetMillis()
err = json.Unmarshal(resp, &ping)
if err != nil {
return err
}
}
if err := rcs.server.GetStore().RemoteCluster().SetLastPingAt(rc.RemoteId); err != nil {
rcs.server.Log().LogM(mlog.MlvlRemoteClusterServiceError, "Failed to update LastPingAt for remote cluster",
mlog.String("remote", rc.DisplayName),
mlog.String("remote_id", rc.RemoteId),
mlog.Err(err),
)
}
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.Log().Log(mlog.LvlRemoteClusterServiceDebug, "Remote cluster ping",
mlog.String("remote", rc.DisplayName),
mlog.String("remote_id", rc.RemoteId),
mlog.String("plugin_id", rc.PluginID),
mlog.Int("sent_at", ping.SentAt),
mlog.Int("recv_at", ping.RecvAt),
mlog.Int("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())
}
}

View File

@@ -0,0 +1,291 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package remotecluster
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/wiggin77/merror"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
)
const (
Recent = 60000
)
func TestPing(t *testing.T) {
t.Run("No error", func(t *testing.T) {
merr := merror.New()
var remotes []*model.RemoteCluster
pingsReceived := make(map[string]struct{})
var mux sync.Mutex
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer w.WriteHeader(200)
var frame model.RemoteClusterFrame
err := json.NewDecoder(r.Body).Decode(&frame)
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
}
// Make sure ping is from a remote that was added for this test.
if !hasRemoteID(frame.RemoteId, remotes) {
merr.Append(fmt.Errorf("RemoteID not in list of remotes for this test; remote_id=%s", frame.RemoteId))
return
}
var ping model.RemoteClusterPing
err = json.Unmarshal(frame.Msg.Payload, &ping)
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
}
mux.Lock()
defer mux.Unlock()
pingsReceived[frame.RemoteId] = struct{}{}
}))
defer ts.Close()
remotes = makeRemoteClusters(NumRemotes, ts.URL, false)
mockServer := newMockServer(t, remotes)
mockApp := newMockApp(t, nil)
service, err := NewRemoteClusterService(mockServer, mockApp)
require.NoError(t, err)
err = service.Start()
require.NoError(t, err)
defer service.Shutdown()
// wait up to 10 seconds for all remotes to get pinged. This will normally take less than 1 second
// unless the server is very busy.
assert.Eventually(t, func() bool {
mux.Lock()
defer mux.Unlock()
return len(pingsReceived) == NumRemotes
}, time.Second*10, time.Millisecond*50, "all remotes must get pinged")
assert.NoError(t, merr.ErrorOrNil())
})
t.Run("HTTP errors", func(t *testing.T) {
merr := merror.New()
var remotes []*model.RemoteCluster
pingsReceived := make(map[string]struct{})
var mux sync.Mutex
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var frame model.RemoteClusterFrame
err := json.NewDecoder(r.Body).Decode(&frame)
if err != nil {
merr.Append(err)
}
// Make sure ping is from a remote that was added for this test.
if !hasRemoteID(frame.RemoteId, remotes) {
merr.Append(fmt.Errorf("RemoteID not in list of remotes for this test; remote_id=%s", frame.RemoteId))
return
}
var ping model.RemoteClusterPing
err = json.Unmarshal(frame.Msg.Payload, &ping)
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)
mux.Lock()
defer mux.Unlock()
pingsReceived[frame.RemoteId] = struct{}{}
}))
defer ts.Close()
remotes = makeRemoteClusters(NumRemotes, ts.URL, false)
mockServer := newMockServer(t, remotes)
mockApp := newMockApp(t, nil)
service, err := NewRemoteClusterService(mockServer, mockApp)
require.NoError(t, err)
err = service.Start()
require.NoError(t, err)
defer service.Shutdown()
// wait up to 10 seconds for all remotes to get pinged. This will normally take less than 1 second
// until the server is very busy.
assert.Eventually(t, func() bool {
mux.Lock()
defer mux.Unlock()
return len(pingsReceived) == NumRemotes
}, time.Second*10, time.Millisecond*50, "all remotes must get pinged")
assert.NoError(t, merr.ErrorOrNil())
})
t.Run("Plugin ping", func(t *testing.T) {
mockServer := newMockServer(t, makeRemoteClusters(NumRemotes, model.NewId(), true))
offline := []string{mockServer.remotes[0].PluginID, mockServer.remotes[1].PluginID}
mockApp := newMockApp(t, offline)
service, err := NewRemoteClusterService(mockServer, mockApp)
require.NoError(t, err)
// high ping frequency so we don't delay unit tests.
service.SetPingFreq(time.Millisecond * 50)
err = service.Start()
require.NoError(t, err)
defer service.Shutdown()
checkPingCount := func() bool {
return mockApp.GetTotalPingCount() >= NumRemotes
}
checkErrorCount := func() bool {
return mockApp.GetTotalPingErrorCount() >= 2
}
assert.Eventually(t, checkPingCount, time.Second*5, 10*time.Millisecond)
assert.Eventually(t, checkErrorCount, time.Second*5, 10*time.Millisecond)
})
}
// pingTestListener records connection-state-change callbacks for the PingNow tests.
type pingTestListener struct {
mu sync.Mutex
calls int
online bool
}
func (l *pingTestListener) callback(rc *model.RemoteCluster, online bool) {
l.mu.Lock()
defer l.mu.Unlock()
l.calls++
l.online = online
}
func (l *pingTestListener) snapshot() (calls int, online bool) {
l.mu.Lock()
defer l.mu.Unlock()
return l.calls, l.online
}
// newPingTestService builds a remote cluster service whose remotes ping a local test server
// that always responds 200, and registers a connection-state listener to observe PingNow.
// The returned remote is already online (recent LastPingAt), so IsOnline() will not flip.
func newPingTestService(t *testing.T) (*Service, *pingTestListener, *model.RemoteCluster) {
t.Helper()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
pong, _ := json.Marshal(model.RemoteClusterPing{SentAt: model.GetMillis(), RecvAt: model.GetMillis()})
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(pong)
}))
t.Cleanup(ts.Close)
rcStoreMock := &mocks.RemoteClusterStore{}
rcStoreMock.On("SetLastPingAt", mock.AnythingOfType("string")).Return(nil)
storeMock := &mocks.Store{}
storeMock.On("RemoteCluster").Return(rcStoreMock)
service, err := NewRemoteClusterService(newMockServerWithStore(t, storeMock), newMockApp(t, nil))
require.NoError(t, err)
listener := &pingTestListener{}
listenerId := service.AddConnectionStateListener(listener.callback)
t.Cleanup(func() { service.RemoveConnectionStateListener(listenerId) })
rc := &model.RemoteCluster{
RemoteId: model.NewId(),
DisplayName: "test-remote",
SiteURL: ts.URL,
Token: model.NewId(),
RemoteToken: model.NewId(),
LastPingAt: model.GetMillis(),
}
return service, listener, rc
}
// TestPingNow_SyncFailureRecovery verifies that PingNow fires a connection-state-change
// event when a sync failure was recorded since the last ping, even if the remote never
// appeared offline (LastPingAt stayed within the 5-minute window).
func TestPingNow_SyncFailureRecovery(t *testing.T) {
service, listener, rc := newPingTestService(t)
// Simulate a sync failure that occurred since the last ping.
service.NotifySyncFailed(rc.RemoteId)
service.PingNow(rc)
calls, wasOnline := listener.snapshot()
assert.Equal(t, 1, calls, "connection state listener should fire exactly once on sync failure recovery")
assert.True(t, wasOnline, "listener should report online=true")
// Marker must be cleared after a successful ping so subsequent pings don't re-fire.
_, ok := service.syncFailedSinceLastPing.Load(rc.RemoteId)
assert.False(t, ok, "syncFailedSinceLastPing marker should be cleared after a successful ping")
}
// TestPingNow_NoSpuriousFire verifies that PingNow does NOT fire a connection-state-change
// event when the remote stays online and no sync failure marker is set.
func TestPingNow_NoSpuriousFire(t *testing.T) {
service, listener, rc := newPingTestService(t)
service.PingNow(rc)
calls, _ := listener.snapshot()
assert.Equal(t, 0, calls, "listener must not fire when remote stays online and no sync failure marker is set")
}
func checkRecent(millis int64, within int64) bool {
now := model.GetMillis()
return millis > now-within && millis < now+within
}
func hasRemoteID(remoteID string, remotes []*model.RemoteCluster) bool {
for _, r := range remotes {
if r.RemoteId == remoteID {
return true
}
}
return false
}

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package remotecluster
import (
"fmt"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/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.Log().LogM(mlog.MlvlRemoteClusterServiceError, "Error from remote cluster message listener",
mlog.String("msg_id", 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
}
// ReceiveInviteConfirmation is called by the Rest API layer when a Remote Cluster accepts an invitation from this
// local cluster.
func (rcs *Service) ReceiveInviteConfirmation(confirm model.RemoteClusterInvite) (*model.RemoteCluster, error) {
store := rcs.server.GetStore().RemoteCluster()
rc, err := store.Get(confirm.RemoteId, false)
if err != nil {
return nil, fmt.Errorf("cannot accept invite confirmation for remote %s: %w", confirm.RemoteId, err)
}
if rc.IsConfirmed() {
return nil, fmt.Errorf("cannot accept invite confirmation for remote %s: %w", confirm.RemoteId, RemoteClusterAlreadyConfirmedError)
}
rc.SiteURL = confirm.SiteURL
rc.RemoteToken = confirm.Token
// If the accepting cluster sent a RefreshedToken (its RemoteToken), set it as our Token
if confirm.Version >= 2 && confirm.RefreshedToken != "" {
if confirm.RefreshedToken == rc.Token {
return nil, fmt.Errorf("cannot accept invite confirmation for remote %s: RefreshedToken must be different from the original invite token", confirm.RemoteId)
}
rc.Token = confirm.RefreshedToken
} else {
// For older versions or if no RefreshedToken was provided, generate a new token
// to invalidate the original invite token and prevent reuse
rc.Token = model.NewId()
}
rcUpdated, err := store.Update(rc)
if err != nil {
return nil, fmt.Errorf("cannot apply invite confirmation for remote %s: %w", confirm.RemoteId, err)
}
// issue the first ping right away. The goroutine will exit when ping completes or PingTimeout exceeded.
go rcs.PingNow(rcUpdated)
return rcUpdated, nil
}

View File

@@ -0,0 +1,387 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package remotecluster
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
)
// TestReceiveInviteConfirmation_TokenInvalidation tests that the invite token is properly
// invalidated after the first successful confirmation, preventing token reuse attacks.
// This addresses the security vulnerability described in MM-65152.
func TestReceiveInviteConfirmation_TokenInvalidation(t *testing.T) {
t.Run("Protocol v2+ with RefreshedToken - token is rotated", func(t *testing.T) {
// Setup
originalToken := model.NewId()
refreshedToken := model.NewId()
remoteId := model.NewId()
originalRC := &model.RemoteCluster{
RemoteId: remoteId,
Token: originalToken,
SiteURL: model.SiteURLPending + model.NewId(),
CreateAt: model.GetMillis(),
}
// Mock store
remoteClusterStoreMock := &mocks.RemoteClusterStore{}
remoteClusterStoreMock.On("Get", remoteId, false).Return(originalRC, nil)
// Capture the Update call to verify the token was rotated
var capturedRC *model.RemoteCluster
remoteClusterStoreMock.On("Update", mock.AnythingOfType("*model.RemoteCluster")).Run(func(args mock.Arguments) {
capturedRC = args.Get(0).(*model.RemoteCluster)
}).Return(func(rc *model.RemoteCluster) *model.RemoteCluster {
return rc
}, nil)
storeMock := &mocks.Store{}
storeMock.On("RemoteCluster").Return(remoteClusterStoreMock)
mockServer := newMockServerWithStore(t, storeMock)
mockApp := newMockApp(t, nil)
service, err := NewRemoteClusterService(mockServer, mockApp)
require.NoError(t, err)
// Create invite confirmation with protocol v2+ (includes RefreshedToken)
confirm := model.RemoteClusterInvite{
RemoteId: remoteId,
SiteURL: "http://example.com",
Token: model.NewId(),
RefreshedToken: refreshedToken,
Version: 3, // v3 protocol
}
// Execute
rcUpdated, err := service.ReceiveInviteConfirmation(confirm)
// Assert
require.NoError(t, err)
require.NotNil(t, rcUpdated)
require.NotNil(t, capturedRC, "Update should have been called")
assert.Equal(t, refreshedToken, capturedRC.Token, "Token should be rotated to RefreshedToken")
assert.NotEqual(t, originalToken, capturedRC.Token, "Original invite token should be invalidated")
assert.Equal(t, remoteId, capturedRC.RemoteId, "RemoteId should be preserved")
remoteClusterStoreMock.AssertExpectations(t)
})
t.Run("Protocol v1 or no RefreshedToken - token is regenerated", func(t *testing.T) {
// Setup
originalToken := model.NewId()
remoteId := model.NewId()
originalRC := &model.RemoteCluster{
RemoteId: remoteId,
Token: originalToken,
SiteURL: model.SiteURLPending + model.NewId(),
CreateAt: model.GetMillis(),
}
// Mock store
remoteClusterStoreMock := &mocks.RemoteClusterStore{}
remoteClusterStoreMock.On("Get", remoteId, false).Return(originalRC, nil)
// Capture what Update was called with for explicit assertions
var capturedRC *model.RemoteCluster
remoteClusterStoreMock.On("Update", mock.AnythingOfType("*model.RemoteCluster")).Run(func(args mock.Arguments) {
capturedRC = args.Get(0).(*model.RemoteCluster)
}).Return(func(rc *model.RemoteCluster) *model.RemoteCluster {
return rc
}, nil)
storeMock := &mocks.Store{}
storeMock.On("RemoteCluster").Return(remoteClusterStoreMock)
mockServer := newMockServerWithStore(t, storeMock)
mockApp := newMockApp(t, nil)
service, err := NewRemoteClusterService(mockServer, mockApp)
require.NoError(t, err)
// Create invite confirmation WITHOUT RefreshedToken (protocol v1)
confirm := model.RemoteClusterInvite{
RemoteId: remoteId,
SiteURL: "http://example.com",
Token: model.NewId(),
RefreshedToken: "", // No refreshed token
Version: 1, // v1 protocol
}
// Execute
rcUpdated, err := service.ReceiveInviteConfirmation(confirm)
// Assert
require.NoError(t, err)
require.NotNil(t, rcUpdated)
require.NotNil(t, capturedRC, "Update should have been called")
assert.NotEmpty(t, capturedRC.Token, "Token should be set")
assert.NotEqual(t, originalToken, capturedRC.Token, "Original invite token must be invalidated")
assert.Len(t, capturedRC.Token, 26, "New token should be a valid ID (26 chars)")
assert.Equal(t, remoteId, capturedRC.RemoteId, "RemoteId should be preserved")
remoteClusterStoreMock.AssertExpectations(t)
})
t.Run("Already confirmed cluster - returns error", func(t *testing.T) {
// Setup - cluster is already confirmed (has a real SiteURL)
remoteId := model.NewId()
confirmedRC := &model.RemoteCluster{
RemoteId: remoteId,
Token: model.NewId(),
SiteURL: "http://already-confirmed.com", // NOT a pending URL
CreateAt: model.GetMillis(),
}
// Mock store
remoteClusterStoreMock := &mocks.RemoteClusterStore{}
remoteClusterStoreMock.On("Get", remoteId, false).Return(confirmedRC, nil)
// Update should NOT be called
storeMock := &mocks.Store{}
storeMock.On("RemoteCluster").Return(remoteClusterStoreMock)
mockServer := newMockServerWithStore(t, storeMock)
mockApp := newMockApp(t, nil)
service, err := NewRemoteClusterService(mockServer, mockApp)
require.NoError(t, err)
confirm := model.RemoteClusterInvite{
RemoteId: remoteId,
SiteURL: "http://attacker.com",
Token: model.NewId(),
RefreshedToken: model.NewId(),
Version: 3,
}
// Execute
rcUpdated, err := service.ReceiveInviteConfirmation(confirm)
// Assert
require.Error(t, err, "Should return error for already confirmed cluster")
assert.Nil(t, rcUpdated)
assert.Contains(t, err.Error(), "already been confirmed",
"Error should indicate cluster is already confirmed")
// Verify Update was NOT called (token reuse prevented)
remoteClusterStoreMock.AssertNotCalled(t, "Update", mock.Anything)
})
t.Run("Token reuse scenario - second confirmation with old token fails", func(t *testing.T) {
// This test simulates the security vulnerability:
// 1. First confirmation succeeds and rotates token
// 2. Attacker tries to reuse the old token
// 3. Second confirmation should fail because token was rotated
originalToken := model.NewId()
newToken := model.NewId()
remoteId := model.NewId()
// Initial state - unconfirmed cluster
originalRC := &model.RemoteCluster{
RemoteId: remoteId,
Token: originalToken,
SiteURL: model.SiteURLPending + model.NewId(),
CreateAt: model.GetMillis(),
}
// After first confirmation - token has been rotated
confirmedRC := &model.RemoteCluster{
RemoteId: remoteId,
Token: newToken, // Token was rotated
RemoteToken: model.NewId(),
SiteURL: "http://legitimate.com",
CreateAt: model.GetMillis(),
}
// Mock store
remoteClusterStoreMock := &mocks.RemoteClusterStore{}
// First call: return unconfirmed cluster
remoteClusterStoreMock.On("Get", remoteId, false).Return(originalRC, nil).Once()
// First update: rotate token
remoteClusterStoreMock.On("Update", mock.MatchedBy(func(rc *model.RemoteCluster) bool {
return rc.RemoteId == remoteId
})).Return(confirmedRC, nil).Once()
// Second call: return confirmed cluster with new token
remoteClusterStoreMock.On("Get", remoteId, false).Return(confirmedRC, nil).Once()
storeMock := &mocks.Store{}
storeMock.On("RemoteCluster").Return(remoteClusterStoreMock)
mockServer := newMockServerWithStore(t, storeMock)
mockApp := newMockApp(t, nil)
service, err := NewRemoteClusterService(mockServer, mockApp)
require.NoError(t, err)
// First confirmation (legitimate)
confirm1 := model.RemoteClusterInvite{
RemoteId: remoteId,
SiteURL: "http://legitimate.com",
Token: model.NewId(),
RefreshedToken: newToken,
Version: 3,
}
rcUpdated, err := service.ReceiveInviteConfirmation(confirm1)
require.NoError(t, err)
require.NotNil(t, rcUpdated)
assert.Equal(t, newToken, rcUpdated.Token, "Token should be rotated after first confirmation")
// Second confirmation attempt (attacker trying to reuse invite)
confirm2 := model.RemoteClusterInvite{
RemoteId: remoteId,
SiteURL: "http://attacker.com", // Attacker's malicious URL
Token: originalToken, // Reusing OLD token from original invite
RefreshedToken: model.NewId(),
Version: 3,
}
rcUpdated2, err := service.ReceiveInviteConfirmation(confirm2)
// Assert: Second confirmation should fail
require.Error(t, err, "Second confirmation should fail - cluster already confirmed")
assert.Nil(t, rcUpdated2, "No cluster should be returned on failed reuse attempt")
assert.Contains(t, err.Error(), "already been confirmed",
"Should indicate cluster already confirmed, preventing token reuse")
remoteClusterStoreMock.AssertExpectations(t)
})
t.Run("Protocol v2+ with RefreshedToken equal to original token - rejected (MM-67098)", func(t *testing.T) {
// Security: RefreshedToken must be different from the original invite token.
// If the remote sends back the same token, they did not actually refresh; reject.
originalToken := model.NewId()
remoteId := model.NewId()
originalRC := &model.RemoteCluster{
RemoteId: remoteId,
Token: originalToken,
SiteURL: model.SiteURLPending + model.NewId(),
CreateAt: model.GetMillis(),
}
remoteClusterStoreMock := &mocks.RemoteClusterStore{}
remoteClusterStoreMock.On("Get", remoteId, false).Return(originalRC, nil)
// Update must NOT be called when RefreshedToken == rc.Token
storeMock := &mocks.Store{}
storeMock.On("RemoteCluster").Return(remoteClusterStoreMock)
mockServer := newMockServerWithStore(t, storeMock)
mockApp := newMockApp(t, nil)
service, err := NewRemoteClusterService(mockServer, mockApp)
require.NoError(t, err)
confirm := model.RemoteClusterInvite{
RemoteId: remoteId,
SiteURL: "http://example.com",
Token: model.NewId(),
RefreshedToken: originalToken, // Same as rc.Token - invalid, must be different
Version: 3,
}
rcUpdated, err := service.ReceiveInviteConfirmation(confirm)
require.Error(t, err)
assert.Nil(t, rcUpdated)
assert.Contains(t, err.Error(), "RefreshedToken must be different from the original invite token")
remoteClusterStoreMock.AssertNotCalled(t, "Update", mock.Anything)
})
}
// TestReceiveInviteConfirmation_EdgeCases tests various edge cases
func TestReceiveInviteConfirmation_EdgeCases(t *testing.T) {
t.Run("Non-existent remote ID", func(t *testing.T) {
remoteId := model.NewId()
remoteClusterStoreMock := &mocks.RemoteClusterStore{}
remoteClusterStoreMock.On("Get", remoteId, false).Return(nil, &model.AppError{
Message: "not found",
})
storeMock := &mocks.Store{}
storeMock.On("RemoteCluster").Return(remoteClusterStoreMock)
mockServer := newMockServerWithStore(t, storeMock)
mockApp := newMockApp(t, nil)
service, err := NewRemoteClusterService(mockServer, mockApp)
require.NoError(t, err)
confirm := model.RemoteClusterInvite{
RemoteId: remoteId,
SiteURL: "http://example.com",
Token: model.NewId(),
Version: 3,
}
rcUpdated, err := service.ReceiveInviteConfirmation(confirm)
require.Error(t, err)
assert.Nil(t, rcUpdated)
assert.Contains(t, err.Error(), "cannot accept invite confirmation")
})
t.Run("Protocol v2+ with empty RefreshedToken - falls back to NewId", func(t *testing.T) {
originalToken := model.NewId()
remoteId := model.NewId()
originalRC := &model.RemoteCluster{
RemoteId: remoteId,
Token: originalToken,
SiteURL: model.SiteURLPending + model.NewId(),
CreateAt: model.GetMillis(),
}
remoteClusterStoreMock := &mocks.RemoteClusterStore{}
remoteClusterStoreMock.On("Get", remoteId, false).Return(originalRC, nil)
// Capture what Update was called with for explicit assertions
var capturedRC *model.RemoteCluster
remoteClusterStoreMock.On("Update", mock.AnythingOfType("*model.RemoteCluster")).Run(func(args mock.Arguments) {
capturedRC = args.Get(0).(*model.RemoteCluster)
}).Return(func(rc *model.RemoteCluster) *model.RemoteCluster {
return rc
}, nil)
storeMock := &mocks.Store{}
storeMock.On("RemoteCluster").Return(remoteClusterStoreMock)
mockServer := newMockServerWithStore(t, storeMock)
mockApp := newMockApp(t, nil)
service, err := NewRemoteClusterService(mockServer, mockApp)
require.NoError(t, err)
// v3 protocol but RefreshedToken is empty
confirm := model.RemoteClusterInvite{
RemoteId: remoteId,
SiteURL: "http://example.com",
Token: model.NewId(),
RefreshedToken: "", // Empty - should trigger fallback
Version: 3,
}
rcUpdated, err := service.ReceiveInviteConfirmation(confirm)
require.NoError(t, err)
require.NotNil(t, rcUpdated)
require.NotNil(t, capturedRC, "Update should have been called")
assert.NotEqual(t, originalToken, capturedRC.Token, "Original token should be invalidated")
assert.Len(t, capturedRC.Token, 26, "Should generate new token via fallback (26 chars)")
assert.Equal(t, remoteId, capturedRC.RemoteId, "RemoteId should be preserved")
remoteClusterStoreMock.AssertExpectations(t)
})
}

View 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 any) error {
raw, err := json.Marshal(v)
if err != nil {
return err
}
r.Payload = raw
return nil
}

View File

@@ -0,0 +1,58 @@
// 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 any) 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 sendProfileImageTask:
rcs.sendProfileImage(task)
}
case <-done:
return
}
}
}

View File

@@ -0,0 +1,209 @@
// 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/public/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()
t.Run("No error", func(t *testing.T) {
var countCallbacks atomic.Int32
var countWebReq atomic.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)
}()
countWebReq.Add(1)
var frame model.RemoteClusterFrame
jsonErr := json.NewDecoder(r.Body).Decode(&frame)
if jsonErr != nil {
merr.Append(jsonErr)
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, &note)
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, false))
mockApp := newMockApp(t, nil)
service, err := NewRemoteClusterService(mockServer, mockApp)
require.NoError(t, err)
service.disablePing = true
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()
countCallbacks.Add(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, &note)
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), countCallbacks.Load())
assert.Equal(t, int32(NumRemotes), countWebReq.Load())
t.Logf("%d callbacks counted; %d web requests counted; %d expected",
countCallbacks.Load(), countWebReq.Load(), 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, false))
mockApp := newMockApp(t, nil)
service, err := NewRemoteClusterService(mockServer, mockApp)
require.NoError(t, err)
service.disablePing = true
err = service.Start()
require.NoError(t, err)
defer service.Shutdown()
msg := makeRemoteClusterMsg(msgId, NoteContent)
var countCallbacks atomic.Int32
var countErrors atomic.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()
countCallbacks.Add(1)
if err != nil {
countErrors.Add(1)
}
})
assert.NoError(t, err)
wg.Wait()
assert.Equal(t, int32(NumRemotes), countCallbacks.Load())
assert.Equal(t, int32(NumRemotes), countErrors.Load())
})
}
func makeRemoteClusters(num int, siteURL string, isPlugin bool) []*model.RemoteCluster {
var remotes []*model.RemoteCluster
for i := range num {
rc := makeRemoteCluster(fmt.Sprintf("test cluster %d", i+1), siteURL, TestTopics)
if isPlugin {
rc.PluginID = model.NewId()
}
remotes = append(remotes, rc)
}
return remotes
}
func makeRemoteCluster(name string, siteURL string, topics string) *model.RemoteCluster {
return &model.RemoteCluster{
RemoteId: model.NewId(),
Name: 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}
}

View File

@@ -0,0 +1,143 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package remotecluster
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"path"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
)
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 task cannot be enqueued before the timeout. A background context will block indefinitely.
//
// Nil or error return indicates success or failure of task 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) {
fi, err := rcs.sendFileToRemote(SendTimeout, task)
var response Response
if err != nil {
rcs.server.Log().LogM(mlog.MlvlRemoteClusterServiceError, "Remote Cluster send file failed",
mlog.String("remote", task.rc.DisplayName),
mlog.String("upload_id", task.us.Id),
mlog.Err(err),
)
response.Status = ResponseStatusFail
response.Err = err.Error()
} else {
rcs.server.Log().Log(mlog.LvlRemoteClusterServiceDebug, "Remote Cluster file sent successfully",
mlog.String("remote", task.rc.DisplayName),
mlog.String("upload_id", 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) {
start := time.Now()
defer func() {
if metrics := rcs.server.GetMetrics(); metrics != nil {
metrics.ObserveSharedChannelsSyncSendStepDuration(task.rc.RemoteId, "Attachments", time.Since(start).Seconds())
}
}()
rcs.server.Log().Log(mlog.LvlRemoteClusterServiceDebug, "sending file to remote...",
mlog.String("remote", task.rc.DisplayName),
mlog.String("upload_id", 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.APIURLSuffix, "remotecluster", "upload", task.us.Id)
req, err := http.NewRequest("POST", u.String(), r)
if err != nil {
return nil, err
}
req.Header.Set(model.HeaderRemoteclusterId, task.rc.RemoteId)
req.Header.Set(model.HeaderRemoteclusterToken, 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 := io.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
}

View File

@@ -0,0 +1,175 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package remotecluster
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"time"
"github.com/wiggin77/merror"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/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(0, 999999, 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 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.Log().LogM(mlog.MlvlRemoteClusterServiceError, "Invalid siteURL while sending message to remote",
mlog.String("remote", task.rc.DisplayName),
mlog.String("msg_id", 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.Log().LogM(mlog.MlvlRemoteClusterServiceError, "Remote Cluster send message failed",
mlog.String("remote", task.rc.DisplayName),
mlog.String("msg_id", task.msg.Id),
mlog.Err(err),
)
errResp = err
} else {
rcs.server.Log().Log(mlog.LvlRemoteClusterServiceDebug, "Remote Cluster message sent successfully",
mlog.String("remote", task.rc.DisplayName),
mlog.String("msg_id", task.msg.Id),
)
if err = json.Unmarshal(respJSON, &response); err != nil {
rcs.server.Log().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.HeaderRemoteclusterId, rc.RemoteId)
req.Header.Set(model.HeaderRemoteclusterToken, 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 = io.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
}

View File

@@ -0,0 +1,153 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package remotecluster
import (
"bytes"
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"path"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
type SendProfileImageResultFunc func(userId string, rc *model.RemoteCluster, resp *Response, err error)
type sendProfileImageTask struct {
rc *model.RemoteCluster
userID string
provider ProfileImageProvider
f SendProfileImageResultFunc
}
type ProfileImageProvider interface {
GetProfileImage(user *model.User) ([]byte, bool, *model.AppError)
}
// SendProfileImage asynchronously sends a user's profile image to a remote cluster.
//
// `ctx` determines behaviour when the outbound queue is full. A timeout or deadline context will return a
// BufferFullError if the task cannot be enqueued before the timeout. A background context will block indefinitely.
//
// Nil or error return indicates success or failure of task enqueue only.
//
// An optional callback can be provided that receives the response from the remote cluster. The `err` provided to the
// callback is regarding image delivery only. The `resp` contains the decoded bytes returned from the remote.
// If a callback is provided it should return quickly.
func (rcs *Service) SendProfileImage(ctx context.Context, userID string, rc *model.RemoteCluster, provider ProfileImageProvider, f SendProfileImageResultFunc) error {
task := sendProfileImageTask{
rc: rc,
userID: userID,
provider: provider,
f: f,
}
return rcs.enqueueTask(ctx, rc.RemoteId, task)
}
// sendProfileImage is called when a sendProfileImageTask is popped from the send channel.
func (rcs *Service) sendProfileImage(task sendProfileImageTask) {
err := rcs.sendProfileImageToRemote(SendTimeout, task)
var response Response
if err != nil {
rcs.server.Log().LogM(mlog.MlvlRemoteClusterServiceWarn, "Remote Cluster send profile image failed",
mlog.String("remote", task.rc.DisplayName),
mlog.String("user_id", task.userID),
mlog.Err(err),
)
response.Status = ResponseStatusFail
response.Err = err.Error()
} else {
rcs.server.Log().Log(mlog.LvlRemoteClusterServiceDebug, "Remote Cluster profile image sent successfully",
mlog.String("remote", task.rc.DisplayName),
mlog.String("user_id", task.userID),
)
response.Status = ResponseStatusOK
}
// If callback provided then call it with the results.
if task.f != nil {
task.f(task.userID, task.rc, &response, err)
}
}
func (rcs *Service) sendProfileImageToRemote(timeout time.Duration, task sendProfileImageTask) error {
start := time.Now()
defer func() {
if metrics := rcs.server.GetMetrics(); metrics != nil {
metrics.ObserveSharedChannelsSyncSendStepDuration(task.rc.RemoteId, "ProfileImages", time.Since(start).Seconds())
}
}()
rcs.server.Log().Log(mlog.LvlRemoteClusterServiceDebug, "sending profile image to remote...",
mlog.String("remote", task.rc.DisplayName),
mlog.String("user_id", task.userID),
)
user, err := rcs.server.GetStore().User().Get(request.EmptyContext(rcs.server.Log()), task.userID)
if err != nil {
return fmt.Errorf("error fetching user while sending profile image to remote %s: %w", task.rc.RemoteId, err)
}
img, _, appErr := task.provider.GetProfileImage(user) // get Reader for the file
if appErr != nil {
return fmt.Errorf("error fetching profile image for user (%s) while sending to remote %s: %w", task.userID, task.rc.RemoteId, appErr)
}
u, err := url.Parse(task.rc.SiteURL)
if err != nil {
return fmt.Errorf("invalid siteURL while sending file to remote %s: %w", task.rc.RemoteId, err)
}
u.Path = path.Join(u.Path, model.APIURLSuffix, "remotecluster", task.userID, "image")
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("image", "profile.png")
if err != nil {
return err
}
if _, err = io.Copy(part, bytes.NewBuffer(img)); err != nil {
return err
}
if err = writer.Close(); err != nil {
return err
}
req, err := http.NewRequest("POST", u.String(), body)
if err != nil {
return err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set(model.HeaderRemoteclusterId, task.rc.RemoteId)
req.Header.Set(model.HeaderRemoteclusterToken, task.rc.RemoteToken)
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
resp, err := rcs.httpClient.Do(req.WithContext(ctx))
if err != nil {
return err
}
defer resp.Body.Close()
_, err = io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected response: %d - %s", resp.StatusCode, resp.Status)
}
return nil
}

View File

@@ -0,0 +1,186 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package remotecluster
import (
"bytes"
"image"
"image/color"
"image/png"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
const (
imageWidth = 128
imageHeight = 128
)
func TestService_sendProfileImageToRemote(t *testing.T) {
shouldError := &flag{}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer io.Copy(io.Discard, r.Body)
if shouldError.get() {
w.WriteHeader(http.StatusInternalServerError)
resp := make(map[string]string)
resp[model.STATUS] = model.StatusFail
w.Write([]byte(model.MapToJSON(resp)))
return
}
status := model.StatusOk
defer func(s *string) {
if *s != model.StatusOk {
w.WriteHeader(http.StatusInternalServerError)
}
resp := make(map[string]string)
resp[model.STATUS] = *s
w.Write([]byte(model.MapToJSON(resp)))
}(&status)
if err := r.ParseMultipartForm(1024 * 1024); err != nil {
status = model.StatusFail
assert.Fail(t, "connect parse multipart form", err)
return
}
m := r.MultipartForm
if m == nil {
status = model.StatusFail
assert.Fail(t, "multipart form missing")
return
}
imageArray, ok := m.File["image"]
if !ok || len(imageArray) != 1 {
status = model.StatusFail
assert.Fail(t, "image missing")
return
}
imageData := imageArray[0]
file, err := imageData.Open()
if err != nil {
status = model.StatusFail
assert.Fail(t, "cannot open multipart form file")
return
}
defer file.Close()
img, err := png.Decode(file)
if err != nil || imageWidth != img.Bounds().Max.X || imageHeight != img.Bounds().Max.Y {
status = model.StatusFail
assert.Fail(t, "cannot decode png", err)
return
}
}))
defer ts.Close()
rc := makeRemoteCluster("remote_test_profile_image", ts.URL, TestTopics)
user := &model.User{
Id: model.NewId(),
RemoteId: new(rc.RemoteId),
}
provider := testImageProvider{}
mockServer := newMockServer(t, makeRemoteClusters(NumRemotes, ts.URL, false))
mockServer.SetUser(user)
mockApp := newMockApp(t, nil)
service, err := NewRemoteClusterService(mockServer, mockApp)
require.NoError(t, err)
service.disablePing = true
err = service.Start()
require.NoError(t, err)
defer service.Shutdown()
t.Run("Server response 200", func(t *testing.T) {
shouldError.set(false)
resultFunc := func(userId string, rc *model.RemoteCluster, resp *Response, err error) {
assert.Equal(t, user.Id, userId, "user ids should match")
assert.NoError(t, err)
assert.True(t, resp.IsSuccess())
}
task := sendProfileImageTask{
rc: rc,
userID: user.Id,
provider: provider,
f: resultFunc,
}
err := service.sendProfileImageToRemote(time.Second*15, task)
assert.NoError(t, err, "request should not error")
})
t.Run("Server response 500", func(t *testing.T) {
shouldError.set(true)
resultFunc := func(userId string, rc *model.RemoteCluster, resp *Response, err error) {
assert.Equal(t, user.Id, userId, "user ids should match")
assert.False(t, resp.IsSuccess())
}
task := sendProfileImageTask{
rc: rc,
userID: user.Id,
provider: provider,
f: resultFunc,
}
err := service.sendProfileImageToRemote(time.Second*15, task)
assert.Error(t, err, "request should error")
})
}
type testImageProvider struct {
}
func (tip testImageProvider) GetProfileImage(user *model.User) ([]byte, bool, *model.AppError) {
img := image.NewRGBA(image.Rectangle{image.Point{0, 0}, image.Point{imageWidth, imageHeight}})
red := color.RGBA{255, 50, 50, 0xff}
for x := range imageWidth {
for y := range imageHeight {
img.Set(x, y, red)
}
}
buf := &bytes.Buffer{}
png.Encode(buf, img)
return buf.Bytes(), true, nil
}
type flag struct {
mux sync.RWMutex
b bool
}
func (f *flag) get() bool {
f.mux.RLock()
defer f.mux.RUnlock()
return f.b
}
func (f *flag) set(b bool) {
f.mux.Lock()
defer f.mux.Unlock()
f.b = b
}

View File

@@ -0,0 +1,312 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package remotecluster
import (
"context"
"net"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/einterfaces"
)
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"
// Raising PingFreq also raises the transport's IdleConnTimeout (PingFreq / 2),
// which must stay below peer idle timeouts (60s default). See MM-69982.
PingFreq = time.Minute
PingTimeout = time.Second * 15
ConfirmInviteURL = "api/v4/remotecluster/confirm_invite"
InvitationTopic = "invitation"
PingTopic = "ping"
ResponseStatusOK = model.StatusOk
ResponseStatusFail = model.StatusFail
InviteExpiresAfter = time.Hour * 48
)
type ServerIface interface {
Config() *model.Config
IsLeader() bool
AddClusterLeaderChangedListener(listener func()) string
RemoveClusterLeaderChangedListener(id string)
GetStore() store.Store
Log() *mlog.Logger
GetMetrics() einterfaces.MetricsInterface
}
type AppIface interface {
OnSharedChannelsPing(rc *model.RemoteCluster) bool
}
// 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
SendProfileImage(ctx context.Context, userID string, rc *model.RemoteCluster, provider ProfileImageProvider, f SendProfileImageResultFunc) error
AcceptInvitation(invite *model.RemoteClusterInvite, name string, displayName string, creatorId string, siteURL string, defaultTeamId string) (*model.RemoteCluster, error)
ReceiveIncomingMsg(rc *model.RemoteCluster, msg model.RemoteClusterMsg) Response
ReceiveInviteConfirmation(invite model.RemoteClusterInvite) (*model.RemoteCluster, error)
PingNow(rc *model.RemoteCluster)
NotifySyncFailed(remoteId string)
}
// 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. In product these are called "Secured Connections".
type Service struct {
server ServerIface
app AppIface
httpClient *http.Client
send []chan any
done chan struct{} // signals sendLoop workers to stop; lifecycle: Start -> Shutdown
active atomic.Bool // true after Start(), false after Shutdown()
disablePing bool // when true, pingStart skips launching the ping loop; for testing
syncFailedSinceLastPing sync.Map // set of remoteIds (string) with a pending sync failure; presence is the marker
// everything below guarded by `mux`
mux sync.RWMutex
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
pingDone chan struct{} // signals ping workers to stop; lifecycle: pingStart -> pingStop
pingFreq time.Duration
}
// NotifySyncFailed records that a sync has failed for the given remote. On the next
// successful ping, PingNow will fire a connection-state-change event so the shared
// channel service re-syncs any affected channels.
func (rcs *Service) NotifySyncFailed(remoteId string) {
rcs.syncFailedSinceLastPing.Store(remoteId, struct{}{})
}
// NewRemoteClusterService creates a RemoteClusterService instance. In product this is called a "Secured Connection".
func NewRemoteClusterService(server ServerIface, app AppIface) (*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,
// Must stay strictly below PingFreq so the pool always discards a connection
// before the next ping reuses it. Otherwise pings race the peer reaping its own
// idle keep-alive connections (ServiceSettings.IdleTimeout, default 60s) and fail
// intermittently with stale-connection errors. See MM-69982.
IdleConnTimeout: PingFreq / 2,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableCompression: false,
}
client := &http.Client{
Transport: transport,
Timeout: SendTimeout,
}
service := &Service{
server: server,
app: app,
httpClient: client,
topicListeners: make(map[string]map[string]TopicListener),
connectionStateListeners: make(map[string]ConnectionStateListener),
}
service.send = make([]chan any, MaxConcurrentSends)
for i := range service.send {
service.send[i] = make(chan any, SendChanBuffer)
}
service.pingFreq = PingFreq
return service, nil
}
// Start is called by the server on server start-up.
func (rcs *Service) Start() error {
if !rcs.active.CompareAndSwap(false, true) {
return nil
}
rcs.done = make(chan struct{})
for i := range rcs.send {
go rcs.sendLoop(i, rcs.done)
}
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 {
if !rcs.active.CompareAndSwap(true, false) {
return nil
}
rcs.mux.Lock()
id := rcs.leaderListenerId
rcs.mux.Unlock()
rcs.server.RemoveClusterLeaderChangedListener(id)
rcs.pingStop()
close(rcs.done)
return nil
}
// Active returns true if this instance of the remote cluster service has been started.
func (rcs *Service) Active() bool {
return rcs.active.Load()
}
// GetPingFreq gets the frequency of pings to each remote.
func (rcs *Service) GetPingFreq() time.Duration {
rcs.mux.Lock()
defer rcs.mux.Unlock()
return rcs.pingFreq
}
// SetPingFreq sets the frequency of pings to each remote. Defaults to `PingFreq`.
// This is typically used to set a higher frequency for testing.
func (rcs *Service) SetPingFreq(freq time.Duration) {
rcs.mux.Lock()
defer rcs.mux.Unlock()
rcs.pingFreq = freq
}
// 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.pingStart()
} else {
rcs.pingStop()
}
}
func (rcs *Service) pingStart() {
rcs.mux.Lock()
defer rcs.mux.Unlock()
if rcs.pingDone != nil {
return
}
rcs.pingDone = make(chan struct{})
if !rcs.disablePing {
// first ping all the plugin remotes immediately, synchronously.
rcs.pingAllNow(model.RemoteClusterQueryFilter{OnlyPlugins: true})
// start the async ping loop
rcs.pingLoop(rcs.pingDone)
}
rcs.server.Log().Debug("Remote Cluster Service ping active")
}
func (rcs *Service) pingStop() {
rcs.mux.Lock()
defer rcs.mux.Unlock()
if rcs.pingDone == nil {
return
}
close(rcs.pingDone)
rcs.pingDone = nil
rcs.server.Log().Debug("Remote Cluster Service ping inactive")
}

View File

@@ -0,0 +1,475 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package remotecluster
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
"github.com/mattermost/mattermost/server/v8/einterfaces"
)
func TestService_AddTopicListener(t *testing.T) {
var count atomic.Int32
l1 := func(msg model.RemoteClusterMsg, rc *model.RemoteCluster, resp *Response) error {
count.Add(1)
return nil
}
l2 := func(msg model.RemoteClusterMsg, rc *model.RemoteCluster, resp *Response) error {
count.Add(1)
return nil
}
l3 := func(msg model.RemoteClusterMsg, rc *model.RemoteCluster, resp *Response) error {
count.Add(1)
return nil
}
mockServer := newMockServer(t, makeRemoteClusters(NumRemotes, "", false))
mockApp := newMockApp(t, nil)
service, err := NewRemoteClusterService(mockServer, mockApp)
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), count.Load())
service.ReceiveIncomingMsg(rc, msg2)
assert.Equal(t, int32(3), count.Load())
service.RemoveTopicListener(l1id)
service.ReceiveIncomingMsg(rc, msg1)
assert.Equal(t, int32(4), count.Load())
service.RemoveTopicListener(l2id)
service.ReceiveIncomingMsg(rc, msg1)
assert.Equal(t, int32(4), count.Load())
service.ReceiveIncomingMsg(rc, msg2)
assert.Equal(t, int32(5), count.Load())
service.RemoveTopicListener(l3id)
service.ReceiveIncomingMsg(rc, msg1)
service.ReceiveIncomingMsg(rc, msg2)
assert.Equal(t, int32(5), count.Load())
listeners = service.getTopicListeners("test")
assert.Empty(t, listeners)
}
func TestRemoteClusterTransportIdleConnTimeout(t *testing.T) {
mockServer := newMockServer(t, nil)
mockApp := newMockApp(t, nil)
service, err := NewRemoteClusterService(mockServer, mockApp)
require.NoError(t, err)
transport, ok := service.httpClient.Transport.(*http.Transport)
require.True(t, ok, "expected the remote cluster client to use an *http.Transport")
// Regression guard for MM-69982: the pooled-connection idle timeout must be
// strictly less than PingFreq. If it is >= PingFreq, a pooled connection
// survives from one ping to the next and every ping reuses a connection that
// the peer has likely already reaped (peers close idle keep-alives after their
// own IdleTimeout, default 60s), producing intermittent stale-connection ping
// failures. Keeping it below PingFreq means the pool discards the connection
// before the next ping, so each ping dials fresh and the race cannot occur.
require.Positive(t, transport.IdleConnTimeout, "IdleConnTimeout must be set")
assert.Less(t, transport.IdleConnTimeout, PingFreq,
"IdleConnTimeout (%s) must be strictly less than PingFreq (%s) to avoid stale keep-alive reuse on pings",
transport.IdleConnTimeout, PingFreq)
}
// leaderAwareMockServer is a mock server that supports toggling leader state
// and firing leader-change listeners, allowing lifecycle tests to simulate
// HA cluster leader transitions.
type leaderAwareMockServer struct {
remotes []*model.RemoteCluster
logger *mlog.Logger
isLeader atomic.Bool
mux sync.Mutex
listeners map[string]func()
}
func newLeaderAwareMockServer(t *testing.T, remotes []*model.RemoteCluster, leader bool) *leaderAwareMockServer {
ms := &leaderAwareMockServer{
remotes: remotes,
logger: mlog.CreateConsoleTestLogger(t),
listeners: make(map[string]func()),
}
ms.isLeader.Store(leader)
return ms
}
func (ms *leaderAwareMockServer) Config() *model.Config { return nil }
func (ms *leaderAwareMockServer) GetMetrics() einterfaces.MetricsInterface { return nil }
func (ms *leaderAwareMockServer) IsLeader() bool { return ms.isLeader.Load() }
func (ms *leaderAwareMockServer) Log() *mlog.Logger { return ms.logger }
func (ms *leaderAwareMockServer) AddClusterLeaderChangedListener(listener func()) string {
ms.mux.Lock()
defer ms.mux.Unlock()
id := model.NewId()
ms.listeners[id] = listener
return id
}
func (ms *leaderAwareMockServer) RemoveClusterLeaderChangedListener(id string) {
ms.mux.Lock()
defer ms.mux.Unlock()
delete(ms.listeners, id)
}
// setLeader changes leader status and fires all registered listeners.
func (ms *leaderAwareMockServer) setLeader(leader bool) {
ms.isLeader.Store(leader)
ms.mux.Lock()
listeners := make([]func(), 0, len(ms.listeners))
for _, l := range ms.listeners {
listeners = append(listeners, l)
}
ms.mux.Unlock()
for _, l := range listeners {
l()
}
}
func (ms *leaderAwareMockServer) GetStore() store.Store {
anyQueryFilter := mock.MatchedBy(func(filter model.RemoteClusterQueryFilter) bool {
return true
})
anyId := mock.AnythingOfType("string")
remoteClusterStoreMock := &mocks.RemoteClusterStore{}
remoteClusterStoreMock.On("GetByTopic", "share").Return(ms.remotes, nil)
remoteClusterStoreMock.On("GetAll", 0, 999999, anyQueryFilter).Return(ms.remotes, nil)
remoteClusterStoreMock.On("SetLastPingAt", anyId).Return(nil)
storeMock := &mocks.Store{}
storeMock.On("RemoteCluster").Return(remoteClusterStoreMock)
return storeMock
}
func TestServiceLifecycle(t *testing.T) {
t.Run("Active after Start, inactive after Shutdown", func(t *testing.T) {
mockServer := newLeaderAwareMockServer(t, nil, false)
mockApp := newMockApp(t, nil)
service, err := NewRemoteClusterService(mockServer, mockApp)
require.NoError(t, err)
assert.False(t, service.Active(), "service should not be active before Start")
err = service.Start()
require.NoError(t, err)
assert.True(t, service.Active(), "service should be active after Start")
err = service.Shutdown()
require.NoError(t, err)
assert.False(t, service.Active(), "service should not be active after Shutdown")
})
t.Run("Double Start is idempotent", func(t *testing.T) {
mockServer := newLeaderAwareMockServer(t, nil, false)
mockApp := newMockApp(t, nil)
service, err := NewRemoteClusterService(mockServer, mockApp)
require.NoError(t, err)
err = service.Start()
require.NoError(t, err)
firstDone := service.done
// Second Start should be a no-op
err = service.Start()
require.NoError(t, err)
assert.Equal(t, firstDone, service.done, "second Start should not replace done channel")
require.NoError(t, service.Shutdown())
})
t.Run("Shutdown before Start does not panic", func(t *testing.T) {
mockServer := newLeaderAwareMockServer(t, nil, false)
mockApp := newMockApp(t, nil)
service, err := NewRemoteClusterService(mockServer, mockApp)
require.NoError(t, err)
err = service.Shutdown()
require.NoError(t, err)
})
t.Run("Active on non-leader node", func(t *testing.T) {
mockServer := newLeaderAwareMockServer(t, nil, false)
mockApp := newMockApp(t, nil)
service, err := NewRemoteClusterService(mockServer, mockApp)
require.NoError(t, err)
err = service.Start()
require.NoError(t, err)
defer func() { require.NoError(t, service.Shutdown()) }()
assert.True(t, service.Active(), "service should be active on non-leader node")
})
}
func TestSendLoopLifecycle(t *testing.T) {
t.Run("sendLoop runs on non-leader node", func(t *testing.T) {
var webReqCount atomic.Int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
webReqCount.Add(1)
w.WriteHeader(200)
resp := Response{}
b, _ := json.Marshal(&resp)
_, _ = w.Write(b)
}))
defer ts.Close()
remotes := makeRemoteClusters(3, ts.URL, false)
mockServer := newLeaderAwareMockServer(t, remotes, false) // non-leader
mockApp := newMockApp(t, nil)
service, err := NewRemoteClusterService(mockServer, mockApp)
require.NoError(t, err)
service.disablePing = true
err = service.Start()
require.NoError(t, err)
defer func() { require.NoError(t, service.Shutdown()) }()
// Verify pings are NOT running (non-leader)
service.mux.RLock()
pingRunning := service.pingDone != nil
service.mux.RUnlock()
assert.False(t, pingRunning, "pings should not be running on non-leader node")
// Send a message — sendLoop should process it even on non-leader
msg := makeRemoteClusterMsg(model.NewId(), NoteContent)
var callbackCount atomic.Int32
wg := &sync.WaitGroup{}
wg.Add(3)
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
err = service.BroadcastMsg(ctx, msg, func(msg model.RemoteClusterMsg, remote *model.RemoteCluster, resp *Response, err error) {
defer wg.Done()
callbackCount.Add(1)
})
require.NoError(t, err)
wg.Wait()
assert.Equal(t, int32(3), callbackCount.Load(), "all callbacks should fire on non-leader node")
assert.Equal(t, int32(3), webReqCount.Load(), "all HTTP requests should be made on non-leader node")
})
t.Run("sendLoop survives leader transitions", func(t *testing.T) {
var webReqCount atomic.Int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
webReqCount.Add(1)
w.WriteHeader(200)
resp := Response{}
b, _ := json.Marshal(&resp)
_, _ = w.Write(b)
}))
defer ts.Close()
remotes := makeRemoteClusters(3, ts.URL, false)
mockServer := newLeaderAwareMockServer(t, remotes, true) // start as leader
mockApp := newMockApp(t, nil)
service, err := NewRemoteClusterService(mockServer, mockApp)
require.NoError(t, err)
service.disablePing = true
err = service.Start()
require.NoError(t, err)
defer func() { require.NoError(t, service.Shutdown()) }()
// Lose leadership
mockServer.setLeader(false)
// Send a message — should still work after losing leadership
msg := makeRemoteClusterMsg(model.NewId(), NoteContent)
wg := &sync.WaitGroup{}
wg.Add(3)
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
err = service.BroadcastMsg(ctx, msg, func(msg model.RemoteClusterMsg, remote *model.RemoteCluster, resp *Response, err error) {
defer wg.Done()
})
require.NoError(t, err)
wg.Wait()
assert.Equal(t, int32(3), webReqCount.Load(), "sends should work after losing leadership")
})
}
func TestPingLifecycle(t *testing.T) {
t.Run("pings start on leader, stop on non-leader", func(t *testing.T) {
mockServer := newLeaderAwareMockServer(t, nil, false)
mockApp := newMockApp(t, nil)
service, err := NewRemoteClusterService(mockServer, mockApp)
require.NoError(t, err)
err = service.Start()
require.NoError(t, err)
defer func() { require.NoError(t, service.Shutdown()) }()
// Non-leader: pings should not be running
service.mux.RLock()
assert.Nil(t, service.pingDone, "pingDone should be nil on non-leader")
service.mux.RUnlock()
// Become leader: pings should start
mockServer.setLeader(true)
service.mux.RLock()
assert.NotNil(t, service.pingDone, "pingDone should be set after becoming leader")
service.mux.RUnlock()
// Lose leadership: pings should stop
mockServer.setLeader(false)
service.mux.RLock()
assert.Nil(t, service.pingDone, "pingDone should be nil after losing leadership")
service.mux.RUnlock()
})
t.Run("pingStart is idempotent", func(t *testing.T) {
mockServer := newLeaderAwareMockServer(t, nil, true)
mockApp := newMockApp(t, nil)
service, err := NewRemoteClusterService(mockServer, mockApp)
require.NoError(t, err)
err = service.Start()
require.NoError(t, err)
defer func() { require.NoError(t, service.Shutdown()) }()
// pingDone should already be set (started as leader)
service.mux.RLock()
firstPingDone := service.pingDone
service.mux.RUnlock()
require.NotNil(t, firstPingDone)
// Call pingStart again — should be a no-op, same channel
service.pingStart()
service.mux.RLock()
secondPingDone := service.pingDone
service.mux.RUnlock()
assert.Equal(t, firstPingDone, secondPingDone, "pingStart should be idempotent")
})
t.Run("pingStop is idempotent", func(t *testing.T) {
mockServer := newLeaderAwareMockServer(t, nil, false)
mockApp := newMockApp(t, nil)
service, err := NewRemoteClusterService(mockServer, mockApp)
require.NoError(t, err)
err = service.Start()
require.NoError(t, err)
defer func() { require.NoError(t, service.Shutdown()) }()
// Already non-leader, pingDone is nil
service.mux.RLock()
assert.Nil(t, service.pingDone)
service.mux.RUnlock()
// Calling pingStop again should not panic
service.pingStop()
service.mux.RLock()
assert.Nil(t, service.pingDone)
service.mux.RUnlock()
})
t.Run("pings fire on leader with real ping loop", func(t *testing.T) {
var pingCount atomic.Int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
pingCount.Add(1)
w.WriteHeader(200)
resp := model.RemoteClusterPing{}
b, _ := json.Marshal(&resp)
_, _ = w.Write(b)
}))
defer ts.Close()
remotes := makeRemoteClusters(2, ts.URL, false)
mockServer := newLeaderAwareMockServer(t, remotes, false) // start as non-leader
mockApp := newMockApp(t, nil)
service, err := NewRemoteClusterService(mockServer, mockApp)
require.NoError(t, err)
service.SetPingFreq(time.Millisecond * 50)
err = service.Start()
require.NoError(t, err)
defer func() { require.NoError(t, service.Shutdown()) }()
// Non-leader: no pings should be sent
assert.Never(t, func() bool {
return pingCount.Load() > 0
}, time.Millisecond*200, time.Millisecond*50, "no pings should fire on non-leader")
// Become leader: pings should start firing
mockServer.setLeader(true)
assert.Eventually(t, func() bool {
return pingCount.Load() >= 2
}, time.Second*5, time.Millisecond*50, "pings should fire after becoming leader")
// Lose leadership: pings should stop
mockServer.setLeader(false)
// Allow in-flight pings to drain, then verify no new pings arrive
assert.Eventually(t, func() bool {
snapshot := pingCount.Load()
time.Sleep(time.Millisecond * 150)
return pingCount.Load() == snapshot
}, time.Second*5, time.Millisecond*50, "no new pings should fire after losing leadership")
})
}

View File

@@ -0,0 +1,10 @@
with-expecter: false
packages:
github.com/mattermost/mattermost/server/v8/platform/services/searchengine:
config:
all: true
dir: "platform/services/searchengine/mocks"
filename: "{{.InterfaceName}}.go"
mockname: "{{.InterfaceName}}"
outpkg: mocks
note: "Regenerate this file using `make searchengine-mocks`."

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package searchengine
import (
"context"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
)
type SearchEngineInterface interface {
// Start initializes the engine connection. Implementations must set the
// initial health state (e.g. healthy) before returning, because the broker
// may call IsHealthy() immediately after Start() returns.
Start(ctx context.Context) *model.AppError
// Stop tears down the engine connection. Implementations must clear the
// health flag (i.e. set unhealthy) during Stop so that the broker does not
// route queries to a stopped engine.
Stop() *model.AppError
HealthCheck(rctx request.CTX) *model.AppError
GetFullVersion() string
GetVersion() int
GetPlugins() []string
UpdateConfig(cfg *model.Config)
GetName() string
// IsEnabled returns a boolean indicating whether the engine is enabled in the settings
IsEnabled() bool
IsActive() bool
// IsHealthy reports whether the engine is reachable. The initial value is
// set by the engine itself during Start() and cleared during Stop(). After
// startup, only the watcher drives transitions by calling SetHealthy(false)
// on the first health-check failure and SetHealthy(true) on success,
// allowing the broker to skip unhealthy engines immediately instead of
// waiting for full stop/restart.
IsHealthy() bool
// SetHealthy is called by the watcher to update the engine's health status
// based on health-check results. Implementations should not call this
// themselves — Start() and Stop() set the initial/final state directly.
SetHealthy(healthy bool)
IsIndexingEnabled() bool
IsSearchEnabled() bool
IsAutocompletionEnabled() bool
IsIndexingSync() bool
IndexPost(post *model.Post, teamId string, channelType string) *model.AppError
SearchPosts(channels model.ChannelList, searchParams []*model.SearchParams, page, perPage int) ([]string, model.PostSearchMatches, *model.AppError)
DeletePost(post *model.Post) *model.AppError
DeleteChannelPosts(rctx request.CTX, channelID string) *model.AppError
UpdatePostsChannelTypeByChannelId(rctx request.CTX, channelID string, channelType string) *model.AppError
BackfillPostsChannelType(rctx request.CTX, channelIDs []string, channelType string) *model.AppError
DeleteUserPosts(rctx request.CTX, userID string) *model.AppError
// IndexChannel indexes a given channel. The userIDs are only populated
// for private channels.
IndexChannel(rctx request.CTX, channel *model.Channel, userIDs, teamMemberIDs []string) *model.AppError
SyncBulkIndexChannels(rctx request.CTX, channels []*model.Channel, getUserIDsForChannel func(channel *model.Channel) ([]string, error), teamMemberIDs []string) *model.AppError
SearchChannels(teamId, userID, term string, isGuest, includeDeleted bool) ([]string, *model.AppError)
DeleteChannel(channel *model.Channel) *model.AppError
IndexUser(rctx request.CTX, user *model.User, teamsIds, channelsIds []string) *model.AppError
SearchUsersInChannel(teamId, channelId string, restrictedToChannels []string, term string, options *model.UserSearchOptions) ([]string, []string, *model.AppError)
SearchUsersInTeam(teamId string, restrictedToChannels []string, term string, options *model.UserSearchOptions) ([]string, *model.AppError)
DeleteUser(user *model.User) *model.AppError
IndexFile(file *model.FileInfo, channelId string) *model.AppError
SearchFiles(channels model.ChannelList, searchParams []*model.SearchParams, page, perPage int) ([]string, *model.AppError)
DeleteFile(fileID string) *model.AppError
DeletePostFiles(rctx request.CTX, postID string) *model.AppError
DeleteUserFiles(rctx request.CTX, userID string) *model.AppError
DeleteFilesBatch(rctx request.CTX, endTime, limit int64) *model.AppError
TestConfig(rctx request.CTX, cfg *model.Config) *model.AppError
PurgeIndexes(rctx request.CTX) *model.AppError
PurgeIndexList(rctx request.CTX, indexes []string) *model.AppError
RefreshIndexes(rctx request.CTX) *model.AppError
DataRetentionDeleteIndexes(rctx request.CTX, cutoff time.Time) *model.AppError
}

View File

@@ -0,0 +1,902 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Regenerate this file using `make searchengine-mocks`.
package mocks
import (
context "context"
model "github.com/mattermost/mattermost/server/public/model"
request "github.com/mattermost/mattermost/server/public/shared/request"
mock "github.com/stretchr/testify/mock"
time "time"
)
// SearchEngineInterface is an autogenerated mock type for the SearchEngineInterface type
type SearchEngineInterface struct {
mock.Mock
}
// BackfillPostsChannelType provides a mock function with given fields: rctx, channelIDs, channelType
func (_m *SearchEngineInterface) BackfillPostsChannelType(rctx request.CTX, channelIDs []string, channelType string) *model.AppError {
ret := _m.Called(rctx, channelIDs, channelType)
if len(ret) == 0 {
panic("no return value specified for BackfillPostsChannelType")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, []string, string) *model.AppError); ok {
r0 = rf(rctx, channelIDs, channelType)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// DataRetentionDeleteIndexes provides a mock function with given fields: rctx, cutoff
func (_m *SearchEngineInterface) DataRetentionDeleteIndexes(rctx request.CTX, cutoff time.Time) *model.AppError {
ret := _m.Called(rctx, cutoff)
if len(ret) == 0 {
panic("no return value specified for DataRetentionDeleteIndexes")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, time.Time) *model.AppError); ok {
r0 = rf(rctx, cutoff)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// DeleteChannel provides a mock function with given fields: channel
func (_m *SearchEngineInterface) DeleteChannel(channel *model.Channel) *model.AppError {
ret := _m.Called(channel)
if len(ret) == 0 {
panic("no return value specified for DeleteChannel")
}
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
}
// DeleteChannelPosts provides a mock function with given fields: rctx, channelID
func (_m *SearchEngineInterface) DeleteChannelPosts(rctx request.CTX, channelID string) *model.AppError {
ret := _m.Called(rctx, channelID)
if len(ret) == 0 {
panic("no return value specified for DeleteChannelPosts")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, string) *model.AppError); ok {
r0 = rf(rctx, channelID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// DeleteFile provides a mock function with given fields: fileID
func (_m *SearchEngineInterface) DeleteFile(fileID string) *model.AppError {
ret := _m.Called(fileID)
if len(ret) == 0 {
panic("no return value specified for DeleteFile")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(string) *model.AppError); ok {
r0 = rf(fileID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// DeleteFilesBatch provides a mock function with given fields: rctx, endTime, limit
func (_m *SearchEngineInterface) DeleteFilesBatch(rctx request.CTX, endTime int64, limit int64) *model.AppError {
ret := _m.Called(rctx, endTime, limit)
if len(ret) == 0 {
panic("no return value specified for DeleteFilesBatch")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, int64, int64) *model.AppError); ok {
r0 = rf(rctx, endTime, limit)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// DeletePost provides a mock function with given fields: post
func (_m *SearchEngineInterface) DeletePost(post *model.Post) *model.AppError {
ret := _m.Called(post)
if len(ret) == 0 {
panic("no return value specified for DeletePost")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(*model.Post) *model.AppError); ok {
r0 = rf(post)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// DeletePostFiles provides a mock function with given fields: rctx, postID
func (_m *SearchEngineInterface) DeletePostFiles(rctx request.CTX, postID string) *model.AppError {
ret := _m.Called(rctx, postID)
if len(ret) == 0 {
panic("no return value specified for DeletePostFiles")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, string) *model.AppError); ok {
r0 = rf(rctx, postID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// DeleteUser provides a mock function with given fields: user
func (_m *SearchEngineInterface) DeleteUser(user *model.User) *model.AppError {
ret := _m.Called(user)
if len(ret) == 0 {
panic("no return value specified for DeleteUser")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(*model.User) *model.AppError); ok {
r0 = rf(user)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// DeleteUserFiles provides a mock function with given fields: rctx, userID
func (_m *SearchEngineInterface) DeleteUserFiles(rctx request.CTX, userID string) *model.AppError {
ret := _m.Called(rctx, userID)
if len(ret) == 0 {
panic("no return value specified for DeleteUserFiles")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, string) *model.AppError); ok {
r0 = rf(rctx, userID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// DeleteUserPosts provides a mock function with given fields: rctx, userID
func (_m *SearchEngineInterface) DeleteUserPosts(rctx request.CTX, userID string) *model.AppError {
ret := _m.Called(rctx, userID)
if len(ret) == 0 {
panic("no return value specified for DeleteUserPosts")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, string) *model.AppError); ok {
r0 = rf(rctx, userID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// GetFullVersion provides a mock function with no fields
func (_m *SearchEngineInterface) GetFullVersion() string {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetFullVersion")
}
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// GetName provides a mock function with no fields
func (_m *SearchEngineInterface) GetName() string {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetName")
}
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// GetPlugins provides a mock function with no fields
func (_m *SearchEngineInterface) GetPlugins() []string {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPlugins")
}
var r0 []string
if rf, ok := ret.Get(0).(func() []string); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
return r0
}
// GetVersion provides a mock function with no fields
func (_m *SearchEngineInterface) GetVersion() int {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetVersion")
}
var r0 int
if rf, ok := ret.Get(0).(func() int); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(int)
}
return r0
}
// HealthCheck provides a mock function with given fields: rctx
func (_m *SearchEngineInterface) HealthCheck(rctx request.CTX) *model.AppError {
ret := _m.Called(rctx)
if len(ret) == 0 {
panic("no return value specified for HealthCheck")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX) *model.AppError); ok {
r0 = rf(rctx)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// IndexChannel provides a mock function with given fields: rctx, channel, userIDs, teamMemberIDs
func (_m *SearchEngineInterface) IndexChannel(rctx request.CTX, channel *model.Channel, userIDs []string, teamMemberIDs []string) *model.AppError {
ret := _m.Called(rctx, channel, userIDs, teamMemberIDs)
if len(ret) == 0 {
panic("no return value specified for IndexChannel")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, *model.Channel, []string, []string) *model.AppError); ok {
r0 = rf(rctx, channel, userIDs, teamMemberIDs)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// IndexFile provides a mock function with given fields: file, channelId
func (_m *SearchEngineInterface) IndexFile(file *model.FileInfo, channelId string) *model.AppError {
ret := _m.Called(file, channelId)
if len(ret) == 0 {
panic("no return value specified for IndexFile")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(*model.FileInfo, string) *model.AppError); ok {
r0 = rf(file, channelId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// IndexPost provides a mock function with given fields: post, teamId, channelType
func (_m *SearchEngineInterface) IndexPost(post *model.Post, teamId string, channelType string) *model.AppError {
ret := _m.Called(post, teamId, channelType)
if len(ret) == 0 {
panic("no return value specified for IndexPost")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(*model.Post, string, string) *model.AppError); ok {
r0 = rf(post, teamId, channelType)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// IndexUser provides a mock function with given fields: rctx, user, teamsIds, channelsIds
func (_m *SearchEngineInterface) IndexUser(rctx request.CTX, user *model.User, teamsIds []string, channelsIds []string) *model.AppError {
ret := _m.Called(rctx, user, teamsIds, channelsIds)
if len(ret) == 0 {
panic("no return value specified for IndexUser")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, *model.User, []string, []string) *model.AppError); ok {
r0 = rf(rctx, user, teamsIds, channelsIds)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// IsActive provides a mock function with no fields
func (_m *SearchEngineInterface) IsActive() bool {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for IsActive")
}
var r0 bool
if rf, ok := ret.Get(0).(func() bool); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// IsAutocompletionEnabled provides a mock function with no fields
func (_m *SearchEngineInterface) IsAutocompletionEnabled() bool {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for IsAutocompletionEnabled")
}
var r0 bool
if rf, ok := ret.Get(0).(func() bool); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// IsEnabled provides a mock function with no fields
func (_m *SearchEngineInterface) IsEnabled() bool {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for IsEnabled")
}
var r0 bool
if rf, ok := ret.Get(0).(func() bool); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// IsHealthy provides a mock function with no fields
func (_m *SearchEngineInterface) IsHealthy() bool {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for IsHealthy")
}
var r0 bool
if rf, ok := ret.Get(0).(func() bool); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// IsIndexingEnabled provides a mock function with no fields
func (_m *SearchEngineInterface) IsIndexingEnabled() bool {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for IsIndexingEnabled")
}
var r0 bool
if rf, ok := ret.Get(0).(func() bool); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// IsIndexingSync provides a mock function with no fields
func (_m *SearchEngineInterface) IsIndexingSync() bool {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for IsIndexingSync")
}
var r0 bool
if rf, ok := ret.Get(0).(func() bool); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// IsSearchEnabled provides a mock function with no fields
func (_m *SearchEngineInterface) IsSearchEnabled() bool {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for IsSearchEnabled")
}
var r0 bool
if rf, ok := ret.Get(0).(func() bool); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// PurgeIndexList provides a mock function with given fields: rctx, indexes
func (_m *SearchEngineInterface) PurgeIndexList(rctx request.CTX, indexes []string) *model.AppError {
ret := _m.Called(rctx, indexes)
if len(ret) == 0 {
panic("no return value specified for PurgeIndexList")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, []string) *model.AppError); ok {
r0 = rf(rctx, indexes)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// PurgeIndexes provides a mock function with given fields: rctx
func (_m *SearchEngineInterface) PurgeIndexes(rctx request.CTX) *model.AppError {
ret := _m.Called(rctx)
if len(ret) == 0 {
panic("no return value specified for PurgeIndexes")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX) *model.AppError); ok {
r0 = rf(rctx)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// RefreshIndexes provides a mock function with given fields: rctx
func (_m *SearchEngineInterface) RefreshIndexes(rctx request.CTX) *model.AppError {
ret := _m.Called(rctx)
if len(ret) == 0 {
panic("no return value specified for RefreshIndexes")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX) *model.AppError); ok {
r0 = rf(rctx)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// SearchChannels provides a mock function with given fields: teamId, userID, term, isGuest, includeDeleted
func (_m *SearchEngineInterface) SearchChannels(teamId string, userID string, term string, isGuest bool, includeDeleted bool) ([]string, *model.AppError) {
ret := _m.Called(teamId, userID, term, isGuest, includeDeleted)
if len(ret) == 0 {
panic("no return value specified for SearchChannels")
}
var r0 []string
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(string, string, string, bool, bool) ([]string, *model.AppError)); ok {
return rf(teamId, userID, term, isGuest, includeDeleted)
}
if rf, ok := ret.Get(0).(func(string, string, string, bool, bool) []string); ok {
r0 = rf(teamId, userID, term, isGuest, includeDeleted)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
if rf, ok := ret.Get(1).(func(string, string, string, bool, bool) *model.AppError); ok {
r1 = rf(teamId, userID, term, isGuest, includeDeleted)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// SearchFiles provides a mock function with given fields: channels, searchParams, page, perPage
func (_m *SearchEngineInterface) SearchFiles(channels model.ChannelList, searchParams []*model.SearchParams, page int, perPage int) ([]string, *model.AppError) {
ret := _m.Called(channels, searchParams, page, perPage)
if len(ret) == 0 {
panic("no return value specified for SearchFiles")
}
var r0 []string
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(model.ChannelList, []*model.SearchParams, int, int) ([]string, *model.AppError)); ok {
return rf(channels, searchParams, page, perPage)
}
if rf, ok := ret.Get(0).(func(model.ChannelList, []*model.SearchParams, int, int) []string); ok {
r0 = rf(channels, searchParams, page, perPage)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
if rf, ok := ret.Get(1).(func(model.ChannelList, []*model.SearchParams, int, int) *model.AppError); ok {
r1 = rf(channels, searchParams, page, perPage)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// SearchPosts provides a mock function with given fields: channels, searchParams, page, perPage
func (_m *SearchEngineInterface) SearchPosts(channels model.ChannelList, searchParams []*model.SearchParams, page int, perPage int) ([]string, model.PostSearchMatches, *model.AppError) {
ret := _m.Called(channels, searchParams, page, perPage)
if len(ret) == 0 {
panic("no return value specified for SearchPosts")
}
var r0 []string
var r1 model.PostSearchMatches
var r2 *model.AppError
if rf, ok := ret.Get(0).(func(model.ChannelList, []*model.SearchParams, int, int) ([]string, model.PostSearchMatches, *model.AppError)); ok {
return rf(channels, searchParams, page, perPage)
}
if rf, ok := ret.Get(0).(func(model.ChannelList, []*model.SearchParams, int, int) []string); ok {
r0 = rf(channels, searchParams, page, perPage)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
if rf, ok := ret.Get(1).(func(model.ChannelList, []*model.SearchParams, int, int) model.PostSearchMatches); ok {
r1 = rf(channels, searchParams, page, perPage)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(model.PostSearchMatches)
}
}
if rf, ok := ret.Get(2).(func(model.ChannelList, []*model.SearchParams, int, int) *model.AppError); ok {
r2 = rf(channels, searchParams, page, perPage)
} else {
if ret.Get(2) != nil {
r2 = ret.Get(2).(*model.AppError)
}
}
return r0, r1, r2
}
// SearchUsersInChannel provides a mock function with given fields: teamId, channelId, restrictedToChannels, term, options
func (_m *SearchEngineInterface) SearchUsersInChannel(teamId string, channelId string, restrictedToChannels []string, term string, options *model.UserSearchOptions) ([]string, []string, *model.AppError) {
ret := _m.Called(teamId, channelId, restrictedToChannels, term, options)
if len(ret) == 0 {
panic("no return value specified for SearchUsersInChannel")
}
var r0 []string
var r1 []string
var r2 *model.AppError
if rf, ok := ret.Get(0).(func(string, string, []string, string, *model.UserSearchOptions) ([]string, []string, *model.AppError)); ok {
return rf(teamId, channelId, restrictedToChannels, term, options)
}
if rf, ok := ret.Get(0).(func(string, string, []string, string, *model.UserSearchOptions) []string); ok {
r0 = rf(teamId, channelId, restrictedToChannels, term, options)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
if rf, ok := ret.Get(1).(func(string, string, []string, string, *model.UserSearchOptions) []string); ok {
r1 = rf(teamId, channelId, restrictedToChannels, term, options)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).([]string)
}
}
if rf, ok := ret.Get(2).(func(string, string, []string, string, *model.UserSearchOptions) *model.AppError); ok {
r2 = rf(teamId, channelId, restrictedToChannels, term, options)
} else {
if ret.Get(2) != nil {
r2 = ret.Get(2).(*model.AppError)
}
}
return r0, r1, r2
}
// SearchUsersInTeam provides a mock function with given fields: teamId, restrictedToChannels, term, options
func (_m *SearchEngineInterface) SearchUsersInTeam(teamId string, restrictedToChannels []string, term string, options *model.UserSearchOptions) ([]string, *model.AppError) {
ret := _m.Called(teamId, restrictedToChannels, term, options)
if len(ret) == 0 {
panic("no return value specified for SearchUsersInTeam")
}
var r0 []string
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(string, []string, string, *model.UserSearchOptions) ([]string, *model.AppError)); ok {
return rf(teamId, restrictedToChannels, term, options)
}
if rf, ok := ret.Get(0).(func(string, []string, string, *model.UserSearchOptions) []string); ok {
r0 = rf(teamId, restrictedToChannels, term, options)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
if rf, ok := ret.Get(1).(func(string, []string, string, *model.UserSearchOptions) *model.AppError); ok {
r1 = rf(teamId, restrictedToChannels, term, options)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// SetHealthy provides a mock function with given fields: healthy
func (_m *SearchEngineInterface) SetHealthy(healthy bool) {
_m.Called(healthy)
}
// Start provides a mock function with given fields: ctx
func (_m *SearchEngineInterface) Start(ctx context.Context) *model.AppError {
ret := _m.Called(ctx)
if len(ret) == 0 {
panic("no return value specified for Start")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(context.Context) *model.AppError); ok {
r0 = rf(ctx)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// Stop provides a mock function with no fields
func (_m *SearchEngineInterface) Stop() *model.AppError {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Stop")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func() *model.AppError); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// SyncBulkIndexChannels provides a mock function with given fields: rctx, channels, getUserIDsForChannel, teamMemberIDs
func (_m *SearchEngineInterface) SyncBulkIndexChannels(rctx request.CTX, channels []*model.Channel, getUserIDsForChannel func(*model.Channel) ([]string, error), teamMemberIDs []string) *model.AppError {
ret := _m.Called(rctx, channels, getUserIDsForChannel, teamMemberIDs)
if len(ret) == 0 {
panic("no return value specified for SyncBulkIndexChannels")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, []*model.Channel, func(*model.Channel) ([]string, error), []string) *model.AppError); ok {
r0 = rf(rctx, channels, getUserIDsForChannel, teamMemberIDs)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// TestConfig provides a mock function with given fields: rctx, cfg
func (_m *SearchEngineInterface) TestConfig(rctx request.CTX, cfg *model.Config) *model.AppError {
ret := _m.Called(rctx, cfg)
if len(ret) == 0 {
panic("no return value specified for TestConfig")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, *model.Config) *model.AppError); ok {
r0 = rf(rctx, cfg)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// UpdateConfig provides a mock function with given fields: cfg
func (_m *SearchEngineInterface) UpdateConfig(cfg *model.Config) {
_m.Called(cfg)
}
// UpdatePostsChannelTypeByChannelId provides a mock function with given fields: rctx, channelID, channelType
func (_m *SearchEngineInterface) UpdatePostsChannelTypeByChannelId(rctx request.CTX, channelID string, channelType string) *model.AppError {
ret := _m.Called(rctx, channelID, channelType)
if len(ret) == 0 {
panic("no return value specified for UpdatePostsChannelTypeByChannelId")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, string, string) *model.AppError); ok {
r0 = rf(rctx, channelID, channelType)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// NewSearchEngineInterface creates a new instance of SearchEngineInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewSearchEngineInterface(t interface {
mock.TestingT
Cleanup(func())
}) *SearchEngineInterface {
mock := &SearchEngineInterface{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package searchengine
import (
"github.com/mattermost/mattermost/server/public/model"
)
func NewBroker(cfg *model.Config) *Broker {
return &Broker{
cfg: cfg,
}
}
func (seb *Broker) RegisterElasticsearchEngine(es SearchEngineInterface) {
seb.ElasticsearchEngine = es
}
type Broker struct {
cfg *model.Config
ElasticsearchEngine SearchEngineInterface
}
func (seb *Broker) UpdateConfig(cfg *model.Config) *model.AppError {
seb.cfg = cfg
if seb.ElasticsearchEngine != nil {
seb.ElasticsearchEngine.UpdateConfig(cfg)
}
return nil
}
func (seb *Broker) GetActiveEngines() []SearchEngineInterface {
engines := []SearchEngineInterface{}
if seb.ElasticsearchEngine != nil && seb.ElasticsearchEngine.IsActive() && seb.ElasticsearchEngine.IsHealthy() {
engines = append(engines, seb.ElasticsearchEngine)
}
return engines
}
func (seb *Broker) ActiveEngine() string {
activeEngines := seb.GetActiveEngines()
if len(activeEngines) > 0 {
return activeEngines[0].GetName()
}
if *seb.cfg.SqlSettings.DisableDatabaseSearch {
return "none"
}
return "database"
}

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package searchengine
import (
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/platform/services/searchengine/mocks"
"github.com/stretchr/testify/assert"
)
func TestActiveEngine(t *testing.T) {
newBroker := func(disableDatabaseSearch bool) *Broker {
cfg := &model.Config{}
cfg.SetDefaults()
cfg.SqlSettings.DisableDatabaseSearch = new(disableDatabaseSearch)
return NewBroker(cfg)
}
getESEngine := func(isActive bool, isHealthy bool) SearchEngineInterface {
esMock := &mocks.SearchEngineInterface{}
esMock.On("IsActive").Return(isActive)
esMock.On("IsHealthy").Return(isHealthy)
esMock.On("GetName").Return("elasticsearch")
return esMock
}
t.Run("default to database", func(t *testing.T) {
b := newBroker(false)
assert.Equal(t, "database", b.ActiveEngine())
})
t.Run("no active engine when DisableDatabaseSearch", func(t *testing.T) {
// Disable database search
b := newBroker(true)
assert.Equal(t, "none", b.ActiveEngine())
})
t.Run("switches to elasticsearch when active and healthy", func(t *testing.T) {
b := newBroker(false)
// Use an active, healthy ES engine
b.ElasticsearchEngine = getESEngine(true, true)
assert.Equal(t, "elasticsearch", b.ActiveEngine())
})
t.Run("active engines", func(t *testing.T) {
for _, isActive := range []bool{true, false} {
for _, isHealthy := range []bool{true, false} {
t.Run("(in)active/(un)healthy engines", func(t *testing.T) {
b := newBroker(false)
b.ElasticsearchEngine = getESEngine(isActive, isHealthy)
engines := b.GetActiveEngines()
// Default to database, since DisableDatabaseSearch is false
if isActive && isHealthy {
assert.Equal(t, "elasticsearch", b.ActiveEngine())
assert.Equal(t, []SearchEngineInterface{b.ElasticsearchEngine}, engines, "active healthy engine should appear in active engines")
} else {
assert.Equal(t, "database", b.ActiveEngine())
assert.Empty(t, engines, "inactive or unhealthy engines should not appear in active engines")
}
})
}
}
})
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package searchengine
import (
"regexp"
"strings"
"github.com/mattermost/mattermost/server/v8/channels/utils"
)
var EmailRegex = regexp.MustCompile(`^[^\s"]+@[^\s"]+$`)
func GetSuggestionInputsSplitBy(term, splitStr string) []string {
splitTerm := strings.Split(strings.ToLower(term), splitStr)
var initialSuggestionList []string
for i := range splitTerm {
initialSuggestionList = append(initialSuggestionList, strings.Join(splitTerm[i:], splitStr))
}
suggestionList := []string{}
// If splitStr is not an empty space, we create a suggestion with it at the beginning
if splitStr == " " {
suggestionList = initialSuggestionList
} else {
for i, suggestion := range initialSuggestionList {
if i == 0 {
suggestionList = append(suggestionList, suggestion)
} else {
suggestionList = append(suggestionList, splitStr+suggestion, suggestion)
}
}
}
return suggestionList
}
func GetSuggestionInputsSplitByMultiple(term string, splitStrs []string) []string {
suggestionList := []string{}
for _, splitStr := range splitStrs {
suggestionList = append(suggestionList, GetSuggestionInputsSplitBy(term, splitStr)...)
}
return utils.RemoveDuplicatesFromStringArray(suggestionList)
}

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package searchengine
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestElasticsearchGetSuggestionsSplitBy(t *testing.T) {
testCases := []struct {
Name string
Term string
SplitStr string ``
Expected []string
}{
{
Name: "Single string",
Term: "string",
SplitStr: " ",
Expected: []string{"string"},
},
{
Name: "String with spaces",
Term: "String with spaces",
SplitStr: " ",
Expected: []string{"string with spaces", "with spaces", "spaces"},
},
{
Name: "Username split by a dot",
Term: "name.surname",
SplitStr: ".",
Expected: []string{"name.surname", ".surname", "surname"},
},
{
Name: "String split by several dashes",
Term: "one-two-three",
SplitStr: "-",
Expected: []string{"one-two-three", "-two-three", "two-three", "-three", "three"},
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
res := GetSuggestionInputsSplitBy(tc.Term, tc.SplitStr)
assert.ElementsMatch(t, res, tc.Expected)
})
}
}
func TestElasticsearchGetSuggestionsSplitByMultiple(t *testing.T) {
r1 := GetSuggestionInputsSplitByMultiple("String with user.name", []string{" ", "."})
expectedR1 := []string{"string with user.name", "with user.name", "user.name", ".name", "name"}
assert.ElementsMatch(t, r1, expectedR1)
}

View File

@@ -0,0 +1,12 @@
with-expecter: false
packages:
github.com/mattermost/mattermost/server/v8/platform/services/sharedchannel:
config:
dir: "platform/services/sharedchannel"
inpackage: true
filename: "mock_{{.InterfaceName}}_test.go"
outpkg: "sharedchannel"
note: "Regenerate this file using `make sharedchannel-mocks`."
interfaces:
ServerIface:
AppIface:

View File

@@ -0,0 +1,519 @@
## Shared Channel Service
Package `sharedchannel` implements Mattermost's shared channels functionality, for sharing channel content across Mattermost instances/clusters. Here are the key responsibilities:
### Channel Sharing:
- Allows channels to be shared between different Mattermost instances/clusters
- Handles inviting remote clusters to shared channels
- Manages permissions and read-only status for shared channels
### Content Synchronization:
- Syncs posts, reactions, user profiles, and file attachments between instances
- Handles permalink processing between instances
- Manages user profile images sync
- Maintains sync state and cursors to track what has been synchronized
### Remote Communication:
- Processes incoming sync messages from remote clusters
- Sends updates to remote clusters when local changes occur
- Handles connection state changes with remote clusters
- Manages retry logic for failed sync attempts
### Security:
- Validates permissions for shared channel operations
- Ensures users can only sync content they have access to
- Verifies remote cluster authenticity
- Sanitizes user data during sync
The service acts as a bridge between Mattermost instances, allowing users from different instances to collaborate in shared channels while keeping content synchronized across all participating instances.
This is implemented through a Service struct that handles all the shared channel operations and maintains the synchronization state. It works in conjunction with the RemoteCluster service to handle the actual communication between instances.
---
## API Calls and Flow Between Mattermost Instances
### Overview
Shared channels enable two Mattermost instances to synchronize specific channels. The architecture uses:
- **Remote Cluster Service** - Handles inter-cluster communication
- **Shared Channel Service** - Manages channel synchronization
- **Push-based sync** - Each server pushes changes to remotes
- **Cursor-based tracking** - Timestamps track sync progress
```mermaid
graph TB
subgraph "Server A"
A1[User/App Layer]
A2[Shared Channel Service]
A3[Remote Cluster Service]
A4[API Endpoints]
A5[Database]
A1 --> A2
A2 --> A3
A2 --> A5
A3 --> A4
end
subgraph "Server B"
B1[User/App Layer]
B2[Shared Channel Service]
B3[Remote Cluster Service]
B4[API Endpoints]
B5[Database]
B1 --> B2
B2 --> B3
B2 --> B5
B3 --> B4
end
A4 -->|"HTTP/HTTPS<br/>Token Auth"| B4
B4 -->|"HTTP/HTTPS<br/>Token Auth"| A4
A3 -.->|"Heartbeat<br/>(60s)"| B4
B3 -.->|"Heartbeat<br/>(60s)"| A4
```
### Key Data Structures
```mermaid
erDiagram
RemoteCluster ||--o{ SharedChannel : "connects to"
RemoteCluster ||--o{ SharedChannelRemote : "has"
SharedChannel ||--o{ SharedChannelRemote : "shared with"
Channel ||--|| SharedChannel : "is"
RemoteCluster ||--o{ User : "has synthetic"
RemoteCluster {
string RemoteId PK
string Name
string SiteURL
string Token
string RemoteToken
int64 LastPingAt
int64 LastGlobalUserSyncAt
}
SharedChannel {
string ChannelId PK
string TeamId
bool Home
bool ReadOnly
string RemoteId FK
string ShareName
}
SharedChannelRemote {
string Id PK
string ChannelId FK
string RemoteId FK
bool IsInviteAccepted
bool IsInviteConfirmed
int64 LastPostCreateAt
int64 LastPostUpdateAt
}
Channel {
string Id PK
string TeamId
string Name
string Type
}
User {
string Id PK
string Username
string Email
string RemoteId FK
}
```
**RemoteCluster** (`server/public/model/remote_cluster.go:56`)
- Connection between two Mattermost instances
- Contains authentication tokens (bidirectional)
- Tracks heartbeat status (`LastPingAt`)
**SharedChannel** (`server/public/model/shared_channel.go:32`)
- Represents a shared channel
- `Home=true`: Hosted locally, `Home=false`: Remote channel
- Contains channel metadata snapshot
**SharedChannelRemote** (`server/public/model/shared_channel.go:102`)
- Junction table linking channels to remote clusters
- Tracks sync cursors (`LastPostCreateAt`, `LastPostUpdateAt`)
### Flow Outline
#### 1. Remote Cluster Connection Setup
```mermaid
sequenceDiagram
participant UA as User A
participant SA as Server A
participant UB as User B
participant SB as Server B
Note over UA,SB: Phase 1: Generate Invitation
UA->>SA: POST /api/v4/remotecluster<br/>{name, display_name, password}
SA->>SA: Generate RemoteId, Token
SA->>SA: Encrypt invitation (PBKDF2 + AES-GCM)
SA->>UA: Return encrypted invite code
Note over UA,SB: Phase 2: Accept Invitation
UA->>UB: Share invite code + password<br/>(out of band)
UB->>SB: POST /api/v4/remotecluster/accept_invite<br/>{invite, password}
SB->>SB: Decrypt invitation
SB->>SB: Create RemoteCluster record
SB->>SA: POST /api/v4/remotecluster/confirm_invite<br/>X-MM-RemoteCluster-Token: [token]<br/>{remote_id, site_url, token}
SA->>SA: Update RemoteCluster with SiteURL
SA->>SB: 200 OK
SB->>UB: Connection established
Note over UA,SB: Phase 3: Continuous Heartbeat
loop Every 60 seconds
SA->>SB: POST /api/v4/remotecluster/ping<br/>{sent_at}
SB->>SA: {sent_at, recv_at}
SB->>SA: POST /api/v4/remotecluster/ping<br/>{sent_at}
SA->>SB: {sent_at, recv_at}
end
```
**Step 1: Create Invitation (Server A)**
- API: `POST /api/v4/remotecluster`
- Generates encrypted invitation with PBKDF2 encryption
- Returns base64-encoded invite code
- Creates pending RemoteCluster record
**Step 2: Accept Invitation (Server B)**
- API: `POST /api/v4/remotecluster/accept_invite`
- Decrypts invitation using password
- Creates local RemoteCluster record
- Sends confirmation to Server A
**Step 3: Confirm Connection (Server A)**
- API: `POST /api/v4/remotecluster/confirm_invite`
- Receives confirmation from Server B
- Updates RemoteCluster with actual SiteURL
- Connection established
**Step 4: Continuous Heartbeat**
- API: `POST /api/v4/remotecluster/ping`
- Every 60 seconds (default)
- Updates `LastPingAt` timestamp
- Remote considered online if pinged within 5 minutes
#### 2. Channel Sharing
```mermaid
sequenceDiagram
participant UA as User A
participant SA as Server A
participant SB as Server B
participant UB as User B
Note over UA,SB: Invite Remote to Channel
UA->>SA: Invite Remote B to Channel
SA->>SA: Create SharedChannel (Home=true)
SA->>SA: Create SharedChannelRemote
SA->>SB: POST /api/v4/remotecluster/msg<br/>Topic: sharedchannel_invite<br/>{channel_id, name, type, ...}
SB->>SB: Validate invitation
SB->>SB: Create local channel
SB->>SB: Create SharedChannel (Home=false)
SB->>SB: Create SharedChannelRemote<br/>(IsInviteAccepted=true)
SB->>SA: 200 OK
SA->>SA: Update SharedChannelRemote<br/>(IsInviteConfirmed=true)
SA->>UA: Ephemeral: Remote added to channel
Note over UA,SB: Initial Sync
SA->>SA: Queue sync task for channel
SA->>SA: Collect users, posts, reactions
SA->>SB: POST /api/v4/remotecluster/msg<br/>Topic: sharedchannel_sync<br/>{users, posts, reactions, ...}
SB->>SB: Process sync message
SB->>SB: Create synthetic users
SB->>SB: Create posts
SB->>SB: Add reactions
SB->>SA: 200 OK {timestamps}
SA->>SA: Update sync cursors
```
**Step 1: Share Channel (Server A)**
- Internal: `InviteRemoteToChannel()`
- Creates SharedChannel record (`Home=true`)
- Sends invitation message via topic `sharedchannel_invite`
- Contains channel metadata (name, type, permissions)
**Step 2: Receive Invitation (Server B)**
- API: `POST /api/v4/remotecluster/msg` (topic: `sharedchannel_invite`)
- Creates local channel (regular, DM, or GM)
- Creates SharedChannel record (`Home=false`)
- Creates SharedChannelRemote record
- Returns 200 OK
**Step 3: Initial Sync Triggered**
- Server A receives confirmation
- Posts ephemeral notification to channel
- Triggers initial content synchronization
#### 3. Content Synchronization
```mermaid
sequenceDiagram
participant UA as User A
participant SA as Server A
participant SB as Server B
participant UB as User B
Note over UA,SB: User A posts message
UA->>SA: Create Post
SA->>SA: Save post to database
SA->>UA: WebSocket: posted event
SA->>SA: Queue sync task (2s delay)
Note over UA,SB: Sync Task Processing
SA->>SA: Collect data:<br/>- Users (updated profiles)<br/>- Posts (new/edited)<br/>- Reactions<br/>- Attachments
SA->>SA: Filter & batch (100 posts max)
alt Has file attachments
SA->>SB: POST /api/v4/remotecluster/msg<br/>Topic: sharedchannel_upload<br/>{upload_session}
SB->>SA: 200 OK {session_id}
SA->>SB: POST /api/v4/remotecluster/upload/{id}<br/>multipart file data
SB->>SB: Save file to filestore
end
SA->>SB: POST /api/v4/remotecluster/msg<br/>Topic: sharedchannel_sync<br/>Headers: X-MM-RemoteCluster-Id, Token<br/>{SyncMsg}
Note over SB: Process Sync Message
SB->>SB: Validate auth token
SB->>SB: Process users (create synthetic)
SB->>SB: Process posts (transform mentions)
SB->>SB: Process reactions
SB->>SB: Process acknowledgements
SB->>SB: Update user statuses
SB->>UB: WebSocket: posted event
SB->>SA: 200 OK {timestamps, syncd_users}
SA->>SA: Update sync cursors:<br/>LastPostCreateAt, LastPostUpdateAt
Note over UA,SB: Bidirectional Sync
UB->>SB: Create Post (reply)
SB->>SB: Save post
SB->>UB: WebSocket: posted event
SB->>SB: Queue sync task
SB->>SA: POST /api/v4/remotecluster/msg<br/>Topic: sharedchannel_sync
SA->>SA: Process sync message
SA->>UA: WebSocket: posted event
SA->>SB: 200 OK {timestamps}
```
**Sync Architecture:**
- **Event-driven**: Channel changes trigger sync tasks
- **Batched**: Groups changes for efficiency (100 posts/batch)
- **Ordered**: Users → Attachments → Posts → Reactions → Acknowledgements
**Sync Task Creation:**
Triggered by:
- Post created/edited/deleted
- Reaction added/removed
- User profile updated
- Channel metadata changed
- User status changed
- Membership changed
```mermaid
graph LR
A[Channel Event] -->|NotifyChannelChanged| B[Task Queue]
B -->|2s min delay| C{Remote Online?}
C -->|Yes| D[Collect Data]
C -->|No| E[Skip, retry later]
D --> F[Batch Data<br/>100 posts max]
F --> G[Send to Remote]
G -->|Success| H[Update Cursors]
G -->|Failure| I{Retry Count < 3?}
I -->|Yes| B
I -->|No| J[Log Error & Drop]
H --> K{More Data?}
K -->|Yes| B
K -->|No| L[Done]
```
**Data Collection:**
- Users: 25 per batch (profiles updated since last sync)
- Posts: 100 per batch (new posts first, then edited)
- Reactions: All for synced posts
- Attachments: All for synced posts
**Send Sync Message (Server A → Server B)**
- API: `POST /api/v4/remotecluster/msg` (topic: `sharedchannel_sync`)
- Headers:
- `X-MM-RemoteCluster-Id`: Remote cluster ID
- `X-MM-RemoteCluster-Token`: Authentication token
- Body: `RemoteClusterFrame` containing `SyncMsg`
**SyncMsg Structure:**
```json
{
"channel_id": "channel_123",
"users": {"user_id": {...}},
"posts": [{...}],
"reactions": [{...}],
"acknowledgements": [{...}],
"statuses": [{...}],
"mention_transforms": {"username": "user_id"}
}
```
**Receive Sync Message (Server B)**
- Validates authentication
- Processes users (creates synthetic users with obfuscated emails)
- Processes posts (transforms mentions, handles edits/deletes)
- Processes reactions (adds/removes)
- Processes acknowledgements
- Updates user statuses
- Returns success response with timestamps
#### 4. File Attachment Synchronization
**Upload Flow:**
1. Server A creates upload session
2. Sends upload creation message (topic: `sharedchannel_upload`)
3. Server B creates matching session
4. Server A streams file data: `POST /api/v4/remotecluster/upload/{upload_id}`
5. Server B saves file and creates FileInfo record
#### 5. Profile Image Synchronization
**Upload Flow:**
1. Detect user image update (`LastPictureUpdate` changed)
2. Server A uploads image: `POST /api/v4/remotecluster/{user_id}/image`
3. Server B validates user belongs to remote
4. Saves image and invalidates cache
#### 6. Membership Synchronization
**Feature Flag:** `EnableSharedChannelsMemberSync`
**Incremental Updates:**
- User added/removed from channel
- Sends `MembershipChangeMsg` in SyncMsg
- Receiving server adds/removes user accordingly
**Batch Member Sync:**
- Syncs all channel members in batches of 100
- Triggered on initial share or reconnection
- Only syncs local users (excludes synthetic remote users)
#### 7. Global User Synchronization
**Feature Flag:** `EnableSyncAllUsersForRemoteCluster`
**Purpose:** Sync all local users for better mention support
**Flow:**
1. Triggered on connection establishment or manual request
2. Collects users in batches of 25
3. Sends via topic `sharedchannel_global_user_sync`
4. Empty `channel_id` indicates global sync
5. Updates `LastGlobalUserSyncAt` cursor
### Authentication & Security
```mermaid
sequenceDiagram
participant SA as Server A
participant SB as Server B
Note over SA,SB: Token Setup During Connection
SA->>SA: Generate Token_A<br/>(for incoming auth)
SB->>SB: Generate Token_B<br/>(for incoming auth)
SA->>SB: Invitation contains Token_A
SB->>SA: Confirmation contains Token_B
SA->>SA: Store RemoteToken = Token_B
SB->>SB: Store RemoteToken = Token_A
Note over SA,SB: Server A sends message to Server B
SA->>SB: POST /api/v4/remotecluster/msg<br/>X-MM-RemoteCluster-Id: RemoteId_B<br/>X-MM-RemoteCluster-Token: Token_B
SB->>SB: Validate RemoteId_B exists
SB->>SB: Validate Token matches stored Token_B
alt Valid Token
SB->>SA: 200 OK + Response Data
else Invalid Token
SB->>SA: 401 Unauthorized
end
Note over SA,SB: Server B sends message to Server A
SB->>SA: POST /api/v4/remotecluster/msg<br/>X-MM-RemoteCluster-Id: RemoteId_A<br/>X-MM-RemoteCluster-Token: Token_A
SA->>SA: Validate RemoteId_A exists
SA->>SA: Validate Token matches stored Token_A
alt Valid Token
SA->>SB: 200 OK + Response Data
else Invalid Token
SA->>SB: 401 Unauthorized
end
```
**Token-Based Authentication:**
- Each server has `Token` (for incoming requests)
- Each server stores `RemoteToken` (for outgoing requests)
- All inter-server API calls include both in headers
**Invitation Encryption:**
- PBKDF2 key derivation (600,000 iterations)
- AES-GCM encryption
- Base64-encoded invite codes
**User Privacy:**
- Synthetic users with obfuscated data
- Username munged: `alice:remote-workspace`
- Email replaced with UUID
- Original values in Props (not exposed to clients)
### Key API Endpoints
**Remote Cluster Management:**
- `POST /api/v4/remotecluster` - Create remote (generate invite)
- `POST /api/v4/remotecluster/accept_invite` - Accept invitation
- `POST /api/v4/remotecluster/confirm_invite` - Confirm connection
- `POST /api/v4/remotecluster/ping` - Heartbeat
- `POST /api/v4/remotecluster/msg` - Message delivery (all topics)
**Shared Channel Management:**
- `GET /api/v4/sharedchannels/{team_id}` - List shared channels
- `POST /api/v4/channels/{channel_id}/remotes/{remote_id}/invite` - Share channel
- `POST /api/v4/channels/{channel_id}/remotes/{remote_id}/uninvite` - Unshare
**File Operations:**
- `POST /api/v4/remotecluster/upload/{upload_id}` - Upload file
- `POST /api/v4/remotecluster/{user_id}/image` - Upload profile image
### Error Handling & Monitoring
**Retry Logic:**
- Max 3 retries per sync task
- Exponential backoff with 2-second minimum delay
- Post-level retry for specific failures
**Offline Handling:**
- Queues pending invitations when remote offline
- Resumes sync when connection restored
- Notifies users with ephemeral messages
**Metrics:**
- `shared_channels_sync_counter` - Sync attempts
- `shared_channels_queue_size` - Queue depth
- `remote_cluster_msg_sent` - Successful messages
- `remote_cluster_msg_errors` - Failed messages
### Key Files
**Models:** `server/public/model/remote_cluster.go`, `server/public/model/shared_channel.go`
**API:** `server/channels/api4/remote_cluster.go`, `server/channels/api4/shared_channel.go`
**Services:**
- `server/platform/services/remotecluster/` - Connection management
- `server/platform/services/sharedchannel/` - Sync logic
This architecture enables secure, bidirectional synchronization of channels between independent Mattermost instances while maintaining data privacy and consistency.

View File

@@ -0,0 +1,210 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"sync"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/platform/services/remotecluster"
)
// postsToAttachments returns the file attachments for a slice of posts that need to be synchronized.
func (scs *Service) shouldSyncAttachment(fi *model.FileInfo, rc *model.RemoteCluster) bool {
sca, err := scs.server.GetStore().SharedChannel().GetAttachment(fi.Id, rc.RemoteId)
if err != nil {
if _, ok := err.(errNotFound); !ok {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "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)
}
if rc.IsPlugin() {
return scs.sendAttachmentToPlugin(fi, post, rc)
}
us := &model.UploadSession{
Id: model.NewId(),
Type: model.UploadTypeAttachment,
UserId: post.UserId,
ChannelId: post.ChannelId,
Filename: fi.Name,
FileSize: fi.Size,
RemoteId: rc.RemoteId,
ReqFileId: fi.Id,
}
payload, err := json.Marshal(us)
if err != nil {
return err
}
msg := model.NewRemoteClusterMsg(TopicUploadCreate, payload)
ctx, cancel := context.WithTimeout(context.Background(), remotecluster.SendTimeout)
defer cancel()
var usResp model.UploadSession
var respErr error
var wg sync.WaitGroup
wg.Add(1)
// creating the upload session on the remote server needs to be done synchronously.
err = rcs.SendMsg(ctx, msg, rc, func(msg model.RemoteClusterMsg, rc *model.RemoteCluster, resp *remotecluster.Response, err error) {
defer wg.Done()
if err != nil {
respErr = err
return
}
if !resp.IsSuccess() {
respErr = errors.New(resp.Err)
return
}
respErr = json.Unmarshal(resp.Payload, &usResp)
})
if err != nil {
return fmt.Errorf("error sending create upload session to remote %s for post %s: %w", rc.RemoteId, post.Id, err)
}
wg.Wait()
if respErr != nil {
return fmt.Errorf("invalid create upload session response for remote %s and post %s: %w", rc.RemoteId, post.Id, respErr)
}
ctx2, cancel2 := context.WithTimeout(context.Background(), remotecluster.SendFileTimeout)
defer cancel2()
return rcs.SendFile(ctx2, &usResp, fi, rc, scs.app, func(us *model.UploadSession, rc *model.RemoteCluster, resp *remotecluster.Response, err error) {
if err != nil {
return // this means the response could not be parsed; already logged
}
if !resp.IsSuccess() {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "send file failed",
mlog.String("remote", rc.DisplayName),
mlog.String("upload_id", usResp.Id),
mlog.String("err", resp.Err),
)
return
}
// response payload should be a model.FileInfo.
var fi model.FileInfo
if err2 := json.Unmarshal(resp.Payload, &fi); err2 != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "invalid file info response after send file",
mlog.String("remote", rc.DisplayName),
mlog.String("upload_id", usResp.Id),
mlog.Err(err2),
)
return
}
// save file attachment record in SharedChannelAttachments table
if err2 := scs.saveSharedAttachment(&fi, rc); err2 != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "error saving SharedChannelAttachment",
mlog.String("remote", rc.DisplayName),
mlog.String("upload_id", usResp.Id),
mlog.Err(err2),
)
return
}
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "send file successful",
mlog.String("remote", rc.DisplayName),
mlog.String("upload_id", usResp.Id),
)
})
}
// sendAttachmentToPlugin asynchronously sends a file attachment to a remote cluster.
func (scs *Service) sendAttachmentToPlugin(fi *model.FileInfo, post *model.Post, rc *model.RemoteCluster) error {
if err := scs.app.OnSharedChannelsAttachmentSyncMsg(fi, post, rc); err != nil {
return fmt.Errorf("cannot send attachment to plugin %s: %w", rc.PluginID, err)
}
return scs.saveSharedAttachment(fi, rc)
}
// saveSharedAttachment saves the attachment in SharedChannelAttachments table.
func (scs *Service) saveSharedAttachment(fi *model.FileInfo, rc *model.RemoteCluster) error {
sca := &model.SharedChannelAttachment{
FileId: fi.Id,
RemoteId: rc.RemoteId,
}
_, err := scs.server.GetStore().SharedChannel().UpsertAttachment(sca)
return err
}
// 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
hasRemote, err := scs.server.GetStore().SharedChannel().HasRemote(us.ChannelId, rc.RemoteId)
if err != nil {
return fmt.Errorf("could not validate upload session for remote: %w", err)
}
if !hasRemote {
return model.NewAppError("createUpload", "api.upload.create.upload_channel_not_shared_with_remote.app_error", nil, "", http.StatusBadRequest)
}
// make sure file attachments are enabled
if scs.server.Config().FileSettings.EnableFileAttachments == nil || !*scs.server.Config().FileSettings.EnableFileAttachments {
return model.NewAppError("ReceiveUploadCreate",
"api.file.attachments.disabled.app_error",
nil, "", http.StatusNotImplemented)
}
// make sure the file size requested does not exceed local server config - MM server's regular
// upload code will ensure the actual bytes sent are within the upload session limit.
if scs.server.Config().FileSettings.MaxFileSize == nil || us.FileSize > *scs.server.Config().FileSettings.MaxFileSize {
return model.NewAppError("createUpload", "api.upload.create.upload_too_large.app_error",
map[string]any{"channelId": us.ChannelId}, "", http.StatusRequestEntityTooLarge)
}
// validate upload type for shared channels - only allow attachments
if us.Type != model.UploadTypeAttachment {
return model.NewAppError("onReceiveUploadCreate", "api.upload.invalid_type_for_shared_channel.app_error",
nil, "", http.StatusBadRequest)
}
us.RemoteId = rc.RemoteId // don't let remotes try to impersonate each other
// create upload session.
usSaved, appErr := scs.app.CreateUploadSession(request.EmptyContext(scs.server.Log()), &us)
if appErr != nil {
return appErr
}
response.SetPayload(usSaved)
return nil
}

View File

@@ -0,0 +1,601 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/platform/services/remotecluster"
)
// 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"` // Deprecated: this field is no longer used. It's only kept for backwards compatibility.
ReadOnly bool `json:"read_only"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Header string `json:"header"`
Purpose string `json:"purpose"`
Type model.ChannelType `json:"type"`
CreatorID string `json:"creator_id"`
DirectParticipantIDs []string `json:"direct_participant_ids"`
DirectParticipants []*model.User `json:"direct_participants"`
}
func (cim channelInviteMsg) DirectParticipantsMap() map[string]*model.User {
dim := make(map[string]*model.User)
for _, user := range cim.DirectParticipants {
dim[user.Id] = user
}
return dim
}
type InviteOption func(msg *channelInviteMsg)
func WithDirectParticipant(participant *model.User, remoteID string) InviteOption {
return func(msg *channelInviteMsg) {
msg.DirectParticipantIDs = append(msg.DirectParticipantIDs, participant.Id)
// if the participant doesn't belong to the remote we're
// sending the invite to, send it as part of the invite
// payload
if participant.GetRemoteID() != remoteID {
msg.DirectParticipants = append(msg.DirectParticipants, sanitizeUserForSync(participant))
}
}
}
func WithCreator(creatorID string) InviteOption {
return func(msg *channelInviteMsg) {
msg.CreatorID = creatorID
}
}
// 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 in the channel for userId.
func (scs *Service) SendChannelInvite(channel *model.Channel, userId string, rc *model.RemoteCluster, options ...InviteOption) error {
rcs := scs.server.GetRemoteClusterService()
if rcs == nil {
return fmt.Errorf("cannot invite remote cluster for channel id %s; Remote Cluster Service not enabled", channel.Id)
}
sc, err := scs.server.GetStore().SharedChannel().Get(channel.Id)
if err != nil {
return err
}
// if the remote is not currently online, we store the invite to
// send it when the connection is restored
if !rc.IsOnline() {
if len(options) > 0 {
// pending invites with options are currently not supported
scs.sendEphemeralPost(channel.Id, userId, fmt.Sprintf("Error sending channel invite for %s: %s", rc.DisplayName, model.ErrOfflineRemote))
return model.ErrOfflineRemote
}
existingScr, getErr := scs.server.GetStore().SharedChannel().GetRemoteByIds(sc.ChannelId, rc.RemoteId)
var errNotFound *store.ErrNotFound
if getErr != nil && !errors.As(getErr, &errNotFound) {
scs.sendEphemeralPost(channel.Id, userId, fmt.Sprintf("Error sending channel invite for %s: %v", rc.DisplayName, getErr))
return getErr
}
if getErr != nil {
existingScr = nil // ErrNotFound: no record, will insert
}
if existingScr != nil {
// If the record is not soft deleted, the remote is already connected — error out
if existingScr.DeleteAt == 0 {
scs.sendEphemeralPost(channel.Id, userId, fmt.Sprintf("Error sending channel invite for %s: %s", rc.DisplayName, model.ErrChannelAlreadyShared))
return model.ErrChannelAlreadyShared
}
// restore previously uninvited (soft-deleted) record instead of inserting
curTime := model.GetMillis()
existingScr.DeleteAt = 0
existingScr.UpdateAt = curTime
existingScr.CreatorId = userId
existingScr.IsInviteAccepted = true
existingScr.IsInviteConfirmed = false
existingScr.LastMembersSyncAt = 0
if _, err = scs.server.GetStore().SharedChannel().UpdateRemote(existingScr); err != nil {
scs.sendEphemeralPost(channel.Id, userId, fmt.Sprintf("Error saving channel invite for %s: %v", rc.DisplayName, err))
return err
}
} else {
scr := &model.SharedChannelRemote{
ChannelId: sc.ChannelId,
CreatorId: userId,
RemoteId: rc.RemoteId,
IsInviteAccepted: true,
IsInviteConfirmed: false,
LastMembersSyncAt: 0,
}
if _, err = scs.server.GetStore().SharedChannel().SaveRemote(scr); err != nil {
scs.sendEphemeralPost(channel.Id, userId, fmt.Sprintf("Error saving channel invite for %s: %v", rc.DisplayName, err))
return err
}
}
return nil
}
invite := channelInviteMsg{
ChannelId: channel.Id,
ReadOnly: sc.ReadOnly,
Name: channel.Name,
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)
// onInvite is called after invite is sent, whether to a remote cluster or plugin.
onInvite := func(_ 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
}
existingScr, err := scs.server.GetStore().SharedChannel().GetRemoteByIds(sc.ChannelId, rc.RemoteId)
var errNotFound *store.ErrNotFound
if err != nil && !errors.As(err, &errNotFound) {
scs.sendEphemeralPost(channel.Id, userId, fmt.Sprintf("Error sending channel invite for %s: %s", rc.DisplayName, err))
return
}
curTime := model.GetMillis()
if existingScr != nil {
if existingScr.DeleteAt == 0 && existingScr.IsInviteConfirmed {
// the shared channel remote exists and is not
// deleted, nothing to do here
return
}
// the shared channel remote was deleted in the past or
// pending confirmation, so with the new invite we restore
// it
existingScr.DeleteAt = 0
existingScr.UpdateAt = curTime
existingScr.LastPostCreateAt = curTime
existingScr.LastPostUpdateAt = curTime
existingScr.IsInviteConfirmed = true
if _, sErr := scs.server.GetStore().SharedChannel().UpdateRemote(existingScr); sErr != nil {
scs.sendEphemeralPost(channel.Id, userId, fmt.Sprintf("Error confirming channel invite for %s: %v", rc.DisplayName, sErr))
return
}
} else {
// the shared channel remote doesn't exists, so we create it
scr := &model.SharedChannelRemote{
ChannelId: sc.ChannelId,
CreatorId: userId,
RemoteId: rc.RemoteId,
IsInviteAccepted: true,
IsInviteConfirmed: true,
LastPostCreateAt: curTime,
LastPostUpdateAt: curTime,
LastMembersSyncAt: 0,
}
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)
// Notify clients that shared channel remotes have been updated so the UI
// can refresh its cached remote names for this channel.
messageWs := model.NewWebSocketEvent(model.WebsocketEventSharedChannelRemoteUpdated, "", sc.ChannelId, "", nil, "")
messageWs.Add("channel_id", sc.ChannelId)
scs.app.Publish(messageWs)
scs.postChannelSharedWithWorkspace(channel, rc)
// Trigger membership sync via the normal sync pipeline (reads from ChannelMemberHistory)
scs.NotifyMembershipChanged(sc.ChannelId, "")
}
if rc.IsPlugin() {
// for now plugins are considered fully invited automatically
// TODO: MM-57537 create plugin hook that passes invitation to plugins if BitflagOptionAutoInvited is not set
onInvite(msg, rc, &remotecluster.Response{Status: remotecluster.ResponseStatusOK}, nil)
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), remotecluster.SendTimeout)
defer cancel()
return rcs.SendMsg(ctx, msg, rc, onInvite)
}
func combineErrors(err error, serror string) string {
var sb strings.Builder
if err != nil {
sb.WriteString(err.Error())
}
if serror != "" {
if sb.Len() > 0 {
sb.WriteString("; ")
}
sb.WriteString(serror)
}
return sb.String()
}
func (scs *Service) onReceiveChannelInvite(msg model.RemoteClusterMsg, rc *model.RemoteCluster, _ *remotecluster.Response) error {
if len(msg.Payload) == 0 {
return nil
}
var invite channelInviteMsg
if err := json.Unmarshal(msg.Payload, &invite); err != nil {
return fmt.Errorf("invalid channel invite: %w", err)
}
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Channel invite received",
mlog.String("remote", rc.DisplayName),
mlog.String("channel_id", invite.ChannelId),
mlog.String("channel_name", invite.Name),
mlog.String("team_id", invite.TeamId),
)
// check if channel already exists
existingScr, err := scs.server.GetStore().SharedChannel().GetRemoteByIds(invite.ChannelId, rc.RemoteId)
var errNotFound *store.ErrNotFound
if err != nil && !errors.As(err, &errNotFound) {
return fmt.Errorf("cannot get deleted shared channel remote (channel_id=%s): %w", invite.ChannelId, err)
}
if existingScr != nil && existingScr.DeleteAt == 0 {
// the channel is already shared, nothing to do
return nil
}
var channel *model.Channel
var created bool
if existingScr == nil {
var err error
_, err = scs.server.GetStore().Channel().Get(invite.ChannelId, true)
if err == nil {
// the channel already exists on this server and was not
// previously shared, so we reject the invite
return fmt.Errorf("cannot create new shared channel (channel_id=%s): %w", invite.ChannelId, model.ErrChannelAlreadyExists)
}
// create new local channel to sync with the remote channel
if channel, created, err = scs.handleChannelCreation(invite, rc); err != nil {
return err
}
// sanity check to ensure the channel returned has the expected id. Otherwise sync will not work as expected and will fail
// silently.
if invite.ChannelId != channel.Id {
// as of this writing, this scenario should only be possible if the invite included a DM or GM channel
// invitation with a combination of user ids that already have a DM or GM on this server. Very unlikely
// unless the remote is compromised AND has knowledge of the local user ids.
// Another possibility would be an actual user ID collision between two servers, where the likelihood is
// infinitesimally small
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Channel invite failed - channel created/fetched with wrong id",
mlog.String("remote", rc.DisplayName),
mlog.String("channel_id", invite.ChannelId),
mlog.String("channel_type", invite.Type),
mlog.String("channel_name", invite.Name),
mlog.String("team_id", invite.TeamId),
mlog.Array("dm_partics", invite.DirectParticipantIDs),
)
return fmt.Errorf("cannot create shared channel (channel_id=%s channel_type=%s): %w", invite.ChannelId, invite.Type, model.ErrChannelAlreadyExists)
}
// mark the newly created channel read-only if requested in the invite
if invite.ReadOnly {
if err := scs.makeChannelReadOnly(channel); err != nil {
return fmt.Errorf("cannot make channel readonly `%s`: %w", invite.ChannelId, err)
}
}
} else {
var err error
channel, err = scs.server.GetStore().Channel().Get(invite.ChannelId, true)
if err != nil {
return fmt.Errorf("cannot get channel (channel_id=%s) to restore a shared channel remote: %w", invite.ChannelId, err)
}
}
sharedChannel := &model.SharedChannel{
ChannelId: channel.Id,
TeamId: channel.TeamId,
Home: false,
ReadOnly: existingScr == nil && invite.ReadOnly, // only set read only flag for new shares
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 {
// delete the newly created channel since we could not create a SharedChannel record for it
if created {
scs.app.PermanentDeleteChannel(request.EmptyContext(scs.server.Log()), channel)
}
return fmt.Errorf("cannot create shared channel (channel_id=%s): %w", invite.ChannelId, err)
}
curTime := model.GetMillis()
if existingScr != nil {
existingScr.DeleteAt = 0
existingScr.UpdateAt = curTime
existingScr.LastPostCreateAt = curTime
existingScr.LastPostUpdateAt = curTime
if _, err := scs.server.GetStore().SharedChannel().UpdateRemote(existingScr); err != nil {
return fmt.Errorf("cannot restore deleted shared channel remote (channel_id=%s): %w", invite.ChannelId, err)
}
// Trigger membership sync via the normal sync pipeline (reads from ChannelMemberHistory)
scs.NotifyMembershipChanged(channel.Id, "")
} else {
creatorID := channel.CreatorId
if creatorID == "" {
creatorID = invite.CreatorID
}
scr := &model.SharedChannelRemote{
Id: model.NewId(),
ChannelId: channel.Id,
CreatorId: creatorID,
IsInviteAccepted: true,
IsInviteConfirmed: true,
RemoteId: rc.RemoteId,
LastPostCreateAt: model.GetMillis(),
LastPostUpdateAt: model.GetMillis(),
LastMembersSyncAt: 0,
}
if _, err := scs.server.GetStore().SharedChannel().SaveRemote(scr); err != nil {
// delete the newly created channel since we could not create a SharedChannelRemote record for it,
// and delete the newly created SharedChannel record as well.
if created {
scs.app.PermanentDeleteChannel(request.EmptyContext(scs.server.Log()), channel)
}
scs.server.GetStore().SharedChannel().Delete(sharedChannel.ChannelId)
return fmt.Errorf("cannot create shared channel remote (channel_id=%s): %w", invite.ChannelId, err)
}
// Trigger membership sync via the normal sync pipeline (reads from ChannelMemberHistory)
scs.NotifyMembershipChanged(channel.Id, "")
}
scs.postChannelSharedWithWorkspace(channel, rc)
return nil
}
// handleChannelCreation creates a new channel to represent the remote channel in the invitation.
// For DMs there is a chance the channel already exists (shared, unshared, shared again) and the boolean
// determines if the channel was newly created (true=new)
func (scs *Service) handleChannelCreation(invite channelInviteMsg, rc *model.RemoteCluster) (*model.Channel, bool, error) {
if invite.Type == model.ChannelTypeDirect {
return scs.createDirectChannel(invite, rc)
}
if invite.Type == model.ChannelTypeGroup {
return scs.createGroupChannel(invite, rc)
}
teamId := rc.DefaultTeamId
// if the remote doesn't have a teamId associated and until the
// acceptance of an invite includes selecting a team, we use the
// first team of the list
if teamId == "" {
teams, err := scs.server.GetStore().Team().GetAllPage(0, 1, nil)
if err != nil {
return nil, false, fmt.Errorf("cannot get team to create the channel `%s`: %w", invite.ChannelId, err)
}
teamId = teams[0].Id
}
channelNew := &model.Channel{
Id: invite.ChannelId,
TeamId: teamId,
Type: invite.Type,
DisplayName: invite.DisplayName,
Name: invite.Name,
Header: invite.Header,
Purpose: invite.Purpose,
CreatorId: rc.CreatorId,
Shared: new(true),
}
// check user perms?
channel, appErr := scs.app.CreateChannelWithUser(request.EmptyContext(scs.server.Log()), channelNew, rc.CreatorId)
if appErr != nil {
return nil, false, fmt.Errorf("cannot create channel `%s`: %w", invite.ChannelId, appErr)
}
return channel, true, nil
}
// getOrCreateUser will try to fetch a user by its ID from the
// database and if it fails, it will try to create it if is present in
// the participantsMap
func (scs *Service) getOrCreateUser(userID string, participantsMap map[string]*model.User, rc *model.RemoteCluster) (*model.User, error) {
user, err := scs.server.GetStore().User().Get(request.EmptyContext(scs.server.Log()), userID)
if err == nil {
return user, nil
}
inviteUser, ok := participantsMap[userID]
if !ok {
// at this point we couldn't fetch the user nor we can create
// it from the invite information, so we return an error
return nil, fmt.Errorf("cannot fetch user `%q`: %w", userID, err)
}
var rctx request.CTX = request.EmptyContext(scs.server.Log())
inviteUser.RemoteId = new(rc.RemoteId)
user, iErr := scs.insertSyncUser(rctx, inviteUser, nil, rc)
if iErr != nil {
return nil, fmt.Errorf("cannot create user `%q` for remote `%q`: %w", inviteUser.Id, rc.RemoteId, iErr)
}
return user, nil
}
// createDirectChannel creates a DM channel, or fetches an existing channel, and returns the channel plus a boolean
// indicating if the channel is new.
func (scs *Service) createDirectChannel(invite channelInviteMsg, rc *model.RemoteCluster) (*model.Channel, bool, error) {
if len(invite.DirectParticipantIDs) != 2 {
return nil, false, fmt.Errorf("cannot create direct channel `%s` insufficient participant count `%d`", invite.ChannelId, len(invite.DirectParticipantIDs))
}
participantsMap := invite.DirectParticipantsMap()
user1, err := scs.getOrCreateUser(invite.DirectParticipantIDs[0], participantsMap, rc)
if err != nil {
return nil, false, fmt.Errorf("cannot create direct channel `%s` from invite: %w", invite.ChannelId, err)
}
user2, err := scs.getOrCreateUser(invite.DirectParticipantIDs[1], participantsMap, rc)
if err != nil {
return nil, false, fmt.Errorf("cannot create direct channel `%s` from invite: %w", invite.ChannelId, err)
}
// determine the remote user
// - if both are remote then the DM channel does not belong on this server
// - if neither are remote then the DM channel should not be created via sync message
// - if only one is remote then we check visibility relative to that user
userRemote := user1
userLocal := user2
if !userRemote.IsRemote() {
userRemote = user2
userLocal = user1
}
if !userRemote.IsRemote() {
return nil, false, fmt.Errorf("cannot create direct channel `%s` remote user is not remote (%s)", invite.ChannelId, userRemote.Id)
}
if userLocal.IsRemote() {
return nil, false, fmt.Errorf("cannot create direct channel `%s` local user is not local (%s)", invite.ChannelId, userLocal.Id)
}
if userRemote.GetRemoteID() != rc.RemoteId {
return nil, false, fmt.Errorf("cannot create direct channel `%s`: %w", invite.ChannelId, ErrRemoteIDMismatch)
}
// ensure remote user is allowed to DM the local user
canSee, appErr := scs.app.UserCanSeeOtherUser(request.EmptyContext(scs.server.Log()), userRemote.Id, userLocal.Id)
if appErr != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "cannot check user visibility for DM creation",
mlog.String("user_remote", userRemote.Id),
mlog.String("user_local", userLocal.Id),
mlog.String("channel_id", invite.ChannelId),
mlog.Err(appErr),
)
return nil, false, fmt.Errorf("cannot check user visibility for DM (%s) creation: %w", invite.ChannelId, appErr)
}
if !canSee {
return nil, false, fmt.Errorf("cannot create direct channel `%s`: %w", invite.ChannelId, ErrUserDMPermission)
}
// check if this DM already exists.
channelName := model.GetDMNameFromIds(userRemote.Id, userLocal.Id)
channelExists, err := scs.server.GetStore().Channel().GetByName("", channelName, true)
if err != nil && !isNotFoundError(err) {
return nil, false, fmt.Errorf("cannot check DM channel exists (%s): %w", channelName, err)
}
if channelExists != nil {
if channelExists.Id == invite.ChannelId {
return channelExists, false, nil
}
return nil, false, fmt.Errorf("cannot create direct channel `%s`: channel exists with wrong id", channelName)
}
// create the channel
channel, appErr := scs.app.GetOrCreateDirectChannel(request.EmptyContext(scs.server.Log()), userRemote.Id, userLocal.Id, model.WithID(invite.ChannelId))
if appErr != nil {
return nil, false, fmt.Errorf("cannot create direct channel `%s`: %w", invite.ChannelId, appErr)
}
return channel, true, nil
}
// createGroupChannel creates a DM channel, or fetches an existing channel, and returns the channel plus a boolean
// indicating if the channel is new.
func (scs *Service) createGroupChannel(invite channelInviteMsg, rc *model.RemoteCluster) (*model.Channel, bool, error) {
if len(invite.DirectParticipantIDs) > model.ChannelGroupMaxUsers || len(invite.DirectParticipantIDs) < model.ChannelGroupMinUsers {
return nil, false, fmt.Errorf("cannot create group channel `%s` bad participant count `%d`", invite.ChannelId, len(invite.DirectParticipantIDs))
}
participantsMap := invite.DirectParticipantsMap()
remoteIDMap := map[string]bool{}
hasLocalUsers := false
for _, participantID := range invite.DirectParticipantIDs {
user, err := scs.getOrCreateUser(participantID, participantsMap, rc)
if err != nil {
return nil, false, fmt.Errorf("cannot create group channel `%s` from invite: %w", invite.ChannelId, err)
}
// we keep track of the origin of the users to check if the
// invite is valid
if user.IsRemote() {
remoteIDMap[user.GetRemoteID()] = true
} else {
hasLocalUsers = true
}
}
// if the invite doesn't contain remote users, GM should not be created via remote invite
if len(remoteIDMap) == 0 {
return nil, false, fmt.Errorf("cannot create group channel `%s` there are no remote users", invite.ChannelId)
}
// if the channel doesn't contain local users, the GM channel doesn't belong to this server
if !hasLocalUsers {
return nil, false, fmt.Errorf("cannot create group channel `%s` there are no local users", invite.ChannelId)
}
// check if this GM already exists.
channelName := model.GetGroupNameFromUserIds(invite.DirectParticipantIDs)
channelExists, err := scs.server.GetStore().Channel().GetByName("", channelName, true)
if err != nil && !isNotFoundError(err) {
return nil, false, fmt.Errorf("cannot check GM channel exists (%s): %w", channelName, err)
}
if channelExists != nil {
if channelExists.Id == invite.ChannelId {
return channelExists, false, nil
}
return nil, false, fmt.Errorf("cannot create group channel `%s`: channel exists with wrong id", channelName)
}
// create the channel
channel, appErr := scs.app.CreateGroupChannel(request.EmptyContext(scs.server.Log()), invite.DirectParticipantIDs, invite.CreatorID, model.WithID(invite.ChannelId))
if appErr != nil {
return nil, false, fmt.Errorf("cannot create group channel `%s`: %w", invite.ChannelId, appErr)
}
return channel, true, nil
}

View File

@@ -0,0 +1,527 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
"github.com/mattermost/mattermost/server/v8/platform/services/remotecluster"
)
var (
mockTypeChannel = mock.AnythingOfType("*model.Channel")
mockTypeUser = mock.AnythingOfType("*model.User")
mockTypeString = mock.AnythingOfType("string")
mockTypeReqContext = mock.AnythingOfType("*request.Context")
mockTypeContext = mock.MatchedBy(func(ctx request.CTX) bool { return true })
)
// stubRemoteClusterService is a no-op implementation of RemoteClusterServiceIFace for tests
// that only need to satisfy the non-nil check in SendChannelInvite (remote offline path).
type stubRemoteClusterService struct{}
func (s *stubRemoteClusterService) Shutdown() error { return nil }
func (s *stubRemoteClusterService) Start() error { return nil }
func (s *stubRemoteClusterService) Active() bool { return false }
func (s *stubRemoteClusterService) AddTopicListener(topic string, listener remotecluster.TopicListener) string {
return ""
}
func (s *stubRemoteClusterService) RemoveTopicListener(listenerId string) {}
func (s *stubRemoteClusterService) AddConnectionStateListener(listener remotecluster.ConnectionStateListener) string {
return ""
}
func (s *stubRemoteClusterService) RemoveConnectionStateListener(listenerId string) {}
func (s *stubRemoteClusterService) SendMsg(ctx context.Context, msg model.RemoteClusterMsg, rc *model.RemoteCluster, f remotecluster.SendMsgResultFunc) error {
return nil
}
func (s *stubRemoteClusterService) SendFile(ctx context.Context, us *model.UploadSession, fi *model.FileInfo, rc *model.RemoteCluster, rp remotecluster.ReaderProvider, f remotecluster.SendFileResultFunc) error {
return nil
}
func (s *stubRemoteClusterService) SendProfileImage(ctx context.Context, userID string, rc *model.RemoteCluster, provider remotecluster.ProfileImageProvider, f remotecluster.SendProfileImageResultFunc) error {
return nil
}
func (s *stubRemoteClusterService) AcceptInvitation(invite *model.RemoteClusterInvite, name string, displayName string, creatorId string, siteURL string, defaultTeamId string) (*model.RemoteCluster, error) {
return nil, nil
}
func (s *stubRemoteClusterService) ReceiveIncomingMsg(rc *model.RemoteCluster, msg model.RemoteClusterMsg) remotecluster.Response {
return remotecluster.Response{}
}
func (s *stubRemoteClusterService) ReceiveInviteConfirmation(invite model.RemoteClusterInvite) (*model.RemoteCluster, error) {
return nil, nil
}
func (s *stubRemoteClusterService) PingNow(rc *model.RemoteCluster) {}
func (s *stubRemoteClusterService) NotifySyncFailed(remoteId string) {}
var _ remotecluster.RemoteClusterServiceIFace = (*stubRemoteClusterService)(nil)
// setupMockServerWithConfig sets up the standard mocks that all tests need
func setupMockServerWithConfig(mockServer *MockServerIface) {
mockConfig := model.Config{}
mockConfig.SetDefaults()
mockServer.On("Config").Return(&mockConfig)
mockServer.On("GetRemoteClusterService").Return(nil)
}
func TestOnReceiveChannelInvite(t *testing.T) {
t.Run("when msg payload is empty, it does nothing", func(t *testing.T) {
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
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{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockApp := &MockAppIface{}
scs := &Service{
server: mockServer,
app: mockApp,
}
mockStore := &mocks.Store{}
remoteCluster := &model.RemoteCluster{RemoteId: model.NewId(), Name: "test", DefaultTeamId: model.NewId()}
invitation := channelInviteMsg{
ChannelId: model.NewId(),
TeamId: model.NewId(),
ReadOnly: true,
Type: model.ChannelTypeOpen,
}
payload, err := json.Marshal(invitation)
require.NoError(t, err)
msg := model.RemoteClusterMsg{
Payload: payload,
}
mockChannelStore := mocks.ChannelStore{}
mockSharedChannelStore := mocks.SharedChannelStore{}
channel := &model.Channel{
Id: invitation.ChannelId,
TeamId: invitation.TeamId,
Type: invitation.Type,
CreatorId: model.NewId(),
}
mockSharedChannelStore.On("GetRemoteByIds", invitation.ChannelId, remoteCluster.RemoteId).Return(nil, store.NewErrNotFound("SharedChannelRemote", ""))
mockChannelStore.On("Get", invitation.ChannelId, true).Return(nil, &store.ErrNotFound{})
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.On("GetStore").Return(mockStore)
setupMockServerWithConfig(mockServer)
createPostPermission := model.ChannelModeratedPermissionsMap[model.PermissionCreatePost.Id]
createReactionPermission := model.ChannelModeratedPermissionsMap[model.PermissionAddReaction.Id]
updateMap := model.ChannelModeratedRolesPatch{
Guests: new(false),
Members: new(false),
}
mockApp.On("CreateChannelWithUser", mockTypeReqContext, mockTypeChannel, mockTypeString).Return(channel, nil)
readonlyChannelModerations := []*model.ChannelModerationPatch{
{
Name: &createPostPermission,
Roles: &updateMap,
},
{
Name: &createReactionPermission,
Roles: &updateMap,
},
}
mockApp.On("PatchChannelModerationsForChannel", mock.Anything, channel, readonlyChannelModerations).Return(nil, nil).Maybe()
mockApp.On("GetSystemBot", mockTypeReqContext).Return(&model.Bot{UserId: model.NewId()}, nil).Once()
mockApp.On("CreatePost", mockTypeReqContext, mock.AnythingOfType("*model.Post"), mockTypeChannel, mock.AnythingOfType("model.CreatePostFlags")).Return(&model.Post{Id: model.NewId()}, false, nil).Once()
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{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockApp := &MockAppIface{}
scs := &Service{
server: mockServer,
app: mockApp,
}
mockStore := &mocks.Store{}
remoteCluster := &model.RemoteCluster{RemoteId: model.NewId(), Name: "test2"}
invitation := channelInviteMsg{
ChannelId: model.NewId(),
TeamId: model.NewId(),
ReadOnly: true,
Type: "0",
}
payload, err := json.Marshal(invitation)
require.NoError(t, err)
msg := model.RemoteClusterMsg{
Payload: payload,
}
mockChannelStore := mocks.ChannelStore{}
channel := &model.Channel{
Id: invitation.ChannelId,
}
mockTeamStore := mocks.TeamStore{}
team := &model.Team{
Id: model.NewId(),
}
mockSharedChannelStore := mocks.SharedChannelStore{}
mockSharedChannelStore.On("GetRemoteByIds", invitation.ChannelId, remoteCluster.RemoteId).Return(nil, store.NewErrNotFound("SharedChannelRemote", ""))
mockChannelStore.On("Get", invitation.ChannelId, true).Return(nil, &store.ErrNotFound{})
mockTeamStore.On("GetAllPage", 0, 1, mock.Anything).Return([]*model.Team{team}, nil)
mockStore.On("Channel").Return(&mockChannelStore)
mockStore.On("Team").Return(&mockTeamStore)
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
mockServer = scs.server.(*MockServerIface)
mockServer.On("GetStore").Return(mockStore)
appErr := model.NewAppError("foo", "bar", nil, "boom", http.StatusBadRequest)
mockApp.On("CreateChannelWithUser", mockTypeReqContext, mockTypeChannel, mockTypeString).Return(channel, nil)
mockApp.On("PatchChannelModerationsForChannel", mock.Anything, channel, mock.Anything).Return(nil, appErr)
defer mockApp.AssertExpectations(t)
err = scs.onReceiveChannelInvite(msg, remoteCluster, nil)
require.Error(t, err)
assert.Equal(t, fmt.Sprintf("cannot make channel readonly `%s`: foo: bar, boom", invitation.ChannelId), err.Error())
})
t.Run("When invitation points to a deleted shared channel remote", func(t *testing.T) {
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockApp := &MockAppIface{}
scs := &Service{
server: mockServer,
app: mockApp,
}
mockStore := &mocks.Store{}
remoteCluster := &model.RemoteCluster{RemoteId: model.NewId(), Name: "test", DefaultTeamId: model.NewId()}
invitation := channelInviteMsg{
ChannelId: model.NewId(),
TeamId: model.NewId(),
Type: model.ChannelTypeOpen,
}
payload, err := json.Marshal(invitation)
require.NoError(t, err)
msg := model.RemoteClusterMsg{
Payload: payload,
}
mockChannelStore := mocks.ChannelStore{}
mockSharedChannelStore := mocks.SharedChannelStore{}
channel := &model.Channel{
Id: invitation.ChannelId,
TeamId: invitation.TeamId,
Type: invitation.Type,
CreatorId: model.NewId(),
}
sharedChannelRemote := &model.SharedChannelRemote{
ChannelId: invitation.ChannelId,
RemoteId: remoteCluster.RemoteId,
DeleteAt: 1234,
}
mockSharedChannelStore.On("GetRemoteByIds", invitation.ChannelId, mock.Anything).Return(sharedChannelRemote, nil)
mockChannelStore.On("Get", invitation.ChannelId, true).Return(channel, nil)
mockSharedChannelStore.On("Save", mock.Anything).Return(nil, nil)
mockSharedChannelStore.On("UpdateRemote", mock.Anything).Return(nil, nil)
mockStore.On("Channel").Return(&mockChannelStore)
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
mockServer.On("GetStore").Return(mockStore)
setupMockServerWithConfig(mockServer)
mockApp.On("GetSystemBot", mockTypeReqContext).Return(&model.Bot{UserId: model.NewId()}, nil).Once()
mockApp.On("CreatePost", mockTypeReqContext, mock.AnythingOfType("*model.Post"), mockTypeChannel, mock.AnythingOfType("model.CreatePostFlags")).Return(&model.Post{Id: model.NewId()}, false, nil).Once()
defer mockApp.AssertExpectations(t)
err = scs.onReceiveChannelInvite(msg, remoteCluster, nil)
require.NoError(t, err)
})
t.Run("When invitation points to an existing shared channel remote", func(t *testing.T) {
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockApp := &MockAppIface{}
scs := &Service{
server: mockServer,
app: mockApp,
}
mockStore := &mocks.Store{}
remoteCluster := &model.RemoteCluster{RemoteId: model.NewId(), Name: "test", DefaultTeamId: model.NewId()}
invitation := channelInviteMsg{
ChannelId: model.NewId(),
TeamId: model.NewId(),
Type: model.ChannelTypeOpen,
}
payload, err := json.Marshal(invitation)
require.NoError(t, err)
msg := model.RemoteClusterMsg{
Payload: payload,
}
mockSharedChannelStore := mocks.SharedChannelStore{}
sharedChannelRemote := &model.SharedChannelRemote{
ChannelId: invitation.ChannelId,
RemoteId: remoteCluster.RemoteId,
DeleteAt: 0,
}
mockServer.On("GetStore").Return(mockStore)
mockSharedChannelStore.On("GetRemoteByIds", invitation.ChannelId, remoteCluster.RemoteId).Return(sharedChannelRemote, nil)
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
defer mockApp.AssertExpectations(t)
err = scs.onReceiveChannelInvite(msg, remoteCluster, nil)
require.NoError(t, err)
})
t.Run("DM channels", func(t *testing.T) {
var testRemoteID = model.NewId()
testCases := []struct {
desc string
user1 *model.User
user2 *model.User
canSee bool
expectSuccess bool
user2InDB bool
user2InParticipants bool
}{
{"valid users", &model.User{Id: model.NewId(), RemoteId: &testRemoteID}, &model.User{Id: model.NewId()}, true, true, true, false},
{"swapped users", &model.User{Id: model.NewId()}, &model.User{Id: model.NewId(), RemoteId: &testRemoteID}, true, true, true, false},
{"two remotes", &model.User{Id: model.NewId(), RemoteId: &testRemoteID}, &model.User{Id: model.NewId(), RemoteId: &testRemoteID}, true, false, true, false},
{"two locals", &model.User{Id: model.NewId()}, &model.User{Id: model.NewId()}, true, false, true, false},
{"can't see", &model.User{Id: model.NewId(), RemoteId: &testRemoteID}, &model.User{Id: model.NewId()}, false, false, true, false},
{"invalid remoteid", &model.User{Id: model.NewId(), RemoteId: new("bogus")}, &model.User{Id: model.NewId()}, true, false, true, false},
{"user2 not in DB but in participants", &model.User{Id: model.NewId(), RemoteId: &testRemoteID}, &model.User{Id: model.NewId()}, true, true, false, true},
{"user2 not in DB and not in participants", &model.User{Id: model.NewId(), RemoteId: &testRemoteID}, &model.User{Id: model.NewId()}, true, false, false, false},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockApp := &MockAppIface{}
scs := &Service{
server: mockServer,
app: mockApp,
}
mockStore := &mocks.Store{}
remoteCluster := &model.RemoteCluster{RemoteId: testRemoteID, Name: "test3", CreatorId: model.NewId()}
invitation := channelInviteMsg{
ChannelId: model.NewId(),
TeamId: model.NewId(),
ReadOnly: false,
Type: model.ChannelTypeDirect,
DirectParticipantIDs: []string{tc.user1.Id, tc.user2.Id},
}
// Add participants to the invitation if needed
if tc.user2InParticipants {
invitation.DirectParticipants = append(invitation.DirectParticipants, tc.user2)
}
payload, err := json.Marshal(invitation)
require.NoError(t, err)
msg := model.RemoteClusterMsg{
Payload: payload,
}
mockChannelStore := mocks.ChannelStore{}
mockSharedChannelStore := mocks.SharedChannelStore{}
channel := &model.Channel{
Id: invitation.ChannelId,
CreatorId: model.NewId(),
}
mockUserStore := mocks.UserStore{}
mockUserStore.On("Get", mockTypeContext, tc.user1.Id).
Return(tc.user1, nil)
if tc.user2InDB {
mockUserStore.On("Get", mockTypeContext, tc.user2.Id).
Return(tc.user2, nil)
} else {
mockUserStore.On("Get", mockTypeContext, tc.user2.Id).
Return(nil, &store.ErrNotFound{})
}
if tc.user2InParticipants {
mockUserStore.On("Save", mock.AnythingOfType("*request.Context"),
mock.MatchedBy(func(u *model.User) bool {
return u.Id == tc.user2.Id
})).Return(tc.user2, nil)
}
mockChannelStore.On("Get", invitation.ChannelId, true).Return(nil, errors.New("boom"))
mockChannelStore.On("GetByName", "", mockTypeString, true).Return(nil, &store.ErrNotFound{})
mockSharedChannelStore.On("GetRemoteByIds", invitation.ChannelId, remoteCluster.RemoteId).Return(nil, store.NewErrNotFound("SharedChannelRemote", ""))
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)
mockStore.On("User").Return(&mockUserStore)
mockServer = scs.server.(*MockServerIface)
mockServer.On("GetStore").Return(mockStore)
setupMockServerWithConfig(mockServer)
mockApp.On("GetOrCreateDirectChannel", mockTypeReqContext, mockTypeString, mockTypeString, mock.AnythingOfType("model.ChannelOption")).
Return(channel, nil).Maybe()
mockApp.On("UserCanSeeOtherUser", mockTypeReqContext, mockTypeString, mockTypeString).Return(tc.canSee, nil).Maybe()
mockApp.On("NotifySharedChannelUserUpdate", mockTypeUser).Return().Maybe()
if tc.expectSuccess {
mockApp.On("GetSystemBot", mockTypeReqContext).Return(&model.Bot{UserId: model.NewId()}, nil).Once()
mockApp.On("CreatePost", mockTypeReqContext, mock.AnythingOfType("*model.Post"), mockTypeChannel, mock.AnythingOfType("model.CreatePostFlags")).Return(&model.Post{Id: model.NewId()}, false, nil).Once()
}
defer mockApp.AssertExpectations(t)
err = scs.onReceiveChannelInvite(msg, remoteCluster, nil)
require.Equal(t, tc.expectSuccess, err == nil)
})
}
})
}
func TestSendChannelInvite_ExistingSharedConnection(t *testing.T) {
channelID := model.NewId()
userID := model.NewId()
remoteID := model.NewId()
sharedChannel := &model.SharedChannel{ChannelId: channelID}
channel := &model.Channel{Id: channelID}
// Remote with LastPingAt 0 is offline (IsOnline() returns false)
rc := &model.RemoteCluster{RemoteId: remoteID, Name: "test-remote", DisplayName: "Test Remote", LastPingAt: 0}
t.Run("when remote is offline and existing connection is not soft deleted, returns ErrChannelAlreadyShared", func(t *testing.T) {
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockApp := &MockAppIface{}
scs := &Service{server: mockServer, app: mockApp}
mockStore := &mocks.Store{}
mockSharedChannelStore := mocks.SharedChannelStore{}
existingScr := &model.SharedChannelRemote{
ChannelId: channelID,
RemoteId: remoteID,
DeleteAt: 0, // not soft deleted -> already connected
}
mockSharedChannelStore.On("Get", channelID).Return(sharedChannel, nil)
mockSharedChannelStore.On("GetRemoteByIds", channelID, remoteID).Return(existingScr, nil)
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
mockServer.On("GetStore").Return(mockStore)
mockServer.On("GetRemoteClusterService").Return(&stubRemoteClusterService{})
mockApp.On("SendEphemeralPost", mockTypeReqContext, userID, mock.MatchedBy(func(post *model.Post) bool {
return post != nil && post.ChannelId == channelID && post.Message != ""
})).Return(nil, true).Once()
err := scs.SendChannelInvite(channel, userID, rc)
require.Error(t, err)
assert.ErrorIs(t, err, model.ErrChannelAlreadyShared)
mockApp.AssertExpectations(t)
})
t.Run("when remote is offline and existing connection is soft deleted, restores and succeeds", func(t *testing.T) {
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockApp := &MockAppIface{}
scs := &Service{server: mockServer, app: mockApp}
mockStore := &mocks.Store{}
mockSharedChannelStore := mocks.SharedChannelStore{}
existingScr := &model.SharedChannelRemote{
ChannelId: channelID,
RemoteId: remoteID,
DeleteAt: 12345, // soft deleted
}
mockSharedChannelStore.On("Get", channelID).Return(sharedChannel, nil)
mockSharedChannelStore.On("GetRemoteByIds", channelID, remoteID).Return(existingScr, nil)
mockSharedChannelStore.On("UpdateRemote", mock.MatchedBy(func(scr *model.SharedChannelRemote) bool {
return scr.ChannelId == channelID && scr.RemoteId == remoteID && scr.DeleteAt == 0 && scr.CreatorId == userID &&
scr.IsInviteAccepted && !scr.IsInviteConfirmed && scr.LastMembersSyncAt == 0
})).Return(nil, nil).Once()
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
mockServer.On("GetStore").Return(mockStore)
mockServer.On("GetRemoteClusterService").Return(&stubRemoteClusterService{})
err := scs.SendChannelInvite(channel, userID, rc)
require.NoError(t, err)
mockSharedChannelStore.AssertExpectations(t)
})
t.Run("when remote is offline and no existing connection, saves new and succeeds", func(t *testing.T) {
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockApp := &MockAppIface{}
scs := &Service{server: mockServer, app: mockApp}
mockStore := &mocks.Store{}
mockSharedChannelStore := mocks.SharedChannelStore{}
mockSharedChannelStore.On("Get", channelID).Return(sharedChannel, nil)
mockSharedChannelStore.On("GetRemoteByIds", channelID, remoteID).Return(nil, store.NewErrNotFound("SharedChannelRemote", ""))
mockSharedChannelStore.On("SaveRemote", mock.MatchedBy(func(scr *model.SharedChannelRemote) bool {
return scr.ChannelId == channelID && scr.RemoteId == remoteID && scr.CreatorId == userID &&
scr.IsInviteAccepted && !scr.IsInviteConfirmed
})).Return(nil, nil).Once()
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
mockServer.On("GetStore").Return(mockStore)
mockServer.On("GetRemoteClusterService").Return(&stubRemoteClusterService{})
err := scs.SendChannelInvite(channel, userID, rc)
require.NoError(t, err)
mockSharedChannelStore.AssertExpectations(t)
})
}

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (scs *Service) isChannelMemberSyncEnabled() bool {
return scs.server.GetRemoteClusterService() != nil
}
// NotifyMembershipChanged is called when users are added or removed from a shared channel.
// It triggers a sync for the channel so that membership changes are picked up from
// ChannelMemberHistory at sync time, following the same pattern as posts and reactions.
// originRemoteID identifies the remote that initiated the change, so it can be skipped
// during sync to prevent echo-back.
func (scs *Service) NotifyMembershipChanged(channelID string, originRemoteID string) {
if !scs.isChannelMemberSyncEnabled() {
return
}
task := newSyncTask(channelID, "", "", nil, nil)
task.originRemoteID = originRemoteID
task.schedule = time.Now().Add(NotifyMinimumDelay)
scs.addTask(task)
}
// ForceMembershipSyncForRemote triggers a sync for all channels shared with the specified remote.
// Called when a remote comes back online to catch up on any membership changes that occurred
// while it was offline. The sync pipeline will read ChannelMemberHistory using the
// LastMembersSyncAt cursor to detect both additions and removals.
func (scs *Service) ForceMembershipSyncForRemote(rc *model.RemoteCluster) {
if !scs.isChannelMemberSyncEnabled() {
return
}
opts := model.SharedChannelRemoteFilterOpts{
RemoteId: rc.RemoteId,
}
scrs, err := scs.server.GetStore().SharedChannel().GetRemotes(0, 999999, opts)
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Failed to fetch shared channel remotes for membership sync",
mlog.String("remote", rc.DisplayName),
mlog.String("remote_id", rc.RemoteId),
mlog.Err(err),
)
return
}
for _, scr := range scrs {
scs.NotifyChannelChanged(scr.ChannelId)
}
}

View File

@@ -0,0 +1,193 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"fmt"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
const (
// Error IDs returned by the app layer that indicate an idempotent no-op.
errIDAddUserToChannelFailed = "api.channel.add_user.to.channel.failed.app_error"
errIDSaveMemberExists = "app.channel.save_member.exists.app_error"
errIDGetChannelMemberMissing = "app.channel.get_member.missing.app_error"
)
// onReceiveMembershipChanges processes channel membership changes from a remote cluster.
// In the new model, the sender derives the authoritative net state from ChannelMemberHistory.
// Both processMemberAdd and processMemberRemove are idempotent:
// - processMemberAdd ignores "already added" errors
// - processMemberRemove ignores "not found" errors
//
// Out-of-order messages resolve naturally: if an old "add" arrives after a newer "remove",
// the sender's next sync cycle will send a corrective "remove" because the history shows the user left.
func (scs *Service) onReceiveMembershipChanges(syncMsg *model.SyncMsg, rc *model.RemoteCluster) error {
if len(syncMsg.MembershipChanges) == 0 {
return fmt.Errorf("onReceiveMembershipChanges: no membership changes")
}
// Get the channel to make sure it exists and is shared
channel, err := scs.server.GetStore().Channel().Get(syncMsg.ChannelId, true)
if err != nil {
return fmt.Errorf("cannot get channel for membership changes: %w", err)
}
// Verify this is a valid shared channel
_, err = scs.server.GetStore().SharedChannel().Get(syncMsg.ChannelId)
if err != nil {
return fmt.Errorf("cannot get shared channel for membership changes: %w", err)
}
// Process each change
var failCount int
for _, change := range syncMsg.MembershipChanges {
if change.ChannelId != syncMsg.ChannelId {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "ChannelId mismatch in membership change",
mlog.String("expected", syncMsg.ChannelId),
mlog.String("got", change.ChannelId),
mlog.String("remote_id", rc.RemoteId),
)
failCount++
continue
}
// Process the membership change based on whether it's an add or remove
var processErr error
if change.IsAdd {
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Adding user to channel from remote cluster",
mlog.String("user_id", change.UserId),
mlog.String("channel_id", change.ChannelId),
mlog.String("remote_id", rc.RemoteId),
)
processErr = scs.processMemberAdd(change, channel, rc, syncMsg)
} else {
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Removing user from channel from remote cluster",
mlog.String("user_id", change.UserId),
mlog.String("channel_id", change.ChannelId),
mlog.String("remote_id", rc.RemoteId),
)
processErr = scs.processMemberRemove(change, rc)
}
if processErr != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Failed to process membership change",
mlog.String("user_id", change.UserId),
mlog.String("channel_id", change.ChannelId),
mlog.String("remote_id", rc.RemoteId),
mlog.Bool("is_add", change.IsAdd),
mlog.Err(processErr),
)
failCount++
continue
}
}
if failCount > 0 {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Some membership changes failed",
mlog.String("channel_id", syncMsg.ChannelId),
mlog.Int("total", len(syncMsg.MembershipChanges)),
mlog.Int("failed", failCount),
)
}
return nil
}
// processMemberAdd handles adding a user to a channel
func (scs *Service) processMemberAdd(change *model.MembershipChangeMsg, channel *model.Channel, rc *model.RemoteCluster, syncMsg *model.SyncMsg) error {
rctx := request.EmptyContext(scs.server.Log())
var user *model.User
var err error
// First try to upsert user from sync message (mirrors mention scenario)
if userProfile, exists := syncMsg.Users[change.UserId]; exists {
user, err = scs.upsertSyncUser(rctx, userProfile, channel, rc)
if err != nil {
return fmt.Errorf("cannot upsert user for channel add: %w", err)
}
} else {
// Fallback to existing lookup for users not in sync message
user, err = scs.server.GetStore().User().Get(rctx, change.UserId)
if err != nil {
return fmt.Errorf("cannot get user for channel add: %w", err)
}
}
// Only process users that originated from the sending remote.
// Local users (RemoteID == "") are managed locally, and users from other
// remotes should not be manipulated by this sender.
remoteID := user.GetRemoteID()
if remoteID == "" {
return nil
}
if remoteID != rc.RemoteId {
return fmt.Errorf("membership add sync failed: %w", ErrRemoteIDMismatch)
}
// Check user permissions for private channels
if channel.Type == model.ChannelTypePrivate {
// Add user to team if needed for private channel
if appErr := scs.app.AddUserToTeamByTeamId(rctx, channel.TeamId, user); appErr != nil {
return fmt.Errorf("cannot add user to team for private channel: %w", appErr)
}
}
// Use the app layer to add the user to the channel
// Skip team member check (true) since we already handled team membership above
_, appErr := scs.app.AddUserToChannel(rctx, user, channel, true)
if appErr != nil {
// Skip "already added" errors — idempotent
if appErr.Id != errIDAddUserToChannelFailed &&
appErr.Id != errIDSaveMemberExists {
return fmt.Errorf("cannot add user to channel: %w", appErr)
}
}
return nil
}
// processMemberRemove handles removing a user from a channel
func (scs *Service) processMemberRemove(change *model.MembershipChangeMsg, rc *model.RemoteCluster) error {
// Get channel so we can use app layer methods properly
channel, err := scs.server.GetStore().Channel().Get(change.ChannelId, true)
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Cannot find channel for member removal",
mlog.String("channel_id", change.ChannelId),
mlog.String("user_id", change.UserId),
mlog.Err(err),
)
return nil // channel might be deleted, nothing to do
}
rctx := request.EmptyContext(scs.server.Log())
user, userErr := scs.server.GetStore().User().Get(rctx, change.UserId)
if userErr != nil {
return fmt.Errorf("cannot get user for channel remove: %w", userErr)
}
remoteID := user.GetRemoteID()
if remoteID == "" {
return nil
}
if remoteID != rc.RemoteId {
return fmt.Errorf("membership remove sync failed: %w", ErrRemoteIDMismatch)
}
// Use the app layer's remove user method
appErr := scs.app.RemoveUserFromChannel(rctx, change.UserId, "", channel)
if appErr != nil {
// Ignore "not found" errors - the user might already be removed
if appErr.Id == errIDGetChannelMemberMissing {
return nil
}
return fmt.Errorf("cannot remove user from channel: %w", appErr)
}
return nil
}

View File

@@ -0,0 +1,397 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
)
// setupMembershipTest creates the common test fixtures for membership sync tests.
// Returns a Service, the mock stores, and IDs for the channel, remote, and team.
func setupMembershipTest(t *testing.T) (*Service, *MockServerIface, *MockAppIface, *mocks.Store, *mocks.SharedChannelStore, *mocks.ChannelStore, *mocks.UserStore) {
t.Helper()
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockApp := &MockAppIface{}
scs := &Service{
server: mockServer,
app: mockApp,
}
mockStore := &mocks.Store{}
mockSharedChannelStore := &mocks.SharedChannelStore{}
mockChannelStore := &mocks.ChannelStore{}
mockUserStore := &mocks.UserStore{}
mockStore.On("SharedChannel").Return(mockSharedChannelStore)
mockStore.On("Channel").Return(mockChannelStore)
mockStore.On("User").Return(mockUserStore)
mockServer.On("GetStore").Return(mockStore)
mockConfig := model.Config{}
mockConfig.SetDefaults()
mockServer.On("Config").Return(&mockConfig)
return scs, mockServer, mockApp, mockStore, mockSharedChannelStore, mockChannelStore, mockUserStore
}
func TestOnReceiveMembershipChanges_ChannelIdMismatch(t *testing.T) {
scs, _, _, _, mockSharedChannelStore, mockChannelStore, _ := setupMembershipTest(t)
channelId := model.NewId()
remoteId := model.NewId()
rc := &model.RemoteCluster{RemoteId: remoteId}
channel := &model.Channel{Id: channelId, Type: model.ChannelTypeOpen}
mockChannelStore.On("Get", channelId, true).Return(channel, nil)
mockSharedChannelStore.On("Get", channelId).Return(&model.SharedChannel{ChannelId: channelId}, nil)
syncMsg := &model.SyncMsg{
ChannelId: channelId,
MembershipChanges: []*model.MembershipChangeMsg{
{
ChannelId: model.NewId(), // different channel ID
UserId: model.NewId(),
IsAdd: true,
ChangeTime: 1000,
},
},
}
err := scs.onReceiveMembershipChanges(syncMsg, rc)
require.NoError(t, err) // function returns nil even on individual failures
}
func TestProcessMemberAdd_RejectsLocalUser(t *testing.T) {
scs, _, mockApp, _, mockSharedChannelStore, mockChannelStore, mockUserStore := setupMembershipTest(t)
channelId := model.NewId()
remoteId := model.NewId()
localUserId := model.NewId()
rc := &model.RemoteCluster{RemoteId: remoteId}
channel := &model.Channel{Id: channelId, Type: model.ChannelTypeOpen}
mockChannelStore.On("Get", channelId, true).Return(channel, nil)
mockSharedChannelStore.On("Get", channelId).Return(&model.SharedChannel{ChannelId: channelId}, nil)
// Local user has no remote ID
localUser := &model.User{Id: localUserId}
mockUserStore.On("Get", mockTypeContext, localUserId).Return(localUser, nil)
syncMsg := &model.SyncMsg{
ChannelId: channelId,
MembershipChanges: []*model.MembershipChangeMsg{
{
ChannelId: channelId,
UserId: localUserId,
IsAdd: true,
ChangeTime: 1000,
},
},
}
err := scs.onReceiveMembershipChanges(syncMsg, rc)
require.NoError(t, err) // function returns nil even on individual failures
mockApp.AssertNotCalled(t, "AddUserToChannel", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}
func TestProcessMemberAdd_RejectsOtherRemoteUser(t *testing.T) {
scs, _, mockApp, _, mockSharedChannelStore, mockChannelStore, mockUserStore := setupMembershipTest(t)
channelId := model.NewId()
remoteId := model.NewId()
otherRemoteId := model.NewId()
userId := model.NewId()
rc := &model.RemoteCluster{RemoteId: remoteId}
channel := &model.Channel{Id: channelId, Type: model.ChannelTypeOpen}
mockChannelStore.On("Get", channelId, true).Return(channel, nil)
mockSharedChannelStore.On("Get", channelId).Return(&model.SharedChannel{ChannelId: channelId}, nil)
// User belongs to a different remote
otherRemoteUser := &model.User{Id: userId, RemoteId: &otherRemoteId}
mockUserStore.On("Get", mockTypeContext, userId).Return(otherRemoteUser, nil)
syncMsg := &model.SyncMsg{
ChannelId: channelId,
MembershipChanges: []*model.MembershipChangeMsg{
{
ChannelId: channelId,
UserId: userId,
IsAdd: true,
ChangeTime: 1000,
},
},
}
err := scs.onReceiveMembershipChanges(syncMsg, rc)
require.NoError(t, err)
mockApp.AssertNotCalled(t, "AddUserToChannel", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}
func TestProcessMemberAdd_AllowsOwnRemoteUser(t *testing.T) {
scs, _, mockApp, _, mockSharedChannelStore, mockChannelStore, mockUserStore := setupMembershipTest(t)
channelId := model.NewId()
remoteId := model.NewId()
userId := model.NewId()
rc := &model.RemoteCluster{RemoteId: remoteId}
channel := &model.Channel{Id: channelId, Type: model.ChannelTypeOpen}
mockChannelStore.On("Get", channelId, true).Return(channel, nil)
mockSharedChannelStore.On("Get", channelId).Return(&model.SharedChannel{ChannelId: channelId}, nil)
// User belongs to the sending remote
remoteUser := &model.User{Id: userId, RemoteId: &remoteId}
mockUserStore.On("Get", mockTypeContext, userId).Return(remoteUser, nil)
// Expect the add to proceed
mockApp.On("AddUserToChannel", mockTypeReqContext, mockTypeUser, mockTypeChannel, true).Return(&model.ChannelMember{}, nil)
syncMsg := &model.SyncMsg{
ChannelId: channelId,
MembershipChanges: []*model.MembershipChangeMsg{
{
ChannelId: channelId,
UserId: userId,
IsAdd: true,
ChangeTime: 1000,
},
},
}
err := scs.onReceiveMembershipChanges(syncMsg, rc)
require.NoError(t, err)
mockApp.AssertCalled(t, "AddUserToChannel", mockTypeReqContext, mockTypeUser, mockTypeChannel, true)
}
func TestProcessMemberRemove_RejectsLocalUser(t *testing.T) {
scs, _, mockApp, _, mockSharedChannelStore, mockChannelStore, mockUserStore := setupMembershipTest(t)
channelId := model.NewId()
remoteId := model.NewId()
localUserId := model.NewId()
rc := &model.RemoteCluster{RemoteId: remoteId}
channel := &model.Channel{Id: channelId, Type: model.ChannelTypeOpen}
mockChannelStore.On("Get", channelId, true).Return(channel, nil)
mockSharedChannelStore.On("Get", channelId).Return(&model.SharedChannel{ChannelId: channelId}, nil)
// Local user has no remote ID
localUser := &model.User{Id: localUserId}
mockUserStore.On("Get", mockTypeContext, localUserId).Return(localUser, nil)
syncMsg := &model.SyncMsg{
ChannelId: channelId,
MembershipChanges: []*model.MembershipChangeMsg{
{
ChannelId: channelId,
UserId: localUserId,
IsAdd: false,
ChangeTime: 1000,
},
},
}
err := scs.onReceiveMembershipChanges(syncMsg, rc)
require.NoError(t, err)
mockApp.AssertNotCalled(t, "RemoveUserFromChannel", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}
func TestProcessMemberRemove_RejectsOtherRemoteUser(t *testing.T) {
scs, _, mockApp, _, mockSharedChannelStore, mockChannelStore, mockUserStore := setupMembershipTest(t)
channelId := model.NewId()
remoteId := model.NewId()
otherRemoteId := model.NewId()
userId := model.NewId()
rc := &model.RemoteCluster{RemoteId: remoteId}
channel := &model.Channel{Id: channelId, Type: model.ChannelTypeOpen}
mockChannelStore.On("Get", channelId, true).Return(channel, nil)
mockSharedChannelStore.On("Get", channelId).Return(&model.SharedChannel{ChannelId: channelId}, nil)
// User belongs to a different remote
otherRemoteUser := &model.User{Id: userId, RemoteId: &otherRemoteId}
mockUserStore.On("Get", mockTypeContext, userId).Return(otherRemoteUser, nil)
syncMsg := &model.SyncMsg{
ChannelId: channelId,
MembershipChanges: []*model.MembershipChangeMsg{
{
ChannelId: channelId,
UserId: userId,
IsAdd: false,
ChangeTime: 1000,
},
},
}
err := scs.onReceiveMembershipChanges(syncMsg, rc)
require.NoError(t, err)
mockApp.AssertNotCalled(t, "RemoveUserFromChannel", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}
func TestProcessMemberRemove_AllowsOwnRemoteUser(t *testing.T) {
scs, _, mockApp, _, mockSharedChannelStore, mockChannelStore, mockUserStore := setupMembershipTest(t)
channelId := model.NewId()
remoteId := model.NewId()
userId := model.NewId()
rc := &model.RemoteCluster{RemoteId: remoteId}
channel := &model.Channel{Id: channelId, Type: model.ChannelTypeOpen}
mockChannelStore.On("Get", channelId, true).Return(channel, nil)
mockSharedChannelStore.On("Get", channelId).Return(&model.SharedChannel{ChannelId: channelId}, nil)
// User belongs to the sending remote
remoteUser := &model.User{Id: userId, RemoteId: &remoteId}
mockUserStore.On("Get", mockTypeContext, userId).Return(remoteUser, nil)
// Expect the remove to proceed
mockApp.On("RemoveUserFromChannel", mockTypeReqContext, userId, "", channel).Return(nil)
syncMsg := &model.SyncMsg{
ChannelId: channelId,
MembershipChanges: []*model.MembershipChangeMsg{
{
ChannelId: channelId,
UserId: userId,
IsAdd: false,
ChangeTime: 1000,
},
},
}
err := scs.onReceiveMembershipChanges(syncMsg, rc)
require.NoError(t, err)
mockApp.AssertCalled(t, "RemoveUserFromChannel", mockTypeReqContext, userId, "", channel)
}
func TestProcessMemberAdd_SkipsLocalUser(t *testing.T) {
scs, _, _, _, _, _, mockUserStore := setupMembershipTest(t)
channelId := model.NewId()
remoteId := model.NewId()
localUserId := model.NewId()
rc := &model.RemoteCluster{RemoteId: remoteId}
channel := &model.Channel{Id: channelId, Type: model.ChannelTypeOpen}
// Local user (no remote ID) — needed for the fallback User().Get path
mockUserStore.On("Get", mockTypeContext, localUserId).Return(&model.User{Id: localUserId}, nil)
change := &model.MembershipChangeMsg{
ChannelId: channelId,
UserId: localUserId,
IsAdd: true,
ChangeTime: 1000,
}
syncMsg := &model.SyncMsg{
ChannelId: channelId,
}
err := scs.processMemberAdd(change, channel, rc, syncMsg)
require.NoError(t, err, "local users should be silently skipped")
}
func TestProcessMemberRemove_SkipsLocalUser(t *testing.T) {
scs, _, _, _, _, mockChannelStore, mockUserStore := setupMembershipTest(t)
channelId := model.NewId()
remoteId := model.NewId()
localUserId := model.NewId()
rc := &model.RemoteCluster{RemoteId: remoteId}
channel := &model.Channel{Id: channelId, Type: model.ChannelTypeOpen}
mockChannelStore.On("Get", channelId, true).Return(channel, nil)
// Local user (no remote ID)
mockUserStore.On("Get", mockTypeContext, localUserId).Return(&model.User{Id: localUserId}, nil)
change := &model.MembershipChangeMsg{
ChannelId: channelId,
UserId: localUserId,
IsAdd: false,
ChangeTime: 1000,
}
err := scs.processMemberRemove(change, rc)
require.NoError(t, err, "local users should be silently skipped")
}
func TestProcessMemberAdd_IdempotentForExistingMember(t *testing.T) {
scs, _, mockApp, _, _, _, mockUserStore := setupMembershipTest(t)
channelId := model.NewId()
remoteId := model.NewId()
userId := model.NewId()
rc := &model.RemoteCluster{RemoteId: remoteId}
channel := &model.Channel{Id: channelId, Type: model.ChannelTypeOpen}
// User belongs to the sending remote
remoteUser := &model.User{Id: userId, RemoteId: &remoteId}
mockUserStore.On("Get", mockTypeContext, userId).Return(remoteUser, nil)
// Simulate "already added" error from AddUserToChannel
alreadyAddedErr := model.NewAppError("AddUserToChannel", errIDSaveMemberExists, nil, "", http.StatusBadRequest)
mockApp.On("AddUserToChannel", mockTypeReqContext, mockTypeUser, mockTypeChannel, true).Return(nil, alreadyAddedErr)
syncMsg := &model.SyncMsg{ChannelId: channelId}
change := &model.MembershipChangeMsg{
ChannelId: channelId,
UserId: userId,
IsAdd: true,
ChangeTime: 1000,
}
err := scs.processMemberAdd(change, channel, rc, syncMsg)
require.NoError(t, err, "should succeed for already-added user (idempotent)")
}
func TestProcessMemberRemove_IdempotentForNonMember(t *testing.T) {
scs, _, mockApp, _, _, mockChannelStore, mockUserStore := setupMembershipTest(t)
channelId := model.NewId()
remoteId := model.NewId()
userId := model.NewId()
rc := &model.RemoteCluster{RemoteId: remoteId}
channel := &model.Channel{Id: channelId, Type: model.ChannelTypeOpen}
mockChannelStore.On("Get", channelId, true).Return(channel, nil)
// User belongs to the sending remote
remoteUser := &model.User{Id: userId, RemoteId: &remoteId}
mockUserStore.On("Get", mockTypeContext, userId).Return(remoteUser, nil)
// Simulate "not found" error from RemoveUserFromChannel
notFoundErr := model.NewAppError("RemoveUserFromChannel", errIDGetChannelMemberMissing, nil, "", http.StatusNotFound)
mockApp.On("RemoveUserFromChannel", mockTypeReqContext, userId, "", channel).Return(notFoundErr)
change := &model.MembershipChangeMsg{
ChannelId: channelId,
UserId: userId,
IsAdd: false,
ChangeTime: 1000,
}
err := scs.processMemberRemove(change, rc)
require.NoError(t, err, "should succeed for already-removed user (idempotent)")
}

View File

@@ -0,0 +1,391 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
)
func setupSendTest(t *testing.T) (*Service, *mocks.Store, *mocks.ChannelMemberHistoryStore, *mocks.SharedChannelStore, *mocks.UserStore, *mocks.RemoteClusterStore) {
t.Helper()
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockServer.On("GetMetrics").Return(nil)
mockServer.On("GetRemoteClusterService").Return(nil)
mockApp := &MockAppIface{}
scs := &Service{
server: mockServer,
app: mockApp,
}
mockStore := &mocks.Store{}
mockCMHStore := &mocks.ChannelMemberHistoryStore{}
mockSharedChannelStore := &mocks.SharedChannelStore{}
mockUserStore := &mocks.UserStore{}
mockRCStore := &mocks.RemoteClusterStore{}
mockStore.On("ChannelMemberHistory").Return(mockCMHStore)
mockStore.On("SharedChannel").Return(mockSharedChannelStore)
mockStore.On("User").Return(mockUserStore)
mockStore.On("RemoteCluster").Return(mockRCStore)
mockServer.On("GetStore").Return(mockStore)
mockConfig := model.Config{}
mockConfig.SetDefaults()
mockServer.On("Config").Return(&mockConfig)
return scs, mockStore, mockCMHStore, mockSharedChannelStore, mockUserStore, mockRCStore
}
func TestFetchMembershipsForSync_NoChanges(t *testing.T) {
scs, _, mockCMHStore, _, _, _ := setupSendTest(t)
channelID := model.NewId()
mockCMHStore.On("GetMembershipChanges", channelID, int64(0), mock.AnythingOfType("int")).
Return([]*model.ChannelMemberHistory{}, nil)
sd := &syncData{
task: syncTask{channelID: channelID},
rc: &model.RemoteCluster{RemoteId: model.NewId()},
scr: &model.SharedChannelRemote{LastMembersSyncAt: 0},
users: make(map[string]*model.User),
}
err := scs.fetchMembershipsForSync(sd)
require.NoError(t, err)
assert.Empty(t, sd.membershipChanges)
assert.Equal(t, int64(0), sd.resultNextMembershipCursor)
}
func TestFetchMembershipsForSync_DeduplicatesJoinLeaveRejoin(t *testing.T) {
scs, _, mockCMHStore, mockSCStore, mockUserStore, _ := setupSendTest(t)
channelID := model.NewId()
remoteID := model.NewId()
userID := model.NewId()
// User joined at 1000, left at 2000, rejoined at 3000
mockCMHStore.On("GetMembershipChanges", channelID, int64(0), mock.AnythingOfType("int")).
Return([]*model.ChannelMemberHistory{
{ChannelId: channelID, UserId: userID, JoinTime: 1000, LeaveTime: new(int64(2000))},
{ChannelId: channelID, UserId: userID, JoinTime: 3000, LeaveTime: nil},
}, nil)
// User profile for the add — local user (no RemoteId) so shouldUserSync includes it
user := &model.User{Id: userID}
mockUserStore.On("Get", mock.Anything, userID).Return(user, nil)
// shouldUserSync needs SharedChannelUser lookup
mockSCStore.On("GetSingleUser", userID, channelID, remoteID).
Return(nil, &notFoundError{})
mockSCStore.On("SaveUser", mock.AnythingOfType("*model.SharedChannelUser")).
Return(&model.SharedChannelUser{}, nil)
sd := &syncData{
task: syncTask{channelID: channelID},
rc: &model.RemoteCluster{RemoteId: remoteID},
scr: &model.SharedChannelRemote{LastMembersSyncAt: 0},
users: make(map[string]*model.User),
}
err := scs.fetchMembershipsForSync(sd)
require.NoError(t, err)
// Should deduplicate to 1 entry: user is currently a member (rejoin at 3000 > leave at 2000)
require.Len(t, sd.membershipChanges, 1)
assert.Equal(t, userID, sd.membershipChanges[0].UserId)
assert.True(t, sd.membershipChanges[0].IsAdd, "user rejoined so should be IsAdd=true")
assert.Equal(t, int64(3000), sd.membershipChanges[0].ChangeTime)
assert.Equal(t, int64(3000), sd.resultNextMembershipCursor)
}
func TestFetchMembershipsForSync_DeduplicatesJoinThenLeave(t *testing.T) {
scs, _, mockCMHStore, _, mockUserStore, _ := setupSendTest(t)
channelID := model.NewId()
remoteID := model.NewId()
userID := model.NewId()
// User joined at 1000, then left at 2000
mockCMHStore.On("GetMembershipChanges", channelID, int64(0), mock.AnythingOfType("int")).
Return([]*model.ChannelMemberHistory{
{ChannelId: channelID, UserId: userID, JoinTime: 1000, LeaveTime: new(int64(2000))},
}, nil)
// User profile needed for remote-origin filtering
mockUserStore.On("Get", mock.Anything, userID).Return(&model.User{Id: userID}, nil)
sd := &syncData{
task: syncTask{channelID: channelID},
rc: &model.RemoteCluster{RemoteId: remoteID},
scr: &model.SharedChannelRemote{LastMembersSyncAt: 0},
users: make(map[string]*model.User),
}
err := scs.fetchMembershipsForSync(sd)
require.NoError(t, err)
require.Len(t, sd.membershipChanges, 1)
assert.False(t, sd.membershipChanges[0].IsAdd, "user left so should be IsAdd=false")
assert.Equal(t, int64(2000), sd.membershipChanges[0].ChangeTime)
assert.Equal(t, int64(2000), sd.resultNextMembershipCursor)
}
func TestFetchMembershipsForSync_MultipleUsers(t *testing.T) {
scs, _, mockCMHStore, mockSCStore, mockUserStore, _ := setupSendTest(t)
channelID := model.NewId()
remoteID := model.NewId()
user1 := model.NewId()
user2 := model.NewId()
user3 := model.NewId()
mockCMHStore.On("GetMembershipChanges", channelID, int64(0), mock.AnythingOfType("int")).
Return([]*model.ChannelMemberHistory{
{ChannelId: channelID, UserId: user1, JoinTime: 1000, LeaveTime: nil}, // joined, still member
{ChannelId: channelID, UserId: user2, JoinTime: 2000, LeaveTime: new(int64(3000))}, // joined then left
{ChannelId: channelID, UserId: user3, JoinTime: 4000, LeaveTime: nil}, // joined, still member
}, nil)
// User profiles for all users — needed for remote-origin filtering
for _, uid := range []string{user1, user2, user3} {
u := &model.User{Id: uid}
mockUserStore.On("Get", mock.Anything, uid).Return(u, nil)
}
// shouldUserSync mocks for adds (user1 and user3) — users are local (no RemoteId)
// so shouldUserSync won't filter them out for the target remote
for _, uid := range []string{user1, user3} {
mockSCStore.On("GetSingleUser", uid, channelID, remoteID).Return(nil, &notFoundError{})
mockSCStore.On("SaveUser", mock.MatchedBy(func(scu *model.SharedChannelUser) bool { return scu.UserId == uid })).
Return(&model.SharedChannelUser{}, nil)
}
sd := &syncData{
task: syncTask{channelID: channelID},
rc: &model.RemoteCluster{RemoteId: remoteID},
scr: &model.SharedChannelRemote{LastMembersSyncAt: 0},
users: make(map[string]*model.User),
}
err := scs.fetchMembershipsForSync(sd)
require.NoError(t, err)
assert.Len(t, sd.membershipChanges, 3)
// Build a map for easy lookup
changeMap := make(map[string]*model.MembershipChangeMsg)
for _, mc := range sd.membershipChanges {
changeMap[mc.UserId] = mc
}
assert.True(t, changeMap[user1].IsAdd, "user1 is still a member")
assert.False(t, changeMap[user2].IsAdd, "user2 left")
assert.True(t, changeMap[user3].IsAdd, "user3 is still a member")
// Max cursor should be 4000 (user3 join)
assert.Equal(t, int64(4000), sd.resultNextMembershipCursor)
// Only user1 and user3 should be in the users map (adds only)
assert.Contains(t, sd.users, user1)
assert.NotContains(t, sd.users, user2, "user2 left, should not be in users map")
assert.Contains(t, sd.users, user3)
}
func TestFetchMembershipsForSync_SetsRepeatWhenLimitHit(t *testing.T) {
scs, _, mockCMHStore, mockSCStore, mockUserStore, _ := setupSendTest(t)
channelID := model.NewId()
remoteID := model.NewId()
// Return exactly as many results as the limit (batch size)
batchSize := scs.GetMemberSyncBatchSize()
histories := make([]*model.ChannelMemberHistory, batchSize)
for i := range batchSize {
uid := model.NewId()
histories[i] = &model.ChannelMemberHistory{
ChannelId: channelID,
UserId: uid,
JoinTime: int64(1000 + i),
LeaveTime: nil,
}
u := &model.User{Id: uid}
mockUserStore.On("Get", mock.Anything, uid).Return(u, nil)
mockSCStore.On("GetSingleUser", uid, channelID, remoteID).Return(nil, &notFoundError{})
mockSCStore.On("SaveUser", mock.MatchedBy(func(scu *model.SharedChannelUser) bool { return scu.UserId == uid })).
Return(&model.SharedChannelUser{}, nil)
}
mockCMHStore.On("GetMembershipChanges", channelID, int64(0), batchSize).
Return(histories, nil)
sd := &syncData{
task: syncTask{channelID: channelID},
rc: &model.RemoteCluster{RemoteId: remoteID},
scr: &model.SharedChannelRemote{LastMembersSyncAt: 0},
users: make(map[string]*model.User),
}
err := scs.fetchMembershipsForSync(sd)
require.NoError(t, err)
assert.True(t, sd.resultRepeat, "should set resultRepeat when limit is hit")
}
func TestFetchMembershipsForSync_CursorFromSCR(t *testing.T) {
scs, _, mockCMHStore, _, _, _ := setupSendTest(t)
channelID := model.NewId()
remoteID := model.NewId()
// Verify that LastMembersSyncAt from SCR is used as the cursor
mockCMHStore.On("GetMembershipChanges", channelID, int64(5000), mock.AnythingOfType("int")).
Return([]*model.ChannelMemberHistory{}, nil)
sd := &syncData{
task: syncTask{channelID: channelID},
rc: &model.RemoteCluster{RemoteId: remoteID},
scr: &model.SharedChannelRemote{LastMembersSyncAt: 5000},
users: make(map[string]*model.User),
}
err := scs.fetchMembershipsForSync(sd)
require.NoError(t, err)
mockCMHStore.AssertCalled(t, "GetMembershipChanges", channelID, int64(5000), mock.AnythingOfType("int"))
}
func TestFetchMembershipsForSync_RepeatedTimestampsAtBoundary(t *testing.T) {
scs, _, mockCMHStore, mockSCStore, mockUserStore, _ := setupSendTest(t)
channelID := model.NewId()
remoteID := model.NewId()
batchSize := scs.GetMemberSyncBatchSize()
// Create batch where the last entries share the same JoinTime (boundary timestamp)
boundaryTime := int64(5000)
histories := make([]*model.ChannelMemberHistory, batchSize)
for i := range batchSize {
uid := model.NewId()
joinTime := int64(1000 + i)
// Last 3 entries share the same timestamp
if i >= batchSize-3 {
joinTime = boundaryTime
}
histories[i] = &model.ChannelMemberHistory{
ChannelId: channelID,
UserId: uid,
JoinTime: joinTime,
LeaveTime: nil,
}
u := &model.User{Id: uid}
mockUserStore.On("Get", mock.Anything, uid).Return(u, nil)
mockSCStore.On("GetSingleUser", uid, channelID, remoteID).Return(nil, &notFoundError{})
mockSCStore.On("SaveUser", mock.MatchedBy(func(scu *model.SharedChannelUser) bool { return scu.UserId == uid })).
Return(&model.SharedChannelUser{}, nil)
}
// First fetch returns full batch (hits limit)
mockCMHStore.On("GetMembershipChanges", channelID, int64(0), batchSize).
Return(histories, nil)
sd := &syncData{
task: syncTask{channelID: channelID},
rc: &model.RemoteCluster{RemoteId: remoteID},
scr: &model.SharedChannelRemote{LastMembersSyncAt: 0},
users: make(map[string]*model.User),
}
err := scs.fetchMembershipsForSync(sd)
require.NoError(t, err)
assert.True(t, sd.resultRepeat, "should signal more data when limit hit")
assert.Len(t, sd.membershipChanges, batchSize, "all entries in batch should be processed")
assert.Equal(t, boundaryTime, sd.resultNextMembershipCursor, "cursor should be at boundary timestamp")
// Second fetch uses the boundary timestamp as cursor (GtOrEq means events
// AT boundaryTime will be re-fetched, but deduplication handles this)
extraUser := model.NewId()
mockCMHStore.On("GetMembershipChanges", channelID, boundaryTime, batchSize).
Return([]*model.ChannelMemberHistory{
// Re-fetched rows at boundary (already processed — dedup will handle)
histories[batchSize-3],
histories[batchSize-2],
histories[batchSize-1],
// New row beyond the boundary
{ChannelId: channelID, UserId: extraUser, JoinTime: boundaryTime + 1, LeaveTime: nil},
}, nil)
extraUserObj := &model.User{Id: extraUser}
mockUserStore.On("Get", mock.Anything, extraUser).Return(extraUserObj, nil)
mockSCStore.On("GetSingleUser", extraUser, channelID, remoteID).Return(nil, &notFoundError{})
mockSCStore.On("SaveUser", mock.MatchedBy(func(scu *model.SharedChannelUser) bool { return scu.UserId == extraUser })).
Return(&model.SharedChannelUser{}, nil)
sd2 := &syncData{
task: syncTask{channelID: channelID},
rc: &model.RemoteCluster{RemoteId: remoteID},
scr: &model.SharedChannelRemote{LastMembersSyncAt: boundaryTime},
users: make(map[string]*model.User),
}
err = scs.fetchMembershipsForSync(sd2)
require.NoError(t, err)
// Should have 4 entries (3 re-fetched + 1 new) — dedup at the caller level
// handles duplicate user IDs across batches
assert.Len(t, sd2.membershipChanges, 4, "should include re-fetched boundary rows and new row")
assert.Equal(t, boundaryTime+1, sd2.resultNextMembershipCursor, "cursor should advance past boundary")
}
func TestFetchMembershipsForSync_SkipsUsersFromTargetRemote(t *testing.T) {
scs, _, mockCMHStore, mockSCStore, mockUserStore, _ := setupSendTest(t)
channelID := model.NewId()
remoteID := model.NewId()
localUser := model.NewId() // local to sender, will be synced
targetRemoteUser := model.NewId() // originated from target remote, should be skipped
mockCMHStore.On("GetMembershipChanges", channelID, int64(0), mock.AnythingOfType("int")).
Return([]*model.ChannelMemberHistory{
{ChannelId: channelID, UserId: localUser, JoinTime: 1000, LeaveTime: nil},
{ChannelId: channelID, UserId: targetRemoteUser, JoinTime: 2000, LeaveTime: nil},
}, nil)
// Local user — no RemoteId, should pass the filter
mockUserStore.On("Get", mock.Anything, localUser).Return(&model.User{Id: localUser}, nil)
mockSCStore.On("GetSingleUser", localUser, channelID, remoteID).Return(nil, &notFoundError{})
mockSCStore.On("SaveUser", mock.MatchedBy(func(scu *model.SharedChannelUser) bool { return scu.UserId == localUser })).
Return(&model.SharedChannelUser{}, nil)
// User from target remote — should be filtered out
mockUserStore.On("Get", mock.Anything, targetRemoteUser).Return(&model.User{Id: targetRemoteUser, RemoteId: &remoteID}, nil)
sd := &syncData{
task: syncTask{channelID: channelID},
rc: &model.RemoteCluster{RemoteId: remoteID},
scr: &model.SharedChannelRemote{LastMembersSyncAt: 0},
users: make(map[string]*model.User),
}
err := scs.fetchMembershipsForSync(sd)
require.NoError(t, err)
require.Len(t, sd.membershipChanges, 1, "should only include local user, not user from target remote")
assert.Equal(t, localUser, sd.membershipChanges[0].UserId)
assert.True(t, sd.membershipChanges[0].IsAdd)
}
// notFoundError implements the errNotFound interface used by the sharedchannel package
type notFoundError struct{}
func (e *notFoundError) Error() string { return "not found" }
func (e *notFoundError) IsErrNotFound() bool { return true }

View File

@@ -0,0 +1,857 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Regenerate this file using `make sharedchannel-mocks`.
package sharedchannel
import (
model "github.com/mattermost/mattermost/server/public/model"
request "github.com/mattermost/mattermost/server/public/shared/request"
filestore "github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
mock "github.com/stretchr/testify/mock"
)
// MockAppIface is an autogenerated mock type for the AppIface type
type MockAppIface struct {
mock.Mock
}
// AddUserToChannel provides a mock function with given fields: rctx, user, channel, skipTeamMemberIntegrityCheck
func (_m *MockAppIface) AddUserToChannel(rctx request.CTX, user *model.User, channel *model.Channel, skipTeamMemberIntegrityCheck bool) (*model.ChannelMember, *model.AppError) {
ret := _m.Called(rctx, user, channel, skipTeamMemberIntegrityCheck)
if len(ret) == 0 {
panic("no return value specified for AddUserToChannel")
}
var r0 *model.ChannelMember
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, *model.User, *model.Channel, bool) (*model.ChannelMember, *model.AppError)); ok {
return rf(rctx, user, channel, skipTeamMemberIntegrityCheck)
}
if rf, ok := ret.Get(0).(func(request.CTX, *model.User, *model.Channel, bool) *model.ChannelMember); ok {
r0 = rf(rctx, user, channel, skipTeamMemberIntegrityCheck)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.ChannelMember)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, *model.User, *model.Channel, bool) *model.AppError); ok {
r1 = rf(rctx, user, channel, skipTeamMemberIntegrityCheck)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// AddUserToTeamByTeamId provides a mock function with given fields: rctx, teamId, user
func (_m *MockAppIface) AddUserToTeamByTeamId(rctx request.CTX, teamId string, user *model.User) *model.AppError {
ret := _m.Called(rctx, teamId, user)
if len(ret) == 0 {
panic("no return value specified for AddUserToTeamByTeamId")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, string, *model.User) *model.AppError); ok {
r0 = rf(rctx, teamId, user)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// CreateChannelWithUser provides a mock function with given fields: rctx, channel, userId
func (_m *MockAppIface) CreateChannelWithUser(rctx request.CTX, channel *model.Channel, userId string) (*model.Channel, *model.AppError) {
ret := _m.Called(rctx, channel, userId)
if len(ret) == 0 {
panic("no return value specified for CreateChannelWithUser")
}
var r0 *model.Channel
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, *model.Channel, string) (*model.Channel, *model.AppError)); ok {
return rf(rctx, channel, userId)
}
if rf, ok := ret.Get(0).(func(request.CTX, *model.Channel, string) *model.Channel); ok {
r0 = rf(rctx, channel, userId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Channel)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, *model.Channel, string) *model.AppError); ok {
r1 = rf(rctx, channel, userId)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// CreateGroupChannel provides a mock function with given fields: rctx, userIDs, creatorId, channelOptions
func (_m *MockAppIface) CreateGroupChannel(rctx request.CTX, userIDs []string, creatorId 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, rctx, userIDs, creatorId)
_ca = append(_ca, _va...)
ret := _m.Called(_ca...)
if len(ret) == 0 {
panic("no return value specified for CreateGroupChannel")
}
var r0 *model.Channel
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, []string, string, ...model.ChannelOption) (*model.Channel, *model.AppError)); ok {
return rf(rctx, userIDs, creatorId, channelOptions...)
}
if rf, ok := ret.Get(0).(func(request.CTX, []string, string, ...model.ChannelOption) *model.Channel); ok {
r0 = rf(rctx, userIDs, creatorId, channelOptions...)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Channel)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, []string, string, ...model.ChannelOption) *model.AppError); ok {
r1 = rf(rctx, userIDs, creatorId, channelOptions...)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// CreatePost provides a mock function with given fields: rctx, post, channel, flags
func (_m *MockAppIface) CreatePost(rctx request.CTX, post *model.Post, channel *model.Channel, flags model.CreatePostFlags) (*model.Post, bool, *model.AppError) {
ret := _m.Called(rctx, post, channel, flags)
if len(ret) == 0 {
panic("no return value specified for CreatePost")
}
var r0 *model.Post
var r1 bool
var r2 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, *model.Post, *model.Channel, model.CreatePostFlags) (*model.Post, bool, *model.AppError)); ok {
return rf(rctx, post, channel, flags)
}
if rf, ok := ret.Get(0).(func(request.CTX, *model.Post, *model.Channel, model.CreatePostFlags) *model.Post); ok {
r0 = rf(rctx, post, channel, flags)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Post)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, *model.Post, *model.Channel, model.CreatePostFlags) bool); ok {
r1 = rf(rctx, post, channel, flags)
} else {
r1 = ret.Get(1).(bool)
}
if rf, ok := ret.Get(2).(func(request.CTX, *model.Post, *model.Channel, model.CreatePostFlags) *model.AppError); ok {
r2 = rf(rctx, post, channel, flags)
} else {
if ret.Get(2) != nil {
r2 = ret.Get(2).(*model.AppError)
}
}
return r0, r1, r2
}
// CreateUploadSession provides a mock function with given fields: rctx, us
func (_m *MockAppIface) CreateUploadSession(rctx request.CTX, us *model.UploadSession) (*model.UploadSession, *model.AppError) {
ret := _m.Called(rctx, us)
if len(ret) == 0 {
panic("no return value specified for CreateUploadSession")
}
var r0 *model.UploadSession
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, *model.UploadSession) (*model.UploadSession, *model.AppError)); ok {
return rf(rctx, us)
}
if rf, ok := ret.Get(0).(func(request.CTX, *model.UploadSession) *model.UploadSession); ok {
r0 = rf(rctx, us)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.UploadSession)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, *model.UploadSession) *model.AppError); ok {
r1 = rf(rctx, us)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// DeleteAcknowledgementForPostWithModel provides a mock function with given fields: rctx, acknowledgement
func (_m *MockAppIface) DeleteAcknowledgementForPostWithModel(rctx request.CTX, acknowledgement *model.PostAcknowledgement) *model.AppError {
ret := _m.Called(rctx, acknowledgement)
if len(ret) == 0 {
panic("no return value specified for DeleteAcknowledgementForPostWithModel")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, *model.PostAcknowledgement) *model.AppError); ok {
r0 = rf(rctx, acknowledgement)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// DeletePost provides a mock function with given fields: rctx, postID, deleteByID
func (_m *MockAppIface) DeletePost(rctx request.CTX, postID string, deleteByID string) (*model.Post, *model.AppError) {
ret := _m.Called(rctx, postID, deleteByID)
if len(ret) == 0 {
panic("no return value specified for DeletePost")
}
var r0 *model.Post
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, string, string) (*model.Post, *model.AppError)); ok {
return rf(rctx, postID, deleteByID)
}
if rf, ok := ret.Get(0).(func(request.CTX, string, string) *model.Post); ok {
r0 = rf(rctx, postID, deleteByID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Post)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, string, string) *model.AppError); ok {
r1 = rf(rctx, 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: rctx, reaction
func (_m *MockAppIface) DeleteReactionForPost(rctx request.CTX, reaction *model.Reaction) *model.AppError {
ret := _m.Called(rctx, reaction)
if len(ret) == 0 {
panic("no return value specified for DeleteReactionForPost")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, *model.Reaction) *model.AppError); ok {
r0 = rf(rctx, 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)
if len(ret) == 0 {
panic("no return value specified for FileReader")
}
var r0 filestore.ReadCloseSeeker
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(string) (filestore.ReadCloseSeeker, *model.AppError)); ok {
return rf(path)
}
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)
}
}
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
}
// GetAcknowledgementsForPost provides a mock function with given fields: postID
func (_m *MockAppIface) GetAcknowledgementsForPost(postID string) ([]*model.PostAcknowledgement, *model.AppError) {
ret := _m.Called(postID)
if len(ret) == 0 {
panic("no return value specified for GetAcknowledgementsForPost")
}
var r0 []*model.PostAcknowledgement
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(string) ([]*model.PostAcknowledgement, *model.AppError)); ok {
return rf(postID)
}
if rf, ok := ret.Get(0).(func(string) []*model.PostAcknowledgement); ok {
r0 = rf(postID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.PostAcknowledgement)
}
}
if rf, ok := ret.Get(1).(func(string) *model.AppError); ok {
r1 = rf(postID)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// GetOrCreateDirectChannel provides a mock function with given fields: rctx, userId, otherUserId, channelOptions
func (_m *MockAppIface) GetOrCreateDirectChannel(rctx request.CTX, userId string, otherUserId string, channelOptions ...model.ChannelOption) (*model.Channel, *model.AppError) {
_va := make([]interface{}, len(channelOptions))
for _i := range channelOptions {
_va[_i] = channelOptions[_i]
}
var _ca []interface{}
_ca = append(_ca, rctx, userId, otherUserId)
_ca = append(_ca, _va...)
ret := _m.Called(_ca...)
if len(ret) == 0 {
panic("no return value specified for GetOrCreateDirectChannel")
}
var r0 *model.Channel
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, string, string, ...model.ChannelOption) (*model.Channel, *model.AppError)); ok {
return rf(rctx, userId, otherUserId, channelOptions...)
}
if rf, ok := ret.Get(0).(func(request.CTX, string, string, ...model.ChannelOption) *model.Channel); ok {
r0 = rf(rctx, userId, otherUserId, channelOptions...)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Channel)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, string, string, ...model.ChannelOption) *model.AppError); ok {
r1 = rf(rctx, userId, otherUserId, channelOptions...)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// GetProfileImage provides a mock function with given fields: user
func (_m *MockAppIface) GetProfileImage(user *model.User) ([]byte, bool, *model.AppError) {
ret := _m.Called(user)
if len(ret) == 0 {
panic("no return value specified for GetProfileImage")
}
var r0 []byte
var r1 bool
var r2 *model.AppError
if rf, ok := ret.Get(0).(func(*model.User) ([]byte, bool, *model.AppError)); ok {
return rf(user)
}
if rf, ok := ret.Get(0).(func(*model.User) []byte); ok {
r0 = rf(user)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]byte)
}
}
if rf, ok := ret.Get(1).(func(*model.User) bool); ok {
r1 = rf(user)
} else {
r1 = ret.Get(1).(bool)
}
if rf, ok := ret.Get(2).(func(*model.User) *model.AppError); ok {
r2 = rf(user)
} else {
if ret.Get(2) != nil {
r2 = ret.Get(2).(*model.AppError)
}
}
return r0, r1, r2
}
// GetSystemBot provides a mock function with given fields: rctx
func (_m *MockAppIface) GetSystemBot(rctx request.CTX) (*model.Bot, *model.AppError) {
ret := _m.Called(rctx)
if len(ret) == 0 {
panic("no return value specified for GetSystemBot")
}
var r0 *model.Bot
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX) (*model.Bot, *model.AppError)); ok {
return rf(rctx)
}
if rf, ok := ret.Get(0).(func(request.CTX) *model.Bot); ok {
r0 = rf(rctx)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Bot)
}
}
if rf, ok := ret.Get(1).(func(request.CTX) *model.AppError); ok {
r1 = rf(rctx)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// MentionsToTeamMembers provides a mock function with given fields: rctx, message, teamID
func (_m *MockAppIface) MentionsToTeamMembers(rctx request.CTX, message string, teamID string) model.UserMentionMap {
ret := _m.Called(rctx, message, teamID)
if len(ret) == 0 {
panic("no return value specified for MentionsToTeamMembers")
}
var r0 model.UserMentionMap
if rf, ok := ret.Get(0).(func(request.CTX, string, string) model.UserMentionMap); ok {
r0 = rf(rctx, message, teamID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(model.UserMentionMap)
}
}
return r0
}
// NotifySharedChannelUserUpdate provides a mock function with given fields: user
func (_m *MockAppIface) NotifySharedChannelUserUpdate(user *model.User) {
_m.Called(user)
}
// OnSharedChannelsAttachmentSyncMsg provides a mock function with given fields: fi, post, rc
func (_m *MockAppIface) OnSharedChannelsAttachmentSyncMsg(fi *model.FileInfo, post *model.Post, rc *model.RemoteCluster) error {
ret := _m.Called(fi, post, rc)
if len(ret) == 0 {
panic("no return value specified for OnSharedChannelsAttachmentSyncMsg")
}
var r0 error
if rf, ok := ret.Get(0).(func(*model.FileInfo, *model.Post, *model.RemoteCluster) error); ok {
r0 = rf(fi, post, rc)
} else {
r0 = ret.Error(0)
}
return r0
}
// OnSharedChannelsProfileImageSyncMsg provides a mock function with given fields: user, rc
func (_m *MockAppIface) OnSharedChannelsProfileImageSyncMsg(user *model.User, rc *model.RemoteCluster) error {
ret := _m.Called(user, rc)
if len(ret) == 0 {
panic("no return value specified for OnSharedChannelsProfileImageSyncMsg")
}
var r0 error
if rf, ok := ret.Get(0).(func(*model.User, *model.RemoteCluster) error); ok {
r0 = rf(user, rc)
} else {
r0 = ret.Error(0)
}
return r0
}
// OnSharedChannelsSyncMsg provides a mock function with given fields: msg, rc
func (_m *MockAppIface) OnSharedChannelsSyncMsg(msg *model.SyncMsg, rc *model.RemoteCluster) (model.SyncResponse, error) {
ret := _m.Called(msg, rc)
if len(ret) == 0 {
panic("no return value specified for OnSharedChannelsSyncMsg")
}
var r0 model.SyncResponse
var r1 error
if rf, ok := ret.Get(0).(func(*model.SyncMsg, *model.RemoteCluster) (model.SyncResponse, error)); ok {
return rf(msg, rc)
}
if rf, ok := ret.Get(0).(func(*model.SyncMsg, *model.RemoteCluster) model.SyncResponse); ok {
r0 = rf(msg, rc)
} else {
r0 = ret.Get(0).(model.SyncResponse)
}
if rf, ok := ret.Get(1).(func(*model.SyncMsg, *model.RemoteCluster) error); ok {
r1 = rf(msg, rc)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// PatchChannelModerationsForChannel provides a mock function with given fields: rctx, channel, channelModerationsPatch
func (_m *MockAppIface) PatchChannelModerationsForChannel(rctx request.CTX, channel *model.Channel, channelModerationsPatch []*model.ChannelModerationPatch) ([]*model.ChannelModeration, *model.AppError) {
ret := _m.Called(rctx, channel, channelModerationsPatch)
if len(ret) == 0 {
panic("no return value specified for PatchChannelModerationsForChannel")
}
var r0 []*model.ChannelModeration
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, *model.Channel, []*model.ChannelModerationPatch) ([]*model.ChannelModeration, *model.AppError)); ok {
return rf(rctx, channel, channelModerationsPatch)
}
if rf, ok := ret.Get(0).(func(request.CTX, *model.Channel, []*model.ChannelModerationPatch) []*model.ChannelModeration); ok {
r0 = rf(rctx, channel, channelModerationsPatch)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.ChannelModeration)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, *model.Channel, []*model.ChannelModerationPatch) *model.AppError); ok {
r1 = rf(rctx, 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: rctx, channel
func (_m *MockAppIface) PermanentDeleteChannel(rctx request.CTX, channel *model.Channel) *model.AppError {
ret := _m.Called(rctx, channel)
if len(ret) == 0 {
panic("no return value specified for PermanentDeleteChannel")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, *model.Channel) *model.AppError); ok {
r0 = rf(rctx, channel)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// PreparePostForClient provides a mock function with given fields: rctx, post, opts
func (_m *MockAppIface) PreparePostForClient(rctx request.CTX, post *model.Post, opts *model.PreparePostForClientOpts) *model.Post {
ret := _m.Called(rctx, post, opts)
if len(ret) == 0 {
panic("no return value specified for PreparePostForClient")
}
var r0 *model.Post
if rf, ok := ret.Get(0).(func(request.CTX, *model.Post, *model.PreparePostForClientOpts) *model.Post); ok {
r0 = rf(rctx, post, opts)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Post)
}
}
return r0
}
// Publish provides a mock function with given fields: message
func (_m *MockAppIface) Publish(message *model.WebSocketEvent) {
_m.Called(message)
}
// RemoveUserFromChannel provides a mock function with given fields: rctx, userID, removerUserId, channel
func (_m *MockAppIface) RemoveUserFromChannel(rctx request.CTX, userID string, removerUserId string, channel *model.Channel) *model.AppError {
ret := _m.Called(rctx, userID, removerUserId, channel)
if len(ret) == 0 {
panic("no return value specified for RemoveUserFromChannel")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, string, string, *model.Channel) *model.AppError); ok {
r0 = rf(rctx, userID, removerUserId, channel)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// SaveAcknowledgementForPostWithModel provides a mock function with given fields: rctx, acknowledgement
func (_m *MockAppIface) SaveAcknowledgementForPostWithModel(rctx request.CTX, acknowledgement *model.PostAcknowledgement) (*model.PostAcknowledgement, *model.AppError) {
ret := _m.Called(rctx, acknowledgement)
if len(ret) == 0 {
panic("no return value specified for SaveAcknowledgementForPostWithModel")
}
var r0 *model.PostAcknowledgement
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, *model.PostAcknowledgement) (*model.PostAcknowledgement, *model.AppError)); ok {
return rf(rctx, acknowledgement)
}
if rf, ok := ret.Get(0).(func(request.CTX, *model.PostAcknowledgement) *model.PostAcknowledgement); ok {
r0 = rf(rctx, acknowledgement)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.PostAcknowledgement)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, *model.PostAcknowledgement) *model.AppError); ok {
r1 = rf(rctx, acknowledgement)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// SaveAcknowledgementsForPost provides a mock function with given fields: rctx, postID, userIDs
func (_m *MockAppIface) SaveAcknowledgementsForPost(rctx request.CTX, postID string, userIDs []string) ([]*model.PostAcknowledgement, *model.AppError) {
ret := _m.Called(rctx, postID, userIDs)
if len(ret) == 0 {
panic("no return value specified for SaveAcknowledgementsForPost")
}
var r0 []*model.PostAcknowledgement
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, string, []string) ([]*model.PostAcknowledgement, *model.AppError)); ok {
return rf(rctx, postID, userIDs)
}
if rf, ok := ret.Get(0).(func(request.CTX, string, []string) []*model.PostAcknowledgement); ok {
r0 = rf(rctx, postID, userIDs)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.PostAcknowledgement)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, string, []string) *model.AppError); ok {
r1 = rf(rctx, postID, userIDs)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// SaveAndBroadcastStatus provides a mock function with given fields: status
func (_m *MockAppIface) SaveAndBroadcastStatus(status *model.Status) {
_m.Called(status)
}
// SaveReactionForPost provides a mock function with given fields: rctx, reaction
func (_m *MockAppIface) SaveReactionForPost(rctx request.CTX, reaction *model.Reaction) (*model.Reaction, *model.AppError) {
ret := _m.Called(rctx, reaction)
if len(ret) == 0 {
panic("no return value specified for SaveReactionForPost")
}
var r0 *model.Reaction
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, *model.Reaction) (*model.Reaction, *model.AppError)); ok {
return rf(rctx, reaction)
}
if rf, ok := ret.Get(0).(func(request.CTX, *model.Reaction) *model.Reaction); ok {
r0 = rf(rctx, reaction)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Reaction)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, *model.Reaction) *model.AppError); ok {
r1 = rf(rctx, reaction)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// SendEphemeralPost provides a mock function with given fields: rctx, userId, post
func (_m *MockAppIface) SendEphemeralPost(rctx request.CTX, userId string, post *model.Post) (*model.Post, bool) {
ret := _m.Called(rctx, userId, post)
if len(ret) == 0 {
panic("no return value specified for SendEphemeralPost")
}
var r0 *model.Post
var r1 bool
if rf, ok := ret.Get(0).(func(request.CTX, string, *model.Post) (*model.Post, bool)); ok {
return rf(rctx, userId, post)
}
if rf, ok := ret.Get(0).(func(request.CTX, string, *model.Post) *model.Post); ok {
r0 = rf(rctx, userId, post)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Post)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, string, *model.Post) bool); ok {
r1 = rf(rctx, userId, post)
} else {
r1 = ret.Get(1).(bool)
}
return r0, r1
}
// UpdatePost provides a mock function with given fields: rctx, post, updatePostOptions
func (_m *MockAppIface) UpdatePost(rctx request.CTX, post *model.Post, updatePostOptions *model.UpdatePostOptions) (*model.Post, bool, *model.AppError) {
ret := _m.Called(rctx, post, updatePostOptions)
if len(ret) == 0 {
panic("no return value specified for UpdatePost")
}
var r0 *model.Post
var r1 bool
var r2 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, *model.Post, *model.UpdatePostOptions) (*model.Post, bool, *model.AppError)); ok {
return rf(rctx, post, updatePostOptions)
}
if rf, ok := ret.Get(0).(func(request.CTX, *model.Post, *model.UpdatePostOptions) *model.Post); ok {
r0 = rf(rctx, post, updatePostOptions)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Post)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, *model.Post, *model.UpdatePostOptions) bool); ok {
r1 = rf(rctx, post, updatePostOptions)
} else {
r1 = ret.Get(1).(bool)
}
if rf, ok := ret.Get(2).(func(request.CTX, *model.Post, *model.UpdatePostOptions) *model.AppError); ok {
r2 = rf(rctx, post, updatePostOptions)
} else {
if ret.Get(2) != nil {
r2 = ret.Get(2).(*model.AppError)
}
}
return r0, r1, r2
}
// UserCanSeeOtherUser provides a mock function with given fields: rctx, userID, otherUserId
func (_m *MockAppIface) UserCanSeeOtherUser(rctx request.CTX, userID string, otherUserId string) (bool, *model.AppError) {
ret := _m.Called(rctx, userID, otherUserId)
if len(ret) == 0 {
panic("no return value specified for UserCanSeeOtherUser")
}
var r0 bool
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, string, string) (bool, *model.AppError)); ok {
return rf(rctx, userID, otherUserId)
}
if rf, ok := ret.Get(0).(func(request.CTX, string, string) bool); ok {
r0 = rf(rctx, userID, otherUserId)
} else {
r0 = ret.Get(0).(bool)
}
if rf, ok := ret.Get(1).(func(request.CTX, string, string) *model.AppError); ok {
r1 = rf(rctx, userID, otherUserId)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// NewMockAppIface creates a new instance of MockAppIface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockAppIface(t interface {
mock.TestingT
Cleanup(func())
}) *MockAppIface {
mock := &MockAppIface{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,174 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Regenerate this file using `make sharedchannel-mocks`.
package sharedchannel
import (
model "github.com/mattermost/mattermost/server/public/model"
mlog "github.com/mattermost/mattermost/server/public/shared/mlog"
store "github.com/mattermost/mattermost/server/v8/channels/store"
einterfaces "github.com/mattermost/mattermost/server/v8/einterfaces"
remotecluster "github.com/mattermost/mattermost/server/v8/platform/services/remotecluster"
mock "github.com/stretchr/testify/mock"
)
// 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)
if len(ret) == 0 {
panic("no return value specified for AddClusterLeaderChangedListener")
}
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 no fields
func (_m *MockServerIface) Config() *model.Config {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Config")
}
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
}
// GetMetrics provides a mock function with no fields
func (_m *MockServerIface) GetMetrics() einterfaces.MetricsInterface {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetMetrics")
}
var r0 einterfaces.MetricsInterface
if rf, ok := ret.Get(0).(func() einterfaces.MetricsInterface); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(einterfaces.MetricsInterface)
}
}
return r0
}
// GetRemoteClusterService provides a mock function with no fields
func (_m *MockServerIface) GetRemoteClusterService() remotecluster.RemoteClusterServiceIFace {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetRemoteClusterService")
}
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 no fields
func (_m *MockServerIface) GetStore() store.Store {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetStore")
}
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 no fields
func (_m *MockServerIface) IsLeader() bool {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for IsLeader")
}
var r0 bool
if rf, ok := ret.Get(0).(func() bool); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// Log provides a mock function with no fields
func (_m *MockServerIface) Log() *mlog.Logger {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Log")
}
var r0 *mlog.Logger
if rf, ok := ret.Get(0).(func() *mlog.Logger); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*mlog.Logger)
}
}
return r0
}
// RemoveClusterLeaderChangedListener provides a mock function with given fields: id
func (_m *MockServerIface) RemoveClusterLeaderChangedListener(id string) {
_m.Called(id)
}
// NewMockServerIface creates a new instance of MockServerIface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockServerIface(t interface {
mock.TestingT
Cleanup(func())
}) *MockServerIface {
mock := &MockServerIface{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,88 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"net/url"
"regexp"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
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 {
// request.CTX is not widely used in the shared channels code.
// Just create a new request.CTX for now.
rctx := request.EmptyContext(scs.server.Log())
var sent bool
return permaLinkRegex.ReplaceAllStringFunc(p.Message, func(msg string) string {
// Extract the postID (This is simple enough not to warrant full-blown URL parsing.)
lastSlash := strings.LastIndexByte(msg, '/')
postID := msg[lastSlash+1:]
opts := model.GetPostsOptions{
SkipFetchThreads: true,
}
postList, err := scs.server.GetStore().Post().Get(rctx, postID, opts, "", map[string]bool{})
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Unable to get post during replacing permalinks", mlog.Err(err))
return msg
}
if len(postList.Order) == 0 {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "No post found for permalink", mlog.String("post_id", postID))
return msg
}
// If postID is for a different channel
if postList.Posts[postList.Order[0]].ChannelId != p.ChannelId {
// Send ephemeral message to OP (only once per message).
if !sent {
scs.sendEphemeralPost(p.ChannelId, p.UserId, i18n.T("sharedchannel.permalink.not_found"))
sent = true
}
// But don't modify msg
return msg
}
// Otherwise, modify pl to plshared as a marker to be replaced by remote sites
return strings.Replace(msg, "/pl/", "/"+permalinkMarker+"/", 1)
})
}
// processPermalinkFromRemote processes all permalinks coming from a remote site.
func (scs *Service) processPermalinkFromRemote(p *model.Post, team *model.Team) string {
return permaLinkSharedRegex.ReplaceAllStringFunc(p.Message, func(remoteLink string) string {
// Extract host name
parsed, err := url.Parse(remoteLink)
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "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)
})
}

View File

@@ -0,0 +1,113 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
"github.com/mattermost/mattermost/server/v8/channels/utils"
)
func TestProcessPermalinkToRemote(t *testing.T) {
scs := &Service{
server: &MockServerIface{},
app: &MockAppIface{},
}
mockStore := &mocks.Store{}
mockPostStore := mocks.PostStore{}
utils.TranslationsPreInit()
logger := mlog.CreateConsoleTestLogger(t)
pl := &model.PostList{}
mockPostStore.On("Get", mock.MatchedBy(func(ctx *request.Context) bool { return true }), "postID", model.GetPostsOptions{SkipFetchThreads: true}, "", map[string]bool{}).Return(pl, nil)
mockStore.On("Post").Return(&mockPostStore)
mockServer := scs.server.(*MockServerIface)
mockServer.On("GetStore").Return(mockStore)
mockServer.On("Log").Return(logger)
mockApp := scs.app.(*MockAppIface)
mockApp.On("SendEphemeralPost", mock.Anything, "user", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, true).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)
})
}

View File

@@ -0,0 +1,488 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"sync"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/einterfaces"
"github.com/mattermost/mattermost/server/v8/platform/services/remotecluster"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
)
const (
TopicSync = "sharedchannel_sync"
TopicChannelInvite = "sharedchannel_invite"
TopicUploadCreate = "sharedchannel_upload"
TopicChannelMembership = "sharedchannel_membership"
TopicGlobalUserSync = "sharedchannel_global_user_sync"
MaxRetries = 3
MaxUsersPerSync = 25
NotifyRemoteOfflineThreshold = time.Second * 10
NotifyMinimumDelay = time.Second * 2
MaxUpsertRetries = 25
ProfileImageSyncTimeout = time.Second * 5
SyncRetryDelay = time.Second * 15
// Default value for MaxMembersPerBatch is defined in config.go as ConnectedWorkspacesSettingsDefaultMemberSyncBatchSize
)
// Mocks can be re-generated with `make sharedchannel-mocks`.
type ServerIface interface {
Config() *model.Config
IsLeader() bool
AddClusterLeaderChangedListener(listener func()) string
RemoveClusterLeaderChangedListener(id string)
GetStore() store.Store
Log() *mlog.Logger
GetRemoteClusterService() remotecluster.RemoteClusterServiceIFace
GetMetrics() einterfaces.MetricsInterface
}
type PlatformIface interface {
InvalidateCacheForUser(userID string)
InvalidateCacheForChannel(channel *model.Channel)
}
type AppIface interface {
SendEphemeralPost(rctx request.CTX, userId string, post *model.Post) (*model.Post, bool)
CreateChannelWithUser(rctx request.CTX, channel *model.Channel, userId string) (*model.Channel, *model.AppError)
GetOrCreateDirectChannel(rctx request.CTX, userId, otherUserId string, channelOptions ...model.ChannelOption) (*model.Channel, *model.AppError)
CreateGroupChannel(rctx request.CTX, userIDs []string, creatorId string, channelOptions ...model.ChannelOption) (*model.Channel, *model.AppError)
UserCanSeeOtherUser(rctx request.CTX, userID string, otherUserId string) (bool, *model.AppError)
AddUserToChannel(rctx request.CTX, user *model.User, channel *model.Channel, skipTeamMemberIntegrityCheck bool) (*model.ChannelMember, *model.AppError)
AddUserToTeamByTeamId(rctx request.CTX, teamId string, user *model.User) *model.AppError
RemoveUserFromChannel(rctx request.CTX, userID string, removerUserId string, channel *model.Channel) *model.AppError
PermanentDeleteChannel(rctx request.CTX, channel *model.Channel) *model.AppError
CreatePost(rctx request.CTX, post *model.Post, channel *model.Channel, flags model.CreatePostFlags) (savedPost *model.Post, isMemberForPreviews bool, err *model.AppError)
GetSystemBot(rctx request.CTX) (*model.Bot, *model.AppError)
UpdatePost(rctx request.CTX, post *model.Post, updatePostOptions *model.UpdatePostOptions) (*model.Post, bool, *model.AppError)
DeletePost(rctx request.CTX, postID, deleteByID string) (*model.Post, *model.AppError)
SaveReactionForPost(rctx request.CTX, reaction *model.Reaction) (*model.Reaction, *model.AppError)
DeleteReactionForPost(rctx request.CTX, reaction *model.Reaction) *model.AppError
SaveAndBroadcastStatus(status *model.Status)
PatchChannelModerationsForChannel(rctx request.CTX, channel *model.Channel, channelModerationsPatch []*model.ChannelModerationPatch) ([]*model.ChannelModeration, *model.AppError)
CreateUploadSession(rctx request.CTX, us *model.UploadSession) (*model.UploadSession, *model.AppError)
FileReader(path string) (filestore.ReadCloseSeeker, *model.AppError)
MentionsToTeamMembers(rctx request.CTX, message, teamID string) model.UserMentionMap
GetProfileImage(user *model.User) ([]byte, bool, *model.AppError)
NotifySharedChannelUserUpdate(user *model.User)
OnSharedChannelsSyncMsg(msg *model.SyncMsg, rc *model.RemoteCluster) (model.SyncResponse, error)
OnSharedChannelsAttachmentSyncMsg(fi *model.FileInfo, post *model.Post, rc *model.RemoteCluster) error
OnSharedChannelsProfileImageSyncMsg(user *model.User, rc *model.RemoteCluster) error
Publish(message *model.WebSocketEvent)
SaveAcknowledgementForPostWithModel(rctx request.CTX, acknowledgement *model.PostAcknowledgement) (*model.PostAcknowledgement, *model.AppError)
DeleteAcknowledgementForPostWithModel(rctx request.CTX, acknowledgement *model.PostAcknowledgement) *model.AppError
SaveAcknowledgementsForPost(rctx request.CTX, postID string, userIDs []string) ([]*model.PostAcknowledgement, *model.AppError)
GetAcknowledgementsForPost(postID string) ([]*model.PostAcknowledgement, *model.AppError)
PreparePostForClient(rctx request.CTX, post *model.Post, opts *model.PreparePostForClientOpts) *model.Post
}
// errNotFound allows checking against Store.ErrNotFound errors without making Store a dependency.
type errNotFound interface {
IsErrNotFound() bool
}
// Service provides shared channel synchronization.
type Service struct {
server ServerIface
platform PlatformIface
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
globalSyncTopicListenerId string
siteURL *url.URL
}
// NewSharedChannelService creates a RemoteClusterService instance.
func NewSharedChannelService(server ServerIface, platform PlatformIface, app AppIface) (*Service, error) {
service := &Service{
server: server,
platform: platform,
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 || !rcs.Active() {
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.globalSyncTopicListenerId = rcs.AddTopicListener(TopicGlobalUserSync, scs.onReceiveSyncMessage)
scs.connectionStateListenerId = rcs.AddConnectionStateListener(scs.onConnectionStateChange)
scs.mux.Unlock()
rcs.AddTopicListener(TopicChannelMembership, scs.onReceiveSyncMessage)
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 || !rcs.Active() {
return errors.New("Shared Channel Service cannot shutdown: requires Remote Cluster Service")
}
scs.mux.Lock()
id := scs.leaderListenerId
rcs.RemoveTopicListener(scs.syncTopicListenerId)
scs.syncTopicListenerId = ""
rcs.RemoveTopicListener(scs.inviteTopicListenerId)
scs.inviteTopicListenerId = ""
rcs.RemoveConnectionStateListener(scs.connectionStateListenerId)
scs.connectionStateListenerId = ""
scs.mux.Unlock()
scs.server.RemoveClusterLeaderChangedListener(id)
scs.pause()
return nil
}
// Active determines whether the service is active on the node or not.
func (scs *Service) Active() bool {
scs.mux.Lock()
defer scs.mux.Unlock()
return scs.active
}
func (scs *Service) sendEphemeralPost(channelId string, userId string, text string) {
ephemeral := &model.Post{
ChannelId: channelId,
Message: text,
CreateAt: model.GetMillis(),
}
scs.app.SendEphemeralPost(request.EmptyContext(scs.server.Log()), userId, ephemeral)
}
// onClusterLeaderChange is called whenever the cluster leader may have changed.
func (scs *Service) onClusterLeaderChange() {
if scs.server.IsLeader() {
scs.resume()
} else {
scs.pause()
}
}
func (scs *Service) resume() {
scs.mux.Lock()
defer scs.mux.Unlock()
if scs.active {
return // already active
}
scs.active = true
scs.done = make(chan struct{})
go scs.syncLoop(scs.done)
scs.server.Log().Debug("Shared Channel Service active")
}
func (scs *Service) pause() {
scs.mux.Lock()
defer scs.mux.Unlock()
if !scs.active {
return // already inactive
}
scs.active = false
close(scs.done)
scs.done = nil
scs.server.Log().Debug("Shared Channel Service inactive")
}
// GetMemberSyncBatchSize returns the configured batch size for member synchronization
func (scs *Service) GetMemberSyncBatchSize() int {
if scs.server.Config().ConnectedWorkspacesSettings.MemberSyncBatchSize != nil {
return *scs.server.Config().ConnectedWorkspacesSettings.MemberSyncBatchSize
}
return model.ConnectedWorkspacesSettingsDefaultMemberSyncBatchSize
}
// Makes the remote channel to be read-only(announcement mode, only admins can create posts and reactions).
func (scs *Service) makeChannelReadOnly(channel *model.Channel) *model.AppError {
createPostPermission := model.ChannelModeratedPermissionsMap[model.PermissionCreatePost.Id]
createReactionPermission := model.ChannelModeratedPermissionsMap[model.PermissionAddReaction.Id]
updateMap := model.ChannelModeratedRolesPatch{
Guests: new(false),
Members: new(false),
}
readonlyChannelModerations := []*model.ChannelModerationPatch{
{
Name: &createPostPermission,
Roles: &updateMap,
},
{
Name: &createReactionPermission,
Roles: &updateMap,
},
}
_, err := scs.app.PatchChannelModerationsForChannel(request.EmptyContext(scs.server.Log()), channel, readonlyChannelModerations)
return err
}
// onConnectionStateChange is called whenever the connection state of a remote cluster changes,
// for example when one comes back online.
func (scs *Service) onConnectionStateChange(rc *model.RemoteCluster, online bool) {
if online {
// when a previously offline remote comes back online force a sync.
scs.SendPendingInvitesForRemote(rc)
scs.ForceSyncForRemote(rc)
scs.ForceMembershipSyncForRemote(rc)
// Schedule global user sync if feature is enabled
scs.scheduleGlobalUserSync(rc)
}
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Remote cluster connection status changed",
mlog.String("remote", rc.DisplayName),
mlog.String("remote_id", rc.RemoteId),
mlog.Bool("online", online),
)
}
func (scs *Service) notifyClientsForSharedChannelConverted(channel *model.Channel) {
scs.platform.InvalidateCacheForChannel(channel)
messageWs := model.NewWebSocketEvent(model.WebsocketEventChannelUpdated, "", channel.Id, "", nil, "")
channelJSON, err := json.Marshal(channel)
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Cannot marshal channel to notify clients",
mlog.String("channel_id", channel.Id),
mlog.Err(err),
)
return
}
messageWs.Add("channel", string(channelJSON))
scs.app.Publish(messageWs)
}
func (scs *Service) notifyClientsForSharedChannelUpdate(channel *model.Channel) {
messageWs := model.NewWebSocketEvent(model.WebsocketEventChannelUpdated, channel.TeamId, "", "", nil, "")
messageWs.Add("channel_id", channel.Id)
scs.app.Publish(messageWs)
}
func remoteClusterDisplayName(rc *model.RemoteCluster) string {
if rc == nil {
return ""
}
if rc.DisplayName != "" {
return rc.DisplayName
}
if rc.Name != "" {
return rc.Name
}
return rc.RemoteId
}
// messageForSharedChannelStatePost returns the server-default-locale (i18n.T) copy for Post.Message so clients
// without a renderer still show the same sentence as the webapp (shared_channel.system_message.*).
func messageForSharedChannelStatePost(props model.StringInterface) string {
raw := props[model.PostPropsSharedChannelState]
state, _ := raw.(string)
switch state {
case model.SharedChannelStatePostValueShared:
wn, _ := props[model.PostPropsSharedChannelWorkspaceName].(string)
return i18n.T("shared_channel.system_message.now_shared", map[string]any{"WorkspaceName": wn})
case model.SharedChannelStatePostValueUnshared:
wn, _ := props[model.PostPropsSharedChannelWorkspaceName].(string)
if wn == "" {
return i18n.T("shared_channel.system_message.no_longer_shared_unknown")
}
return i18n.T("shared_channel.system_message.no_longer_shared", map[string]any{"WorkspaceName": wn})
default:
return i18n.T("shared_channel.system_message.no_longer_shared_unknown")
}
}
// postSharedChannelStatePost creates a system_shared_chan_state post. Message uses the system locale (i18n.T);
// capable clients still render from props in the viewer's language (not replicated via shared-channel post sync).
func (scs *Service) postSharedChannelStatePost(channel *model.Channel, props model.StringInterface) {
if channel == nil {
return
}
rctx := request.EmptyContext(scs.server.Log())
systemBot, botErr := scs.app.GetSystemBot(rctx)
if botErr != nil {
scs.server.Log().LogM(
mlog.MlvlSharedChannelServiceWarn,
"Could not get system bot for shared channel state post",
mlog.String("channel_id", channel.Id),
mlog.Err(botErr),
)
return
}
post := &model.Post{
UserId: systemBot.UserId,
ChannelId: channel.Id,
Message: messageForSharedChannelStatePost(props),
Type: model.PostTypeSharedChannelState,
Props: props,
}
_, _, appErr := scs.app.CreatePost(rctx, post, channel, model.CreatePostFlags{})
if appErr != nil {
scs.server.Log().LogM(
mlog.MlvlSharedChannelServiceWarn,
"Error creating shared channel state notification post",
mlog.String("channel_id", channel.Id),
mlog.Err(appErr),
)
}
}
func (scs *Service) postChannelSharedWithWorkspace(channel *model.Channel, peer *model.RemoteCluster) {
if channel == nil || peer == nil {
return
}
workspaceName := remoteClusterDisplayName(peer)
if workspaceName == "" {
workspaceName = peer.RemoteId
}
props := model.StringInterface{
model.PostPropsSharedChannelState: model.SharedChannelStatePostValueShared,
model.PostPropsSharedChannelWorkspaceName: workspaceName,
}
scs.postSharedChannelStatePost(channel, props)
}
func (scs *Service) postChannelUnsharedWithWorkspace(channel *model.Channel, workspaceName string) {
if channel == nil {
return
}
props := model.StringInterface{
model.PostPropsSharedChannelState: model.SharedChannelStatePostValueUnshared,
}
if workspaceName != "" {
props[model.PostPropsSharedChannelWorkspaceName] = workspaceName
}
scs.postSharedChannelStatePost(channel, props)
}
// IsRemoteClusterDirectlyConnected checks if a remote cluster has a direct connection to the current server
func (scs *Service) IsRemoteClusterDirectlyConnected(remoteId string) bool {
if remoteId == "" {
return true // Local server is always "directly connected"
}
// Check if the remote cluster exists and confirmed
rc, err := scs.server.GetStore().RemoteCluster().Get(remoteId, false)
if err != nil {
return false
}
isConfirmed := rc.IsConfirmed()
hasCreator := rc.CreatorId != ""
// For a direct connection, the remote cluster must be confirmed AND have a creator
// (someone on this server initiated or accepted the connection)
// Remote clusters known only through synthetic users won't have a creator
directConnection := isConfirmed && hasCreator
return directConnection
}
// OnReceiveSyncMessageForTesting is a wrapper to expose onReceiveSyncMessage for testing purposes
// isGlobalUserSyncEnabled checks if the global user sync feature is enabled
func (scs *Service) isGlobalUserSyncEnabled() bool {
cfg := scs.server.Config()
return cfg.FeatureFlags.EnableSyncAllUsersForRemoteCluster ||
(cfg.ConnectedWorkspacesSettings.SyncUsersOnConnectionOpen != nil && *cfg.ConnectedWorkspacesSettings.SyncUsersOnConnectionOpen)
}
// scheduleGlobalUserSync schedules a task to sync all users with a remote cluster
func (scs *Service) scheduleGlobalUserSync(rc *model.RemoteCluster) {
if !scs.isGlobalUserSyncEnabled() {
return
}
// Schedule the sync task
go func() {
// Create a special sync task with empty channelID
// This empty channelID is a deliberate marker for a global user sync task
task := newSyncTask("", "", rc.RemoteId, nil, nil)
task.schedule = time.Now().Add(NotifyMinimumDelay)
scs.addTask(task)
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Scheduled global user sync task for remote",
mlog.String("remote", rc.DisplayName),
mlog.String("remote_id", rc.RemoteId),
)
}()
}
// HasPendingTasksForTesting returns true if there are pending sync tasks in the queue
func (scs *Service) HasPendingTasksForTesting() bool {
scs.mux.RLock()
defer scs.mux.RUnlock()
return len(scs.tasks) > 0
}
// HandleSyncAllUsersForTesting exposes syncAllUsers for testing
func (scs *Service) HandleSyncAllUsersForTesting(rc *model.RemoteCluster) error {
return scs.syncAllUsers(rc)
}
// OnReceiveSyncMessageForTesting exposes onReceiveSyncMessage for testing
func (scs *Service) OnReceiveSyncMessageForTesting(msg model.RemoteClusterMsg, rc *model.RemoteCluster, response *remotecluster.Response) error {
return scs.onReceiveSyncMessage(msg, rc, response)
}
// HandleChannelNotSharedErrorForTesting is a wrapper to expose handleChannelNotSharedError for testing purposes
func (scs *Service) HandleChannelNotSharedErrorForTesting(msg *model.SyncMsg, rc *model.RemoteCluster) {
scs.handleChannelNotSharedError(msg, rc)
}
// TransformMentionsOnReceiveForTesting allows testing the full mention transformation flow
func (scs *Service) TransformMentionsOnReceiveForTesting(rctx request.CTX, post *model.Post, targetChannel *model.Channel, rc *model.RemoteCluster, mentionTransforms map[string]string) {
scs.transformMentionsOnReceive(rctx, post, targetChannel, rc, mentionTransforms)
}

View File

@@ -0,0 +1,377 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"errors"
"fmt"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
// ShareChannel marks a local channel as shared. If the channel is already shared this method has
// no effect and returns without error.
// TeamId, type, displayname, purpose, and header are fetched from the channel if not provided.
func (scs *Service) ShareChannel(sc *model.SharedChannel) (*model.SharedChannel, error) {
channel, err := scs.server.GetStore().Channel().Get(sc.ChannelId, true)
if err != nil {
return nil, fmt.Errorf("cannot fetch channel while sharing channel %s: %w", sc.ChannelId, err)
}
if !scs.server.Config().FeatureFlags.EnableSharedChannelsDMs && (channel.Type == model.ChannelTypeDirect || channel.Type == model.ChannelTypeGroup) {
return nil, errors.New("cannot share a direct or group channel")
}
// check if channel is already shared
scExisting, err := scs.server.GetStore().SharedChannel().Get(sc.ChannelId)
if err == nil {
// already shared, nothing to do
return scExisting, nil
}
if !isNotFoundError(err) {
return nil, fmt.Errorf("cannot check if channel %s is shared: %w", sc.ChannelId, err)
}
if sc.TeamId == "" {
sc.TeamId = channel.TeamId
}
if sc.Type == "" {
sc.Type = channel.Type
}
if sc.ShareName == "" {
sc.ShareName = channel.Name
}
if sc.ShareDisplayName == "" {
sc.ShareDisplayName = channel.DisplayName
}
if sc.SharePurpose == "" {
sc.SharePurpose = channel.Purpose
}
if sc.ShareHeader == "" {
sc.ShareHeader = channel.Header
}
if sc.CreatorId == "" {
sc.CreatorId = channel.CreatorId
}
// stores the SharedChannel and sets the share flag on the channel.
scNew, err := scs.server.GetStore().SharedChannel().Save(sc)
if err != nil {
return nil, err
}
// we get the channel to get the updated fields, including updateAt
// and Shared flag
channel, err = scs.server.GetStore().Channel().Get(sc.ChannelId, false)
if err != nil {
return nil, fmt.Errorf("cannot fetch channel after share: %w", err)
}
scs.notifyClientsForSharedChannelConverted(channel)
return scNew, nil
}
// UpdateSharedChannel updates the shared channel details such as displayname, purpose, or header
func (scs *Service) UpdateSharedChannel(sc *model.SharedChannel) (*model.SharedChannel, error) {
channel, err := scs.server.GetStore().Channel().Get(sc.ChannelId, true)
if err != nil {
return nil, err
}
scUpdated, err := scs.server.GetStore().SharedChannel().Update(sc)
if err != nil {
return nil, err
}
scs.notifyClientsForSharedChannelUpdate(channel)
return scUpdated, nil
}
// UnshareChannel unshares the channel by deleting the SharedChannels record and unsets the Channel `shared` flag.
// Returns true if a shared channel existed and was deleted.
func (scs *Service) UnshareChannel(channelID string) (bool, error) {
_, err := scs.server.GetStore().Channel().Get(channelID, true)
if err != nil {
return false, err
}
var workspaceNames []string
remotes, remotesErr := scs.server.GetStore().SharedChannel().GetRemotes(0, 10000, model.SharedChannelRemoteFilterOpts{ChannelId: channelID, IncludeUnconfirmed: true})
if remotesErr != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Could not list remotes before unshare; skipping unshare system posts",
mlog.String("channel_id", channelID),
mlog.Err(remotesErr),
)
} else {
workspaceNames = make([]string, 0, len(remotes))
for _, scr := range remotes {
rc, rcErr := scs.server.GetStore().RemoteCluster().Get(scr.RemoteId, false)
workspaceName := scr.RemoteId
if rcErr == nil && rc != nil {
workspaceName = remoteClusterDisplayName(rc)
} else if rcErr != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Could not load remote cluster while preparing unshare system post",
mlog.String("channel_id", channelID),
mlog.String("remote_id", scr.RemoteId),
mlog.Err(rcErr),
)
}
workspaceNames = append(workspaceNames, workspaceName)
}
}
// deletes the ShareChannel, unsets the share flag on the channel, deletes all records in SharedChannelRemotes for the channel.
deleted, err := scs.server.GetStore().SharedChannel().Delete(channelID)
if err != nil {
return false, err
}
// we get the channel to get the updated fields, including updateAt
// and Shared flag
channel, err := scs.server.GetStore().Channel().Get(channelID, false)
if err != nil {
return false, fmt.Errorf("cannot fetch channel after unshare: %w", err)
}
if deleted {
for _, workspaceName := range workspaceNames {
scs.postChannelUnsharedWithWorkspace(channel, workspaceName)
}
}
scs.notifyClientsForSharedChannelConverted(channel)
return deleted, nil
}
// InviteRemoteToChannel sends an invite to the remote to a shared channel. If `shareIfNotShared` is true
// then the channel is marked as `shared` first if needed.
func (scs *Service) InviteRemoteToChannel(channelID, remoteID, userID string, shareIfNotShared bool) error {
scStore := scs.server.GetStore().SharedChannel()
rcStore := scs.server.GetStore().RemoteCluster()
// check if remote already invited to channel
hasRemote, err := scStore.HasRemote(channelID, remoteID)
if err != nil {
return model.NewAppError("InviteRemoteToChannel", "api.command_share.fetch_remote.error",
map[string]any{"Error": err.Error()}, "", http.StatusInternalServerError)
}
if hasRemote {
// already invited, nothing to do
return nil
}
// set the channel `shared` flag if needed
if shareIfNotShared {
sc := &model.SharedChannel{
ChannelId: channelID,
CreatorId: userID,
Home: true,
RemoteId: "", // channel originates locally
}
if _, err = scs.ShareChannel(sc); err != nil {
return model.NewAppError("InviteRemoteToChannel", "api.command_share.share_channel.error",
map[string]any{"Error": err.Error()}, "", http.StatusBadRequest)
}
} else {
if err = scs.CheckChannelIsShared(channelID); err != nil {
return model.NewAppError("InviteRemoteToChannel", "api.command_share.channel_not_shared.error",
map[string]any{"ChannelID": channelID}, "", http.StatusBadRequest)
}
}
rc, err := rcStore.Get(remoteID, false)
if err != nil {
return model.NewAppError("InviteRemoteToChannel", "api.command_share.remote_id_invalid.error",
map[string]any{"Error": err.Error()}, "", http.StatusInternalServerError).Wrap(err)
}
// don't allow invitation to shared channel originating from remote.
// (also blocks cyclic invitations)
if err = scs.CheckCanInviteToSharedChannel(channelID); err != nil {
if errors.Is(err, model.ErrChannelHomedOnRemote) {
return model.NewAppError("InviteRemoteToChannel", "api.command_share.channel_invite_not_home.error", nil, "", http.StatusBadRequest)
}
scs.server.Log().Debug("InviteRemoteToChannel failed to check if can-invite",
mlog.String("name", rc.Name),
mlog.String("channel_id", channelID),
mlog.Err(err),
)
return model.NewAppError("InviteRemoteToChannel", "api.command_share.channel_invite.error",
map[string]any{"Name": rc.DisplayName, "Error": err.Error()}, "CheckCanInviteToSharedChannel", http.StatusInternalServerError).Wrap(err)
}
channel, err := scs.server.GetStore().Channel().Get(channelID, true)
if err != nil {
return model.NewAppError("InviteRemoteToChannel", "api.command_share.channel_invite.error",
map[string]any{"Name": rc.DisplayName, "Error": err.Error()}, "", http.StatusInternalServerError).Wrap(err)
}
// send channel invite to remote cluster. Will notify clients of channel change.
if err := scs.SendChannelInvite(channel, userID, rc); err != nil {
return model.NewAppError("InviteRemoteToChannel", "api.command_share.channel_invite.error",
map[string]any{"Name": rc.DisplayName, "Error": err.Error()}, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}
// unshareChannelIfNoActiveRemotes checks if there are any remaining
// non-deleted remotes for the channel and unshares the channel if
// there are none. Returns true if the channel was unshared.
func (scs *Service) unshareChannelIfNoActiveRemotes(channelID string) (bool, error) {
opts := model.SharedChannelRemoteFilterOpts{ChannelId: channelID, IncludeUnconfirmed: true}
remotes, err := scs.server.GetStore().SharedChannel().GetRemotes(0, 1, opts)
if err != nil {
return false, fmt.Errorf("failed to check remaining remotes: %w", err)
}
// If no remotes remain, unshare the channel
if len(remotes) == 0 {
unshared, err := scs.UnshareChannel(channelID)
if err != nil {
return false, fmt.Errorf("failed to automatically unshare channel after removing last remote: %w", err)
}
return unshared, nil
}
return false, nil
}
func (scs *Service) UninviteRemoteFromChannel(channelID, remoteID string) error {
scr, err := scs.server.GetStore().SharedChannel().GetRemoteByIds(channelID, remoteID)
if err != nil || scr.ChannelId != channelID || scr.DeleteAt != 0 {
return model.NewAppError("UninviteRemoteFromChannel", "api.command_share.channel_remote_id_not_exists",
map[string]any{"RemoteId": remoteID}, "", http.StatusInternalServerError)
}
deleted, err := scs.server.GetStore().SharedChannel().DeleteRemote(scr.Id)
if err != nil || !deleted {
code := http.StatusInternalServerError
if err == nil {
err = errors.New("not found")
code = http.StatusBadRequest
}
return model.NewAppError("UninviteRemoteFromChannel", "api.command_share.could_not_uninvite.error",
map[string]any{"RemoteId": remoteID, "Error": err.Error()}, "", code)
}
channel, chErr := scs.server.GetStore().Channel().Get(channelID, true)
if chErr != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Could not load channel after uninvite for system post",
mlog.String("channel_id", channelID),
mlog.String("remote_id", remoteID),
mlog.Err(chErr),
)
} else {
rc, rcErr := scs.server.GetStore().RemoteCluster().Get(remoteID, false)
workspaceName := remoteID
if rcErr == nil && rc != nil {
workspaceName = remoteClusterDisplayName(rc)
} else if rcErr != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Could not load remote cluster after uninvite for system post",
mlog.String("channel_id", channelID),
mlog.String("remote_id", remoteID),
mlog.Err(rcErr),
)
}
scs.postChannelUnsharedWithWorkspace(channel, workspaceName)
}
_, unshareErr := scs.unshareChannelIfNoActiveRemotes(channelID)
if unshareErr != nil {
// We don't want to fail the uninvite operation if the unshare fails
scs.server.Log().Error("Error during automatic unshare after uninvite",
mlog.String("channel_id", channelID),
mlog.Err(unshareErr),
)
}
return nil
}
// CheckChannelNotShared returns nil only if the channel is not already shared. Otherwise ErrChannelAlreadyShared is
// returned if the channel is shared, or database error.
func (scs *Service) CheckChannelNotShared(channelID string) error {
// check that channel exists.
if _, err := scs.server.GetStore().Channel().Get(channelID, true); err != nil {
return fmt.Errorf("cannot find channel %s: %w", channelID, err)
}
// Check channel is not already shared.
if _, err := scs.server.GetStore().SharedChannel().Get(channelID); err == nil {
return model.ErrChannelAlreadyShared
}
return nil
}
// CheckChannelIsShared returns nil only if the channel is shared. Otherwise a store.ErrNotFound is returned
// or database error.
func (scs *Service) CheckChannelIsShared(channelID string) error {
if _, err := scs.server.GetStore().SharedChannel().Get(channelID); err != nil {
var errNotFound *store.ErrNotFound
if errors.As(err, &errNotFound) {
return fmt.Errorf("%w: %v", model.ErrChannelNotShared, errNotFound)
}
return fmt.Errorf("cannot check if channel %s is shared: %w", channelID, err)
}
return nil
}
// CheckCanInviteToSharedChannel checks if an invitation can be sent for the specified channel.
// - don't allow invitations to a shared channel originating from remote.
// - block cyclic invitations
// - the channel must exist
func (scs *Service) CheckCanInviteToSharedChannel(channelId string) error {
sc, err := scs.server.GetStore().SharedChannel().Get(channelId)
if err != nil {
if isNotFoundError(err) {
return fmt.Errorf("%w: %v", model.ErrChannelNotShared, err)
}
return fmt.Errorf("cannot find channel: %w", err)
}
if !sc.Home {
return model.ErrChannelHomedOnRemote
}
return nil
}
// updateMembershipSyncCursor updates the LastMembersSyncAt value for the shared channel remote
// This provides centralized and consistent cursor management
func (scs *Service) updateMembershipSyncCursor(channelID string, remoteID string, newTimestamp int64) error {
// Get the remote record
scr, err := scs.server.GetStore().SharedChannel().GetRemoteByIds(channelID, remoteID)
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Failed to get shared channel remote for cursor update",
mlog.String("channel_id", channelID),
mlog.String("remote_id", remoteID),
mlog.Int("timestamp", int(newTimestamp)),
mlog.Err(err),
)
return fmt.Errorf("failed to get shared channel remote for cursor update: %w", err)
}
if scr == nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Shared channel remote not found for cursor update",
mlog.String("channel_id", channelID),
mlog.String("remote_id", remoteID),
)
return fmt.Errorf("shared channel remote not found for channel %s and remote %s", channelID, remoteID)
}
// Update the cursor - the store will handle ensuring it only moves forward
err = scs.server.GetStore().SharedChannel().UpdateRemoteMembershipCursor(scr.Id, newTimestamp)
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Failed to update membership cursor",
mlog.String("channel_id", channelID),
mlog.String("remote_id", remoteID),
mlog.String("remote_record_id", scr.Id),
mlog.Int("timestamp", int(newTimestamp)),
mlog.Err(err),
)
}
return err
}

View File

@@ -0,0 +1,324 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
)
// testNoopPlatform satisfies PlatformIface for API tests that trigger websocket/cache notifications.
type testNoopPlatform struct{}
func (testNoopPlatform) InvalidateCacheForUser(userID string) {}
func (testNoopPlatform) InvalidateCacheForChannel(channel *model.Channel) {}
func TestUnshareChannel_systemPostsForEachRemoteWorkspace(t *testing.T) {
channelID := model.NewId()
teamID := model.NewId()
channel := &model.Channel{Id: channelID, TeamId: teamID, Name: "town-square", DisplayName: "Town Square"}
remoteIDA := model.NewId()
remoteIDB := model.NewId()
remotes := []*model.SharedChannelRemote{
{Id: model.NewId(), ChannelId: channelID, RemoteId: remoteIDA, DeleteAt: 0},
{Id: model.NewId(), ChannelId: channelID, RemoteId: remoteIDB, DeleteAt: 0},
}
rcA := &model.RemoteCluster{RemoteId: remoteIDA, DisplayName: "Workspace A"}
rcB := &model.RemoteCluster{RemoteId: remoteIDB, Name: "internal-b"}
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockStore := &mocks.Store{}
mockChannelStore := mocks.ChannelStore{}
mockSharedChannelStore := mocks.SharedChannelStore{}
mockRemoteClusterStore := mocks.RemoteClusterStore{}
mockServer.On("GetStore").Return(mockStore)
mockStore.On("Channel").Return(&mockChannelStore)
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
mockStore.On("RemoteCluster").Return(&mockRemoteClusterStore)
mockChannelStore.On("Get", channelID, true).Return(channel, nil).Once()
mockChannelStore.On("Get", channelID, false).Return(channel, nil).Once()
mockSharedChannelStore.On("GetRemotes", 0, 10000, model.SharedChannelRemoteFilterOpts{
ChannelId: channelID,
IncludeUnconfirmed: true,
}).Return(remotes, nil).Once()
mockSharedChannelStore.On("Delete", channelID).Return(true, nil).Once()
mockRemoteClusterStore.On("Get", remoteIDA, false).Return(rcA, nil).Once()
mockRemoteClusterStore.On("Get", remoteIDB, false).Return(rcB, nil).Once()
mockApp := &MockAppIface{}
bot := &model.Bot{UserId: model.NewId()}
var postedWorkspaces []string
mockApp.On("GetSystemBot", mock.Anything).Return(bot, (*model.AppError)(nil))
mockApp.On("Publish", mock.Anything).Return()
mockApp.On("CreatePost", mock.Anything, mock.AnythingOfType("*model.Post"), mock.AnythingOfType("*model.Channel"), mock.Anything).
Run(func(args mock.Arguments) {
post := args.Get(1).(*model.Post)
wn, _ := post.GetProps()[model.PostPropsSharedChannelWorkspaceName].(string)
assert.Equal(t, model.SharedChannelStatePostValueUnshared, post.GetProps()[model.PostPropsSharedChannelState])
postedWorkspaces = append(postedWorkspaces, wn)
assert.Equal(t,
i18n.T("shared_channel.system_message.no_longer_shared", map[string]any{"WorkspaceName": wn}),
post.Message,
)
}).Return(&model.Post{}, false, (*model.AppError)(nil))
scs := &Service{
server: mockServer,
platform: testNoopPlatform{},
app: mockApp,
}
deleted, err := scs.UnshareChannel(channelID)
require.NoError(t, err)
assert.True(t, deleted)
mockApp.AssertNumberOfCalls(t, "CreatePost", 2)
assert.ElementsMatch(t, []string{"Workspace A", "internal-b"}, postedWorkspaces)
mockApp.AssertExpectations(t)
mockSharedChannelStore.AssertExpectations(t)
mockRemoteClusterStore.AssertExpectations(t)
}
func TestUnshareChannel_whenListRemotesFails_stillUnsharesWithoutSystemPosts(t *testing.T) {
channelID := model.NewId()
channel := &model.Channel{Id: channelID, TeamId: model.NewId()}
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockStore := &mocks.Store{}
mockChannelStore := mocks.ChannelStore{}
mockSharedChannelStore := mocks.SharedChannelStore{}
mockServer.On("GetStore").Return(mockStore)
mockStore.On("Channel").Return(&mockChannelStore)
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
mockChannelStore.On("Get", channelID, true).Return(channel, nil).Once()
mockChannelStore.On("Get", channelID, false).Return(channel, nil).Once()
listErr := errors.New("store remotes unavailable")
mockSharedChannelStore.On("GetRemotes", 0, 10000, model.SharedChannelRemoteFilterOpts{
ChannelId: channelID,
IncludeUnconfirmed: true,
}).Return(([]*model.SharedChannelRemote)(nil), listErr).Once()
mockSharedChannelStore.On("Delete", channelID).Return(true, nil).Once()
mockApp := &MockAppIface{}
mockApp.On("Publish", mock.Anything).Return()
scs := &Service{
server: mockServer,
platform: testNoopPlatform{},
app: mockApp,
}
deleted, err := scs.UnshareChannel(channelID)
require.NoError(t, err)
assert.True(t, deleted)
mockApp.AssertNotCalled(t, "CreatePost")
mockApp.AssertExpectations(t)
}
func TestUnshareChannel_whenRemoteClusterMissingUsesRemoteIdInPost(t *testing.T) {
channelID := model.NewId()
channel := &model.Channel{Id: channelID, TeamId: model.NewId()}
remoteID := model.NewId()
remotes := []*model.SharedChannelRemote{
{Id: model.NewId(), ChannelId: channelID, RemoteId: remoteID, DeleteAt: 0},
}
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockStore := &mocks.Store{}
mockChannelStore := mocks.ChannelStore{}
mockSharedChannelStore := mocks.SharedChannelStore{}
mockRemoteClusterStore := mocks.RemoteClusterStore{}
mockServer.On("GetStore").Return(mockStore)
mockStore.On("Channel").Return(&mockChannelStore)
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
mockStore.On("RemoteCluster").Return(&mockRemoteClusterStore)
mockChannelStore.On("Get", channelID, true).Return(channel, nil).Once()
mockChannelStore.On("Get", channelID, false).Return(channel, nil).Once()
mockSharedChannelStore.On("GetRemotes", 0, 10000, model.SharedChannelRemoteFilterOpts{
ChannelId: channelID,
IncludeUnconfirmed: true,
}).Return(remotes, nil).Once()
mockSharedChannelStore.On("Delete", channelID).Return(true, nil).Once()
mockRemoteClusterStore.On("Get", remoteID, false).Return((*model.RemoteCluster)(nil), errors.New("not found")).Once()
mockApp := &MockAppIface{}
bot := &model.Bot{UserId: model.NewId()}
mockApp.On("GetSystemBot", mock.Anything).Return(bot, (*model.AppError)(nil))
mockApp.On("Publish", mock.Anything).Return()
mockApp.On("CreatePost", mock.Anything, mock.AnythingOfType("*model.Post"), mock.AnythingOfType("*model.Channel"), mock.Anything).
Run(func(args mock.Arguments) {
post := args.Get(1).(*model.Post)
assert.Equal(t, remoteID, post.GetProps()[model.PostPropsSharedChannelWorkspaceName])
}).Return(&model.Post{}, false, (*model.AppError)(nil))
scs := &Service{
server: mockServer,
platform: testNoopPlatform{},
app: mockApp,
}
_, err := scs.UnshareChannel(channelID)
require.NoError(t, err)
mockApp.AssertNumberOfCalls(t, "CreatePost", 1)
}
func TestUninviteRemoteFromChannel_postsUnsharedSystemMessage(t *testing.T) {
channelID := model.NewId()
remoteID := model.NewId()
scrID := model.NewId()
channel := &model.Channel{Id: channelID, TeamId: model.NewId()}
scr := &model.SharedChannelRemote{
Id: scrID,
ChannelId: channelID,
RemoteId: remoteID,
DeleteAt: 0,
}
rc := &model.RemoteCluster{RemoteId: remoteID, DisplayName: "Peer workspace"}
remaining := []*model.SharedChannelRemote{{Id: model.NewId(), ChannelId: channelID, RemoteId: model.NewId(), DeleteAt: 0}}
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
setupMockServerWithConfig(mockServer)
mockStore := &mocks.Store{}
mockChannelStore := mocks.ChannelStore{}
mockSharedChannelStore := mocks.SharedChannelStore{}
mockRemoteClusterStore := mocks.RemoteClusterStore{}
mockServer.On("GetStore").Return(mockStore)
mockStore.On("Channel").Return(&mockChannelStore)
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
mockStore.On("RemoteCluster").Return(&mockRemoteClusterStore)
mockSharedChannelStore.On("GetRemoteByIds", channelID, remoteID).Return(scr, nil).Once()
mockSharedChannelStore.On("DeleteRemote", scrID).Return(true, nil).Once()
mockChannelStore.On("Get", channelID, true).Return(channel, nil).Once()
mockRemoteClusterStore.On("Get", remoteID, false).Return(rc, nil).Once()
mockSharedChannelStore.On("GetRemotes", 0, 1, model.SharedChannelRemoteFilterOpts{
ChannelId: channelID,
IncludeUnconfirmed: true,
}).Return(remaining, nil).Once()
mockApp := &MockAppIface{}
bot := &model.Bot{UserId: model.NewId()}
mockApp.On("GetSystemBot", mock.Anything).Return(bot, (*model.AppError)(nil))
mockApp.On("CreatePost", mock.Anything, mock.AnythingOfType("*model.Post"), mock.AnythingOfType("*model.Channel"), mock.Anything).
Run(func(args mock.Arguments) {
post := args.Get(1).(*model.Post)
assert.Equal(t, "Peer workspace", post.GetProps()[model.PostPropsSharedChannelWorkspaceName])
assert.Equal(t, model.SharedChannelStatePostValueUnshared, post.GetProps()[model.PostPropsSharedChannelState])
}).Return(&model.Post{}, false, (*model.AppError)(nil))
scs := &Service{
server: mockServer,
platform: testNoopPlatform{},
app: mockApp,
}
err := scs.UninviteRemoteFromChannel(channelID, remoteID)
require.NoError(t, err)
mockApp.AssertNumberOfCalls(t, "CreatePost", 1)
mockSharedChannelStore.AssertNotCalled(t, "Delete", mock.Anything)
mockApp.AssertExpectations(t)
mockRemoteClusterStore.AssertExpectations(t)
}
func TestUninviteRemoteFromChannel_whenLastRemoteUnsharesChannel(t *testing.T) {
channelID := model.NewId()
remoteID := model.NewId()
scrID := model.NewId()
channel := &model.Channel{Id: channelID, TeamId: model.NewId()}
scr := &model.SharedChannelRemote{
Id: scrID,
ChannelId: channelID,
RemoteId: remoteID,
DeleteAt: 0,
}
rc := &model.RemoteCluster{RemoteId: remoteID, DisplayName: "Last peer"}
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
setupMockServerWithConfig(mockServer)
mockStore := &mocks.Store{}
mockChannelStore := mocks.ChannelStore{}
mockSharedChannelStore := mocks.SharedChannelStore{}
mockRemoteClusterStore := mocks.RemoteClusterStore{}
mockServer.On("GetStore").Return(mockStore)
mockStore.On("Channel").Return(&mockChannelStore)
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
mockStore.On("RemoteCluster").Return(&mockRemoteClusterStore)
mockSharedChannelStore.On("GetRemoteByIds", channelID, remoteID).Return(scr, nil).Once()
mockSharedChannelStore.On("DeleteRemote", scrID).Return(true, nil).Once()
// Channel load for uninvite system post, then UnshareChannel initial get, then UnshareChannel post-delete get.
mockChannelStore.On("Get", channelID, true).Return(channel, nil).Times(2)
mockChannelStore.On("Get", channelID, false).Return(channel, nil).Once()
mockRemoteClusterStore.On("Get", remoteID, false).Return(rc, nil).Once()
mockSharedChannelStore.On("GetRemotes", 0, 1, model.SharedChannelRemoteFilterOpts{
ChannelId: channelID,
IncludeUnconfirmed: true,
}).Return([]*model.SharedChannelRemote{}, nil).Once()
mockSharedChannelStore.On("GetRemotes", 0, 10000, model.SharedChannelRemoteFilterOpts{
ChannelId: channelID,
IncludeUnconfirmed: true,
}).Return([]*model.SharedChannelRemote{}, nil).Once()
mockSharedChannelStore.On("Delete", channelID).Return(true, nil).Once()
mockApp := &MockAppIface{}
bot := &model.Bot{UserId: model.NewId()}
mockApp.On("GetSystemBot", mock.Anything).Return(bot, (*model.AppError)(nil))
mockApp.On("Publish", mock.Anything).Return()
mockApp.On("CreatePost", mock.Anything, mock.AnythingOfType("*model.Post"), mock.AnythingOfType("*model.Channel"), mock.Anything).
Return(&model.Post{}, false, (*model.AppError)(nil))
scs := &Service{
server: mockServer,
platform: testNoopPlatform{},
app: mockApp,
}
err := scs.UninviteRemoteFromChannel(channelID, remoteID)
require.NoError(t, err)
// One system post from UninviteRemoteFromChannel; UnshareChannel sees no remotes so it does not add more.
mockApp.AssertNumberOfCalls(t, "CreatePost", 1)
mockSharedChannelStore.AssertCalled(t, "Delete", channelID)
mockRemoteClusterStore.AssertExpectations(t)
}

View File

@@ -0,0 +1,118 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func TestRemoteClusterDisplayName(t *testing.T) {
t.Run("nil remote", func(t *testing.T) {
assert.Equal(t, "", remoteClusterDisplayName(nil))
})
t.Run("only RemoteId set", func(t *testing.T) {
rc := &model.RemoteCluster{RemoteId: "rid-abc"}
assert.Equal(t, "rid-abc", remoteClusterDisplayName(rc))
})
t.Run("Name used when DisplayName empty", func(t *testing.T) {
rc := &model.RemoteCluster{RemoteId: "rid-abc", Name: "internal-name"}
assert.Equal(t, "internal-name", remoteClusterDisplayName(rc))
})
t.Run("DisplayName preferred over Name and RemoteId", func(t *testing.T) {
rc := &model.RemoteCluster{
RemoteId: "rid-abc",
Name: "internal-name",
DisplayName: "Pretty Workspace",
}
assert.Equal(t, "Pretty Workspace", remoteClusterDisplayName(rc))
})
}
func TestMessageForSharedChannelStatePost(t *testing.T) {
t.Run("shared", func(t *testing.T) {
props := model.StringInterface{
model.PostPropsSharedChannelState: model.SharedChannelStatePostValueShared,
model.PostPropsSharedChannelWorkspaceName: "Acme",
}
got := messageForSharedChannelStatePost(props)
assert.Equal(t, i18n.T("shared_channel.system_message.now_shared", map[string]any{"WorkspaceName": "Acme"}), got)
})
t.Run("unshared with known workspace", func(t *testing.T) {
props := model.StringInterface{
model.PostPropsSharedChannelState: model.SharedChannelStatePostValueUnshared,
model.PostPropsSharedChannelWorkspaceName: "Acme",
}
got := messageForSharedChannelStatePost(props)
assert.Equal(t, i18n.T("shared_channel.system_message.no_longer_shared", map[string]any{"WorkspaceName": "Acme"}), got)
})
t.Run("unshared without workspace name", func(t *testing.T) {
props := model.StringInterface{
model.PostPropsSharedChannelState: model.SharedChannelStatePostValueUnshared,
}
got := messageForSharedChannelStatePost(props)
assert.Equal(t, i18n.T("shared_channel.system_message.no_longer_shared_unknown"), got)
})
t.Run("default empty or unknown state uses unknown unshared copy", func(t *testing.T) {
assert.Equal(t,
i18n.T("shared_channel.system_message.no_longer_shared_unknown"),
messageForSharedChannelStatePost(model.StringInterface{}),
)
assert.Equal(t,
i18n.T("shared_channel.system_message.no_longer_shared_unknown"),
messageForSharedChannelStatePost(model.StringInterface{
model.PostPropsSharedChannelState: "unexpected",
}),
)
})
}
func TestPostChannelUnsharedWithWorkspace_emptyWorkspaceName(t *testing.T) {
mockServer := &MockServerIface{}
logger := mlog.CreateConsoleTestLogger(t)
mockServer.On("Log").Return(logger)
mockApp := &MockAppIface{}
scs := &Service{
server: mockServer,
app: mockApp,
}
bot := &model.Bot{UserId: model.NewId()}
mockApp.On("GetSystemBot", mock.Anything).Return(bot, (*model.AppError)(nil))
mockApp.On("CreatePost", mock.Anything, mock.AnythingOfType("*model.Post"), mock.AnythingOfType("*model.Channel"), mock.Anything).
Run(func(args mock.Arguments) {
post := args.Get(1).(*model.Post)
ch := args.Get(2).(*model.Channel)
assert.Equal(t, model.PostTypeSharedChannelState, post.Type)
assert.Equal(t, bot.UserId, post.UserId)
assert.Equal(t, ch.Id, post.ChannelId)
assert.Equal(t, model.SharedChannelStatePostValueUnshared, post.GetProps()[model.PostPropsSharedChannelState])
_, hasWorkspaceName := post.GetProps()[model.PostPropsSharedChannelWorkspaceName]
assert.False(t, hasWorkspaceName)
assert.Equal(t,
i18n.T("shared_channel.system_message.no_longer_shared_unknown"),
post.Message,
)
}).Return(&model.Post{}, false, (*model.AppError)(nil))
channel := &model.Channel{Id: model.NewId(), TeamId: model.NewId()}
scs.postChannelUnsharedWithWorkspace(channel, "")
mockApp.AssertExpectations(t)
}

View File

@@ -0,0 +1,854 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/platform/services/remotecluster"
)
var (
ErrRemoteIDMismatch = errors.New("remoteID mismatch")
ErrChannelIDMismatch = errors.New("channelID mismatch")
ErrUserDMPermission = errors.New("users cannot DM each other")
ErrChannelNotShared = errors.New("channel is no longer shared")
)
func (scs *Service) onReceiveSyncMessage(msg model.RemoteClusterMsg, rc *model.RemoteCluster, response *remotecluster.Response) error {
if msg.Topic != TopicSync && msg.Topic != TopicChannelMembership && msg.Topic != TopicGlobalUserSync {
return fmt.Errorf("wrong topic, expected sync-related topic, got `%s`", msg.Topic)
}
if len(msg.Payload) == 0 {
return errors.New("empty sync message")
}
if scs.server.Log().IsLevelEnabled(mlog.LvlSharedChannelServiceMessagesInbound) {
scs.server.Log().Log(mlog.LvlSharedChannelServiceMessagesInbound, "inbound message",
mlog.String("remote", rc.DisplayName),
mlog.String("msg", msg.Payload),
)
}
var sm model.SyncMsg
if err := json.Unmarshal(msg.Payload, &sm); err != nil {
return fmt.Errorf("invalid sync message: %w", err)
}
return scs.processSyncMessageForRemote(request.EmptyContext(scs.server.Log()), &sm, rc, response)
}
// processGlobalUserSync processes a global user sync message from a remote cluster or plugin.
// Returns the SyncResponse directly.
func (scs *Service) processGlobalUserSync(rctx request.CTX, syncMsg *model.SyncMsg, rc *model.RemoteCluster) (model.SyncResponse, error) {
syncResp := model.SyncResponse{
UserErrors: make([]string, 0),
UsersSyncd: make([]string, 0),
}
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Processing global user sync",
mlog.String("remote", rc.Name),
mlog.Int("user_count", len(syncMsg.Users)),
)
// Process all users in the sync message
for _, user := range syncMsg.Users {
if userSaved, err := scs.upsertSyncUser(rctx, user, nil, rc); err != nil {
syncResp.UserErrors = append(syncResp.UserErrors, user.Id)
} else {
syncResp.UsersSyncd = append(syncResp.UsersSyncd, userSaved.Id)
if syncResp.UsersLastUpdateAt < user.UpdateAt {
syncResp.UsersLastUpdateAt = user.UpdateAt
}
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Global user upserted via sync",
mlog.String("remote", rc.Name),
mlog.String("user_id", user.Id),
)
}
}
return syncResp, nil
}
// ProcessSyncMessage processes an inbound sync message from a remote cluster or plugin.
// Returns the SyncResponse directly.
func (scs *Service) ProcessSyncMessage(rctx request.CTX, syncMsg *model.SyncMsg, rc *model.RemoteCluster) (model.SyncResponse, error) {
var targetChannel *model.Channel
var team *model.Team
var err error
syncResp := model.SyncResponse{
UserErrors: make([]string, 0),
UsersSyncd: make([]string, 0),
PostErrors: make([]string, 0),
ReactionErrors: make([]string, 0),
AcknowledgementErrors: make([]string, 0),
MembershipErrors: make([]string, 0),
}
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Sync msg received",
mlog.String("remote", rc.Name),
mlog.String("channel_id", syncMsg.ChannelId),
mlog.Int("user_count", len(syncMsg.Users)),
mlog.Int("post_count", len(syncMsg.Posts)),
mlog.Int("reaction_count", len(syncMsg.Reactions)),
mlog.Int("acknowledgement_count", len(syncMsg.Acknowledgements)),
mlog.Int("status_count", len(syncMsg.Statuses)),
mlog.Int("membership_change_count", len(syncMsg.MembershipChanges)),
)
// Check if this is a global user sync message (no channel ID and only users)
if syncMsg.ChannelId == "" {
if len(syncMsg.Posts) != 0 ||
len(syncMsg.Reactions) != 0 ||
len(syncMsg.Statuses) != 0 {
return syncResp, fmt.Errorf("global user sync message should not contain posts, reactions or statuses")
}
if len(syncMsg.Users) == 0 {
return syncResp, nil
}
// Check if feature flag is enabled
if !scs.isGlobalUserSyncEnabled() {
return syncResp, nil
}
return scs.processGlobalUserSync(rctx, syncMsg, rc)
}
// For regular sync messages, we need a specific channel
if targetChannel, err = scs.server.GetStore().Channel().Get(syncMsg.ChannelId, true); err != nil {
// if the channel doesn't exist then none of these sync items are going to work.
return syncResp, fmt.Errorf("channel not found processing sync message: %w", err)
}
// make sure target channel is shared with the remote
exists, err := scs.server.GetStore().SharedChannel().HasRemote(targetChannel.Id, rc.RemoteId)
if err != nil {
return syncResp, fmt.Errorf("cannot check channel share state for sync message: %w", err)
}
if !exists {
return syncResp, fmt.Errorf("cannot process sync message; %w: %s",
ErrChannelNotShared, syncMsg.ChannelId)
}
// add/update users before posts
for _, user := range syncMsg.Users {
if userSaved, err := scs.upsertSyncUser(rctx, user, targetChannel, rc); err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Error upserting sync user",
mlog.String("remote", rc.Name),
mlog.String("channel_id", syncMsg.ChannelId),
mlog.String("user_id", user.Id),
mlog.Err(err))
} else {
syncResp.UsersSyncd = append(syncResp.UsersSyncd, userSaved.Id)
if syncResp.UsersLastUpdateAt < user.UpdateAt {
syncResp.UsersLastUpdateAt = user.UpdateAt
}
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "User upserted via sync",
mlog.String("remote", rc.Name),
mlog.String("channel_id", syncMsg.ChannelId),
mlog.String("user_id", user.Id),
)
}
}
for _, post := range syncMsg.Posts {
if post.Type == model.PostTypeCard {
continue
}
if syncMsg.ChannelId != post.ChannelId {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "ChannelId mismatch",
mlog.String("remote", rc.Name),
mlog.String("sm_channel_id", syncMsg.ChannelId),
mlog.String("sm_post_channel_id", post.ChannelId),
mlog.String("post_id", post.Id),
)
syncResp.PostErrors = append(syncResp.PostErrors, post.Id)
continue
}
if (targetChannel.Type != model.ChannelTypeDirect && targetChannel.Type != model.ChannelTypeGroup) && team == nil {
var err2 error
team, err2 = scs.server.GetStore().Channel().GetTeamForChannel(syncMsg.ChannelId)
if err2 != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Error getting Team for Channel",
mlog.String("channel_id", post.ChannelId),
mlog.String("post_id", post.Id),
mlog.String("remote", rc.Name),
mlog.Err(err2),
)
syncResp.PostErrors = append(syncResp.PostErrors, post.Id)
continue
}
}
// process perma-links for remote
if team != nil {
post.Message = scs.processPermalinkFromRemote(post, team)
}
// add/update post
rpost, err := scs.upsertSyncPost(post, targetChannel, rc, syncMsg.MentionTransforms)
if err != nil {
syncResp.PostErrors = append(syncResp.PostErrors, post.Id)
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Error upserting sync post",
mlog.String("post_id", post.Id),
mlog.String("channel_id", post.ChannelId),
mlog.String("remote", rc.Name),
mlog.Err(err),
)
} else if syncResp.PostsLastUpdateAt < rpost.UpdateAt {
syncResp.PostsLastUpdateAt = rpost.UpdateAt
}
}
// add/remove reactions
for _, reaction := range syncMsg.Reactions {
if _, err := scs.upsertSyncReaction(reaction, targetChannel, rc); err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Error upserting sync reaction",
mlog.String("remote", rc.Name),
mlog.String("user_id", reaction.UserId),
mlog.String("post_id", reaction.PostId),
mlog.String("emoji", reaction.EmojiName),
mlog.Int("delete_at", reaction.DeleteAt),
mlog.Err(err),
)
} else {
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Reaction upserted via sync",
mlog.String("remote", rc.Name),
mlog.String("user_id", reaction.UserId),
mlog.String("post_id", reaction.PostId),
mlog.String("emoji", reaction.EmojiName),
mlog.Int("delete_at", reaction.DeleteAt),
)
if syncResp.ReactionsLastUpdateAt < reaction.UpdateAt {
syncResp.ReactionsLastUpdateAt = reaction.UpdateAt
}
}
}
// add/remove acknowledgements
for _, acknowledgement := range syncMsg.Acknowledgements {
if _, err := scs.upsertSyncAcknowledgement(acknowledgement, targetChannel, rc); err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Error upserting sync acknowledgement",
mlog.String("remote", rc.Name),
mlog.String("user_id", acknowledgement.UserId),
mlog.String("post_id", acknowledgement.PostId),
mlog.Int("acknowledged_at", acknowledgement.AcknowledgedAt),
mlog.Err(err),
)
syncResp.AcknowledgementErrors = append(syncResp.AcknowledgementErrors, acknowledgement.PostId)
} else {
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Acknowledgement upserted via sync",
mlog.String("remote", rc.Name),
mlog.String("user_id", acknowledgement.UserId),
mlog.String("post_id", acknowledgement.PostId),
mlog.Int("acknowledged_at", acknowledgement.AcknowledgedAt),
)
if syncResp.AcknowledgementsLastUpdateAt < acknowledgement.AcknowledgedAt {
syncResp.AcknowledgementsLastUpdateAt = acknowledgement.AcknowledgedAt
}
}
}
for _, status := range syncMsg.Statuses {
if err := scs.upsertSyncUserStatus(rctx, status, rc); err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Error upserting sync user status",
mlog.String("remote", rc.Name),
mlog.String("user_id", status.UserId),
mlog.Err(err))
syncResp.StatusErrors = append(syncResp.StatusErrors, status.UserId)
}
}
// Process membership changes after users have been synced
if len(syncMsg.MembershipChanges) > 0 {
if err := scs.onReceiveMembershipChanges(syncMsg, rc); err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Error processing membership changes",
mlog.String("remote", rc.Name),
mlog.String("channel_id", syncMsg.ChannelId),
mlog.Int("change_count", len(syncMsg.MembershipChanges)),
mlog.Err(err),
)
syncResp.MembershipErrors = append(syncResp.MembershipErrors, err.Error())
// Don't fail the entire sync if membership changes fail
}
}
return syncResp, nil
}
// processSyncMessageForRemote wraps ProcessSyncMessage for the remote cluster transport layer.
func (scs *Service) processSyncMessageForRemote(rctx request.CTX, syncMsg *model.SyncMsg, rc *model.RemoteCluster, response *remotecluster.Response) error {
syncResp, err := scs.ProcessSyncMessage(rctx, syncMsg, rc)
if err != nil {
return err
}
return response.SetPayload(syncResp)
}
func (scs *Service) upsertSyncUser(rctx request.CTX, user *model.User, channel *model.Channel, rc *model.RemoteCluster) (*model.User, error) {
var err error
// Check if user already exists
euser, err := scs.server.GetStore().User().Get(rctx, user.Id)
if err != nil {
if _, ok := err.(errNotFound); !ok {
return nil, fmt.Errorf("error checking sync user: %w", err)
}
}
var userSaved *model.User
if euser == nil {
// new user. Make sure the remoteID is correct and insert the record
// Preserve original remote ID before overwriting RemoteId
originalRemoteId := user.GetRemoteID()
user.RemoteId = new(rc.RemoteId)
if user.Props == nil || user.Props[model.UserPropsKeyOriginalRemoteId] == "" {
if originalRemoteId == "" {
originalRemoteId = rc.RemoteId // If no original RemoteId, use current sync sender
}
user.SetProp(model.UserPropsKeyOriginalRemoteId, originalRemoteId)
}
if userSaved, err = scs.insertSyncUser(rctx, user, channel, rc); err != nil {
return nil, err
}
} else {
// existing user. Make sure user belongs to the remote that issued the update
if euser.GetRemoteID() != rc.RemoteId {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "RemoteID mismatch sync'ing user",
mlog.String("remote", rc.Name),
mlog.String("user_id", user.Id),
mlog.String("existing_user_remote_id", euser.GetRemoteID()),
mlog.String("update_user_remote_id", user.GetRemoteID()),
)
return nil, fmt.Errorf("error updating user: %w", ErrRemoteIDMismatch)
}
// save the updated username and email in props
user.SetProp(model.UserPropsKeyRemoteUsername, user.Username)
user.SetProp(model.UserPropsKeyRemoteEmail, user.Email)
patch := &model.UserPatch{
Username: &user.Username,
Nickname: &user.Nickname,
FirstName: &user.FirstName,
LastName: &user.LastName,
Props: user.Props,
Position: &user.Position,
Locale: &user.Locale,
Timezone: user.Timezone,
}
if userSaved, err = scs.updateSyncUser(rctx, patch, euser, channel, rc); err != nil {
return nil, err
}
}
// Add user to team and channel. We do this here regardless of whether the user was
// just created or patched since there are three steps to adding a user
// (insert rec, add to team, add to channel) and any one could fail.
// Instead of undoing what succeeded on any failure we simply do all steps each
// time. AddUserToChannel & AddUserToTeamByTeamId do not error if user was already
// added and exit quickly. Not needed for DMs where teamId is empty.
if channel != nil && channel.TeamId != "" {
// add user to team
if err := scs.app.AddUserToTeamByTeamId(rctx, 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(rctx, userSaved, channel, false); err != nil {
return nil, fmt.Errorf("error adding sync user to ChannelMembers: %w", err)
}
}
return userSaved, nil
}
func (scs *Service) insertSyncUser(rctx request.CTX, user *model.User, _ *model.Channel, rc *model.RemoteCluster) (*model.User, error) {
var err error
var userSaved *model.User
var suffix string
// ensure the new user is created with system_user role and random password.
user = sanitizeUserForSync(user)
// save the original username and email in props
user.SetProp(model.UserPropsKeyRemoteUsername, user.Username)
user.SetProp(model.UserPropsKeyRemoteEmail, user.Email)
// Apply a suffix to the username until it is unique. Collisions will be quite
// rare since we are joining a username that is unique at a remote site with a unique
// name for that site. However we need to truncate the combined name to 64 chars and
// that might introduce a collision.
for i := 1; i <= MaxUpsertRetries; i++ {
if i > 1 {
suffix = strconv.FormatInt(int64(i), 10)
}
user.Username = mungUsername(user.Username, rc.Name, suffix, model.UserNameMaxLength)
user.Email = model.NewId()
if userSaved, err = scs.server.GetStore().User().Save(rctx, user); err != nil {
field, ok := isConflictError(err)
if !ok {
break
}
if field == "email" || field == "username" {
// username or email collision; try again with different suffix
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Collision inserting sync user",
mlog.String("field", field),
mlog.String("username", user.Username),
mlog.String("email", user.Email),
mlog.Int("attempt", i),
mlog.Err(err),
)
}
} else {
scs.app.NotifySharedChannelUserUpdate(userSaved)
return userSaved, nil
}
}
return nil, fmt.Errorf("error inserting sync user %s: %w", user.Id, err)
}
func (scs *Service) updateSyncUser(rctx request.CTX, patch *model.UserPatch, user *model.User, _ *model.Channel, rc *model.RemoteCluster) (*model.User, error) {
var err error
var update *model.UserUpdate
var suffix string
// preserve existing real username/email since Patch will over-write them;
// the real username/email in props can be updated if they don't contain colons,
// meaning the update is coming from the user's origin server (not munged).
realUsername, _ := user.GetProp(model.UserPropsKeyRemoteUsername)
realEmail, _ := user.GetProp(model.UserPropsKeyRemoteEmail)
if patch.Username != nil && !strings.Contains(*patch.Username, ":") {
realUsername = *patch.Username
}
if patch.Email != nil && !strings.Contains(*patch.Email, ":") {
realEmail = *patch.Email
}
user.Patch(patch)
user = sanitizeUserForSync(user)
user.SetProp(model.UserPropsKeyRemoteUsername, realUsername)
user.SetProp(model.UserPropsKeyRemoteEmail, realEmail)
// Apply a suffix to the username until it is unique.
for i := 1; i <= MaxUpsertRetries; i++ {
if i > 1 {
suffix = strconv.FormatInt(int64(i), 10)
}
user.Username = mungUsername(user.Username, rc.Name, suffix, model.UserNameMaxLength)
user.Email = model.NewId()
if update, err = scs.server.GetStore().User().Update(rctx, user, false); err != nil {
field, ok := isConflictError(err)
if !ok {
break
}
if field == "email" || field == "username" {
// username or email collision; try again with different suffix
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Collision updating sync user",
mlog.String("field", field),
mlog.String("username", user.Username),
mlog.String("email", user.Email),
mlog.Int("attempt", i),
mlog.Err(err),
)
}
} else {
scs.platform.InvalidateCacheForUser(update.New.Id)
scs.app.NotifySharedChannelUserUpdate(update.New)
return update.New, nil
}
}
return nil, fmt.Errorf("error updating sync user %s: %w", user.Id, err)
}
func (scs *Service) upsertSyncPost(post *model.Post, targetChannel *model.Channel, rc *model.RemoteCluster, mentionTransforms map[string]string) (*model.Post, error) {
var appErr *model.AppError
post.RemoteId = new(rc.RemoteId)
rctx := request.EmptyContext(scs.server.Log())
rpost, err := scs.server.GetStore().Post().GetSingle(rctx, post.Id, true)
if err != nil {
if _, ok := err.(errNotFound); !ok {
return nil, fmt.Errorf("error checking sync post: %w", err)
}
}
// ensure the post is in the target channel. This ensures the post can only be associated with a channel
// that is shared with the remote.
if post.ChannelId != targetChannel.Id || (rpost != nil && rpost.ChannelId != targetChannel.Id) {
return nil, fmt.Errorf("post sync failed: %w", ErrChannelIDMismatch)
}
if rpost == nil {
// post doesn't exist; check that user belongs to remote and create post.
// user is not checked for edit/delete because admins can perform those actions
user, err := scs.server.GetStore().User().Get(rctx, post.UserId)
if err != nil {
return nil, fmt.Errorf("error fetching user for post sync: %w", err)
}
if user.GetRemoteID() != rc.RemoteId {
return nil, fmt.Errorf("post sync failed: %w", ErrRemoteIDMismatch)
}
scs.transformMentionsOnReceive(rctx, post, targetChannel, rc, mentionTransforms)
// The post is federated (RemoteId set) and its author is verified above
// to belong to the remote, which already enforced mm_blocks_actions
// authority. Preserve the prop through the create-time strip, mirroring
// how SanitizeProps preserves integration/notification props for
// federated posts.
rpost, _, appErr = scs.app.CreatePost(rctx, post, targetChannel, model.CreatePostFlags{
TriggerWebhooks: true,
SetOnline: true,
AllowMmBlocksActions: post.GetProp(model.PostPropsMmBlocksActions) != nil,
})
if appErr == nil {
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Created sync post",
mlog.String("post_id", post.Id),
mlog.String("channel_id", post.ChannelId),
)
}
} else if post.DeleteAt > 0 {
// make sure the post being deleted is owned by the remote
if rpost.GetRemoteID() != rc.RemoteId {
return nil, fmt.Errorf("post sync failed: %w", ErrRemoteIDMismatch)
}
// delete post
rpost, appErr = scs.app.DeletePost(rctx, post.Id, post.UserId)
if appErr == nil {
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Deleted sync post",
mlog.String("post_id", post.Id),
mlog.String("channel_id", post.ChannelId),
)
}
} else if post.EditAt > rpost.EditAt || post.Message != rpost.Message || post.UpdateAt > rpost.UpdateAt || post.Metadata != nil {
// make sure the post being edited is owned by the remote
if rpost.GetRemoteID() != rc.RemoteId {
return nil, fmt.Errorf("post sync failed: %w", ErrRemoteIDMismatch)
}
scs.transformMentionsOnReceive(rctx, post, targetChannel, rc, mentionTransforms)
var priority *model.PostPriority
var acknowledgements []*model.PostAcknowledgement
if post.Metadata != nil {
// Save the received priority
if post.Metadata.Priority != nil {
priority = post.Metadata.Priority
}
// Save the received acknowledgements
if post.Metadata.Acknowledgements != nil {
acknowledgements = post.Metadata.Acknowledgements
}
}
// First update the basic post. The post is federated and remote-owned
// (verified above); the origin cluster already enforced both
// mm_blocks_actions and integration-prop authority, so allow the synced
// values through the UpdatePost freeze — button edits made upstream
// propagate, and the remote's display-identity values are honored
// instead of restoring stale local ones from the prior post.
rpost, _, appErr = scs.app.UpdatePost(rctx, post, &model.UpdatePostOptions{
AllowMmBlocksActionsUpdate: true,
AllowIdentityPropsUpdate: true,
})
if appErr != nil {
rerr := errors.New(appErr.Error())
return nil, rerr
}
// Handle priority metadata separately if needed
if priority != nil {
rpost = scs.syncRemotePriorityMetadata(rctx, post, priority, rpost)
}
// Handle acknowledgements metadata separately if needed
if acknowledgements != nil {
rpost = scs.syncRemoteAcknowledgementsMetadata(rctx, post, acknowledgements, rpost)
}
} else {
// nothing to update
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Update to sync post ignored",
mlog.String("post_id", post.Id),
mlog.String("channel_id", post.ChannelId),
)
}
var rerr error
if appErr != nil {
rerr = errors.New(appErr.Error())
}
return rpost, rerr
}
// syncRemotePriorityMetadata handles syncing priority metadata from a remote post.
// It completely replaces existing priority settings with the ones from the remote post,
// regardless of update type.
func (scs *Service) syncRemotePriorityMetadata(rctx request.CTX, post *model.Post, priority *model.PostPriority, rpost *model.Post) *model.Post {
// First, create a new priority object with proper post and channel IDs
newPriority := &model.PostPriority{
PostId: post.Id,
ChannelId: post.ChannelId,
}
// Copy the priority values from the remote post
if priority.Priority != nil {
newPriority.Priority = priority.Priority
}
if priority.RequestedAck != nil {
newPriority.RequestedAck = priority.RequestedAck
}
if priority.PersistentNotifications != nil {
newPriority.PersistentNotifications = priority.PersistentNotifications
}
// Save the new priority - this will replace any existing priority for the post
savedPriority, priorityErr := scs.server.GetStore().PostPriority().Save(newPriority)
if priorityErr != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Error saving post priority from remote",
mlog.String("post_id", post.Id),
mlog.String("channel_id", post.ChannelId),
mlog.Err(priorityErr),
)
} else {
// If the priority is successfully saved, ensure it's in the returned post
if rpost.Metadata == nil {
rpost.Metadata = &model.PostMetadata{}
}
// Use the saved priority from the database operation
rpost.Metadata.Priority = savedPriority
}
return rpost
}
// syncRemoteAcknowledgementsMetadata handles syncing acknowledgements metadata from a remote post.
// It replaces all existing acknowledgements with the ones from the remote post.
func (scs *Service) syncRemoteAcknowledgementsMetadata(rctx request.CTX, post *model.Post, acknowledgements []*model.PostAcknowledgement, rpost *model.Post) *model.Post {
// When syncing from remote, we completely replace the existing acknowledgements
// with the ones received from the remote, regardless of update type
// Get existing acknowledgements and delete them using batch operation
existingAcks, appErrGet := scs.app.GetAcknowledgementsForPost(post.Id)
if appErrGet != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Error getting existing acknowledgements for remote sync",
mlog.String("post_id", post.Id),
mlog.Err(appErrGet),
)
} else if len(existingAcks) > 0 {
// Use batch delete for better performance
if nErr := scs.server.GetStore().PostAcknowledgement().BatchDelete(existingAcks); nErr != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Error batch deleting acknowledgements for remote sync",
mlog.String("post_id", post.Id),
mlog.Int("count", len(existingAcks)),
mlog.Err(nErr),
)
}
}
// Extract all user IDs from acknowledgements for batch processing
userIDs := make([]string, 0, len(acknowledgements))
for _, ack := range acknowledgements {
userIDs = append(userIDs, ack.UserId)
}
// Use batch operation to save all acknowledgements at once
var savedAcks []*model.PostAcknowledgement
if len(userIDs) > 0 {
var appErrAck *model.AppError
savedAcks, appErrAck = scs.app.SaveAcknowledgementsForPost(rctx, post.Id, userIDs)
if appErrAck != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Error syncing remote post acknowledgements",
mlog.String("post_id", post.Id),
mlog.Int("count", len(userIDs)),
mlog.Err(appErrAck),
)
// Fall back to original acknowledgements if batch save fails
savedAcks = acknowledgements
}
}
// Update acknowledgements in the returned post
if rpost.Metadata == nil {
rpost.Metadata = &model.PostMetadata{}
}
rpost.Metadata.Acknowledgements = savedAcks
return rpost
}
func (scs *Service) upsertSyncReaction(reaction *model.Reaction, targetChannel *model.Channel, rc *model.RemoteCluster) (*model.Reaction, error) {
savedReaction := reaction
var appErr *model.AppError
// check that the reaction's post is in the target channel. This ensures the reaction can only be associated with a post
// that is in a channel shared with the remote.
rctx := request.EmptyContext(scs.server.Log())
post, err := scs.server.GetStore().Post().GetSingle(rctx, reaction.PostId, true)
if err != nil {
return nil, fmt.Errorf("error fetching post for reaction sync: %w", err)
}
if post.ChannelId != targetChannel.Id {
return nil, fmt.Errorf("reaction sync failed: %w", ErrChannelIDMismatch)
}
existingReaction, err := scs.server.GetStore().Reaction().GetSingle(reaction.UserId, reaction.PostId, rc.RemoteId, reaction.EmojiName)
if err != nil && !isNotFoundError(err) {
return nil, fmt.Errorf("error fetching reaction for sync: %w", err)
}
if existingReaction == nil {
// reaction does not exist; check that user belongs to remote and create reaction
// this is not done for delete since deletion can be done by admins on the remote
user, err := scs.server.GetStore().User().Get(rctx, reaction.UserId)
if err != nil {
return nil, fmt.Errorf("error fetching user for reaction sync: %w", err)
}
if user.GetRemoteID() != rc.RemoteId {
return nil, fmt.Errorf("reaction sync failed: %w", ErrRemoteIDMismatch)
}
reaction.RemoteId = new(rc.RemoteId)
savedReaction, appErr = scs.app.SaveReactionForPost(request.EmptyContext(scs.server.Log()), reaction)
} else {
// make sure the reaction being deleted is owned by the remote
if existingReaction.GetRemoteID() != rc.RemoteId {
return nil, fmt.Errorf("reaction sync failed: %w", ErrRemoteIDMismatch)
}
appErr = scs.app.DeleteReactionForPost(request.EmptyContext(scs.server.Log()), reaction)
}
var retErr error
if appErr != nil {
retErr = errors.New(appErr.Error())
}
return savedReaction, retErr
}
func (scs *Service) upsertSyncAcknowledgement(acknowledgement *model.PostAcknowledgement, targetChannel *model.Channel, rc *model.RemoteCluster) (*model.PostAcknowledgement, error) {
savedAcknowledgement := acknowledgement
var appErr *model.AppError
// check that the acknowledgement's post is in the target channel. This ensures the acknowledgement can only be associated with a post
// that is in a channel shared with the remote.
rctx := request.EmptyContext(scs.server.Log())
post, err := scs.server.GetStore().Post().GetSingle(rctx, acknowledgement.PostId, true)
if err != nil {
return nil, fmt.Errorf("error fetching post for acknowledgement sync: %w", err)
}
if post.ChannelId != targetChannel.Id {
return nil, fmt.Errorf("acknowledgement sync failed: %w", ErrChannelIDMismatch)
}
existingAcknowledgement, err := scs.server.GetStore().PostAcknowledgement().GetSingle(acknowledgement.UserId, acknowledgement.PostId, rc.RemoteId)
if err != nil && !isNotFoundError(err) {
return nil, fmt.Errorf("error fetching acknowledgement for sync: %w", err)
}
if existingAcknowledgement == nil {
// acknowledgement does not exist; check that user belongs to remote and create acknowledgement
// this is not done for delete since deletion can be done by admins on the remote
user, err := scs.server.GetStore().User().Get(rctx, acknowledgement.UserId)
if err != nil {
return nil, fmt.Errorf("error fetching user for acknowledgement sync: %w", err)
}
if user.GetRemoteID() != rc.RemoteId {
return nil, fmt.Errorf("acknowledgement sync failed: %w", ErrRemoteIDMismatch)
}
acknowledgement.RemoteId = new(rc.RemoteId)
acknowledgement.ChannelId = targetChannel.Id
savedAcknowledgement, appErr = scs.app.SaveAcknowledgementForPostWithModel(request.EmptyContext(scs.server.Log()), acknowledgement)
} else {
// make sure the acknowledgement being deleted is owned by the remote
if existingAcknowledgement.GetRemoteID() != rc.RemoteId {
return nil, fmt.Errorf("acknowledgement sync failed: %w", ErrRemoteIDMismatch)
}
if acknowledgement.AcknowledgedAt == 0 {
// Delete the acknowledgement
appErr = scs.app.DeleteAcknowledgementForPostWithModel(request.EmptyContext(scs.server.Log()), acknowledgement)
}
}
var retErr error
if appErr != nil {
retErr = errors.New(appErr.Error())
}
return savedAcknowledgement, retErr
}
func (scs *Service) upsertSyncUserStatus(rctx request.CTX, status *model.Status, rc *model.RemoteCluster) error {
user, err := scs.server.GetStore().User().Get(rctx, status.UserId)
if err != nil {
return fmt.Errorf("error getting user when syncing status: %w", err)
}
if user.GetRemoteID() != rc.RemoteId {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "RemoteID mismatch sync'ing user status",
mlog.String("remote", rc.Name),
mlog.String("user_id", status.UserId),
mlog.String("user_remote_id", user.GetRemoteID()),
)
return fmt.Errorf("error updating user status: %w", ErrRemoteIDMismatch)
}
scs.app.SaveAndBroadcastStatus(status)
return nil
}
// transformMentionsOnReceive transforms mentions in received posts using explicit mentionTransforms.
func (scs *Service) transformMentionsOnReceive(rctx request.CTX, post *model.Post, targetChannel *model.Channel, rc *model.RemoteCluster, mentionTransforms map[string]string) {
if post.Message == "" || len(mentionTransforms) == 0 {
return
}
// Process mentions directly using mentionTransforms - no need to re-parse with regex
for mention, userID := range mentionTransforms {
oldMention := "@" + mention
var newMention string
// Get the user to determine transformation type
if user, err := scs.server.GetStore().User().Get(rctx, userID); err == nil && user != nil {
// User exists in receiver's database
if strings.Contains(mention, ":") {
// Colon mention (e.g., "@admin:remote1") - always use the user's actual username
newMention = "@" + user.Username
} else {
// Simple mention (e.g., "@admin")
if user.GetRemoteID() == "" {
// This is a local user, keep as-is
newMention = "@" + mention
} else {
// This is a remote user that was synced, use their synced username
newMention = "@" + user.Username
}
}
} else {
// User doesn't exist in receiver's database
if strings.Contains(mention, ":") {
// Colon mention for unknown user - keep as-is
newMention = oldMention
} else {
// Simple mention for unknown user - add cluster suffix to indicate it's from remote
newMention = "@" + mention + ":" + rc.Name
}
}
post.Message = strings.ReplaceAll(post.Message, oldMention, newMention)
}
}

View File

@@ -0,0 +1,371 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
)
func TestProcessSyncMessage(t *testing.T) {
remoteID := model.NewId()
channelID := model.NewId()
rc := &model.RemoteCluster{
RemoteId: remoteID,
Name: "test-remote",
}
makeConfig := func() *model.Config {
cfg := &model.Config{}
cfg.SetDefaults()
return cfg
}
t.Run("returns SyncResponse directly for empty sync message", func(t *testing.T) {
channel := &model.Channel{Id: channelID, Type: model.ChannelTypeOpen}
mockChannelStore := &mocks.ChannelStore{}
mockChannelStore.On("Get", channelID, true).Return(channel, nil)
mockSCStore := &mocks.SharedChannelStore{}
mockSCStore.On("HasRemote", channelID, remoteID).Return(true, nil)
mockStore := &mocks.Store{}
mockStore.On("Channel").Return(mockChannelStore)
mockStore.On("SharedChannel").Return(mockSCStore)
logger := mlog.CreateConsoleTestLogger(t)
mockServer := &MockServerIface{}
mockServer.On("GetStore").Return(mockStore)
mockServer.On("Log").Return(logger)
mockServer.On("Config").Return(makeConfig())
scs := &Service{server: mockServer}
syncMsg := &model.SyncMsg{
ChannelId: channelID,
}
resp, err := scs.ProcessSyncMessage(request.TestContext(t), syncMsg, rc)
require.NoError(t, err)
assert.Empty(t, resp.PostErrors)
assert.Empty(t, resp.UserErrors)
})
t.Run("returns error when channel not found", func(t *testing.T) {
mockChannelStore := &mocks.ChannelStore{}
mockChannelStore.On("Get", channelID, true).Return(nil, store.NewErrNotFound("Channel", channelID))
mockStore := &mocks.Store{}
mockStore.On("Channel").Return(mockChannelStore)
logger := mlog.CreateConsoleTestLogger(t)
mockServer := &MockServerIface{}
mockServer.On("GetStore").Return(mockStore)
mockServer.On("Log").Return(logger)
mockServer.On("Config").Return(makeConfig())
scs := &Service{server: mockServer}
syncMsg := &model.SyncMsg{
ChannelId: channelID,
}
_, err := scs.ProcessSyncMessage(request.TestContext(t), syncMsg, rc)
require.Error(t, err)
assert.Contains(t, err.Error(), "channel not found")
})
t.Run("returns error when channel not shared with remote", func(t *testing.T) {
channel := &model.Channel{Id: channelID, Type: model.ChannelTypeOpen}
mockChannelStore := &mocks.ChannelStore{}
mockChannelStore.On("Get", channelID, true).Return(channel, nil)
mockSCStore := &mocks.SharedChannelStore{}
mockSCStore.On("HasRemote", channelID, remoteID).Return(false, nil)
mockStore := &mocks.Store{}
mockStore.On("Channel").Return(mockChannelStore)
mockStore.On("SharedChannel").Return(mockSCStore)
logger := mlog.CreateConsoleTestLogger(t)
mockServer := &MockServerIface{}
mockServer.On("GetStore").Return(mockStore)
mockServer.On("Log").Return(logger)
mockServer.On("Config").Return(makeConfig())
scs := &Service{server: mockServer}
syncMsg := &model.SyncMsg{
ChannelId: channelID,
}
_, err := scs.ProcessSyncMessage(request.TestContext(t), syncMsg, rc)
require.Error(t, err)
assert.ErrorIs(t, err, ErrChannelNotShared)
})
t.Run("returns error for global user sync with posts", func(t *testing.T) {
logger := mlog.CreateConsoleTestLogger(t)
mockServer := &MockServerIface{}
mockServer.On("Log").Return(logger)
mockServer.On("Config").Return(makeConfig())
scs := &Service{server: mockServer}
syncMsg := &model.SyncMsg{
ChannelId: "", // global user sync
Posts: []*model.Post{{Id: model.NewId()}},
}
_, err := scs.ProcessSyncMessage(request.TestContext(t), syncMsg, rc)
require.Error(t, err)
assert.Contains(t, err.Error(), "should not contain posts")
})
t.Run("returns empty response for global user sync with no users", func(t *testing.T) {
logger := mlog.CreateConsoleTestLogger(t)
mockServer := &MockServerIface{}
mockServer.On("Log").Return(logger)
mockServer.On("Config").Return(makeConfig())
scs := &Service{server: mockServer}
syncMsg := &model.SyncMsg{
ChannelId: "", // global user sync
}
resp, err := scs.ProcessSyncMessage(request.TestContext(t), syncMsg, rc)
require.NoError(t, err)
assert.Empty(t, resp.UsersSyncd)
})
t.Run("post with mismatched channelID is reported in PostErrors", func(t *testing.T) {
channel := &model.Channel{Id: channelID, Type: model.ChannelTypeOpen}
wrongChannelID := model.NewId()
postID := model.NewId()
mockChannelStore := &mocks.ChannelStore{}
mockChannelStore.On("Get", channelID, true).Return(channel, nil)
mockSCStore := &mocks.SharedChannelStore{}
mockSCStore.On("HasRemote", channelID, remoteID).Return(true, nil)
mockStore := &mocks.Store{}
mockStore.On("Channel").Return(mockChannelStore)
mockStore.On("SharedChannel").Return(mockSCStore)
logger := mlog.CreateConsoleTestLogger(t)
mockServer := &MockServerIface{}
mockServer.On("GetStore").Return(mockStore)
mockServer.On("Log").Return(logger)
mockServer.On("Config").Return(makeConfig())
scs := &Service{server: mockServer}
syncMsg := &model.SyncMsg{
ChannelId: channelID,
Posts: []*model.Post{
{Id: postID, ChannelId: wrongChannelID},
},
}
resp, err := scs.ProcessSyncMessage(request.TestContext(t), syncMsg, rc)
require.NoError(t, err)
assert.Contains(t, resp.PostErrors, postID)
})
}
func TestUpsertSyncUserStatus(t *testing.T) {
setup := func(remoteID string, user *model.User) (*Service, *MockAppIface, *model.Status, *model.RemoteCluster) {
var userID string
if user == nil {
userID = model.NewId()
} else {
userID = user.Id
}
status := &model.Status{
UserId: userID,
Status: model.StatusDnd,
}
remoteCluster := &model.RemoteCluster{
RemoteId: remoteID,
Name: "test-remote",
}
mockUserStore := &mocks.UserStore{}
if user == nil {
mockUserStore.On("Get", mockTypeContext, mock.Anything).Return(nil, store.NewErrNotFound("User", userID))
} else {
mockUserStore.On("Get", mockTypeContext, user.Id).Return(user, nil)
}
mockStore := &mocks.Store{}
mockStore.On("User").Return(mockUserStore)
logger := mlog.CreateConsoleTestLogger(t)
mockServer := &MockServerIface{}
mockServer.On("GetStore").Return(mockStore)
mockServer.On("Log").Return(logger)
mockApp := &MockAppIface{}
mockApp.On("SaveAndBroadcastStatus", status).Return()
scs := &Service{
server: mockServer,
app: mockApp,
}
return scs, mockApp, status, remoteCluster
}
t.Run("should broadcast changes to a remote user's status", func(t *testing.T) {
remoteID := model.NewId()
user := &model.User{
Id: model.NewId(),
RemoteId: new(remoteID),
}
scs, mockApp, status, remoteCluster := setup(remoteID, user)
err := scs.upsertSyncUserStatus(request.TestContext(t), status, remoteCluster)
require.NoError(t, err)
mockApp.AssertCalled(t, "SaveAndBroadcastStatus", status)
})
t.Run("should return an error when the user doesn't exist locally", func(t *testing.T) {
remoteID := model.NewId()
var user *model.User
scs, mockApp, status, remoteCluster := setup(remoteID, user)
rctx := request.TestContext(t)
err := scs.upsertSyncUserStatus(rctx, status, remoteCluster)
require.Error(t, err)
assert.Contains(t, err.Error(), "error getting user when syncing status")
mockApp.AssertNotCalled(t, "SaveAndBroadcastStatus")
})
t.Run("should return an error when attempting to sync a local user", func(t *testing.T) {
remoteID := model.NewId()
user := &model.User{
Id: model.NewId(),
RemoteId: nil,
}
scs, mockApp, status, remoteCluster := setup(remoteID, user)
rctx := request.TestContext(t)
err := scs.upsertSyncUserStatus(rctx, status, remoteCluster)
require.Error(t, err)
assert.ErrorIs(t, err, ErrRemoteIDMismatch)
assert.Contains(t, err.Error(), "error updating user status")
mockApp.AssertNotCalled(t, "SaveAndBroadcastStatus")
})
t.Run("should return an error when attempting to sync a user from a different remote", func(t *testing.T) {
remoteID := model.NewId()
anotherRemoteID := model.NewId()
user := &model.User{
Id: model.NewId(),
RemoteId: new(anotherRemoteID),
}
scs, mockApp, status, remoteCluster := setup(remoteID, user)
rctx := request.TestContext(t)
err := scs.upsertSyncUserStatus(rctx, status, remoteCluster)
require.Error(t, err)
assert.ErrorIs(t, err, ErrRemoteIDMismatch)
assert.Contains(t, err.Error(), "error updating user status")
mockApp.AssertNotCalled(t, "SaveAndBroadcastStatus")
})
}
func TestUpsertSyncPost(t *testing.T) {
remoteID := model.NewId()
channelID := model.NewId()
channel := &model.Channel{Id: channelID, Type: model.ChannelTypeOpen}
rc := &model.RemoteCluster{RemoteId: remoteID, Name: "test-remote"}
setup := func(t *testing.T, existing *model.Post) (*Service, *MockAppIface) {
mockPostStore := &mocks.PostStore{}
mockPostStore.On("GetSingle", mock.Anything, existing.Id, true).Return(existing, nil)
mockStore := &mocks.Store{}
mockStore.On("Post").Return(mockPostStore)
logger := mlog.CreateConsoleTestLogger(t)
mockServer := &MockServerIface{}
mockServer.On("GetStore").Return(mockStore)
mockServer.On("Log").Return(logger)
mockApp := &MockAppIface{}
return &Service{server: mockServer, app: mockApp}, mockApp
}
t.Run("rejects edit of a post owned by a different remote", func(t *testing.T) {
otherRemoteID := model.NewId()
postID := model.NewId()
existing := &model.Post{Id: postID, ChannelId: channelID, Message: "original", RemoteId: new(otherRemoteID)}
scs, mockApp := setup(t, existing)
_, err := scs.upsertSyncPost(&model.Post{Id: postID, ChannelId: channelID, Message: "tampered"}, channel, rc, nil)
require.Error(t, err)
assert.ErrorIs(t, err, ErrRemoteIDMismatch)
mockApp.AssertNotCalled(t, "UpdatePost", mock.Anything, mock.Anything, mock.Anything)
})
t.Run("rejects delete of a post owned by a local user", func(t *testing.T) {
postID := model.NewId()
existing := &model.Post{Id: postID, ChannelId: channelID, Message: "original", RemoteId: nil}
scs, mockApp := setup(t, existing)
_, err := scs.upsertSyncPost(&model.Post{Id: postID, ChannelId: channelID, DeleteAt: model.GetMillis()}, channel, rc, nil)
require.Error(t, err)
assert.ErrorIs(t, err, ErrRemoteIDMismatch)
mockApp.AssertNotCalled(t, "DeletePost", mock.Anything, mock.Anything, mock.Anything)
})
t.Run("allows edit of a post owned by the sending remote", func(t *testing.T) {
postID := model.NewId()
existing := &model.Post{Id: postID, ChannelId: channelID, Message: "original", RemoteId: new(remoteID)}
scs, mockApp := setup(t, existing)
updated := &model.Post{Id: postID, ChannelId: channelID, Message: "updated"}
// A federated, remote-owned edit must be allowed to carry mm_blocks_actions
// changes through the UpdatePost freeze — the origin cluster is the trust
// boundary. Assert the option is set so a revert to nil options is caught.
allowsMmBlocks := mock.MatchedBy(func(opts *model.UpdatePostOptions) bool {
return opts != nil && opts.AllowMmBlocksActionsUpdate
})
mockApp.On("UpdatePost", mock.Anything, mock.Anything, allowsMmBlocks).Return(updated, false, (*model.AppError)(nil))
_, err := scs.upsertSyncPost(&model.Post{Id: postID, ChannelId: channelID, Message: "updated"}, channel, rc, nil)
require.NoError(t, err)
mockApp.AssertCalled(t, "UpdatePost", mock.Anything, mock.Anything, allowsMmBlocks)
})
}

View File

@@ -0,0 +1,748 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"context"
"database/sql"
"errors"
"fmt"
"slices"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/platform/services/remotecluster"
)
type syncTask struct {
id string
channelID string
userID string
remoteID string
AddedAt time.Time
// existingMsg is used to add information to the task on creation
// instead of waiting until the task is processed to fetch it. If
// a new task with the same ID is scheduled, its existingMsg will
// replace the previous one
existingMsg *model.SyncMsg
retryCount int
retryMsg *model.SyncMsg
schedule time.Time
// originRemoteID is the remote that initiated this change; it will be
// skipped when syncing to prevent echo-back.
originRemoteID string
}
func newSyncTask(channelID, userID string, remoteID string, existingMsg, retryMsg *model.SyncMsg) syncTask {
var retryID string
if retryMsg != nil {
retryID = retryMsg.Id
}
// Generate a unique task ID
taskID := channelID + userID + remoteID + retryID // combination of ids to avoid duplicates
// For batch tasks, add a batch identifier to make the ID unique
if existingMsg != nil && len(existingMsg.MembershipChanges) > 1 {
batchID := model.NewId()[:8] // Use a short unique ID for the batch
taskID = channelID + "batch" + batchID + remoteID + retryID
}
return syncTask{
id: taskID,
channelID: channelID,
userID: userID,
remoteID: remoteID, // empty means update all remote clusters
existingMsg: existingMsg,
retryMsg: retryMsg,
schedule: time.Now(),
}
}
// incRetry increments the retry counter and returns true if MaxRetries not exceeded.
func (st *syncTask) incRetry() bool {
st.retryCount++
return st.retryCount <= MaxRetries
}
// NotifyChannelChanged is called to indicate that a shared channel has been modified,
// thus triggering an update to all remote clusters.
func (scs *Service) NotifyChannelChanged(channelID string) {
if rcs := scs.server.GetRemoteClusterService(); rcs == nil {
return
}
task := newSyncTask(channelID, "", "", nil, nil)
task.schedule = time.Now().Add(NotifyMinimumDelay)
scs.addTask(task)
}
// NotifyUserProfileChanged is called to indicate that a user has modified their user
// profile (name, username, email, custom status, profile image)
func (scs *Service) NotifyUserProfileChanged(userID string) {
if rcs := scs.server.GetRemoteClusterService(); rcs == nil {
return
}
scusers, err := scs.server.GetStore().SharedChannel().GetUsersForUser(userID)
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Failed to fetch shared channel users",
mlog.String("user_id", userID),
mlog.Err(err),
)
return
}
if len(scusers) == 0 {
return
}
notified := make(map[string]struct{})
for _, user := range scusers {
// update every user + remote combination they belong to.
// Redundant updates (ie. to same remote for multiple channels) will be
// filtered out.
combo := user.UserId + user.RemoteId
if _, ok := notified[combo]; ok {
continue
}
notified[combo] = struct{}{}
task := newSyncTask(user.ChannelId, "", user.RemoteId, nil, nil)
task.schedule = time.Now().Add(NotifyMinimumDelay)
scs.addTask(task)
}
}
// NotifyUserStatusChanged is called to indicate that a user has modified their status
func (scs *Service) NotifyUserStatusChanged(status *model.Status) {
if rcs := scs.server.GetRemoteClusterService(); rcs == nil {
return
}
if *scs.server.Config().ConnectedWorkspacesSettings.DisableSharedChannelsStatusSync {
return
}
if status.UserId == "" {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Received invalid status for sync",
mlog.String("user_id", status.UserId),
)
return
}
scusers, err := scs.server.GetStore().SharedChannel().GetUsersForUser(status.UserId)
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Failed to fetch shared channel users",
mlog.String("user_id", status.UserId),
mlog.Err(err),
)
return
}
if len(scusers) == 0 {
return
}
existingMsg := &model.SyncMsg{Statuses: []*model.Status{status}}
notified := make(map[string]struct{})
for _, user := range scusers {
// update every user + remote combination they belong to.
// Redundant updates (ie. to same remote for multiple channels) will be
// filtered out.
combo := user.UserId + user.RemoteId
if _, ok := notified[combo]; ok {
continue
}
notified[combo] = struct{}{}
task := newSyncTask(user.ChannelId, user.UserId, user.RemoteId, existingMsg, nil)
task.schedule = time.Now().Add(NotifyMinimumDelay)
scs.addTask(task)
}
}
func (scs *Service) SendPendingInvitesForRemote(rc *model.RemoteCluster) {
if rcs := scs.server.GetRemoteClusterService(); rcs == nil {
return
}
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Processing pending invites for remote after reconnection",
mlog.String("remote", rc.DisplayName),
mlog.String("remote_id", rc.RemoteId),
)
opts := model.SharedChannelRemoteFilterOpts{
RemoteId: rc.RemoteId,
ExcludeConfirmed: true,
}
scrs, err := scs.server.GetStore().SharedChannel().GetRemotes(0, 999999, opts)
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Failed to fetch shared channel remotes for pending invites",
mlog.String("remote", rc.DisplayName),
mlog.String("remote_id", rc.RemoteId),
mlog.Err(err),
)
return
}
for _, scr := range scrs {
channel, err := scs.server.GetStore().Channel().Get(scr.ChannelId, true)
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Failed to fetch channel for pending invite",
mlog.String("remote_id", scr.RemoteId),
mlog.String("channel_id", scr.ChannelId),
mlog.String("sharedchannelremote_id", scr.Id),
mlog.Err(err),
)
continue
}
if err := scs.SendChannelInvite(channel, scr.CreatorId, rc); err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Failed to send pending invite",
mlog.String("remote_id", scr.RemoteId),
mlog.String("channel_id", scr.ChannelId),
mlog.String("sharedchannelremote_id", scr.Id),
mlog.Err(err),
)
continue
}
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Pending invite sent",
mlog.String("remote", rc.DisplayName),
mlog.String("remote_id", rc.RemoteId),
mlog.String("channel_id", scr.ChannelId),
mlog.String("sharedchannelremote_id", scr.Id),
)
}
}
// 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(0, 999999, opts)
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Failed to fetch shared channel remotes",
mlog.String("remote", rc.DisplayName),
mlog.String("remote_id", rc.RemoteId),
mlog.Err(err),
)
return
}
for _, scr := range scrs {
task := newSyncTask(scr.ChannelId, "", rc.RemoteId, nil, 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 originalTask, ok := scs.tasks[task.id]; ok {
// if the task was already scheduled, we only update the
// existingMsg in case there is new information
originalTask.existingMsg = task.existingMsg
// originRemoteID identifies which remote initiated a change so processTask
// can skip sending back to that remote. When multiple events merge within
// the NotifyMinimumDelay window we can only safely skip a remote if every
// merged event came from that same remote. If the origins differ (e.g.
// remote-A join + remote-B join, or remote join + local join) we must clear
// originRemoteID so the sync fans out to all remotes. The receiver is
// idempotent, so the worst case is a redundant sync to the originating remote.
if task.originRemoteID != originalTask.originRemoteID {
originalTask.originRemoteID = ""
}
scs.tasks[task.id] = originalTask
} else {
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
metrics := scs.server.GetMetrics()
if metrics != nil {
scs.mux.Lock()
size := len(scs.tasks)
scs.mux.Unlock()
metrics.ObserveSharedChannelsQueueSize(int64(size))
}
for {
task, ok, shortestWait = scs.removeOldestTask()
if !ok {
break
}
if metrics != nil {
metrics.ObserveSharedChannelsTaskInQueueDuration(time.Since(task.AddedAt).Seconds())
}
if err := scs.processTask(task); err != nil {
// put task back into map so it will update again
if task.incRetry() {
task.schedule = time.Now().Add(SyncRetryDelay)
scs.addTask(task)
} else {
scs.server.Log().Error("Failed to synchronize shared channel",
mlog.String("channel_id", task.channelID),
mlog.String("remote_id", 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 {
// map is used to ensure remotes don't get sync'd twice, such as when
// they have the autoinvited flag and have explicitly subscribed to a channel.
remotesMap := make(map[string]*model.RemoteCluster)
if task.remoteID == "" {
filter := model.RemoteClusterQueryFilter{
InChannel: task.channelID,
OnlyConfirmed: true,
}
remotes, err := scs.server.GetStore().RemoteCluster().GetAll(0, 999999, filter)
if err != nil {
return err
}
for _, r := range remotes {
// Skip the remote that originated this membership change
if task.originRemoteID != "" && r.RemoteId == task.originRemoteID {
continue
}
remotesMap[r.RemoteId] = r
}
// add all confirmed remotes that have the autoinvited option.
filter = model.RemoteClusterQueryFilter{
RequireOptions: model.BitflagOptionAutoInvited,
OnlyConfirmed: true,
}
remotesAutoInvited, err := scs.server.GetStore().RemoteCluster().GetAll(0, 999999, filter)
if err != nil {
return err
}
for _, r := range remotesAutoInvited {
// Skip the remote that originated this membership change
if task.originRemoteID != "" && r.RemoteId == task.originRemoteID {
continue
}
remotesMap[r.RemoteId] = r
}
} else {
rc, err := scs.server.GetStore().RemoteCluster().Get(task.remoteID, false)
if err != nil {
// The remote cluster has been deleted or no longer exists; there is
// nothing to sync, so drop the task rather than retrying and logging
// an error on every change for the orphaned reference.
if errors.Is(err, sql.ErrNoRows) {
scs.selfHealOrphanedSharedChannelRemote(task.channelID, task.remoteID)
return nil
}
return err
}
if !rc.IsOnline() {
return fmt.Errorf("Failed updating shared channel '%s' for offline remote cluster '%s'", task.channelID, rc.DisplayName)
}
remotesMap[rc.RemoteId] = rc
}
for _, rc := range remotesMap {
rtask := task
rtask.remoteID = rc.RemoteId
if err := scs.syncForRemote(rtask, rc); err != nil {
// retry, spaced by SyncRetryDelay to avoid hammering the remote...
if rtask.incRetry() {
// A task with no specific remote fans out to every remote in this loop, and
// each rtask is a copy that still carries the original (remote-less) task id.
// Give the per-remote retry a distinct, remote-specific id so addTask (which
// merges on id) keeps a separate retry task per failed remote instead of
// collapsing them into one. Single-remote tasks already have a remote-specific
// id, so leave those untouched (recomputing would grow the id on every retry).
if task.remoteID == "" {
rtask.id = task.id + rc.RemoteId
}
rtask.schedule = time.Now().Add(SyncRetryDelay)
scs.addTask(rtask)
} else {
scs.server.Log().Error("Failed to synchronize shared channel for remote cluster",
mlog.String("channel_id", rtask.channelID),
mlog.String("remote", rc.DisplayName),
mlog.Err(err),
)
}
}
}
return nil
}
// selfHealOrphanedSharedChannelRemote handles a SharedChannelRemote row that still
// references a RemoteCluster which has been soft-deleted or removed. Left alone, the
// live SCR row (DeleteAt = 0) keeps getting picked up by the sync loop, spamming a log
// entry on every tick. Soft-deleting the orphaned SCR row stops the recurrence without
// requiring manual DB intervention.
func (scs *Service) selfHealOrphanedSharedChannelRemote(channelID, remoteID string) {
scr, err := scs.server.GetStore().SharedChannel().GetRemoteByIds(channelID, remoteID)
if err != nil {
// The SCR row is already gone (or unreadable); nothing left to self-heal.
scs.server.Log().Warn("Skipping sync for deleted remote cluster",
mlog.String("channel_id", channelID),
mlog.String("remote_id", remoteID),
mlog.Err(err),
)
return
}
if scr.DeleteAt != 0 {
// The SCR row is already soft-deleted, so there is nothing to heal. A deleted
// remote no longer syncing is the expected steady state, so this is logged at
// debug rather than warn to avoid noise.
scs.server.Log().Debug("Skipping sync for deleted remote cluster",
mlog.String("channel_id", channelID),
mlog.String("remote_id", remoteID),
)
return
}
if _, err := scs.server.GetStore().SharedChannel().DeleteRemote(scr.Id); err != nil {
scs.server.Log().Warn("Failed to self-heal orphaned shared channel remote for deleted remote cluster",
mlog.String("channel_id", channelID),
mlog.String("remote_id", remoteID),
mlog.String("shared_channel_remote_id", scr.Id),
mlog.Err(err),
)
return
}
scs.server.Log().Warn("Self-healed orphaned shared channel remote for deleted remote cluster",
mlog.String("channel_id", channelID),
mlog.String("remote_id", remoteID),
mlog.String("shared_channel_remote_id", scr.Id),
)
}
func (scs *Service) handlePostError(postId string, task syncTask, rc *model.RemoteCluster) {
if task.retryMsg != nil && len(task.retryMsg.Posts) == 1 && task.retryMsg.Posts[0].Id == postId {
// this was a retry for specific post that failed previously. Try again if within MaxRetries.
if task.incRetry() {
scs.addTask(task)
} else {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "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(request.EmptyContext(scs.server.Log()), postId, true)
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "error fetching post for sync retry",
mlog.String("remote", rc.DisplayName),
mlog.String("post_id", postId),
mlog.Err(err),
)
return
}
// Populate metadata for the retry post
post = scs.app.PreparePostForClient(request.EmptyContext(scs.server.Log()), post, &model.PreparePostForClientOpts{IncludePriority: true})
syncMsg := model.NewSyncMsg(task.channelID)
syncMsg.Posts = []*model.Post{post}
scs.addTask(newSyncTask(task.channelID, task.userID, task.remoteID, nil, syncMsg))
}
func (scs *Service) handleStatusError(userId string, task syncTask, rc *model.RemoteCluster) {
if task.retryMsg != nil && len(task.retryMsg.Statuses) == 1 && task.retryMsg.Statuses[0].UserId == userId {
// this was a retry for specific status that failed previously. Try again if within MaxRetries.
if task.incRetry() {
scs.addTask(task)
} else {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "error syncing status",
mlog.String("remote", rc.DisplayName),
mlog.String("user_id", userId),
)
}
return
}
// this status failed as part of a group of statuses. Retry as an individual status.
status, err := scs.server.GetStore().Status().Get(userId)
if err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "error fetching status for sync retry",
mlog.String("remote", rc.DisplayName),
mlog.String("user_id", userId),
mlog.Err(err),
)
return
}
syncMsg := model.NewSyncMsg(task.channelID)
syncMsg.Statuses = []*model.Status{status}
scs.addTask(newSyncTask(task.channelID, task.userID, task.remoteID, nil, syncMsg))
}
// notifyRemoteOffline creates an ephemeral post to the author for any posts created recently to remotes
// that are offline.
func (scs *Service) notifyRemoteOffline(posts []*model.Post, rc *model.RemoteCluster) {
// only send one ephemeral post per author.
notified := make(map[string]bool)
// range the slice in reverse so the newest posts are visited first; this ensures an ephemeral
// get added where it is mostly likely to be seen.
for _, post := range slices.Backward(posts) {
if didNotify := notified[post.UserId]; didNotify {
continue
}
postCreateAt := model.GetTimeForMillis(post.CreateAt)
if post.DeleteAt == 0 && post.UserId != "" && time.Since(postCreateAt) < NotifyRemoteOfflineThreshold {
T := scs.getUserTranslations(post.UserId)
ephemeral := &model.Post{
ChannelId: post.ChannelId,
Message: T("sharedchannel.cannot_deliver_post", map[string]any{"Remote": rc.DisplayName}),
CreateAt: post.CreateAt + 1,
}
scs.app.SendEphemeralPost(request.EmptyContext(scs.server.Log()), post.UserId, ephemeral)
notified[post.UserId] = true
}
}
}
func (scs *Service) updateCursorForRemote(scrId string, rc *model.RemoteCluster, cursor model.GetPostsSinceForSyncCursor) {
if err := scs.server.GetStore().SharedChannel().UpdateRemoteCursor(scrId, cursor); err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "error updating cursor for shared channel remote",
mlog.String("remote", rc.DisplayName),
mlog.Err(err),
)
return
}
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "updated cursor for remote",
mlog.String("remote_id", rc.RemoteId),
mlog.String("remote", rc.DisplayName),
mlog.Int("last_post_create_at", cursor.LastPostCreateAt),
mlog.String("last_post_create_id", cursor.LastPostCreateID),
mlog.Int("last_post_update_at", cursor.LastPostUpdateAt),
mlog.String("last_post_update_id", cursor.LastPostUpdateID),
)
}
func (scs *Service) getUserTranslations(userId string) i18n.TranslateFunc {
var locale string
user, err := scs.server.GetStore().User().Get(request.EmptyContext(scs.server.Log()), userId)
if err == nil {
locale = user.Locale
}
if locale == "" {
locale = model.DefaultLocale
}
return i18n.GetUserTranslations(locale)
}
// shouldUserSync determines if a user needs to be synchronized.
// User should be synchronized if it has no entry in the SharedChannelUsers table for the specified channel,
// or there is an entry but the LastSyncAt is less than user.UpdateAt
func (scs *Service) shouldUserSync(user *model.User, channelID string, rc *model.RemoteCluster) (sync bool, syncImage bool, err error) {
// don't sync users with the remote they originated from.
if user.RemoteId != nil && *user.RemoteId == rc.RemoteId {
return false, false, nil
}
scu, err := scs.server.GetStore().SharedChannel().GetSingleUser(user.Id, channelID, rc.RemoteId)
if err != nil {
if _, ok := err.(errNotFound); !ok {
return false, false, err
}
// user not in the SharedChannelUsers table, so we must add them.
scu = &model.SharedChannelUser{
UserId: user.Id,
RemoteId: rc.RemoteId,
ChannelId: channelID,
}
if _, err = scs.server.GetStore().SharedChannel().SaveUser(scu); err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceError, "Error adding user to shared channel users",
mlog.String("user_id", user.Id),
mlog.String("channel_id", channelID),
mlog.String("remote_id", rc.RemoteId),
mlog.Err(err),
)
} else {
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Added user to shared channel users",
mlog.String("user_id", user.Id),
mlog.String("channel_id", channelID),
mlog.String("remote_id", rc.RemoteId),
)
}
return true, true, nil
}
return user.UpdateAt > scu.LastSyncAt, user.LastPictureUpdate > scu.LastSyncAt, nil
}
func (scs *Service) syncProfileImage(user *model.User, channelID string, rc *model.RemoteCluster) {
rcs := scs.server.GetRemoteClusterService()
if rcs == nil {
return
}
if rc.IsPlugin() {
scs.sendProfileImageToPlugin(user, channelID, rc)
return
}
ctx, cancel := context.WithTimeout(context.Background(), ProfileImageSyncTimeout)
defer cancel()
rcs.SendProfileImage(ctx, user.Id, rc, scs.app, func(userId string, rc *model.RemoteCluster, resp *remotecluster.Response, err error) {
if resp.IsSuccess() {
scs.recordProfileImageSuccess(user.Id, channelID, rc.RemoteId)
return
}
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Error synchronizing users profile image",
mlog.String("user_id", user.Id),
mlog.String("channel_id", channelID),
mlog.String("remote_id", rc.RemoteId),
mlog.Err(err),
)
})
}
func (scs *Service) sendProfileImageToPlugin(user *model.User, channelID string, rc *model.RemoteCluster) {
if err := scs.app.OnSharedChannelsProfileImageSyncMsg(user, rc); err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Error synchronizing users profile image for plugin",
mlog.String("user_id", user.Id),
mlog.String("channel_id", channelID),
mlog.String("remote_id", rc.RemoteId),
mlog.Err(err),
)
}
scs.recordProfileImageSuccess(user.Id, channelID, rc.RemoteId)
}
func (scs *Service) recordProfileImageSuccess(userID, channelID, remoteID string) {
scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Users profile image synchronized",
mlog.String("user_id", userID),
mlog.String("channel_id", channelID),
mlog.String("remote_id", remoteID),
)
// update LastSyncAt for user in SharedChannelUsers table
if err := scs.server.GetStore().SharedChannel().UpdateUserLastSyncAt(userID, channelID, remoteID); err != nil {
scs.server.Log().LogM(mlog.MlvlSharedChannelServiceWarn, "Error updating users LastSyncTime after profile image update",
mlog.String("user_id", userID),
mlog.String("channel_id", channelID),
mlog.String("remote_id", remoteID),
mlog.Err(err),
)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,508 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"context"
"database/sql"
"errors"
"fmt"
"maps"
"sync"
"testing"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
"github.com/mattermost/mattermost/server/v8/platform/services/remotecluster"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func newTestService() *Service {
return &Service{
changeSignal: make(chan struct{}, 1),
tasks: make(map[string]syncTask),
}
}
func TestProcessTask_RemoteClusterLookup(t *testing.T) {
// Mirror how the store wraps the not-found case for a soft-deleted/missing remote.
notFound := fmt.Errorf("failed to find RemoteCluster: %w", sql.ErrNoRows)
newService := func(t *testing.T, remoteID string, rc *model.RemoteCluster, getErr error) (*Service, *mocks.SharedChannelStore) {
t.Helper()
mockRemoteClusterStore := &mocks.RemoteClusterStore{}
mockRemoteClusterStore.On("Get", remoteID, false).Return(rc, getErr)
mockSharedChannelStore := &mocks.SharedChannelStore{}
mockStore := &mocks.Store{}
mockStore.On("RemoteCluster").Return(mockRemoteClusterStore)
mockStore.On("SharedChannel").Return(mockSharedChannelStore)
mockServer := &MockServerIface{}
mockServer.On("GetStore").Return(mockStore)
mockServer.On("GetMetrics").Return(nil)
mockServer.On("Log").Return(mlog.CreateConsoleTestLogger(t))
return &Service{
server: mockServer,
changeSignal: make(chan struct{}, 1),
tasks: make(map[string]syncTask),
}, mockSharedChannelStore
}
t.Run("orphaned remote self-heals by soft-deleting the live SCR row", func(t *testing.T) {
channelID := model.NewId()
remoteID := model.NewId()
scr := &model.SharedChannelRemote{Id: model.NewId(), ChannelId: channelID, RemoteId: remoteID, DeleteAt: 0}
scs, scStore := newService(t, remoteID, nil, notFound)
scStore.On("GetRemoteByIds", channelID, remoteID).Return(scr, nil)
scStore.On("DeleteRemote", scr.Id).Return(true, nil)
err := scs.processTask(newSyncTask(channelID, "", remoteID, nil, nil))
require.NoError(t, err, "an orphaned remote should be skipped, not surfaced as an error")
scStore.AssertNumberOfCalls(t, "DeleteRemote", 1)
scStore.AssertCalled(t, "DeleteRemote", scr.Id)
})
t.Run("self-healed orphan is drained from the sync queue and not retried", func(t *testing.T) {
channelID := model.NewId()
remoteID := model.NewId()
scr := &model.SharedChannelRemote{Id: model.NewId(), ChannelId: channelID, RemoteId: remoteID, DeleteAt: 0}
scs, scStore := newService(t, remoteID, nil, notFound)
scStore.On("GetRemoteByIds", channelID, remoteID).Return(scr, nil)
scStore.On("DeleteRemote", scr.Id).Return(true, nil)
task := newSyncTask(channelID, "", remoteID, nil, nil)
scs.addTask(task)
scs.doSync()
assert.Empty(t, scs.tasks, "the orphaned task must be drained, not re-enqueued for retry")
scStore.AssertNumberOfCalls(t, "DeleteRemote", 1)
})
t.Run("already soft-deleted SCR row is not deleted again", func(t *testing.T) {
channelID := model.NewId()
remoteID := model.NewId()
scr := &model.SharedChannelRemote{Id: model.NewId(), ChannelId: channelID, RemoteId: remoteID, DeleteAt: model.GetMillis()}
scs, scStore := newService(t, remoteID, nil, notFound)
scStore.On("GetRemoteByIds", channelID, remoteID).Return(scr, nil)
err := scs.processTask(newSyncTask(channelID, "", remoteID, nil, nil))
require.NoError(t, err)
scStore.AssertNotCalled(t, "DeleteRemote", mock.Anything)
})
t.Run("missing SCR row is skipped without error or delete", func(t *testing.T) {
channelID := model.NewId()
remoteID := model.NewId()
scs, scStore := newService(t, remoteID, nil, notFound)
scStore.On("GetRemoteByIds", channelID, remoteID).Return(nil, store.NewErrNotFound("SharedChannelRemote", "missing"))
err := scs.processTask(newSyncTask(channelID, "", remoteID, nil, nil))
require.NoError(t, err)
scStore.AssertNotCalled(t, "DeleteRemote", mock.Anything)
})
t.Run("failure to soft-delete the orphan is swallowed without retry", func(t *testing.T) {
channelID := model.NewId()
remoteID := model.NewId()
scr := &model.SharedChannelRemote{Id: model.NewId(), ChannelId: channelID, RemoteId: remoteID, DeleteAt: 0}
scs, scStore := newService(t, remoteID, nil, notFound)
scStore.On("GetRemoteByIds", channelID, remoteID).Return(scr, nil)
scStore.On("DeleteRemote", scr.Id).Return(false, errors.New("db is down"))
err := scs.processTask(newSyncTask(channelID, "", remoteID, nil, nil))
require.NoError(t, err, "a self-heal delete failure must not surface as a sync error or trigger a retry")
scStore.AssertCalled(t, "DeleteRemote", scr.Id)
})
t.Run("transient error is propagated for retry", func(t *testing.T) {
remoteID := model.NewId()
dbErr := errors.New("write tcp: connection reset by peer")
scs, scStore := newService(t, remoteID, nil, dbErr)
err := scs.processTask(newSyncTask("channel-1", "", remoteID, nil, nil))
require.Error(t, err, "a transient lookup error must still be returned so the task retries")
assert.ErrorContains(t, err, "connection reset by peer")
scStore.AssertNotCalled(t, "GetRemoteByIds", mock.Anything, mock.Anything)
scStore.AssertNotCalled(t, "DeleteRemote", mock.Anything)
})
}
func TestAddTask_OriginRemoteIDMerge(t *testing.T) {
tests := []struct {
name string
firstOrigin string
secondOrigin string
expectedOrigin string
}{
{
name: "same remote origin is preserved",
firstOrigin: "remote-A",
secondOrigin: "remote-A",
expectedOrigin: "remote-A",
},
{
name: "local then remote clears origin",
firstOrigin: "",
secondOrigin: "remote-A",
expectedOrigin: "",
},
{
name: "remote then local clears origin",
firstOrigin: "remote-A",
secondOrigin: "",
expectedOrigin: "",
},
{
name: "different remotes clears origin",
firstOrigin: "remote-A",
secondOrigin: "remote-B",
expectedOrigin: "",
},
{
name: "both local stays empty",
firstOrigin: "",
secondOrigin: "",
expectedOrigin: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
scs := newTestService()
channelID := "channel-1"
first := newSyncTask(channelID, "", "", nil, nil)
first.originRemoteID = tc.firstOrigin
scs.addTask(first)
second := newSyncTask(channelID, "", "", nil, nil)
second.originRemoteID = tc.secondOrigin
scs.addTask(second)
merged, ok := scs.tasks[first.id]
require.True(t, ok, "task should exist")
assert.Equal(t, tc.expectedOrigin, merged.originRemoteID)
})
}
}
// mockRCSForSync is a minimal no-op implementation of RemoteClusterServiceIFace that
// records NotifySyncFailed calls. By default its SendMsg simulates an unreachable remote
// (delivery failure reported via the callback); set cbResp/cbErr to instead deliver a
// specific response envelope (e.g. an unparseable or empty payload with no transport error).
type mockRCSForSync struct {
mu sync.Mutex
notifyCalls []string
cbConfigured bool
cbResp *remotecluster.Response
cbErr error
enqueueErr error // when set, SendMsg returns this WITHOUT invoking the callback
}
func (m *mockRCSForSync) NotifySyncFailed(remoteId string) {
m.mu.Lock()
defer m.mu.Unlock()
m.notifyCalls = append(m.notifyCalls, remoteId)
}
func (m *mockRCSForSync) Shutdown() error { return nil }
func (m *mockRCSForSync) Start() error { return nil }
func (m *mockRCSForSync) Active() bool { return true }
func (m *mockRCSForSync) AddTopicListener(topic string, listener remotecluster.TopicListener) string {
return ""
}
func (m *mockRCSForSync) RemoveTopicListener(listenerId string) {}
func (m *mockRCSForSync) AddConnectionStateListener(listener remotecluster.ConnectionStateListener) string {
return ""
}
func (m *mockRCSForSync) RemoveConnectionStateListener(listenerId string) {}
func (m *mockRCSForSync) SendMsg(_ context.Context, msg model.RemoteClusterMsg, rc *model.RemoteCluster, f remotecluster.SendMsgResultFunc) error {
// Simulate an enqueue failure (e.g. BufferFullError): the callback never runs.
if m.enqueueErr != nil {
return m.enqueueErr
}
// The enqueue succeeds (return nil); the outcome is reported asynchronously via the
// callback, mirroring how remotecluster.Service.sendMsg surfaces a send result.
if f != nil {
if m.cbConfigured {
f(msg, rc, m.cbResp, m.cbErr)
} else {
// Default: simulate an unreachable remote (transport error).
f(msg, rc, &remotecluster.Response{Err: "remote unreachable"}, errors.New("remote unreachable"))
}
}
return nil
}
func (m *mockRCSForSync) SendFile(_ context.Context, _ *model.UploadSession, _ *model.FileInfo, _ *model.RemoteCluster, _ remotecluster.ReaderProvider, _ remotecluster.SendFileResultFunc) error {
return errors.New("remote unreachable")
}
func (m *mockRCSForSync) SendProfileImage(_ context.Context, _ string, _ *model.RemoteCluster, _ remotecluster.ProfileImageProvider, _ remotecluster.SendProfileImageResultFunc) error {
return errors.New("remote unreachable")
}
func (m *mockRCSForSync) AcceptInvitation(_ *model.RemoteClusterInvite, _, _, _, _, _ string) (*model.RemoteCluster, error) {
return nil, nil
}
func (m *mockRCSForSync) ReceiveIncomingMsg(_ *model.RemoteCluster, _ model.RemoteClusterMsg) remotecluster.Response {
return remotecluster.Response{}
}
func (m *mockRCSForSync) ReceiveInviteConfirmation(_ model.RemoteClusterInvite) (*model.RemoteCluster, error) {
return nil, nil
}
func (m *mockRCSForSync) PingNow(_ *model.RemoteCluster) {}
var _ remotecluster.RemoteClusterServiceIFace = (*mockRCSForSync)(nil)
// TestProcessTask_RetryDelay verifies that a failed per-remote sync re-enqueues the task
// with a schedule at least SyncRetryDelay in the future.
func TestProcessTask_RetryDelay(t *testing.T) {
channelID := model.NewId()
remoteID := model.NewId()
rc := &model.RemoteCluster{
RemoteId: remoteID,
DisplayName: "test-remote",
LastPingAt: model.GetMillis(), // online
}
rcStoreMock := &mocks.RemoteClusterStore{}
rcStoreMock.On("Get", remoteID, false).Return(rc, nil)
mockStore := &mocks.Store{}
mockStore.On("RemoteCluster").Return(rcStoreMock)
mockServer := &MockServerIface{}
mockServer.On("GetStore").Return(mockStore)
mockServer.On("GetMetrics").Return(nil)
mockServer.On("GetRemoteClusterService").Return(nil) // nil rcs causes syncForRemote to fail immediately
scs := &Service{
server: mockServer,
changeSignal: make(chan struct{}, 1),
tasks: make(map[string]syncTask),
}
task := newSyncTask(channelID, "", remoteID, nil, nil)
scs.addTask(task)
before := time.Now()
scs.doSync()
scs.mux.RLock()
tasksCopy := make(map[string]syncTask, len(scs.tasks))
maps.Copy(tasksCopy, scs.tasks)
scs.mux.RUnlock()
require.Len(t, tasksCopy, 1, "failed task should be re-enqueued for retry")
for _, retried := range tasksCopy {
assert.Equal(t, 1, retried.retryCount, "retry count should be 1 after first failure")
assert.True(t, retried.schedule.After(before.Add(SyncRetryDelay)),
"re-enqueued task schedule should be at least SyncRetryDelay in the future (got %s, want after %s)",
retried.schedule, before.Add(SyncRetryDelay))
}
}
// TestProcessTask_FanOutRetryPerRemote verifies that when a task with no specific remote
// fans out to several remotes and each fails, every remote keeps its own retry task (rather
// than all but one being discarded by addTask's merge on the shared task id).
func TestProcessTask_FanOutRetryPerRemote(t *testing.T) {
channelID := model.NewId()
rcA := &model.RemoteCluster{RemoteId: model.NewId(), DisplayName: "remote-A"}
rcB := &model.RemoteCluster{RemoteId: model.NewId(), DisplayName: "remote-B"}
rcStoreMock := &mocks.RemoteClusterStore{}
// Both the in-channel and auto-invited lookups return the two remotes; remotesMap dedups.
rcStoreMock.On("GetAll", 0, 999999, mock.Anything).Return([]*model.RemoteCluster{rcA, rcB}, nil)
mockStore := &mocks.Store{}
mockStore.On("RemoteCluster").Return(rcStoreMock)
mockServer := &MockServerIface{}
mockServer.On("GetStore").Return(mockStore)
mockServer.On("GetMetrics").Return(nil)
mockServer.On("GetRemoteClusterService").Return(nil) // nil rcs makes syncForRemote fail for each remote
scs := &Service{
server: mockServer,
changeSignal: make(chan struct{}, 1),
tasks: make(map[string]syncTask),
}
// Fan-out task: empty remoteID.
scs.addTask(newSyncTask(channelID, "", "", nil, nil))
scs.doSync()
scs.mux.RLock()
tasksCopy := make(map[string]syncTask, len(scs.tasks))
maps.Copy(tasksCopy, scs.tasks)
scs.mux.RUnlock()
require.Len(t, tasksCopy, 2, "each failed remote should retain its own retry task")
gotRemotes := make(map[string]bool)
for _, retried := range tasksCopy {
assert.Equal(t, 1, retried.retryCount, "retry count should be 1 after first failure")
gotRemotes[retried.remoteID] = true
}
assert.True(t, gotRemotes[rcA.RemoteId], "remote A should have its own retry task")
assert.True(t, gotRemotes[rcB.RemoteId], "remote B should have its own retry task")
}
// TestSendSyncMsgToRemote_NotifiesOnDeliveryFailure guards the offline-recovery fix: when a
// send fails asynchronously (remote unreachable), sendSyncMsgToRemote does NOT surface an
// error to the caller — the send is fire-and-forget and the cursor is left un-advanced — but
// it signals the remote cluster service via NotifySyncFailed so the next successful ping
// drives a ForceSyncForRemote recovery.
func TestSendSyncMsgToRemote_NotifiesOnDeliveryFailure(t *testing.T) {
mockRCS := &mockRCSForSync{}
mockServer := &MockServerIface{}
mockServer.On("GetRemoteClusterService").Return(mockRCS)
mockServer.On("Log").Return(mlog.CreateConsoleTestLogger(t))
scs := &Service{
server: mockServer,
changeSignal: make(chan struct{}, 1),
tasks: make(map[string]syncTask),
}
remoteID := model.NewId()
rc := &model.RemoteCluster{RemoteId: remoteID, Name: "test-remote", SiteURL: "http://example.invalid"}
msg := model.NewSyncMsg(model.NewId())
var callbackInvoked bool
err := scs.sendSyncMsgToRemote(msg, rc, func(model.SyncResponse, error) { callbackInvoked = true })
require.NoError(t, err, "a delivery failure must not be surfaced as an error; the send is fire-and-forget")
assert.False(t, callbackInvoked, "the result callback must not run on a delivery failure, so the cursor stays un-advanced")
mockRCS.mu.Lock()
notifyCalls := mockRCS.notifyCalls
mockRCS.mu.Unlock()
require.Len(t, notifyCalls, 1, "NotifySyncFailed should be called once on delivery failure")
assert.Equal(t, remoteID, notifyCalls[0])
}
// TestSendSyncMsgToRemote_NotifiesOnUnconfirmedResponse covers the case where the send
// succeeds at the transport level (no errResp) but the response envelope can't be treated
// as a confirmed sync: an unparseable payload, or an empty payload (the receive side always
// sets a SyncResponse payload on success). In both cases sendSyncMsgToRemote must not error,
// must not advance the cursor, and must signal NotifySyncFailed for a ping-driven resync.
func TestSendSyncMsgToRemote_NotifiesOnUnconfirmedResponse(t *testing.T) {
cases := []struct {
name string
resp *remotecluster.Response
}{
{name: "unparseable payload", resp: &remotecluster.Response{Status: model.StatusOk, Payload: []byte("not json")}},
{name: "empty payload", resp: &remotecluster.Response{Status: model.StatusOk}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mockRCS := &mockRCSForSync{cbConfigured: true, cbResp: tc.resp, cbErr: nil}
mockServer := &MockServerIface{}
mockServer.On("GetRemoteClusterService").Return(mockRCS)
mockServer.On("Log").Return(mlog.CreateConsoleTestLogger(t))
scs := &Service{
server: mockServer,
changeSignal: make(chan struct{}, 1),
tasks: make(map[string]syncTask),
}
remoteID := model.NewId()
rc := &model.RemoteCluster{RemoteId: remoteID, Name: "test-remote", SiteURL: "http://example.invalid"}
msg := model.NewSyncMsg(model.NewId())
var callbackInvoked bool
err := scs.sendSyncMsgToRemote(msg, rc, func(model.SyncResponse, error) { callbackInvoked = true })
require.NoError(t, err, "an unconfirmed response must not be surfaced as an error")
assert.False(t, callbackInvoked, "the result callback must not run for an unconfirmed response, so the cursor stays un-advanced")
mockRCS.mu.Lock()
notifyCalls := mockRCS.notifyCalls
mockRCS.mu.Unlock()
require.Len(t, notifyCalls, 1, "NotifySyncFailed should be called once for an unconfirmed response")
assert.Equal(t, remoteID, notifyCalls[0])
})
}
}
// TestSendSyncMsgToRemote_EnqueueErrorDoesNotHang verifies that when SendMsg fails to enqueue
// (e.g. BufferFullError) — so the result callback never runs — sendSyncMsgToRemote returns the
// error instead of blocking forever on the callback's WaitGroup, which would freeze the single
// doSync goroutine and stall all shared-channel sync.
func TestSendSyncMsgToRemote_EnqueueErrorDoesNotHang(t *testing.T) {
mockRCS := &mockRCSForSync{enqueueErr: errors.New("buffer full")}
mockServer := &MockServerIface{}
mockServer.On("GetRemoteClusterService").Return(mockRCS)
mockServer.On("Log").Return(mlog.CreateConsoleTestLogger(t))
scs := &Service{
server: mockServer,
changeSignal: make(chan struct{}, 1),
tasks: make(map[string]syncTask),
}
rc := &model.RemoteCluster{RemoteId: model.NewId(), Name: "test-remote", SiteURL: "http://example.invalid"}
msg := model.NewSyncMsg(model.NewId())
// Guard with a timeout so a regression (hang on wg.Wait) fails fast instead of blocking
// the whole suite until the test binary times out.
done := make(chan error, 1)
go func() { done <- scs.sendSyncMsgToRemote(msg, rc, nil) }()
select {
case err := <-done:
require.Error(t, err, "an enqueue failure must be returned to the caller")
assert.ErrorContains(t, err, "buffer full")
case <-time.After(5 * time.Second):
require.Fail(t, "sendSyncMsgToRemote hung on wg.Wait() after an enqueue failure")
}
// The send never left the queue, so there is no delivery outcome to notify about; that
// path is the caller's retry (the returned error), not a ping-driven resync.
mockRCS.mu.Lock()
notifyCount := len(mockRCS.notifyCalls)
mockRCS.mu.Unlock()
assert.Zero(t, notifyCount, "no sync-failed notification should fire when the enqueue itself failed")
}
func TestStripSharedChannelStatePostsForSync(t *testing.T) {
sd := &syncData{
posts: []*model.Post{
{Id: "state-1", Type: model.PostTypeSharedChannelState, ChannelId: "ch1", Message: "ignored"},
{Id: "user-1", Type: model.PostTypeDefault, ChannelId: "ch1", Message: "hello"},
},
}
stripSharedChannelStatePostsForSync(sd)
require.Len(t, sd.posts, 1)
assert.Equal(t, "user-1", sd.posts[0].Id)
assert.Equal(t, model.PostTypeDefault, sd.posts[0].Type)
}

View File

@@ -0,0 +1,139 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"errors"
"fmt"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
// fixMention transforms @username:remotename mentions to @username format
// Used when syncing posts to a user's home cluster
func fixMention(post *model.Post, mentionMap model.UserMentionMap, user *model.User) {
if post == nil || len(mentionMap) == 0 {
return
}
realUsername, ok := user.GetProp(model.UserPropsKeyRemoteUsername)
if !ok {
return
}
for mention, id := range mentionMap {
// Only process mentions with colons that match this user's ID
if id == user.Id && strings.Contains(mention, ":") {
post.Message = strings.ReplaceAll(post.Message, "@"+mention, "@"+realUsername)
}
}
}
func sanitizeUserForSync(user *model.User) *model.User {
user.Password = model.NewId()
user.AuthData = nil
user.AuthService = ""
user.Roles = "system_user"
user.AllowMarketing = false
user.NotifyProps = model.StringMap{}
user.LastPasswordUpdate = 0
user.LastPictureUpdate = 0
user.FailedAttempts = 0
user.MfaActive = false
user.MfaSecret = ""
return user
}
const MungUsernameSeparator = "-"
// mungUsername creates a new username by combining username and remote cluster name, plus
// a suffix to create uniqueness. If the resulting username exceeds the max length then
// it is truncated and ellipses added.
func mungUsername(username string, remotename string, suffix string, maxLen int) string {
if suffix != "" {
suffix = MungUsernameSeparator + suffix
}
// If the username already contains a colon then another server already munged it.
// In that case we can split on the colon and use the existing remote name.
// We still need to re-mung with suffix in case of collision.
comps := strings.Split(username, ":")
if len(comps) >= 2 {
username = comps[0]
remotename = strings.Join(comps[1:], "")
}
var userEllipses string
var remoteEllipses string
// The remotename is allowed to use up to half the maxLen, and the username gets the remaining space.
// Username might have a suffix to account for, and remotename always has a preceding colon.
half := maxLen / 2
// If the remotename is less than half the maxLen, then the left over space can be given to
// the username.
extra := max(half-(len(remotename)+1), 0)
truncUser := (len(username) + len(suffix)) - (half + extra)
if truncUser > 0 {
username = username[:len(username)-truncUser-3]
userEllipses = "..."
}
truncRemote := (len(remotename) + 1) - (maxLen - (len(username) + len(userEllipses) + len(suffix)))
if truncRemote > 0 {
remotename = remotename[:len(remotename)-truncRemote-3]
remoteEllipses = "..."
}
return fmt.Sprintf("%s%s%s:%s%s", username, suffix, userEllipses, remotename, remoteEllipses)
}
func isConflictError(err error) (string, bool) {
if err == nil {
return "", false
}
var errConflict *store.ErrConflict
if errors.As(err, &errConflict) {
return strings.ToLower(errConflict.Resource), true
}
var errInput *store.ErrInvalidInput
if errors.As(err, &errInput) {
_, field, _ := errInput.InvalidInputInfo()
return strings.ToLower(field), true
}
return "", false
}
func isNotFoundError(err error) bool {
if err == nil {
return false
}
var errNotFound *store.ErrNotFound
return errors.As(err, &errNotFound)
}
func postsSliceToMap(posts []*model.Post) map[string]*model.Post {
m := make(map[string]*model.Post, len(posts))
for _, p := range posts {
m[p.Id] = p
}
return m
}
func reducePostsSliceInCache(posts []*model.Post, cache map[string]*model.Post) []*model.Post {
reduced := make([]*model.Post, 0, len(posts))
for _, p := range posts {
if _, ok := cache[p.Id]; !ok {
reduced = append(reduced, p)
}
}
return reduced
}

View File

@@ -0,0 +1,250 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"strings"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/require"
)
func Test_mungUsername(t *testing.T) {
type args struct {
username string
remotename string
suffix string
maxLen int
}
tests := []struct {
name string
args args
want string
}{
{"everything empty", args{username: "", remotename: "", suffix: "", maxLen: 64}, ":"},
{"no trunc, no suffix", args{username: "bart", remotename: "example.com", suffix: "", maxLen: 64}, "bart:example.com"},
{"no trunc, suffix", args{username: "bart", remotename: "example.com", suffix: "2", maxLen: 64}, "bart-2:example.com"},
{"trunc remote, no suffix", args{username: "bart", remotename: "example1234567890.com", suffix: "", maxLen: 24}, "bart:example123456789..."},
{"trunc remote, suffix", args{username: "bart", remotename: "example1234567890.com", suffix: "2", maxLen: 24}, "bart-2:example1234567..."},
{"trunc both, no suffix", args{username: R(24, "A"), remotename: R(24, "B"), suffix: "", maxLen: 24}, "AAAAAAAAA...:BBBBBBBB..."},
{"trunc both, suffix", args{username: R(24, "A"), remotename: R(24, "B"), suffix: "10", maxLen: 24}, "AAAAAA-10...:BBBBBBBB..."},
{"trunc user, no suffix", args{username: R(40, "A"), remotename: "abc", suffix: "", maxLen: 24}, "AAAAAAAAAAAAAAAAA...:abc"},
{"trunc user, suffix", args{username: R(40, "A"), remotename: "abc", suffix: "11", maxLen: 24}, "AAAAAAAAAAAAAA-11...:abc"},
{"trunc user, remote, no suffix", args{username: R(40, "A"), remotename: "abcdefghijk", suffix: "", maxLen: 24}, "AAAAAAAAA...:abcdefghijk"},
{"trunc user, remote, suffix", args{username: R(40, "A"), remotename: "abcdefghijk", suffix: "19", maxLen: 24}, "AAAAAA-19...:abcdefghijk"},
{"short user, long remote, no suffix", args{username: "bart", remotename: R(40, "B"), suffix: "", maxLen: 24}, "bart:BBBBBBBBBBBBBBBB..."},
{"long user, short remote, no suffix", args{username: R(40, "A"), remotename: "abc.com", suffix: "", maxLen: 24}, "AAAAAAAAAAAAA...:abc.com"},
{"short user, long remote, suffix", args{username: "bart", remotename: R(40, "B"), suffix: "12", maxLen: 24}, "bart-12:BBBBBBBBBBBBB..."},
{"long user, short remote, suffix", args{username: R(40, "A"), remotename: "abc.com", suffix: "12", maxLen: 24}, "AAAAAAAAAA-12...:abc.com"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := mungUsername(tt.args.username, tt.args.remotename, tt.args.suffix, tt.args.maxLen); got != tt.want {
t.Errorf("mungUsername() = %v, want %v", got, tt.want)
}
})
}
}
func Test_mungUsernameFuzz(t *testing.T) {
// ensure no index out of bounds panic for any combination
for i := range 70 {
for j := range 70 {
for k := range 3 {
username := R(i, "A")
remotename := R(j, "B")
suffix := R(k, "1")
result := mungUsername(username, remotename, suffix, 64)
require.LessOrEqual(t, len(result), 64)
}
}
}
}
func Test_fixMention(t *testing.T) {
tests := []struct {
name string
post *model.Post
mentionMap model.UserMentionMap
user *model.User
expected string
}{
{
name: "nil post",
post: nil,
mentionMap: model.UserMentionMap{"user:remote": "userid"},
user: &model.User{Id: "userid"},
expected: "",
},
{
name: "empty mentionMap",
post: &model.Post{Message: "hello @user:remote"},
mentionMap: model.UserMentionMap{},
user: &model.User{Id: "userid"},
expected: "hello @user:remote",
},
{
name: "no RemoteUsername prop",
post: &model.Post{Message: "hello @user:remote"},
mentionMap: model.UserMentionMap{"user:remote": "userid"},
user: &model.User{Id: "userid"},
expected: "hello @user:remote",
},
// Username clash test cases (same username on both local and remote)
// With the new behavior, when syncing to a user's home cluster, we always remove the remote suffix
{
name: "username clash - simple mention",
post: &model.Post{Message: "hello @user:remote"},
mentionMap: model.UserMentionMap{"user:remote": "userid"},
user: &model.User{Id: "userid", RemoteId: new("remoteid"), Props: model.StringMap{model.UserPropsKeyRemoteUsername: "user"}},
expected: "hello @user",
},
{
name: "username clash - mention at start",
post: &model.Post{Message: "@user:remote hello"},
mentionMap: model.UserMentionMap{"user:remote": "userid"},
user: &model.User{Id: "userid", RemoteId: new("remoteid"), Props: model.StringMap{model.UserPropsKeyRemoteUsername: "user"}},
expected: "@user hello",
},
{
name: "username clash - multiple mentions",
post: &model.Post{Message: "hello @user:remote and @user:remote again"},
mentionMap: model.UserMentionMap{"user:remote": "userid"},
user: &model.User{Id: "userid", RemoteId: new("remoteid"), Props: model.StringMap{model.UserPropsKeyRemoteUsername: "user"}},
expected: "hello @user and @user again",
},
{
name: "simple mention different username",
post: &model.Post{Message: "hello @user:remote"},
mentionMap: model.UserMentionMap{"user:remote": "userid"},
user: &model.User{Id: "userid", RemoteId: new("remoteid"), Props: model.StringMap{model.UserPropsKeyRemoteUsername: "realuser"}},
expected: "hello @realuser",
},
{
name: "simple mention same username",
post: &model.Post{Message: "hello @user:remote"},
mentionMap: model.UserMentionMap{"user:remote": "userid"},
user: &model.User{Id: "userid", RemoteId: new("remoteid"), Props: model.StringMap{model.UserPropsKeyRemoteUsername: "user"}},
expected: "hello @user",
},
{
name: "mention at start",
post: &model.Post{Message: "@user:remote hello"},
mentionMap: model.UserMentionMap{"user:remote": "userid"},
user: &model.User{Id: "userid", RemoteId: new("remoteid"), Props: model.StringMap{model.UserPropsKeyRemoteUsername: "realuser"}},
expected: "@realuser hello",
},
{
name: "mention at end",
post: &model.Post{Message: "hello @user:remote"},
mentionMap: model.UserMentionMap{"user:remote": "userid"},
user: &model.User{Id: "userid", RemoteId: new("remoteid"), Props: model.StringMap{model.UserPropsKeyRemoteUsername: "realuser"}},
expected: "hello @realuser",
},
{
name: "multiple mentions of same user",
post: &model.Post{Message: "hello @user:remote and @user:remote again"},
mentionMap: model.UserMentionMap{"user:remote": "userid"},
user: &model.User{Id: "userid", RemoteId: new("remoteid"), Props: model.StringMap{model.UserPropsKeyRemoteUsername: "realuser"}},
expected: "hello @realuser and @realuser again",
},
{
name: "multiple different mentions",
post: &model.Post{Message: "hello @user:remote and @other:remote"},
mentionMap: model.UserMentionMap{"user:remote": "userid", "other:remote": "otherid"},
user: &model.User{Id: "userid", RemoteId: new("remoteid"), Props: model.StringMap{model.UserPropsKeyRemoteUsername: "realuser"}},
expected: "hello @realuser and @other:remote",
},
{
name: "mention without colon",
post: &model.Post{Message: "hello @user"},
mentionMap: model.UserMentionMap{"user": "userid"},
user: &model.User{Id: "userid", RemoteId: new("remoteid"), Props: model.StringMap{model.UserPropsKeyRemoteUsername: "realuser"}},
expected: "hello @user",
},
{
name: "mention different user id",
post: &model.Post{Message: "hello @user:remote"},
mentionMap: model.UserMentionMap{"user:remote": "different"},
user: &model.User{Id: "userid", RemoteId: new("remoteid"), Props: model.StringMap{model.UserPropsKeyRemoteUsername: "realuser"}},
expected: "hello @user:remote",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fixMention(tt.post, tt.mentionMap, tt.user)
if tt.post != nil {
require.Equal(t, tt.expected, tt.post.Message)
}
})
}
}
// R returns a string with the specified string repeated `count` times.
func R(count int, s string) string {
return strings.Repeat(s, count)
}
func TestHandleUserMentions(t *testing.T) {
t.Run("should format mentions across clusters", func(t *testing.T) {
// This test verifies the behavior when same username exists on multiple clusters
// Define remote cluster names (not server names)
clusterBName := "remote_cluster_B"
// Create user with admin username on remote cluster B
adminUserB := &model.User{
Id: "adminB",
Username: "admin",
RemoteId: new(clusterBName),
Props: model.StringMap{model.UserPropsKeyRemoteUsername: "admin"},
}
// Scenario: User on Cluster B mentions local admin
// The mention should show as @admin on Cluster B
postOnClusterB := &model.Post{
Message: "Hey @admin, please review this.",
}
// Mentions are preserved when synced between clusters
// so that the local admin on Cluster A would receive a notification
// We're verifying the post will keep its mention format after being synced
require.Contains(t, postOnClusterB.Message, "@admin",
"Message should preserve @admin mention when synced between clusters")
// Scenario: User on Cluster A explicitly mentions admin on Cluster B
// It will show as @admin:remote_cluster_B on Cluster A
postWithRemoteMention := &model.Post{
Message: "Let's ask @admin:remote_cluster_B about this",
}
mentionMapWithRemote := model.UserMentionMap{
"admin:remote_cluster_B": "adminB",
}
// When this post is synced to Cluster B, fixMention will transform
// @admin:remote_cluster_B to @admin (removing the remote suffix)
postCopy := &model.Post{Message: postWithRemoteMention.Message}
fixMention(postCopy, mentionMapWithRemote, adminUserB)
// fixMention should replace @username:remote with @realusername (without the remote suffix)
// when syncing to the user's home cluster
expected := "Let's ask @admin about this"
// The part we're testing is that mentions are simplified when sent to the user's home cluster
require.Equal(t, expected, postCopy.Message,
"Should remove remote cluster suffix when message is viewed on user's home server")
})
}

View File

@@ -0,0 +1,197 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Regenerate this file using `make telemetry-mocks`.
package mocks
import (
httpservice "github.com/mattermost/mattermost/server/public/shared/httpservice"
mock "github.com/stretchr/testify/mock"
model "github.com/mattermost/mattermost/server/public/model"
plugin "github.com/mattermost/mattermost/server/public/plugin"
request "github.com/mattermost/mattermost/server/public/shared/request"
)
// ServerIface is an autogenerated mock type for the ServerIface type
type ServerIface struct {
mock.Mock
}
// Config provides a mock function with no fields
func (_m *ServerIface) Config() *model.Config {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Config")
}
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
}
// GetPluginsEnvironment provides a mock function with no fields
func (_m *ServerIface) GetPluginsEnvironment() *plugin.Environment {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPluginsEnvironment")
}
var r0 *plugin.Environment
if rf, ok := ret.Get(0).(func() *plugin.Environment); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*plugin.Environment)
}
}
return r0
}
// GetRoleByName provides a mock function with given fields: _a0, _a1
func (_m *ServerIface) GetRoleByName(_a0 request.CTX, _a1 string) (*model.Role, *model.AppError) {
ret := _m.Called(_a0, _a1)
if len(ret) == 0 {
panic("no return value specified for GetRoleByName")
}
var r0 *model.Role
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, string) (*model.Role, *model.AppError)); ok {
return rf(_a0, _a1)
}
if rf, ok := ret.Get(0).(func(request.CTX, string) *model.Role); ok {
r0 = rf(_a0, _a1)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Role)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, string) *model.AppError); ok {
r1 = rf(_a0, _a1)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// GetSchemes provides a mock function with given fields: _a0, _a1, _a2
func (_m *ServerIface) GetSchemes(_a0 string, _a1 int, _a2 int) ([]*model.Scheme, *model.AppError) {
ret := _m.Called(_a0, _a1, _a2)
if len(ret) == 0 {
panic("no return value specified for GetSchemes")
}
var r0 []*model.Scheme
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(string, int, int) ([]*model.Scheme, *model.AppError)); ok {
return rf(_a0, _a1, _a2)
}
if rf, ok := ret.Get(0).(func(string, int, int) []*model.Scheme); ok {
r0 = rf(_a0, _a1, _a2)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.Scheme)
}
}
if rf, ok := ret.Get(1).(func(string, int, int) *model.AppError); ok {
r1 = rf(_a0, _a1, _a2)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// HTTPService provides a mock function with no fields
func (_m *ServerIface) HTTPService() httpservice.HTTPService {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for HTTPService")
}
var r0 httpservice.HTTPService
if rf, ok := ret.Get(0).(func() httpservice.HTTPService); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(httpservice.HTTPService)
}
}
return r0
}
// IsLeader provides a mock function with no fields
func (_m *ServerIface) IsLeader() bool {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for IsLeader")
}
var r0 bool
if rf, ok := ret.Get(0).(func() bool); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// License provides a mock function with no fields
func (_m *ServerIface) License() *model.License {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for License")
}
var r0 *model.License
if rf, ok := ret.Get(0).(func() *model.License); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.License)
}
}
return r0
}
// NewServerIface creates a new instance of ServerIface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewServerIface(t interface {
mock.TestingT
Cleanup(func())
}) *ServerIface {
mock := &ServerIface{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package telemetry
import (
"fmt"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
const (
DBAccessAttempts = 3
DBAccessTimeoutSecs = 10
)
type ServerIface interface {
Config() *model.Config
IsLeader() bool
}
type TelemetryService struct {
srv ServerIface
dbStore store.Store
log *mlog.Logger
ServerID string
}
func New(srv ServerIface, dbStore store.Store, log *mlog.Logger) (*TelemetryService, error) {
service := &TelemetryService{
srv: srv,
dbStore: dbStore,
log: log,
}
if err := service.ensureServerID(); err != nil {
return nil, fmt.Errorf("unable to ensure telemetry ID: %w", err)
}
return service, nil
}
func (ts *TelemetryService) ensureServerID() error {
if ts.ServerID != "" {
return nil
}
id := model.NewId()
var err error
for range DBAccessAttempts {
ts.log.Info("Ensuring the telemetry ID..")
systemID := &model.System{Name: model.SystemServerId, Value: id}
systemID, err = ts.dbStore.System().InsertIfExists(systemID)
if err != nil {
ts.log.Info("Unable to get/set the telemetry ID", mlog.Err(err))
time.Sleep(DBAccessTimeoutSecs * time.Second)
continue
}
ts.ServerID = systemID.Value
ts.log.Info("server ID is set", mlog.String("id", ts.ServerID))
return nil
}
return fmt.Errorf("unable to get the server ID: %w", err)
}

View File

@@ -0,0 +1,97 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package telemetry
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
storeMocks "github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
"github.com/mattermost/mattermost/server/v8/platform/services/telemetry/mocks"
)
func TestEnsureServerID(t *testing.T) {
t.Run("test ID in database and does not run twice", func(t *testing.T) {
storeMock := &storeMocks.Store{}
systemStore := storeMocks.SystemStore{}
returnValue := &model.System{
Name: model.SystemServerId,
Value: "test",
}
systemStore.On("InsertIfExists", mock.AnythingOfType("*model.System")).Return(returnValue, nil).Once()
storeMock.On("System").Return(&systemStore)
serverIfaceMock := &mocks.ServerIface{}
testLogger, _ := mlog.NewLogger()
telemetryService, err := New(serverIfaceMock, storeMock, testLogger)
require.NoError(t, err)
assert.Equal(t, "test", telemetryService.ServerID)
telemetryService.ensureServerID()
assert.Equal(t, "test", telemetryService.ServerID)
// No more calls to the store if we try to ensure it again
telemetryService.ensureServerID()
assert.Equal(t, "test", telemetryService.ServerID)
})
t.Run("new test ID created", func(t *testing.T) {
storeMock := &storeMocks.Store{}
systemStore := storeMocks.SystemStore{}
returnValue := &model.System{
Name: model.SystemServerId,
}
var generatedID string
systemStore.On("InsertIfExists", mock.AnythingOfType("*model.System")).Return(returnValue, nil).Once().Run(func(args mock.Arguments) {
s := args.Get(0).(*model.System)
returnValue.Value = s.Value
generatedID = s.Value
})
storeMock.On("System").Return(&systemStore)
serverIfaceMock := &mocks.ServerIface{}
testLogger, _ := mlog.NewLogger()
telemetryService, err := New(serverIfaceMock, storeMock, testLogger)
require.NoError(t, err)
assert.Equal(t, generatedID, telemetryService.ServerID)
})
t.Run("fail to save test ID", func(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode.")
}
storeMock := &storeMocks.Store{}
systemStore := storeMocks.SystemStore{}
insertError := errors.New("insert error")
systemStore.On("InsertIfExists", mock.AnythingOfType("*model.System")).Return(nil, insertError).Times(DBAccessAttempts)
storeMock.On("System").Return(&systemStore)
serverIfaceMock := &mocks.ServerIface{}
testLogger, _ := mlog.NewLogger()
_, err := New(serverIfaceMock, storeMock, testLogger)
require.Error(t, err)
})
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package upgrader
import (
"fmt"
)
// InvalidArch indicates that the current operating system or cpu architecture doesn't support upgrades
type InvalidArch struct{}
func NewInvalidArch() *InvalidArch {
return &InvalidArch{}
}
func (e *InvalidArch) Error() string {
return "invalid operating system or processor architecture"
}
// InvalidSignature indicates that the downloaded file doesn't have a valid signature.
type InvalidSignature struct{}
func NewInvalidSignature() *InvalidSignature {
return &InvalidSignature{}
}
func (e *InvalidSignature) Error() string {
return "invalid file signature"
}
// InvalidPermissions indicates that the file permissions doesn't allow to upgrade
type InvalidPermissions struct {
ErrType string
Path string
FileUsername string
MattermostUsername string
}
func NewInvalidPermissions(errType string, path string, mattermostUsername string, fileUsername string) *InvalidPermissions {
return &InvalidPermissions{
ErrType: errType,
Path: path,
FileUsername: fileUsername,
MattermostUsername: mattermostUsername,
}
}
func (e *InvalidPermissions) Error() string {
return fmt.Sprintf("the user %s is unable to update the %s file", e.MattermostUsername, e.Path)
}

View File

@@ -0,0 +1,18 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQENBFjZQxwBCAC6kNn3zDlq/aY83M9V7MHVPoK2jnZ3BfH7sA+ibQXsijCkPSR4
5bCUJ9qVA4XKGK+cpO9vkolSNs10igCaaemaUZNB6ksu3gT737/SZcCAfRO+cLX7
Q2la+jwTvu1YeT/M5xDZ1KHTFxsGskeIenz2rZHeuZwBl9qep34QszWtRX40eRts
fl6WltLrepiExTp6NMZ50k+Em4JGM6CWBMo22ucy0jYjZXO5hEGb3o6NGiG+Dx2z
b2J78LksCKGsSrn0F1rLJeA933bFL4g9ozv9asBlzmpgG77ESg6YE1N/Rh7WDzVA
prIR0MuB5JjElASw5LDVxDV6RZsxEVQr7ETLABEBAAG0KU1hdHRlcm1vc3QgQnVp
bGQgPGRldi1vcHNAbWF0dGVybW9zdC5jb20+iQFUBBMBCAA+AhsDBQsJCAcCBhUI
CQoLAgQWAgMBAh4BAheAFiEEobMdRvDzoQsCzy1E+PLDF0R3SygFAmJOqWgFCQ03
zUwACgkQ+PLDF0R3Syg/rQf8D5BgvVFnGuHDYNu2eiasZdfxmuhg1C7JGSLHqoCT
SB/0SLLQyMeHsJLye/gbo3yhK8G9XYOm+obGF+NDxB0LtRaPv5Q6pIQYt88ZxOGA
Kh6RG2DjYA5j410wYrN0mNzhudqnS2yZdyq215nEr7Z6l1T7L9OPcz0u0mF9RraQ
nawzxbxc8mPuC5tMLTedViSkTYLgMY12TCSYhykseUIGrl/FBfMbmKwBHM52SZJh
maBevuNymlFbODTciyE9Q7mJHkaamGKTXaa3Enlcf16oSoemawSBJuspaS0sZOW8
dgi5l3V5YvfFvSk45axiZbnGYfN81G5mkSGAENSGSKVtMA==
=kkvg
-----END PGP PUBLIC KEY BLOCK-----

View File

@@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build !linux
package upgrader
func CanIUpgradeToE0() error {
return &InvalidArch{}
}
func UpgradeToE0() error {
return &InvalidArch{}
}
func UpgradeToE0Status() (int64, error) {
return 0, &InvalidArch{}
}

View File

@@ -0,0 +1,368 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package upgrader
import (
"archive/tar"
"bytes"
"compress/gzip"
_ "embed"
"fmt"
"io"
"net/http"
"os"
"os/user"
"path"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"github.com/pkg/errors"
"golang.org/x/crypto/openpgp" //nolint:staticcheck
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
//go:embed pubkey.gpg
var mattermostBuildPublicKeys []byte
var (
upgradePercentage int64
m sync.Mutex
upgradeError error
upgrading int32
)
type writeCounter struct {
total int64
read int64
}
func (wc *writeCounter) Write(p []byte) (int, error) {
n := len(p)
wc.read += int64(n)
if wc.total <= 0 {
// skip the percentage calculation for invalid totals
setUpgradePercentage(50)
return n, nil
}
percentage := (wc.read * 100) / wc.total
if percentage == 0 {
percentage = 1
} else if percentage >= 100 {
percentage = 99
}
setUpgradePercentage(percentage)
return n, nil
}
func getUpgradePercentage() int64 {
return atomic.LoadInt64(&upgradePercentage)
}
func setUpgradePercentage(to int64) {
atomic.StoreInt64(&upgradePercentage, to)
}
func getUpgradeError() error {
m.Lock()
defer m.Unlock()
return upgradeError
}
func setUpgradeError(err error) {
m.Lock()
defer m.Unlock()
upgradeError = err
}
func getCurrentVersionTgzURL() string {
version := model.CurrentVersion
if strings.HasPrefix(model.BuildNumber, version+"-rc") {
version = model.BuildNumber
}
return "https://releases.mattermost.com/" + version + "/mattermost-" + version + "-linux-amd64.tar.gz"
}
func verifySignature(filename string, sigfilename string, publicKey []byte) error {
keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(publicKey))
if err != nil {
mlog.Debug("Unable to load the public key to verify the file signature", mlog.Err(err))
return NewInvalidSignature()
}
mattermost_tar, err := os.Open(filename)
if err != nil {
mlog.Debug("Unable to open the Mattermost .tar file to verify the file signature", mlog.Err(err))
return NewInvalidSignature()
}
signature, err := os.Open(sigfilename)
if err != nil {
mlog.Debug("Unable to open the Mattermost .sig file verify the file signature", mlog.Err(err))
return NewInvalidSignature()
}
_, err = openpgp.CheckDetachedSignature(keyring, mattermost_tar, signature)
if err != nil {
mlog.Debug("Unable to verify the Mattermost file signature", mlog.Err(err))
return NewInvalidSignature()
}
return nil
}
func canIWriteTheExecutable() error {
executablePath, err := os.Executable()
if err != nil {
return errors.New("error getting the path of the executable")
}
executableInfo, err := os.Stat(path.Dir(executablePath))
if err != nil {
return errors.New("error getting the executable info")
}
stat, ok := executableInfo.Sys().(*syscall.Stat_t)
if !ok {
return errors.New("error getting the executable info")
}
fileUID := int(stat.Uid)
fileUser, err := user.LookupId(strconv.Itoa(fileUID))
if err != nil {
return errors.New("error getting the executable info")
}
mattermostUID := os.Getuid()
mattermostUser, err := user.LookupId(strconv.Itoa(mattermostUID))
if err != nil {
return errors.New("error getting the executable info")
}
mode := executableInfo.Mode()
if fileUID != mattermostUID && mode&(1<<1) == 0 && mode&(1<<7) == 0 {
return NewInvalidPermissions("invalid-user-and-permission", path.Dir(executablePath), mattermostUser.Username, fileUser.Username)
}
if fileUID != mattermostUID && mode&(1<<1) == 0 && mode&(1<<7) != 0 {
return NewInvalidPermissions("invalid-user", path.Dir(executablePath), mattermostUser.Username, fileUser.Username)
}
if fileUID == mattermostUID && mode&(1<<7) == 0 {
return NewInvalidPermissions("invalid-permission", path.Dir(executablePath), mattermostUser.Username, fileUser.Username)
}
return nil
}
func canIUpgrade() error {
if runtime.GOARCH != "amd64" {
return NewInvalidArch()
}
if runtime.GOOS != "linux" {
return NewInvalidArch()
}
return canIWriteTheExecutable()
}
func CanIUpgradeToE0() error {
if err := canIUpgrade(); err != nil {
return errors.Wrap(err, "unable to upgrade from TE to E0")
}
if model.BuildEnterpriseReady == "true" {
mlog.Warn("Unable to upgrade from TE to E0. The server is already running E0.")
return errors.New("you cannot upgrade your server from TE to E0 because you are already running Mattermost Enterprise Edition")
}
return nil
}
func UpgradeToE0() error {
if !atomic.CompareAndSwapInt32(&upgrading, 0, 1) {
mlog.Warn("Trying to upgrade while another upgrade is running")
return errors.New("another upgrade is already running")
}
defer atomic.CompareAndSwapInt32(&upgrading, 1, 0)
setUpgradePercentage(1)
setUpgradeError(nil)
executablePath, err := os.Executable()
if err != nil {
setUpgradeError(errors.New("error getting the executable path"))
mlog.Error("Unable to get the path of the Mattermost executable", mlog.Err(err))
setUpgradePercentage(0)
return err
}
filename, err := download(getCurrentVersionTgzURL())
if err != nil {
if filename != "" {
os.Remove(filename)
}
setUpgradeError(fmt.Errorf("error downloading the new Mattermost server binary file (percentage: %d)", getUpgradePercentage()))
mlog.Error("Unable to download the Mattermost server binary file", mlog.Int("percentage", getUpgradePercentage()), mlog.String("url", getCurrentVersionTgzURL()), mlog.Err(err))
setUpgradePercentage(0)
return err
}
defer os.Remove(filename)
sigfilename, err := download(getCurrentVersionTgzURL() + ".sig")
if err != nil {
if sigfilename != "" {
os.Remove(sigfilename)
}
setUpgradeError(errors.New("error downloading the signature file of the new server"))
mlog.Error("Unable to download the signature file of the new Mattermost server", mlog.String("url", getCurrentVersionTgzURL()+".sig"), mlog.Err(err))
setUpgradePercentage(0)
return err
}
defer os.Remove(sigfilename)
err = verifySignature(filename, sigfilename, mattermostBuildPublicKeys)
if err != nil {
setUpgradeError(errors.New("unable to verify the signature of the downloaded file"))
mlog.Error("Unable to verify the signature of the downloaded file", mlog.Err(err))
setUpgradePercentage(0)
return err
}
err = extractBinary(executablePath, filename)
if err != nil {
setUpgradeError(err)
mlog.Error("Unable to extract the binary from the downloaded file", mlog.Err(err))
setUpgradePercentage(0)
return err
}
setUpgradePercentage(100)
return nil
}
func UpgradeToE0Status() (int64, error) {
return getUpgradePercentage(), getUpgradeError()
}
func download(url string) (string, error) {
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
io.Copy(io.Discard, resp.Body)
return "", errors.Errorf("error downloading file %s: %s", url, resp.Status)
}
out, err := os.CreateTemp("", "*_mattermost.tar.gz")
if err != nil {
return "", err
}
defer out.Close()
counter := &writeCounter{total: resp.ContentLength}
_, err = io.Copy(out, io.TeeReader(resp.Body, counter))
if err != nil {
return "", err
}
return out.Name(), nil
}
func getFilePermissionsOrDefault(filename string, def os.FileMode) os.FileMode {
file, err := os.Open(filename)
if err != nil {
mlog.Warn("Unable to get the file permissions", mlog.String("filename", filename), mlog.Err(err))
return def
}
defer file.Close()
fileStats, err := file.Stat()
if err != nil {
mlog.Warn("Unable to get the file permissions", mlog.String("filename", filename), mlog.Err(err))
return def
}
return fileStats.Mode()
}
func extractBinary(executablePath string, filename string) error {
gzipStream, err := os.Open(filename)
if err != nil {
return err
}
uncompressedStream, err := gzip.NewReader(gzipStream)
if err != nil {
return err
}
tarReader := tar.NewReader(uncompressedStream)
for {
header, err := tarReader.Next()
if err == io.EOF {
return errors.New("unable to find the Mattermost binary in the downloaded version")
}
if err != nil {
return err
}
if header.Typeflag == tar.TypeReg && header.Name == "mattermost/bin/mattermost" {
permissions := getFilePermissionsOrDefault(executablePath, 0755)
tmpFile, err := os.CreateTemp(path.Dir(executablePath), "*")
if err != nil {
return err
}
tmpFileName := tmpFile.Name()
os.Remove(tmpFileName)
err = os.Rename(executablePath, tmpFileName)
if err != nil {
return err
}
outFile, err := os.Create(executablePath)
if err != nil {
err2 := os.Rename(tmpFileName, executablePath)
if err2 != nil {
mlog.Fatal("Unable to restore the backup of the executable file. Restore the executable file manually.")
return errors.Wrap(err2, "critical error: unable to upgrade the binary or restore the old binary version. Please restore it manually")
}
return err
}
defer outFile.Close()
if _, err = io.Copy(outFile, tarReader); err != nil {
err2 := os.Remove(executablePath)
if err2 != nil {
mlog.Fatal("Unable to restore the backup of the executable file. Restore the executable file manually.")
return errors.Wrap(err2, "critical error: unable to upgrade the binary or restore the old binary version. Please restore it manually")
}
err2 = os.Rename(tmpFileName, executablePath)
if err2 != nil {
mlog.Fatal("Unable to restore the backup of the executable file. Restore the executable file manually.")
return errors.Wrap(err2, "critical error: unable to upgrade the binary or restore the old binary version. Please restore it manually")
}
return err
}
err = os.Remove(tmpFileName)
if err != nil {
mlog.Warn("Unable to clean up the binary backup file.", mlog.Err(err))
}
err = os.Chmod(executablePath, permissions)
if err != nil {
mlog.Warn("Unable to set the correct permissions for the file.", mlog.Err(err))
}
break
}
}
return nil
}

View File

@@ -0,0 +1,164 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package upgrader
import (
"archive/tar"
"compress/gzip"
"io"
"os"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestCanIUpgradeToE0(t *testing.T) {
t.Run("when you are already in an enterprise build", func(t *testing.T) {
buildEnterprise := model.BuildEnterpriseReady
model.BuildEnterpriseReady = "true"
defer func() {
model.BuildEnterpriseReady = buildEnterprise
}()
require.Error(t, CanIUpgradeToE0())
})
t.Run("when you are not in an enterprise build", func(t *testing.T) {
buildEnterprise := model.BuildEnterpriseReady
model.BuildEnterpriseReady = "false"
defer func() {
model.BuildEnterpriseReady = buildEnterprise
}()
require.NoError(t, CanIUpgradeToE0())
})
}
func TestGetCurrentVersionTgzURL(t *testing.T) {
t.Run("get release version in regular version", func(t *testing.T) {
currentVersion := model.CurrentVersion
buildNumber := model.CurrentVersion
model.CurrentVersion = "5.22.0"
model.BuildNumber = "5.22.0"
defer func() {
model.CurrentVersion = currentVersion
model.BuildNumber = buildNumber
}()
require.Equal(t, "https://releases.mattermost.com/5.22.0/mattermost-5.22.0-linux-amd64.tar.gz", getCurrentVersionTgzURL())
})
t.Run("get release version in dev version", func(t *testing.T) {
currentVersion := model.CurrentVersion
buildNumber := model.CurrentVersion
model.CurrentVersion = "5.22.0"
model.BuildNumber = "5.22.0-dev"
defer func() {
model.CurrentVersion = currentVersion
model.BuildNumber = buildNumber
}()
require.Equal(t, "https://releases.mattermost.com/5.22.0/mattermost-5.22.0-linux-amd64.tar.gz", getCurrentVersionTgzURL())
})
t.Run("get release version in rc version", func(t *testing.T) {
currentVersion := model.CurrentVersion
buildNumber := model.CurrentVersion
model.CurrentVersion = "5.22.0"
model.BuildNumber = "5.22.0-rc2"
defer func() {
model.CurrentVersion = currentVersion
model.BuildNumber = buildNumber
}()
require.Equal(t, "https://releases.mattermost.com/5.22.0-rc2/mattermost-5.22.0-rc2-linux-amd64.tar.gz", getCurrentVersionTgzURL())
})
}
func TestExtractBinary(t *testing.T) {
t.Run("extract from empty file", func(t *testing.T) {
tmpMockTarGz, err := os.CreateTemp("", "mock_tgz")
require.NoError(t, err)
defer os.Remove(tmpMockTarGz.Name())
tmpMockTarGz.Close()
tmpMockExecutable, err := os.CreateTemp("", "mock_exe")
require.NoError(t, err)
defer os.Remove(tmpMockExecutable.Name())
tmpMockExecutable.Close()
extractBinary(tmpMockExecutable.Name(), tmpMockTarGz.Name())
})
t.Run("extract from empty tar.gz file", func(t *testing.T) {
tmpMockTarGz, err := os.CreateTemp("", "mock_tgz")
require.NoError(t, err)
defer os.Remove(tmpMockTarGz.Name())
gz := gzip.NewWriter(tmpMockTarGz)
tw := tar.NewWriter(gz)
tw.Close()
gz.Close()
tmpMockTarGz.Close()
tmpMockExecutable, err := os.CreateTemp("", "mock_exe")
require.NoError(t, err)
defer os.Remove(tmpMockExecutable.Name())
tmpMockExecutable.Close()
require.Error(t, extractBinary(tmpMockExecutable.Name(), tmpMockTarGz.Name()))
})
t.Run("extract from tar.gz without mattermost/bin/mattermost file", func(t *testing.T) {
tmpMockTarGz, err := os.CreateTemp("", "mock_tgz")
require.NoError(t, err)
defer os.Remove(tmpMockTarGz.Name())
gz := gzip.NewWriter(tmpMockTarGz)
tw := tar.NewWriter(gz)
tw.WriteHeader(&tar.Header{
Typeflag: tar.TypeReg,
Name: "test-filename",
Size: 4,
})
tw.Write([]byte("test"))
gz.Close()
tmpMockTarGz.Close()
tmpMockExecutable, err := os.CreateTemp("", "mock_exe")
require.NoError(t, err)
defer os.Remove(tmpMockExecutable.Name())
tmpMockExecutable.Close()
require.Error(t, extractBinary(tmpMockExecutable.Name(), tmpMockTarGz.Name()))
})
t.Run("extract from tar.gz with mattermost/bin/mattermost file", func(t *testing.T) {
tmpMockTarGz, err := os.CreateTemp("", "mock_tgz")
require.NoError(t, err)
defer os.Remove(tmpMockTarGz.Name())
gz := gzip.NewWriter(tmpMockTarGz)
tw := tar.NewWriter(gz)
tw.WriteHeader(&tar.Header{
Typeflag: tar.TypeReg,
Name: "mattermost/bin/mattermost",
Size: 4,
})
tw.Write([]byte("test"))
gz.Close()
tmpMockTarGz.Close()
tmpMockExecutable, err := os.CreateTemp("", "mock_exe")
require.NoError(t, err)
defer os.Remove(tmpMockExecutable.Name())
tmpMockExecutable.Close()
require.NoError(t, extractBinary(tmpMockExecutable.Name(), tmpMockTarGz.Name()))
tmpMockExecutableAfter, err := os.Open(tmpMockExecutable.Name())
require.NoError(t, err)
defer tmpMockExecutableAfter.Close()
bytes, err := io.ReadAll(tmpMockExecutableAfter)
require.NoError(t, err)
require.Equal(t, []byte("test"), bytes)
})
}