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
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:
156
server/platform/services/awsmeter/awsmeter.go
Normal file
156
server/platform/services/awsmeter/awsmeter.go
Normal 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
|
||||
}
|
||||
146
server/platform/services/awsmeter/awsmeter_test.go
Normal file
146
server/platform/services/awsmeter/awsmeter_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
10
server/platform/services/cache/.mockery.yaml
vendored
Normal file
10
server/platform/services/cache/.mockery.yaml
vendored
Normal 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
68
server/platform/services/cache/cache.go
vendored
Normal 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
237
server/platform/services/cache/lru.go
vendored
Normal 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)
|
||||
}
|
||||
162
server/platform/services/cache/lru_striped.go
vendored
Normal file
162
server/platform/services/cache/lru_striped.go
vendored
Normal 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
|
||||
}
|
||||
94
server/platform/services/cache/lru_striped_bench_test.go
vendored
Normal file
94
server/platform/services/cache/lru_striped_bench_test.go
vendored
Normal 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()
|
||||
}
|
||||
}
|
||||
136
server/platform/services/cache/lru_striped_test.go
vendored
Normal file
136
server/platform/services/cache/lru_striped_test.go
vendored
Normal 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_)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
661
server/platform/services/cache/lru_test.go
vendored
Normal file
661
server/platform/services/cache/lru_test.go
vendored
Normal 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()
|
||||
}
|
||||
213
server/platform/services/cache/mocks/Cache.go
vendored
Normal file
213
server/platform/services/cache/mocks/Cache.go
vendored
Normal 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
|
||||
}
|
||||
249
server/platform/services/cache/mocks/ExternalCache.go
vendored
Normal file
249
server/platform/services/cache/mocks/ExternalCache.go
vendored
Normal 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
|
||||
}
|
||||
129
server/platform/services/cache/mocks/Provider.go
vendored
Normal file
129
server/platform/services/cache/mocks/Provider.go
vendored
Normal 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
|
||||
}
|
||||
146
server/platform/services/cache/provider.go
vendored
Normal file
146
server/platform/services/cache/provider.go
vendored
Normal 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
|
||||
}
|
||||
173
server/platform/services/cache/provider_test.go
vendored
Normal file
173
server/platform/services/cache/provider_test.go
vendored
Normal 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
341
server/platform/services/cache/redis.go
vendored
Normal 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
|
||||
}
|
||||
120
server/platform/services/docextractor/archive.go
Normal file
120
server/platform/services/docextractor/archive.go
Normal 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
|
||||
}
|
||||
117
server/platform/services/docextractor/archive_test.go
Normal file
117
server/platform/services/docextractor/archive_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
55
server/platform/services/docextractor/combine.go
Normal file
55
server/platform/services/docextractor/combine.go
Normal 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
|
||||
}
|
||||
40
server/platform/services/docextractor/concurrency.go
Normal file
40
server/platform/services/docextractor/concurrency.go
Normal 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)
|
||||
}
|
||||
149
server/platform/services/docextractor/docextractor.go
Normal file
149
server/platform/services/docextractor/docextractor.go
Normal 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())
|
||||
}
|
||||
}
|
||||
608
server/platform/services/docextractor/docextractor_test.go
Normal file
608
server/platform/services/docextractor/docextractor_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
69
server/platform/services/docextractor/documents.go
Normal file
69
server/platform/services/docextractor/documents.go
Normal 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
|
||||
}
|
||||
16
server/platform/services/docextractor/interface.go
Normal file
16
server/platform/services/docextractor/interface.go
Normal 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
|
||||
}
|
||||
90
server/platform/services/docextractor/mmpreview.go
Normal file
90
server/platform/services/docextractor/mmpreview.go
Normal 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
|
||||
}
|
||||
72
server/platform/services/docextractor/pdf.go
Normal file
72
server/platform/services/docextractor/pdf.go
Normal 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
|
||||
}
|
||||
92
server/platform/services/docextractor/pdf_test.go
Normal file
92
server/platform/services/docextractor/pdf_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
56
server/platform/services/docextractor/plain.go
Normal file
56
server/platform/services/docextractor/plain.go
Normal 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
|
||||
}
|
||||
54
server/platform/services/docextractor/plain_test.go
Normal file
54
server/platform/services/docextractor/plain_test.go
Normal 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)
|
||||
}
|
||||
8
server/platform/services/imageproxy/error.go
Normal file
8
server/platform/services/imageproxy/error.go
Normal 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
|
||||
}
|
||||
178
server/platform/services/imageproxy/imageproxy.go
Normal file
178
server/platform/services/imageproxy/imageproxy.go
Normal 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]
|
||||
}
|
||||
135
server/platform/services/imageproxy/imageproxy_test.go
Normal file
135
server/platform/services/imageproxy/imageproxy_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
400
server/platform/services/imageproxy/local.go
Normal file
400
server/platform/services/imageproxy/local.go
Normal 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)
|
||||
}
|
||||
527
server/platform/services/imageproxy/local_test.go
Normal file
527
server/platform/services/imageproxy/local_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
130
server/platform/services/marketplace/client.go
Normal file
130
server/platform/services/marketplace/client.go
Normal 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)
|
||||
}
|
||||
44
server/platform/services/marketplace/client_test.go
Normal file
44
server/platform/services/marketplace/client_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
36
server/platform/services/remotecluster/README.md
Normal file
36
server/platform/services/remotecluster/README.md
Normal 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.
|
||||
31
server/platform/services/remotecluster/error.go
Normal file
31
server/platform/services/remotecluster/error.go
Normal 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)
|
||||
}
|
||||
97
server/platform/services/remotecluster/invitation.go
Normal file
97
server/platform/services/remotecluster/invitation.go
Normal 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
|
||||
}
|
||||
135
server/platform/services/remotecluster/mocks_test.go
Normal file
135
server/platform/services/remotecluster/mocks_test.go
Normal 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]
|
||||
}
|
||||
210
server/platform/services/remotecluster/ping.go
Normal file
210
server/platform/services/remotecluster/ping.go
Normal 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())
|
||||
}
|
||||
}
|
||||
291
server/platform/services/remotecluster/ping_test.go
Normal file
291
server/platform/services/remotecluster/ping_test.go
Normal 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
|
||||
}
|
||||
93
server/platform/services/remotecluster/recv.go
Normal file
93
server/platform/services/remotecluster/recv.go
Normal 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
|
||||
}
|
||||
387
server/platform/services/remotecluster/recv_test.go
Normal file
387
server/platform/services/remotecluster/recv_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
30
server/platform/services/remotecluster/response.go
Normal file
30
server/platform/services/remotecluster/response.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package remotecluster
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// Response represents the bytes replied from a remote server when a message is sent.
|
||||
type Response struct {
|
||||
Status string `json:"status"`
|
||||
Err string `json:"err"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
// IsSuccess returns true if the response status indicates success.
|
||||
func (r *Response) IsSuccess() bool {
|
||||
return r.Status == ResponseStatusOK
|
||||
}
|
||||
|
||||
// SetPayload serializes an arbitrary struct as a RawMessage.
|
||||
func (r *Response) SetPayload(v any) error {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Payload = raw
|
||||
return nil
|
||||
}
|
||||
58
server/platform/services/remotecluster/send.go
Normal file
58
server/platform/services/remotecluster/send.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
209
server/platform/services/remotecluster/send_test.go
Normal file
209
server/platform/services/remotecluster/send_test.go
Normal 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, ¬e)
|
||||
if err != nil {
|
||||
merr.Append(err)
|
||||
return
|
||||
}
|
||||
if note.Note != NoteContent {
|
||||
merr.Append(fmt.Errorf("webrequest payload expected %s, got %s", NoteContent, note.Note))
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
mockServer := newMockServer(t, makeRemoteClusters(NumRemotes, ts.URL, 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, ¬e)
|
||||
if err2 != nil {
|
||||
merr.Append(fmt.Errorf("unmarshal payload error: %w", err2))
|
||||
return
|
||||
}
|
||||
if note.Note != NoteContent {
|
||||
merr.Append(fmt.Errorf("compare payload failed: expected '%s', got '%s'", NoteContent, note))
|
||||
}
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
wg.Wait()
|
||||
|
||||
assert.NoError(t, merr.ErrorOrNil())
|
||||
|
||||
assert.Equal(t, int32(NumRemotes), 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}
|
||||
}
|
||||
143
server/platform/services/remotecluster/sendfile.go
Normal file
143
server/platform/services/remotecluster/sendfile.go
Normal 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
|
||||
}
|
||||
175
server/platform/services/remotecluster/sendmsg.go
Normal file
175
server/platform/services/remotecluster/sendmsg.go
Normal 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
|
||||
}
|
||||
153
server/platform/services/remotecluster/sendprofileImage.go
Normal file
153
server/platform/services/remotecluster/sendprofileImage.go
Normal 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
|
||||
}
|
||||
186
server/platform/services/remotecluster/sendprofileImage_test.go
Normal file
186
server/platform/services/remotecluster/sendprofileImage_test.go
Normal 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
|
||||
}
|
||||
312
server/platform/services/remotecluster/service.go
Normal file
312
server/platform/services/remotecluster/service.go
Normal 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")
|
||||
}
|
||||
475
server/platform/services/remotecluster/service_test.go
Normal file
475
server/platform/services/remotecluster/service_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
10
server/platform/services/searchengine/.mockery.yaml
Normal file
10
server/platform/services/searchengine/.mockery.yaml
Normal 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`."
|
||||
75
server/platform/services/searchengine/interface.go
Normal file
75
server/platform/services/searchengine/interface.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
51
server/platform/services/searchengine/searchengine.go
Normal file
51
server/platform/services/searchengine/searchengine.go
Normal 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"
|
||||
}
|
||||
73
server/platform/services/searchengine/searchengine_test.go
Normal file
73
server/platform/services/searchengine/searchengine_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
44
server/platform/services/searchengine/utils.go
Normal file
44
server/platform/services/searchengine/utils.go
Normal 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)
|
||||
}
|
||||
57
server/platform/services/searchengine/utils_test.go
Normal file
57
server/platform/services/searchengine/utils_test.go
Normal 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)
|
||||
}
|
||||
12
server/platform/services/sharedchannel/.mockery.yaml
Normal file
12
server/platform/services/sharedchannel/.mockery.yaml
Normal 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:
|
||||
519
server/platform/services/sharedchannel/README.md
Normal file
519
server/platform/services/sharedchannel/README.md
Normal 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.
|
||||
210
server/platform/services/sharedchannel/attachment.go
Normal file
210
server/platform/services/sharedchannel/attachment.go
Normal 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
|
||||
}
|
||||
601
server/platform/services/sharedchannel/channelinvite.go
Normal file
601
server/platform/services/sharedchannel/channelinvite.go
Normal 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
|
||||
}
|
||||
527
server/platform/services/sharedchannel/channelinvite_test.go
Normal file
527
server/platform/services/sharedchannel/channelinvite_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
57
server/platform/services/sharedchannel/membership.go
Normal file
57
server/platform/services/sharedchannel/membership.go
Normal 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)
|
||||
}
|
||||
}
|
||||
193
server/platform/services/sharedchannel/membership_recv.go
Normal file
193
server/platform/services/sharedchannel/membership_recv.go
Normal 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
|
||||
}
|
||||
397
server/platform/services/sharedchannel/membership_recv_test.go
Normal file
397
server/platform/services/sharedchannel/membership_recv_test.go
Normal 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)")
|
||||
}
|
||||
391
server/platform/services/sharedchannel/membership_send_test.go
Normal file
391
server/platform/services/sharedchannel/membership_send_test.go
Normal 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, ¬FoundError{})
|
||||
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, ¬FoundError{})
|
||||
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, ¬FoundError{})
|
||||
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, ¬FoundError{})
|
||||
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, ¬FoundError{})
|
||||
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, ¬FoundError{})
|
||||
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 }
|
||||
857
server/platform/services/sharedchannel/mock_AppIface_test.go
Normal file
857
server/platform/services/sharedchannel/mock_AppIface_test.go
Normal 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
|
||||
}
|
||||
174
server/platform/services/sharedchannel/mock_ServerIface_test.go
Normal file
174
server/platform/services/sharedchannel/mock_ServerIface_test.go
Normal 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
|
||||
}
|
||||
88
server/platform/services/sharedchannel/permalink.go
Normal file
88
server/platform/services/sharedchannel/permalink.go
Normal 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)
|
||||
})
|
||||
}
|
||||
113
server/platform/services/sharedchannel/permalink_test.go
Normal file
113
server/platform/services/sharedchannel/permalink_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
488
server/platform/services/sharedchannel/service.go
Normal file
488
server/platform/services/sharedchannel/service.go
Normal 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)
|
||||
}
|
||||
377
server/platform/services/sharedchannel/service_api.go
Normal file
377
server/platform/services/sharedchannel/service_api.go
Normal 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
|
||||
}
|
||||
324
server/platform/services/sharedchannel/service_api_test.go
Normal file
324
server/platform/services/sharedchannel/service_api_test.go
Normal 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)
|
||||
}
|
||||
118
server/platform/services/sharedchannel/service_test.go
Normal file
118
server/platform/services/sharedchannel/service_test.go
Normal 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)
|
||||
}
|
||||
854
server/platform/services/sharedchannel/sync_recv.go
Normal file
854
server/platform/services/sharedchannel/sync_recv.go
Normal 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)
|
||||
}
|
||||
}
|
||||
371
server/platform/services/sharedchannel/sync_recv_test.go
Normal file
371
server/platform/services/sharedchannel/sync_recv_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
748
server/platform/services/sharedchannel/sync_send.go
Normal file
748
server/platform/services/sharedchannel/sync_send.go
Normal 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
1321
server/platform/services/sharedchannel/sync_send_remote.go
Normal file
1321
server/platform/services/sharedchannel/sync_send_remote.go
Normal file
File diff suppressed because it is too large
Load Diff
508
server/platform/services/sharedchannel/sync_send_test.go
Normal file
508
server/platform/services/sharedchannel/sync_send_test.go
Normal 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)
|
||||
}
|
||||
139
server/platform/services/sharedchannel/util.go
Normal file
139
server/platform/services/sharedchannel/util.go
Normal 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
|
||||
}
|
||||
250
server/platform/services/sharedchannel/util_test.go
Normal file
250
server/platform/services/sharedchannel/util_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
197
server/platform/services/telemetry/mocks/ServerIface.go
Normal file
197
server/platform/services/telemetry/mocks/ServerIface.go
Normal 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
|
||||
}
|
||||
70
server/platform/services/telemetry/telemetry.go
Normal file
70
server/platform/services/telemetry/telemetry.go
Normal 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)
|
||||
}
|
||||
97
server/platform/services/telemetry/telemetry_test.go
Normal file
97
server/platform/services/telemetry/telemetry_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
51
server/platform/services/upgrader/errors.go
Normal file
51
server/platform/services/upgrader/errors.go
Normal 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)
|
||||
}
|
||||
18
server/platform/services/upgrader/pubkey.gpg
Normal file
18
server/platform/services/upgrader/pubkey.gpg
Normal 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-----
|
||||
18
server/platform/services/upgrader/upgrader.go
Normal file
18
server/platform/services/upgrader/upgrader.go
Normal 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{}
|
||||
}
|
||||
368
server/platform/services/upgrader/upgrader_linux.go
Normal file
368
server/platform/services/upgrader/upgrader_linux.go
Normal 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
|
||||
}
|
||||
164
server/platform/services/upgrader/upgrader_linux_test.go
Normal file
164
server/platform/services/upgrader/upgrader_linux_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user