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

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,168 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"net/http"
"slices"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
// renderableActionConfig controls fallback behavior when ABAC is inactive or evaluation fails.
type renderableActionConfig struct {
ResourceType string
DefaultWhenInactive bool
// FailClosedOnError returns denied+evaluated on subject-build or PDP errors
// rather than falling back to DefaultWhenInactive. Set for security-sensitive actions.
FailClosedOnError bool
}
// renderableABACActions is the allowlist of ABAC actions that may be queried
// through the render-decision (Action Search) API. Any action not present here
// is rejected with 400 to prevent arbitrary action probing and to centralize the
// security posture for each renderable affordance.
var renderableABACActions = map[string]renderableActionConfig{
model.AccessControlPolicyActionUploadFileAttachment: {
ResourceType: model.AccessControlPolicyTypeChannel,
DefaultWhenInactive: true,
FailClosedOnError: true,
},
model.AccessControlPolicyActionDownloadFileAttachment: {
ResourceType: model.AccessControlPolicyTypeChannel,
DefaultWhenInactive: true,
FailClosedOnError: true,
},
}
// SearchAllowedActionsForCurrentUser computes non-authoritative, render-time ABAC
// decisions for the current session user on a single resource. It mirrors the
// enforcement path (BuildAccessControlSubjectForSession + AccessEvaluation with
// the same Resource shape) so a render "allowed" can never disagree with what
// enforcement would decide. Results MUST NOT be used to authorize an action; the
// protected endpoints always re-evaluate the PDP live.
//
// When req.Actions is nil/empty the function operates in discovery mode: it
// evaluates all renderable actions registered for the resource type and returns
// the permitted set. When req.Actions is non-empty only those specific actions
// are evaluated (targeted mode).
func (a *App) SearchAllowedActionsForCurrentUser(rctx request.CTX, req model.ActionSearchRequest) (*model.ActionSearchResponse, *model.AppError) {
if appErr := req.IsValid(); appErr != nil {
return nil, appErr
}
// Subject reservation: any Subject whose ID != session user is rejected.
// This gate exists to make Phase 3 (arbitrary-subject evaluation, Enterprise)
// a non-breaking extension — the field is in the contract but gated here.
if req.Subject != nil && req.Subject.ID != rctx.Session().UserId {
return nil, model.NewAppError(
"SearchAllowedActionsForCurrentUser",
"app.access_control_decision.subject_mismatch.app_error",
nil, "", http.StatusForbidden)
}
// Discovery mode: collect registry entries for the resource type, then sort for
// deterministic wire output — Go map iteration is non-deterministic.
// Targeted mode: validate each requested action against the registry.
var candidates []string
if len(req.Actions) == 0 {
for action, cfg := range renderableABACActions {
if cfg.ResourceType == req.Resource.Type {
candidates = append(candidates, action)
}
}
slices.Sort(candidates)
} else {
for _, action := range req.Actions {
cfg, ok := renderableABACActions[action]
if !ok || cfg.ResourceType != req.Resource.Type {
return nil, model.NewAppError("SearchAllowedActionsForCurrentUser", "app.access_control_decision.unsupported_action.app_error", map[string]any{"Action": action}, "", http.StatusBadRequest)
}
}
candidates = req.Actions
}
resp := &model.ActionSearchResponse{
Resource: req.Resource,
Results: []model.ActionSearchResult{}, // always non-nil so it serializes as []
Decisions: make(map[string]model.RenderPermissionDecision, len(candidates)), // always non-nil so it serializes as {}
}
record := func(action string, d model.RenderPermissionDecision) {
resp.Decisions[action] = d
if d.Allowed {
resp.Results = append(resp.Results, model.ActionSearchResult{Action: model.ActionSearchResultAction{Name: action}})
}
}
// No active policy — return per-action defaults.
acs := a.Srv().Channels().AccessControl
if acs == nil || !a.attributeBasedAccessControlEnabled() {
for _, action := range candidates {
record(action, model.RenderPermissionDecision{
Allowed: renderableABACActions[action].DefaultWhenInactive,
Evaluated: true,
})
}
return resp, nil
}
// All currently registered resource types are channel-scoped, so req.Resource.ID
// is always a channel ID here. If a non-channel resource type is ever added to
// renderableABACActions, this call must be updated to pass the correct channel ID.
subject, appErr := a.BuildAccessControlSubjectForSession(rctx, req.Resource.ID)
if appErr != nil {
rctx.Logger().Info("Failed to build ABAC subject for render-decision search",
mlog.String("resource_type", req.Resource.Type),
mlog.String("resource_id", req.Resource.ID),
mlog.Err(appErr),
)
for _, action := range candidates {
record(action, renderDecisionOnError(action))
}
return resp, nil
}
for _, action := range candidates {
decision, evalErr := acs.AccessEvaluation(rctx, model.AccessRequest{
Subject: *subject,
Resource: req.Resource,
Action: action,
})
if evalErr != nil {
rctx.Logger().Debug("ABAC render-decision evaluation failed",
mlog.String("action", action),
mlog.String("resource_id", req.Resource.ID),
mlog.Err(evalErr),
)
record(action, renderDecisionOnError(action))
continue
}
record(action, model.RenderPermissionDecision{
Allowed: decision.Decision,
Evaluated: true,
})
}
return resp, nil
}
// renderDecisionOnError returns the conservative decision for an action whose
// subject build or PDP evaluation failed: fail closed (deny + generic reason)
// for security-sensitive actions, otherwise fall back to the inactive default.
func renderDecisionOnError(action string) model.RenderPermissionDecision {
cfg := renderableABACActions[action]
if cfg.FailClosedOnError {
return model.RenderPermissionDecision{
Allowed: false,
Evaluated: true,
Reason: model.RenderDecisionReasonRestrictedByPolicy,
}
}
return model.RenderPermissionDecision{
Allowed: cfg.DefaultWhenInactive,
Evaluated: true,
}
}

View File

@@ -0,0 +1,313 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"fmt"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
eMocks "github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
)
func TestSearchAllowedActionsForCurrentUser(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.PermissionPolicies = true
}).InitBasic(t)
// rctx carrying a real session for BasicUser, required by the
// session-subject build path.
session, appErr := th.App.CreateSession(th.Context, &model.Session{UserId: th.BasicUser.Id, Props: model.StringMap{}})
require.Nil(t, appErr)
rctx := th.Context.WithSession(session)
channelResource := model.Resource{Type: model.AccessControlPolicyTypeChannel, ID: th.BasicChannel.Id}
enableABAC := func(t *testing.T) {
t.Helper()
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = true
})
}
disableABAC := func(t *testing.T) {
t.Helper()
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = false
})
}
withMockACS := func(t *testing.T) *eMocks.AccessControlServiceInterface {
t.Helper()
mockACS := &eMocks.AccessControlServiceInterface{}
original := th.App.Srv().ch.AccessControl
th.App.Srv().ch.AccessControl = mockACS
t.Cleanup(func() { th.App.Srv().ch.AccessControl = original })
return mockACS
}
t.Run("invalid request returns bad request", func(t *testing.T) {
_, appErr := th.App.SearchAllowedActionsForCurrentUser(rctx, model.ActionSearchRequest{
Resource: model.Resource{Type: "", ID: th.BasicChannel.Id},
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
})
require.NotNil(t, appErr)
require.Equal(t, 400, appErr.StatusCode)
})
t.Run("unsupported action returns bad request", func(t *testing.T) {
_, appErr := th.App.SearchAllowedActionsForCurrentUser(rctx, model.ActionSearchRequest{
Resource: channelResource,
Actions: []string{"definitely_not_a_real_action"},
})
require.NotNil(t, appErr)
require.Equal(t, 400, appErr.StatusCode)
})
t.Run("action with wrong resource type returns bad request", func(t *testing.T) {
_, appErr := th.App.SearchAllowedActionsForCurrentUser(rctx, model.ActionSearchRequest{
Resource: model.Resource{Type: model.AccessControlPolicyTypeTeam, ID: th.BasicTeam.Id},
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
})
require.NotNil(t, appErr)
require.Equal(t, 400, appErr.StatusCode)
})
t.Run("ABAC inactive returns allowed and evaluated", func(t *testing.T) {
disableABAC(t)
original := th.App.Srv().ch.AccessControl
th.App.Srv().ch.AccessControl = nil
defer func() { th.App.Srv().ch.AccessControl = original }()
resp, appErr := th.App.SearchAllowedActionsForCurrentUser(rctx, model.ActionSearchRequest{
Resource: channelResource,
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment, model.AccessControlPolicyActionDownloadFileAttachment},
})
require.Nil(t, appErr)
require.Len(t, resp.Decisions, 2)
for _, action := range []string{model.AccessControlPolicyActionUploadFileAttachment, model.AccessControlPolicyActionDownloadFileAttachment} {
require.True(t, resp.Decisions[action].Evaluated, action)
require.True(t, resp.Decisions[action].Allowed, action)
require.Empty(t, resp.Decisions[action].Reason, action)
}
})
t.Run("ABAC allow returns allowed", func(t *testing.T) {
enableABAC(t)
mockACS := withMockACS(t)
mockACS.On("AccessEvaluation", mock.Anything, mock.MatchedBy(func(req model.AccessRequest) bool {
return req.Resource.ID == th.BasicChannel.Id && req.Action == model.AccessControlPolicyActionUploadFileAttachment
})).Return(model.AccessDecision{Decision: true}, (*model.AppError)(nil))
resp, appErr := th.App.SearchAllowedActionsForCurrentUser(rctx, model.ActionSearchRequest{
Resource: channelResource,
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
})
require.Nil(t, appErr)
require.True(t, resp.Decisions[model.AccessControlPolicyActionUploadFileAttachment].Allowed)
require.True(t, resp.Decisions[model.AccessControlPolicyActionUploadFileAttachment].Evaluated)
})
t.Run("ABAC deny returns not allowed", func(t *testing.T) {
enableABAC(t)
mockACS := withMockACS(t)
mockACS.On("AccessEvaluation", mock.Anything, mock.MatchedBy(func(req model.AccessRequest) bool {
return req.Resource.ID == th.BasicChannel.Id && req.Action == model.AccessControlPolicyActionUploadFileAttachment
})).Return(model.AccessDecision{Decision: false}, (*model.AppError)(nil))
resp, appErr := th.App.SearchAllowedActionsForCurrentUser(rctx, model.ActionSearchRequest{
Resource: channelResource,
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
})
require.Nil(t, appErr)
require.False(t, resp.Decisions[model.AccessControlPolicyActionUploadFileAttachment].Allowed)
require.True(t, resp.Decisions[model.AccessControlPolicyActionUploadFileAttachment].Evaluated)
})
t.Run("evaluation error fails closed for sensitive action", func(t *testing.T) {
enableABAC(t)
mockACS := withMockACS(t)
mockACS.On("AccessEvaluation", mock.Anything, mock.Anything).
Return(model.AccessDecision{}, model.NewAppError("test", "test.error", nil, "", 500))
resp, appErr := th.App.SearchAllowedActionsForCurrentUser(rctx, model.ActionSearchRequest{
Resource: channelResource,
Actions: []string{model.AccessControlPolicyActionDownloadFileAttachment},
})
require.Nil(t, appErr)
d := resp.Decisions[model.AccessControlPolicyActionDownloadFileAttachment]
require.False(t, d.Allowed)
require.True(t, d.Evaluated)
require.Equal(t, model.RenderDecisionReasonRestrictedByPolicy, d.Reason)
})
t.Run("builds subject once and evaluates once per action", func(t *testing.T) {
enableABAC(t)
mockACS := withMockACS(t)
mockACS.On("AccessEvaluation", mock.Anything, mock.MatchedBy(func(req model.AccessRequest) bool {
return req.Resource.ID == th.BasicChannel.Id
})).Return(model.AccessDecision{Decision: true}, (*model.AppError)(nil))
resp, appErr := th.App.SearchAllowedActionsForCurrentUser(rctx, model.ActionSearchRequest{
Resource: channelResource,
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment, model.AccessControlPolicyActionDownloadFileAttachment},
})
require.Nil(t, appErr)
require.Len(t, resp.Decisions, 2)
mockACS.AssertNumberOfCalls(t, "AccessEvaluation", 2)
})
for _, want := range []bool{true, false} {
t.Run(fmt.Sprintf("render decision matches enforcement (pdp=%v)", want), func(t *testing.T) {
enableABAC(t)
mockACS := withMockACS(t)
mockACS.On("AccessEvaluation", mock.Anything, mock.MatchedBy(func(req model.AccessRequest) bool {
return req.Resource.ID == th.BasicChannel.Id && req.Action == model.AccessControlPolicyActionUploadFileAttachment
})).Return(model.AccessDecision{Decision: want}, (*model.AppError)(nil))
resp, appErr := th.App.SearchAllowedActionsForCurrentUser(rctx, model.ActionSearchRequest{
Resource: channelResource,
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
})
require.Nil(t, appErr)
enforced := th.App.HasPermissionToFileAction(rctx, th.BasicUser.Id, th.BasicUser.Roles, th.BasicChannel.Id, model.AccessControlPolicyActionUploadFileAttachment)
require.Equal(t, enforced, resp.Decisions[model.AccessControlPolicyActionUploadFileAttachment].Allowed)
require.Equal(t, want, enforced)
})
}
// --- Discovery mode ---
t.Run("discovery mode ABAC inactive returns all registry actions in results", func(t *testing.T) {
disableABAC(t)
resp, appErr := th.App.SearchAllowedActionsForCurrentUser(rctx, model.ActionSearchRequest{
Resource: channelResource,
})
require.Nil(t, appErr)
require.Len(t, resp.Decisions, 2)
require.Len(t, resp.Results, 2)
resultNames := make(map[string]bool, len(resp.Results))
for _, r := range resp.Results {
resultNames[r.Action.Name] = true
}
require.True(t, resultNames[model.AccessControlPolicyActionUploadFileAttachment])
require.True(t, resultNames[model.AccessControlPolicyActionDownloadFileAttachment])
})
t.Run("discovery mode ABAC active permitted in results denied excluded", func(t *testing.T) {
enableABAC(t)
mockACS := withMockACS(t)
mockACS.On("AccessEvaluation", mock.Anything, mock.MatchedBy(func(req model.AccessRequest) bool {
return req.Action == model.AccessControlPolicyActionUploadFileAttachment
})).Return(model.AccessDecision{Decision: true}, (*model.AppError)(nil))
mockACS.On("AccessEvaluation", mock.Anything, mock.MatchedBy(func(req model.AccessRequest) bool {
return req.Action == model.AccessControlPolicyActionDownloadFileAttachment
})).Return(model.AccessDecision{Decision: false}, (*model.AppError)(nil))
resp, appErr := th.App.SearchAllowedActionsForCurrentUser(rctx, model.ActionSearchRequest{
Resource: channelResource,
})
require.Nil(t, appErr)
require.Len(t, resp.Decisions, 2)
require.Len(t, resp.Results, 1)
require.Equal(t, model.AccessControlPolicyActionUploadFileAttachment, resp.Results[0].Action.Name)
})
t.Run("discovery mode wrong resource type returns empty candidates without error", func(t *testing.T) {
disableABAC(t)
resp, appErr := th.App.SearchAllowedActionsForCurrentUser(rctx, model.ActionSearchRequest{
Resource: model.Resource{Type: model.AccessControlPolicyTypeTeam, ID: th.BasicTeam.Id},
// No actions → discovery mode, but no registry entries for team type.
})
require.Nil(t, appErr)
require.Empty(t, resp.Decisions)
require.Empty(t, resp.Results)
})
t.Run("discovery mode results order is deterministic", func(t *testing.T) {
disableABAC(t)
var firstOrder []string
for i := range 5 {
resp, appErr := th.App.SearchAllowedActionsForCurrentUser(rctx, model.ActionSearchRequest{
Resource: channelResource,
})
require.Nil(t, appErr)
names := make([]string, len(resp.Results))
for j, r := range resp.Results {
names[j] = r.Action.Name
}
if i == 0 {
firstOrder = names
} else {
require.Equal(t, firstOrder, names, "Results order changed on iteration %d", i)
}
}
})
t.Run("results is empty slice not nil when all denied", func(t *testing.T) {
enableABAC(t)
mockACS := withMockACS(t)
mockACS.On("AccessEvaluation", mock.Anything, mock.Anything).
Return(model.AccessDecision{Decision: false}, (*model.AppError)(nil))
resp, appErr := th.App.SearchAllowedActionsForCurrentUser(rctx, model.ActionSearchRequest{
Resource: channelResource,
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
})
require.Nil(t, appErr)
require.Empty(t, resp.Results)
// Wire check: "results":[] not "results":null
wire, err := json.Marshal(resp)
require.NoError(t, err)
require.Contains(t, string(wire), `"results":[]`)
require.Contains(t, string(wire), `"decisions":{`)
})
t.Run("subject matching session user is accepted", func(t *testing.T) {
disableABAC(t)
_, appErr := th.App.SearchAllowedActionsForCurrentUser(rctx, model.ActionSearchRequest{
Resource: channelResource,
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
Subject: &model.ActionSearchSubject{ID: session.UserId},
})
require.Nil(t, appErr)
})
t.Run("subject not matching session user returns 403", func(t *testing.T) {
_, appErr := th.App.SearchAllowedActionsForCurrentUser(rctx, model.ActionSearchRequest{
Resource: channelResource,
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
Subject: &model.ActionSearchSubject{ID: model.NewId()},
})
require.NotNil(t, appErr)
require.Equal(t, 403, appErr.StatusCode)
})
t.Run("subject invalid ID format returns 400 from IsValid", func(t *testing.T) {
_, appErr := th.App.SearchAllowedActionsForCurrentUser(rctx, model.ActionSearchRequest{
Resource: channelResource,
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
Subject: &model.ActionSearchSubject{ID: "not-a-valid-id"},
})
require.NotNil(t, appErr)
require.Equal(t, 400, appErr.StatusCode)
})
}

View File

@@ -0,0 +1,880 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"maps"
"net/http"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
// GetMaskedVisualAST converts the given CEL expression to a VisualExpression and
// filters each condition's literal values to the subset visible to callerID.
//
// Masking is attribute-based, not role-based: every caller (including system
// admins) sees only values they themselves hold for shared_only fields, all
// values for public fields, and no values for source_only fields. Conditions
// whose values are partially or fully filtered get HasMaskedValues=true so the
// client can render the masked-chip UI.
func (a *App) GetMaskedVisualAST(rctx request.CTX, expression string, callerID string) (*model.VisualExpression, *model.AppError) {
visualAST, appErr := a.ExpressionToVisualAST(rctx, expression)
if appErr != nil {
return nil, appErr
}
if visualAST == nil || len(visualAST.Conditions) == 0 {
return visualAST, nil
}
cpaGroup, appErr := a.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
if appErr != nil {
return nil, model.NewAppError("GetMaskedVisualAST", "app.pap.get_masked_visual_ast.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
cpaGroupID := cpaGroup.ID
// Embed callerID in context so GetPropertyFieldByName applies per-caller option filtering.
rctxWithCaller := RequestContextWithCallerID(rctx, callerID)
// Pre-fetch the holdings-bearing field for each referenced condition once to
// avoid N+1 DB queries across conditions.
fieldsByKey := a.fetchConditionFields(rctxWithCaller, visualAST.Conditions, cpaGroupID)
for i := range visualAST.Conditions {
a.maskConditionValues(rctxWithCaller, callerID, &visualAST.Conditions[i], cpaGroupID, fieldsByKey)
}
return visualAST, nil
}
// maskingHoldings pairs the field whose per-caller values determine which
// literals are visible (holdings) with the access mode that governs the
// reference. For a channel attribute these come from different fields: the
// access mode is the channel field's own (it defines whether the attribute is
// protected), while holdings are read from the user-side sibling — see
// holdingsFieldFor.
type maskingHoldings struct {
field *model.PropertyField
accessMode string
}
// fetchConditionFields collects the unique CPA references from conditions and
// fetches, for each, the holdings/access-mode pair that determines its
// visibility (see holdingsFieldFor). The map is keyed by
// "<objectType>/<fieldName>" so a user and a channel reference sharing a name
// resolve independently. Lookup failures are logged and omitted; read-path
// callers treat missing entries as fail-closed (mask the value).
func (a *App) fetchConditionFields(rctx request.CTX, conditions []model.Condition, cpaGroupID string) map[string]*maskingHoldings {
type ref struct{ objectType, fieldName string }
seen := make(map[ref]struct{})
for _, c := range conditions {
if c.ValueType == model.AttrValue {
continue
}
if objectType, fieldName, ok := splitCPAAttribute(c.Attribute); ok {
seen[ref{objectType, fieldName}] = struct{}{}
}
}
fields := make(map[string]*maskingHoldings, len(seen))
for r := range seen {
h, appErr := a.holdingsFieldFor(rctx, cpaGroupID, r.objectType, r.fieldName)
if appErr != nil {
rctx.Logger().Warn("Failed to look up field for masking, failing closed",
mlog.String("object_type", r.objectType),
mlog.String("field_name", r.fieldName),
mlog.Err(appErr),
)
continue
}
fields[holdingsKey(r.objectType, r.fieldName)] = h
}
return fields
}
// appMaskingResolver implements model.MaskingFieldResolver for the app layer,
// caching resolved fields to avoid N+1 DB lookups within a single request.
type appMaskingResolver struct {
app *App
rctxWithCaller request.CTX
cpaGroupID string
callerID string
cache map[string]*model.MaskingFieldInfo
}
func newMaskingResolver(a *App, rctx request.CTX, callerID string) (*appMaskingResolver, *model.AppError) {
cpaGroup, appErr := a.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
if appErr != nil {
return nil, appErr
}
return &appMaskingResolver{
app: a,
rctxWithCaller: RequestContextWithCallerID(rctx, callerID),
cpaGroupID: cpaGroup.ID,
callerID: callerID,
cache: make(map[string]*model.MaskingFieldInfo),
}, nil
}
func (r *appMaskingResolver) Resolve(objectType, fieldName string) (*model.MaskingFieldInfo, error) {
// The cache key includes the object type: a user field and a channel field
// can share a name yet have different visibility.
cacheKey := holdingsKey(objectType, fieldName)
if info, ok := r.cache[cacheKey]; ok {
return info, nil
}
h, appErr := r.app.holdingsFieldFor(r.rctxWithCaller, r.cpaGroupID, objectType, fieldName)
if appErr != nil {
return nil, appErr
}
info := r.fieldToMaskingInfo(h)
r.cache[cacheKey] = info
return info, nil
}
// holdingsFieldFor returns the holdings-bearing field plus the access mode that
// governs a reference to (objectType, fieldName). The field is fetched with
// caller context so its options are already filtered to the caller's holdings.
//
// For a user reference both come from the field itself. For a channel reference
// they come from different fields: the access mode is the CHANNEL field's own —
// whether the attribute is protected is a property of the channel field, and a
// linked field does NOT inherit access mode from its template at creation, so
// reading it off the user sibling can silently under-protect. Holdings, by
// contrast, must come from the user-side sibling linked to the same template,
// because users never hold channel-side values directly.
//
// A caller's held option names are only pre-filtered onto the sibling by the
// read path when the sibling is itself shared_only. If the channel field is
// shared_only but the sibling is not, the sibling's option list is unfiltered,
// so its options are dropped (fail closed); text holdings are queried directly
// and are unaffected. When a channel field is unlinked or has no user sibling,
// its own (caller-filtered) field is used — for shared_only that yields no
// visible values (the caller holds nothing channel-side), the fail-closed
// direction.
func (a *App) holdingsFieldFor(rctx request.CTX, groupID, objectType, fieldName string) (*maskingHoldings, *model.AppError) {
var lookupType string
switch objectType {
case model.PropertyFieldObjectTypeChannel:
lookupType = model.PropertyFieldObjectTypeChannel
case model.PropertyFieldObjectTypeUser:
lookupType = model.PropertyFieldObjectTypeUser
default:
// Only user./resource. CPA roots reach here (see cpaAttributeRoots); fail
// loud rather than silently treating an unexpected type as a user lookup.
return nil, model.NewAppError("holdingsFieldFor", "app.pap.masking.unknown_object_type.app_error", map[string]any{"ObjectType": objectType}, "", http.StatusInternalServerError)
}
field, appErr := a.GetPropertyFieldByNameForObjectType(rctx, groupID, "", lookupType, fieldName)
if appErr != nil {
return nil, appErr
}
if lookupType != model.PropertyFieldObjectTypeChannel {
return &maskingHoldings{field: field, accessMode: field.GetAccessMode()}, nil
}
accessMode := field.GetAccessMode()
if field.LinkedFieldID != nil && *field.LinkedFieldID != "" {
sibling, appErr := a.userSiblingField(rctx, groupID, *field.LinkedFieldID)
if appErr != nil {
return nil, appErr
}
if sibling != nil {
// The sibling supplies the caller's held values, but its options are
// only held-filtered by the read path when the sibling is itself
// shared_only. If the channel field is protected but the sibling is
// not, the sibling's option list is unfiltered — drop it so no
// unheld option name leaks (text holdings query the caller directly
// and are unaffected).
if accessMode == model.PropertyAccessModeSharedOnly &&
sibling.GetAccessMode() != model.PropertyAccessModeSharedOnly &&
sibling.Type.SupportsOptions() {
sibling = fieldWithEmptyOptions(sibling)
}
return &maskingHoldings{field: sibling, accessMode: accessMode}, nil
}
}
return &maskingHoldings{field: field, accessMode: accessMode}, nil
}
// fieldWithEmptyOptions returns a shallow copy of f with an empty options list,
// so extractVisibleOptionNames yields nothing. Used to fail closed without
// mutating the (possibly cached) source field.
func fieldWithEmptyOptions(f *model.PropertyField) *model.PropertyField {
cp := *f
cp.Attrs = make(model.StringInterface, len(f.Attrs)+1)
maps.Copy(cp.Attrs, f.Attrs)
cp.Attrs[model.PropertyFieldAttributeOptions] = []any{}
return &cp
}
// userSiblingField returns the user-object-type CPA field linked to the same
// template (linkedFieldID), fetched through rctx so its options are filtered to
// that caller's holdings. Returns nil when no such field exists.
func (a *App) userSiblingField(rctx request.CTX, groupID, linkedFieldID string) (*model.PropertyField, *model.AppError) {
fields, appErr := a.SearchPropertyFields(rctx, groupID, model.PropertyFieldSearchOpts{
ObjectTypes: []string{model.PropertyFieldObjectTypeUser},
LinkedFieldID: linkedFieldID,
PerPage: 2,
})
if appErr != nil {
return nil, appErr
}
// The masking decision uses this sibling's held values to gate a channel
// field's option names, so it must resolve to exactly one field. Nothing
// enforces LinkedFieldID uniqueness at the DB level, so a second match would
// make the choice depend on store order and could disclose a value via the
// "wrong" sibling. Error on ambiguity rather than guess — the caller fails
// this field closed (precompute logs and skips it, masking every value).
// PerPage:2 is enough to detect the second match.
var match *model.PropertyField
for _, f := range fields {
if f == nil {
continue
}
if match != nil {
return nil, model.NewAppError("userSiblingField", "app.pap.masking.ambiguous_sibling.app_error", map[string]any{"LinkedFieldID": linkedFieldID}, "multiple user fields link the same template", http.StatusInternalServerError)
}
match = f
}
return match, nil
}
func (r *appMaskingResolver) fieldToMaskingInfo(h *maskingHoldings) *model.MaskingFieldInfo {
info := &model.MaskingFieldInfo{}
// h.accessMode is authoritative (the channel field's own for a channel
// reference); h.field supplies the caller's held values.
switch h.accessMode {
case model.PropertyAccessModePublic:
info.Access = model.MaskingFieldAccessPublic
case model.PropertyAccessModeSourceOnly:
info.Access = model.MaskingFieldAccessSourceOnly
case model.PropertyAccessModeSharedOnly:
info.Access = model.MaskingFieldAccessSharedOnly
// Same split as maskConditionValues, through the same predicate: an
// options-bearing field's visible values are its caller-filtered option
// names, anything else's are the caller's stored text values. Spelling the
// type list out here instead would drop rank — whose values are options
// too — and the two paths would disagree about what a caller can see.
if h.field.Type.SupportsOptions() {
info.VisibleValues = extractVisibleOptionNames(h.field)
} else {
info.VisibleValues = r.app.getCallerTextValues(r.rctxWithCaller, r.callerID, h.field, r.cpaGroupID)
}
default:
info.Access = model.MaskingFieldAccessUnknown
}
return info
}
// maskConditionValues applies masking to a single condition in place.
//
// Masking semantics differ by field type:
//
// - select / multiselect (partial masking): each value in a multi-value
// condition is independently masked or visible. A row may end up with some
// visible chips plus the masked-token covering the omitted values.
// - text (binary masking): a text condition is a single string comparison.
// The condition's value is either visible in full (the caller's stored
// text value matches it exactly) or fully masked. No partial chip behavior
// is possible because there's no multi-value list to filter.
func (a *App) maskConditionValues(rctx request.CTX, callerID string, condition *model.Condition, cpaGroupID string, fieldsByKey map[string]*maskingHoldings) {
// AttrValue conditions compare two attributes (e.g. user.attr1 == user.attr2) — no literal values to mask.
if condition.ValueType == model.AttrValue {
return
}
objectType, fieldName, ok := splitCPAAttribute(condition.Attribute)
if !ok {
return
}
// h pairs the holdings-bearing field (the user sibling for a channel
// attribute) with the authoritative access mode (the channel field's own),
// keyed by object type so a shared name does not collide across roots.
h, ok := fieldsByKey[holdingsKey(objectType, fieldName)]
if !ok {
// Fail closed: field lookup failed at prefetch time.
condition.Value = nil
condition.HasMaskedValues = true
return
}
switch h.accessMode {
case model.PropertyAccessModePublic:
// no-op
case model.PropertyAccessModeSourceOnly:
condition.Value = nil
condition.HasMaskedValues = true
case model.PropertyAccessModeSharedOnly:
if h.field.Type.SupportsOptions() {
filterConditionValues(condition, extractVisibleOptionNames(h.field))
} else {
filterConditionValues(condition, a.getCallerTextValues(rctx, callerID, h.field, cpaGroupID))
}
default:
// Unknown access mode: fail closed.
condition.Value = nil
condition.HasMaskedValues = true
}
}
// cpaAttributeRoots maps a CEL attribute-path prefix to the PropertyField
// object type whose CPA schema backs it: user.attributes.* is the requesting
// user, resource.attributes.* is the accessed channel.
var cpaAttributeRoots = []struct{ prefix, objectType string }{
{userAttributesPathPrefix, model.PropertyFieldObjectTypeUser},
{resourceAttributesPathPrefix, model.PropertyFieldObjectTypeChannel},
}
// splitCPAAttribute splits a CEL attribute path into its CPA object type and
// field name. ok is false for any path that is not a non-empty custom-attribute
// selector (native selectors such as user.email or resource.id carry no
// ".attributes." segment and own no maskable literal).
func splitCPAAttribute(attribute string) (objectType, fieldName string, ok bool) {
for _, r := range cpaAttributeRoots {
if name, found := strings.CutPrefix(attribute, r.prefix); found && name != "" {
return r.objectType, name, true
}
}
return "", "", false
}
// holdingsKey keys the prefetched-holdings map and the resolver cache. Object
// type is part of the key because a user field and a channel field can share
// a name and differ in visibility.
func holdingsKey(objectType, fieldName string) string {
return objectType + "/" + fieldName
}
// extractVisibleOptionNames pulls option names from a pre-filtered PropertyField's
// Attrs["options"]. The field is expected to have already been filtered by
// PropertyAccessService.applyFieldReadAccessControl to the caller's holdings,
// so the names returned here are exactly what the caller can see.
func extractVisibleOptionNames(field *model.PropertyField) map[string]struct{} {
names := make(map[string]struct{})
if field.Attrs == nil {
return names
}
optionsRaw, ok := field.Attrs[model.PropertyFieldAttributeOptions]
if !ok {
return names
}
optionsSlice, ok := optionsRaw.([]any)
if !ok {
return names
}
for _, opt := range optionsSlice {
optMap, ok := opt.(map[string]any)
if !ok {
continue
}
name, ok := optMap["name"].(string)
if ok && name != "" {
names[name] = struct{}{}
}
}
return names
}
// getCallerTextValues returns the caller's stored text value(s) for the given
// text-type field, as the visible-names set used by filterConditionValues.
// A user has at most one text value per field, so this set has zero or one
// element. Empty values are treated as no value.
func (a *App) getCallerTextValues(rctx request.CTX, callerID string, field *model.PropertyField, cpaGroupID string) map[string]struct{} {
visible := make(map[string]struct{})
// Each (user, field) pair has at most one text value.
values, appErr := a.SearchPropertyValues(rctx, cpaGroupID, model.PropertyValueSearchOpts{
FieldID: field.ID,
TargetIDs: []string{callerID},
PerPage: 1,
})
if appErr != nil {
rctx.Logger().Warn("Failed to look up caller text value for masking, failing closed",
mlog.String("field_id", field.ID),
mlog.String("caller_id", callerID),
mlog.Err(appErr),
)
return visible
}
for _, pv := range values {
var textVal string
if err := json.Unmarshal(pv.Value, &textVal); err != nil {
rctx.Logger().Warn("Failed to unmarshal caller text value for masking, treating as no value",
mlog.String("field_id", field.ID),
mlog.String("caller_id", callerID),
mlog.String("value_id", pv.ID),
mlog.Err(err),
)
continue
}
if textVal != "" {
visible[textVal] = struct{}{}
}
}
return visible
}
// filterConditionValues drops any element of condition.Value that is not in the
// visibleNames set, setting HasMaskedValues=true if anything was dropped.
//
// For multi-value conditions ([]any), each string element is checked individually
// (partial masking). For single-value conditions (string), the whole value is
// either kept or replaced with nil (binary masking).
func filterConditionValues(condition *model.Condition, visibleNames map[string]struct{}) {
switch v := condition.Value.(type) {
case []any:
filtered := make([]any, 0, len(v))
totalStrings := 0
for _, val := range v {
strVal, ok := val.(string)
if !ok {
continue // non-string elements are not masking candidates
}
totalStrings++
if _, visible := visibleNames[strVal]; visible {
filtered = append(filtered, val)
}
}
if len(filtered) < totalStrings {
condition.HasMaskedValues = true
}
condition.Value = filtered
case string:
if _, visible := visibleNames[v]; !visible {
condition.Value = nil
condition.HasMaskedValues = true
}
}
}
// maskedTokenValue is a terse local alias for model.MaskingTokenValue, the
// single source of truth for the masked-value sentinel shared with the
// canonical CEL walker. Never a valid attribute value.
const maskedTokenValue = model.MaskingTokenValue
// rejectMaskedTokens rejects any rule expression that still contains the masked
// token after merge — it is a response-only placeholder (server-generated, never
// a real attribute value) that must never reach the store.
//
// The fail-closed sentinel (maskFailClosedSentinel, "false") is deliberately NOT
// rejected here: "false" is also a legitimate, author-written deny-all expression,
// and persisting it is harmless (deny is the safe direction). The dangerous case —
// round-tripping the sentinel back over a stored rule whose values the caller
// could not see — is caught on the update path by the canonical merge, which
// fails closed (ErrMergeNodeDeleted / ErrMergeShapeMismatch → 403) when the
// submitted node can't be paired with the masked stored node.
func rejectMaskedTokens(policy *model.AccessControlPolicy) *model.AppError {
for _, rule := range policy.Rules {
if strings.Contains(rule.Expression, maskedTokenValue) {
return model.NewAppError("CreateOrUpdateAccessControlPolicy",
"app.pap.save_policy.masked_token_in_expression", nil,
"expression contains a masked token that could not be resolved to a stored value",
http.StatusBadRequest)
}
}
return nil
}
// validatePolicyExpressionValues checks that all submitted literal values are held by the caller.
// Returns the same generic error for every rejection to prevent value enumeration.
func (a *App) validatePolicyExpressionValues(rctx request.CTX, policy *model.AccessControlPolicy, resolver model.MaskingFieldResolver) *model.AppError {
acs := a.Srv().ch.AccessControl
if acs == nil {
return nil
}
for _, rule := range policy.Rules {
if rule.Expression == "" || rule.Expression == "true" {
continue
}
if appErr := acs.ValidateExpressionValuesForCaller(rctx, rule.Expression, resolver); appErr != nil {
return appErr
}
}
return nil
}
func (a *App) GetMaskedExpression(rctx request.CTX, expression string, callerID string) (string, *model.AppError) {
if expression == "" || expression == "true" {
return expression, nil
}
acs := a.Srv().ch.AccessControl
if acs == nil {
return expression, nil
}
resolver, appErr := newMaskingResolver(a, rctx, callerID)
if appErr != nil {
return "", appErr
}
masked, _, appErr := acs.MaskExpressionForCaller(rctx, expression, resolver)
if appErr != nil {
return "", appErr
}
return masked, nil
}
// MaskSimulationPolicyLiteralsForCaller re-applies attribute-value
// masking to every CEL expression and per-leaf ExpectedValue the
// simulator returned. Without this pass, the response would leak the
// literal values that mergeStoredPolicyExpressions re-injected before
// evaluation — the simulator's verdicts are correct because the
// engine sees the real (unmasked) policy, but the response surfaces
// (Blame.Expression, MergedRules expressions, every leaf in the
// evaluation tree) would otherwise carry those re-injected literals
// back to the caller.
//
// Masking is attribute-based, not role-based: system admins are NOT
// bypassed. A caller who doesn't hold the literal sees the
// "--------" sentinel regardless of role, mirroring the policy GET
// masking contract enforced by MaskPolicyExpressions.
//
// Failure handling is per-surface fail-closed: any masking error on
// a single expression clears that field (Expression -> "",
// ExpectedValue -> sentinel) rather than leaving the unmasked literal
// visible. A top-level CPA group lookup failure wipes every literal
// surface in the response.
//
// No-op when AttributeValueMasking is disabled — same gate as the
// stored-policy merge that precedes evaluation; either both run or
// neither does, so the response always matches the policy state that
// produced it.
func (a *App) MaskSimulationPolicyLiteralsForCaller(rctx request.CTX, resp *model.PolicySimulationResponse, callerID string) {
if resp == nil || callerID == "" {
return
}
if !a.Config().FeatureFlags.AttributeValueMasking {
return
}
if a.Srv().ch.AccessControl == nil {
return
}
resolver, appErr := newMaskingResolver(a, rctx, callerID)
if appErr != nil {
rctx.Logger().Warn(
"MaskSimulationPolicyLiteralsForCaller: failed to resolve CPA group, clearing every simulation literal as fail-closed default",
mlog.Err(appErr),
)
clearAllSimulationLiterals(resp)
return
}
mc := &simulationMaskContext{
cpaGroupID: resolver.cpaGroupID,
rctxWithCaller: resolver.rctxWithCaller,
callerID: callerID,
resolver: resolver,
}
for i := range resp.Results {
for action, dec := range resp.Results[i].Decisions {
a.maskSimulationDecisionLiterals(&dec, mc)
resp.Results[i].Decisions[action] = dec
}
for k := range resp.Results[i].Sessions {
for action, dec := range resp.Results[i].Sessions[k].Decisions {
a.maskSimulationDecisionLiterals(&dec, mc)
resp.Results[i].Sessions[k].Decisions[action] = dec
}
}
}
}
// simulationMaskContext is the per-request mask cache shared across
// every expression in a single simulate response. resolver caches
// MaskingFieldInfo per field name so each unique field is resolved
// at most once across the entire trace.
type simulationMaskContext struct {
cpaGroupID string
rctxWithCaller request.CTX
callerID string
resolver model.MaskingFieldResolver
}
// maskExpressionWithCache masks literal values in `expression` using the
// canonical CEL AST walker, sharing the resolver cache from mc. Returns ""
// on any parse or lookup failure so the caller can drop the surface
// entirely (fail-closed). Preserves ||/!/grouping structure — no Visual
// AST flattening.
func (a *App) maskExpressionWithCache(expression string, mc *simulationMaskContext) string {
if expression == "" || expression == "true" {
return expression
}
acs := a.Srv().ch.AccessControl
if acs == nil {
return ""
}
masked, _, appErr := acs.MaskExpressionForCaller(mc.rctxWithCaller, expression, mc.resolver)
if appErr != nil {
return ""
}
return masked
}
// maskSimulationDecisionLiterals masks every Expression and per-leaf
// ExpectedValue on every blame entry the action decision carries.
// Walks merged-rule sub-surfaces with the same rules so they stay in
// sync with the parent Blame.Expression.
func (a *App) maskSimulationDecisionLiterals(dec *model.PolicySimulationActionDecision, mc *simulationMaskContext) {
for i := range dec.Blame {
b := &dec.Blame[i]
// Mask the evaluation tree first; the root's rebuilt
// Expression preserves the original OR / NOT structure so
// when we backfill Blame.Expression from it below the
// caller-visible CEL keeps the same boolean shape the rule
// author wrote.
if b.EvaluationTree != nil {
a.maskSimulationEvaluationTree(b.EvaluationTree, mc)
}
if b.Expression != "" {
if b.EvaluationTree != nil {
b.Expression = b.EvaluationTree.Expression
} else {
b.Expression = a.maskExpressionWithCache(b.Expression, mc)
}
}
for j := range b.MergedRules {
m := &b.MergedRules[j]
if m.EvaluationTree != nil {
a.maskSimulationEvaluationTree(m.EvaluationTree, mc)
}
if m.Expression != "" {
if m.EvaluationTree != nil {
m.Expression = m.EvaluationTree.Expression
} else {
m.Expression = a.maskExpressionWithCache(m.Expression, mc)
}
}
}
}
}
// maskSimulationEvaluationTree walks `node` and its children bottom-
// up. Leaf-shaped nodes (compare / function / other) have their
// Expression re-masked through maskExpressionWithCache and their
// ExpectedValue overwritten with the sentinel whenever the masker
// hid at least one literal in the leaf. Compound nodes (and / or /
// not) rebuild their Expression from the already-masked children's
// Expressions, preserving the original boolean shape. Leaf masking
// (canonical CEL walker) handles a single comparison; the compound
// rebuild stitches those masked leaves back together so the caller-
// visible CEL faithfully reflects the rule's || / ! structure.
func (a *App) maskSimulationEvaluationTree(node *model.PolicySimulationEvaluationNode, mc *simulationMaskContext) {
if node == nil {
return
}
for i := range node.Children {
a.maskSimulationEvaluationTree(&node.Children[i], mc)
}
switch node.Kind {
case model.PolicySimulationEvaluationKindAnd:
node.Expression = joinChildExpressions(node.Children, "&&")
case model.PolicySimulationEvaluationKindOr:
node.Expression = joinChildExpressions(node.Children, "||")
case model.PolicySimulationEvaluationKindNot:
if len(node.Children) == 0 {
node.Expression = ""
} else if child := node.Children[0].Expression; child == "" {
node.Expression = ""
} else {
node.Expression = "!(" + child + ")"
}
default:
// compare / function / other — leaf-shaped. Mask the leaf
// expression in place, then drop ExpectedValue to the
// sentinel whenever the masker hid at least one literal —
// the sentinel can never be a legitimate value (write-path
// validation rejects it on save), so its presence in the
// masked CEL is unambiguous evidence that masking applied.
if node.Expression != "" {
masked := a.maskExpressionWithCache(node.Expression, mc)
if masked == "" {
node.Expression = ""
node.ExpectedValue = maskedTokenValue
} else {
if node.ExpectedValue != "" && strings.Contains(masked, maskedTokenValue) {
node.ExpectedValue = maskedTokenValue
}
node.Expression = masked
}
}
// ActualValue is the simulated user's recorded value —
// independent from the rule literal we just masked above,
// but just as sensitive under AVM. A caller who couldn't
// see "il5" as a rule literal would still see "il5"
// surface in the leaf's "Actual: il5" line without this
// pass. Apply the same per-value access-mode check the rule
// literal uses (source_only hides every value, shared_only
// hides values the caller doesn't hold, public passes
// through), so the trace stays in lockstep with the
// policy GET masking contract end-to-end. Skips when the
// leaf has no attribute path (function-call leaves with
// non-attribute operands).
if node.Attribute != "" && node.ActualValue != "" {
a.maskLeafActualValue(node, mc)
}
}
}
// maskLeafActualValue replaces node.ActualValue with the masked token when the
// caller cannot see that value. Uses mc.resolver so field info is cached across
// all leaves in the trace — no per-leaf DB calls. Fails closed on resolver error.
func (a *App) maskLeafActualValue(node *model.PolicySimulationEvaluationNode, mc *simulationMaskContext) {
objectType, fieldName, ok := splitCPAAttribute(node.Attribute)
if !ok {
return
}
info, err := mc.resolver.Resolve(objectType, fieldName)
if err != nil {
node.ActualValue = maskedTokenValue
return
}
if info.IsValueHidden(node.ActualValue) {
node.ActualValue = maskedTokenValue
}
}
// joinChildExpressions wraps every non-empty child Expression in
// parens and joins them with " <op> ". Empty children (e.g. a leaf
// whose maskExpressionWithCache failed-closed) are skipped so the
// rebuilt parent doesn't carry a dangling operator. The parens are
// unconditional so the result stays unambiguous when the parent op
// has lower precedence than a child's internal op.
func joinChildExpressions(children []model.PolicySimulationEvaluationNode, op string) string {
parts := make([]string, 0, len(children))
for i := range children {
if children[i].Expression == "" {
continue
}
parts = append(parts, "("+children[i].Expression+")")
}
return strings.Join(parts, " "+op+" ")
}
// clearAllSimulationLiterals wipes every literal-carrying surface on
// `resp`: Expression / EvaluationTree on each Blame and each
// MergedRule, plus ExpectedValue on every leaf the tree contained.
// Companion to MaskSimulationPolicyLiteralsForCaller's top-level
// fail-closed branch: when the CPA group can't be resolved we don't
// know which fields are public vs masked, so we drop every literal
// rather than risk shipping a hidden value back to the caller.
func clearAllSimulationLiterals(resp *model.PolicySimulationResponse) {
if resp == nil {
return
}
for i := range resp.Results {
for action, dec := range resp.Results[i].Decisions {
clearDecisionLiterals(&dec)
resp.Results[i].Decisions[action] = dec
}
for k := range resp.Results[i].Sessions {
for action, dec := range resp.Results[i].Sessions[k].Decisions {
clearDecisionLiterals(&dec)
resp.Results[i].Sessions[k].Decisions[action] = dec
}
}
}
}
func clearDecisionLiterals(dec *model.PolicySimulationActionDecision) {
for i := range dec.Blame {
b := &dec.Blame[i]
b.Expression = ""
if b.EvaluationTree != nil {
clearEvaluationTreeLiterals(b.EvaluationTree)
}
for j := range b.MergedRules {
b.MergedRules[j].Expression = ""
if b.MergedRules[j].EvaluationTree != nil {
clearEvaluationTreeLiterals(b.MergedRules[j].EvaluationTree)
}
}
}
}
func clearEvaluationTreeLiterals(node *model.PolicySimulationEvaluationNode) {
if node == nil {
return
}
node.Expression = ""
if node.ExpectedValue != "" {
node.ExpectedValue = maskedTokenValue
}
// ActualValue is the simulated user's value — also a literal
// the masker normally checks against per-caller AVM semantics.
// When the CPA group lookup fails we can't tell whether the
// field is public or protected, so we collapse to the sentinel
// rather than risk leaving an actual value visible.
if node.ActualValue != "" {
node.ActualValue = maskedTokenValue
}
for i := range node.Children {
clearEvaluationTreeLiterals(&node.Children[i])
}
}
// maskFailClosedSentinel is the CEL expression written into a response rule when masking
// cannot safely produce a redacted version (parse failure or CPA group unavailable).
// "false" is used because it is deny-all if ever evaluated literally, matching the
// fail-closed intent. This value only ever appears in API responses — the stored DB
// expression is never overwritten by this path.
const maskFailClosedSentinel = "false"
// MaskPolicyExpressions masks non-held literal values in all policy rule expressions, in place.
// Fails closed (sets a rule to maskFailClosedSentinel) if its expression cannot be parsed or masked.
func (a *App) MaskPolicyExpressions(rctx request.CTX, policy *model.AccessControlPolicy, callerID string) {
acs := a.Srv().ch.AccessControl
if acs == nil {
return
}
resolver, appErr := newMaskingResolver(a, rctx, callerID)
if appErr != nil {
rctx.Logger().Warn("MaskPolicyExpressions: failed to resolve CPA group, masking all rules closed",
mlog.Err(appErr),
)
for i, rule := range policy.Rules {
if rule.Expression == "" || rule.Expression == "true" {
continue
}
policy.Rules[i].Expression = maskFailClosedSentinel
}
return
}
for i, rule := range policy.Rules {
if rule.Expression == "" || rule.Expression == "true" {
continue
}
masked, _, appErr := acs.MaskExpressionForCaller(rctx, rule.Expression, resolver)
if appErr != nil {
rctx.Logger().Warn("MaskPolicyExpressions: failed to mask rule expression, failing closed",
mlog.Err(appErr),
)
policy.Rules[i].Expression = maskFailClosedSentinel
continue
}
policy.Rules[i].Expression = masked
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/assert"
)
func TestIsMembershipRule(t *testing.T) {
t.Run("nil rule is not a membership rule", func(t *testing.T) {
assert.False(t, isMembershipRule(nil))
})
t.Run("named rule with membership action is not a membership rule", func(t *testing.T) {
// v0.4 permission rules always carry a Name; treat a non-empty Name as a
// permission rule even if its Actions happen to mention membership, so a
// rename can't accidentally collide with the membership slot.
rule := &model.AccessControlPolicyRule{
Name: "Custom",
Actions: []string{model.AccessControlPolicyActionMembership},
}
assert.False(t, isMembershipRule(rule))
})
t.Run("unnamed rule without membership action is not a membership rule", func(t *testing.T) {
// Anonymous non-membership rules can't be safely identified across the
// submit boundary; mergeStoredPolicyExpressions deliberately skips them
// rather than mispair, so this helper must report false too.
rule := &model.AccessControlPolicyRule{
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
}
assert.False(t, isMembershipRule(rule))
})
t.Run("unnamed rule with membership action is a membership rule", func(t *testing.T) {
rule := &model.AccessControlPolicyRule{
Actions: []string{model.AccessControlPolicyActionMembership},
}
assert.True(t, isMembershipRule(rule))
})
t.Run("unnamed rule with membership action among others is a membership rule", func(t *testing.T) {
rule := &model.AccessControlPolicyRule{
Actions: []string{
model.AccessControlPolicyActionUploadFileAttachment,
model.AccessControlPolicyActionMembership,
},
}
assert.True(t, isMembershipRule(rule))
})
t.Run("empty actions list is not a membership rule", func(t *testing.T) {
rule := &model.AccessControlPolicyRule{}
assert.False(t, isMembershipRule(rule))
})
}

View File

@@ -0,0 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestMaskedTokenConstant(t *testing.T) {
// The masked-token sentinel must be the eight-dash string the frontend
// renders for hidden chips and the server emits when masking raw CEL
// on GET / search responses.
assert.Equal(t, "--------", maskedTokenValue)
}

View File

@@ -0,0 +1,242 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"fmt"
"io"
"maps"
"net/http"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/platform/services/cache"
"github.com/mattermost/mattermost/server/v8/platform/shared/mail"
)
var latestVersionCache = cache.NewLRU(&cache.CacheOptions{
Size: 1,
})
func (s *Server) GetLogs(rctx request.CTX, page, perPage int) ([]string, *model.AppError) {
var lines []string
license := s.License()
if license != nil && *license.Features.Cluster && s.platform.Cluster() != nil && *s.platform.Config().ClusterSettings.Enable {
if info := s.platform.Cluster().GetMyClusterInfo(); info != nil {
lines = append(lines, "-----------------------------------------------------------------------------------------------------------")
lines = append(lines, "-----------------------------------------------------------------------------------------------------------")
lines = append(lines, info.Hostname)
lines = append(lines, "-----------------------------------------------------------------------------------------------------------")
lines = append(lines, "-----------------------------------------------------------------------------------------------------------")
} else {
rctx.Logger().Error("Could not get cluster info")
}
}
melines, err := s.GetLogsSkipSend(rctx, page, perPage, &model.LogFilter{})
if err != nil {
return nil, err
}
lines = append(lines, melines...)
if s.platform.Cluster() != nil && *s.platform.Config().ClusterSettings.Enable {
clines, err := s.platform.Cluster().GetLogs(rctx, page, perPage)
if err != nil {
return nil, err
}
lines = append(lines, clines...)
}
return lines, nil
}
func (s *Server) QueryLogs(rctx request.CTX, page, perPage int, logFilter *model.LogFilter) (map[string][]string, *model.AppError) {
logData := make(map[string][]string)
serverName := "default"
license := s.License()
if license != nil && *license.Features.Cluster && s.platform.Cluster() != nil && *s.platform.Config().ClusterSettings.Enable {
if info := s.platform.Cluster().GetMyClusterInfo(); info != nil {
serverName = info.Hostname
} else {
rctx.Logger().Error("Could not get cluster info")
}
}
var appErr *model.AppError
serverNames := logFilter.ServerNames
if len(serverNames) > 0 {
for _, nodeName := range serverNames {
if nodeName == "default" {
appErr = addLocalLogs(rctx, logData, s, page, perPage, nodeName, logFilter)
}
}
} else {
appErr = addLocalLogs(rctx, logData, s, page, perPage, serverName, logFilter)
}
if appErr != nil {
return nil, appErr
}
if s.platform.Cluster() != nil && *s.Config().ClusterSettings.Enable {
clusterLogs, err := s.platform.Cluster().QueryLogs(rctx, page, perPage)
if err != nil {
return nil, err
}
if clusterLogs != nil && len(serverNames) > 0 {
for _, filteredNodeName := range serverNames {
logData[filteredNodeName] = clusterLogs[filteredNodeName]
}
} else {
maps.Copy(logData, clusterLogs)
}
}
return logData, nil
}
func addLocalLogs(rctx request.CTX, logData map[string][]string, s *Server, page, perPage int, serverName string, logFilter *model.LogFilter) *model.AppError {
currentServerLogs, err := s.GetLogsSkipSend(rctx, page, perPage, logFilter)
if err != nil {
return err
}
logData[serverName] = currentServerLogs
return nil
}
func (a *App) QueryLogs(rctx request.CTX, page, perPage int, logFilter *model.LogFilter) (map[string][]string, *model.AppError) {
return a.Srv().QueryLogs(rctx, page, perPage, logFilter)
}
func (a *App) GetLogs(rctx request.CTX, page, perPage int) ([]string, *model.AppError) {
return a.Srv().GetLogs(rctx, page, perPage)
}
func (s *Server) GetLogsSkipSend(rctx request.CTX, page, perPage int, logFilter *model.LogFilter) ([]string, *model.AppError) {
return s.platform.GetLogsSkipSend(rctx, page, perPage, logFilter)
}
func (a *App) GetLogsSkipSend(rctx request.CTX, page, perPage int, logFilter *model.LogFilter) ([]string, *model.AppError) {
return a.Srv().GetLogsSkipSend(rctx, page, perPage, logFilter)
}
func (a *App) GetClusterStatus(rctx request.CTX) ([]*model.ClusterInfo, error) {
if a.Cluster() == nil {
return make([]*model.ClusterInfo, 0), nil
}
return a.Cluster().GetClusterInfos()
}
func (s *Server) InvalidateAllCaches() *model.AppError {
return s.platform.InvalidateAllCaches()
}
func (s *Server) InvalidateAllCachesSkipSend() *model.AppError {
return s.platform.InvalidateAllCachesSkipSend()
}
func (a *App) RecycleDatabaseConnection(rctx request.CTX) {
rctx.Logger().Info("Attempting to recycle database connections.")
// This works by setting 10 seconds as the max conn lifetime for all DB connections.
// This allows in gradually closing connections as they expire. In future, we can think
// of exposing this as a param from the REST api.
a.Srv().Store().RecycleDBConnections(10 * time.Second)
rctx.Logger().Info("Finished recycling database connections.")
}
func (a *App) TestSiteURL(rctx request.CTX, siteURL string) *model.AppError {
url := fmt.Sprintf("%s/api/v4/system/ping", siteURL)
res, err := http.Get(url)
if err != nil || res.StatusCode != 200 {
return model.NewAppError("testSiteURL", "app.admin.test_site_url.failure", nil, "", http.StatusBadRequest)
}
defer func() {
_, _ = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
}()
return nil
}
func (a *App) TestEmail(rctx request.CTX, userID string, cfg *model.Config) *model.AppError {
if *cfg.EmailSettings.SMTPServer == "" {
return model.NewAppError("testEmail", "api.admin.test_email.missing_server", nil, i18n.T("api.context.invalid_param.app_error", map[string]any{"Name": "SMTPServer"}), http.StatusBadRequest)
}
// if the user hasn't changed their email settings, fill in the actual SMTP password so that
// the user can verify an existing SMTP connection
if *cfg.EmailSettings.SMTPPassword == model.FakeSetting {
if *cfg.EmailSettings.SMTPServer == *a.Config().EmailSettings.SMTPServer &&
*cfg.EmailSettings.SMTPPort == *a.Config().EmailSettings.SMTPPort &&
*cfg.EmailSettings.SMTPUsername == *a.Config().EmailSettings.SMTPUsername {
*cfg.EmailSettings.SMTPPassword = *a.Config().EmailSettings.SMTPPassword
} else {
return model.NewAppError("testEmail", "api.admin.test_email.reenter_password", nil, "", http.StatusBadRequest)
}
}
user, err := a.GetUser(rctx, userID)
if err != nil {
return err
}
T := i18n.GetUserTranslations(user.Locale)
license := a.Srv().License()
mailConfig := a.Srv().MailServiceConfig()
if err := mail.SendMailUsingConfig(user.Email, T("api.admin.test_email.subject"), T("api.admin.test_email.body"), mailConfig, license != nil && *license.Features.Compliance, "", "", "", "", ""); err != nil {
return model.NewAppError("testEmail", "app.admin.test_email.failure", map[string]any{"Error": err.Error()}, "", http.StatusInternalServerError)
}
return nil
}
func (a *App) GetLatestVersion(rctx request.CTX, latestVersionUrl string) (*model.GithubReleaseInfo, *model.AppError) {
var cachedLatestVersion *model.GithubReleaseInfo
if cacheErr := latestVersionCache.Get("latest_version_cache", &cachedLatestVersion); cacheErr == nil {
return cachedLatestVersion, nil
}
res, err := http.Get(latestVersionUrl)
if err != nil {
return nil, model.NewAppError("GetLatestVersion", model.NoTranslation, nil, "", http.StatusInternalServerError).Wrap(err)
}
defer res.Body.Close()
responseData, err := io.ReadAll(res.Body)
if err != nil {
return nil, model.NewAppError("GetLatestVersion", model.NoTranslation, nil, "", http.StatusInternalServerError).Wrap(err)
}
var releaseInfoResponse *model.GithubReleaseInfo
err = json.Unmarshal(responseData, &releaseInfoResponse)
if err != nil {
return nil, model.NewAppError("GetLatestVersion", model.NoTranslation, nil, "", http.StatusInternalServerError).Wrap(err)
}
if validErr := releaseInfoResponse.IsValid(); validErr != nil {
return nil, model.NewAppError("GetLatestVersion", model.NoTranslation, nil, "", http.StatusInternalServerError).Wrap(validErr)
}
err = latestVersionCache.SetWithExpiry("latest_version_cache", releaseInfoResponse, 24*time.Hour)
if err != nil {
return nil, model.NewAppError("GetLatestVersion", model.NoTranslation, nil, "", http.StatusInternalServerError).Wrap(err)
}
return releaseInfoResponse, nil
}
func (a *App) clearLatestVersionCache() error {
return latestVersionCache.Remove("latest_version_cache")
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestGetLatestVersion(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
version := &model.GithubReleaseInfo{
Id: 57117096,
TagName: "v6.3.0",
Name: "v6.3.0",
CreatedAt: "2022-01-13T14:19:44Z",
PublishedAt: "2022-01-14T13:45:09Z",
Body: "Mattermost Platform Release v6.3.0",
Url: "https://github.com/mattermost/mattermost-server/releases/tag/v6.3.0",
}
validJSON, jsonErr := json.Marshal(version)
require.NoError(t, jsonErr)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := w.Write(validJSON)
require.NoError(t, err)
}))
defer ts.Close()
t.Run("get latest mm version happy path", func(t *testing.T) {
_, err := th.App.GetLatestVersion(th.Context, ts.URL)
require.Nil(t, err)
})
t.Run("get latest mm version from cache", func(t *testing.T) {
err := th.App.clearLatestVersionCache()
require.NoError(t, err)
originalResult, appErr := th.App.GetLatestVersion(th.Context, ts.URL)
require.Nil(t, appErr)
// Call same function but mock the GET request to return a different result.
// We are hoping the function will use the cache instead of making the GET request
v := &model.GithubReleaseInfo{
Id: 57117096,
TagName: "v6.3.1",
Name: "v6.3.1",
CreatedAt: "2022-01-13T14:19:44Z",
PublishedAt: "2022-01-14T13:45:09Z",
Body: "Mattermost Platform Release v6.3.0",
Url: "https://github.com/mattermost/mattermost-server/releases/tag/v6.3.0",
}
updatedJSON, jsonErr := json.Marshal(v)
require.NoError(t, jsonErr)
updatedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := w.Write(updatedJSON)
require.NoError(t, err)
}))
defer updatedServer.Close()
cachedResult, appErr := th.App.GetLatestVersion(th.Context, updatedServer.URL)
require.Nil(t, appErr)
require.Equal(t, originalResult.TagName, cachedResult.TagName, "did not get cached result")
})
t.Run("get latest mm version error from external", func(t *testing.T) {
err := th.App.clearLatestVersionCache()
require.NoError(t, err)
errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, err := w.Write([]byte(`
{
"message": "internal server error"
}
`))
require.NoError(t, err)
}))
defer errorServer.Close()
_, appErr := th.App.GetLatestVersion(th.Context, errorServer.URL)
require.NotNil(t, appErr)
})
}

View File

@@ -0,0 +1,99 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"net/http"
agentclient "github.com/mattermost/mattermost-plugin-ai/public/bridgeclient"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
// GetBridgeClient remains as a compatibility helper for downstream enterprise code.
// New server code should use a.ch.agentsBridge instead of relying on the concrete bridge client.
func (a *App) GetBridgeClient(userID string) *agentclient.Client {
return agentclient.NewClientFromApp(a, userID)
}
// ServiceCompletion remains as a compatibility helper for downstream enterprise
// code that needs service-based bridge completions while using the shared
// AgentsBridge abstraction.
func (a *App) ServiceCompletion(sessionUserID, serviceID string, req BridgeCompletionRequest) (string, error) {
return a.ch.agentsBridge.ServiceCompletion(sessionUserID, serviceID, req)
}
// GetAIPluginBridgeStatus checks if the mattermost-ai plugin is active and supports the bridge API (v1.5.0+)
// It returns a boolean indicating availability, and a reason string (translation ID) if unavailable.
func (a *App) GetAIPluginBridgeStatus(rctx request.CTX) (bool, string) {
return a.ch.agentsBridge.Status(rctx)
}
// GetAgents retrieves all available agents from the bridge API
func (a *App) GetAgents(rctx request.CTX, userID string) ([]model.BridgeAgentInfo, *model.AppError) {
sessionUserID := ""
if session := rctx.Session(); session != nil {
sessionUserID = session.UserId
}
agents, err := a.ch.agentsBridge.GetAgents(sessionUserID, userID)
if err != nil {
rctx.Logger().Error("Failed to get agents from bridge",
mlog.Err(err),
mlog.String("user_id", userID),
)
return nil, model.NewAppError("GetAgents", "app.agents.get_agents.bridge_call_failed", nil, err.Error(), http.StatusInternalServerError)
}
return agents, nil
}
// GetUsersForAgents retrieves the User objects for all available agents
func (a *App) GetUsersForAgents(rctx request.CTX, userID string) ([]*model.User, *model.AppError) {
agents, appErr := a.GetAgents(rctx, userID)
if appErr != nil {
return nil, appErr
}
if len(agents) == 0 {
return []*model.User{}, nil
}
users := make([]*model.User, 0, len(agents))
for _, agent := range agents {
// Agents have a username field that corresponds to the bot user's username
user, err := a.Srv().Store().User().GetByUsername(agent.Username)
if err != nil {
rctx.Logger().Warn("Failed to get user for agent",
mlog.Err(err),
mlog.String("agent_id", agent.ID),
mlog.String("username", agent.Username),
)
continue
}
users = append(users, user)
}
return users, nil
}
// GetLLMServices retrieves all available LLM services from the bridge API
func (a *App) GetLLMServices(rctx request.CTX, userID string) ([]model.BridgeServiceInfo, *model.AppError) {
sessionUserID := ""
if session := rctx.Session(); session != nil {
sessionUserID = session.UserId
}
services, err := a.ch.agentsBridge.GetServices(sessionUserID, userID)
if err != nil {
rctx.Logger().Error("Failed to get LLM services from bridge",
mlog.Err(err),
mlog.String("user_id", userID),
)
return nil, model.NewAppError("GetLLMServices", "app.agents.get_services.bridge_call_failed", nil, err.Error(), http.StatusInternalServerError)
}
return services, nil
}

View File

@@ -0,0 +1,247 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"github.com/blang/semver/v4"
agentclient "github.com/mattermost/mattermost-plugin-ai/public/bridgeclient"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
const (
aiPluginID = "mattermost-ai"
minAIPluginVersionForBridge = "1.5.0"
)
type BridgeOperation string
const (
BridgeOperationAutoTranslate BridgeOperation = "auto_translate"
BridgeOperationRecapSummary BridgeOperation = "recap_summary"
BridgeOperationRewrite BridgeOperation = "rewrite"
)
type BridgeMessage struct {
Role string
Message string
FileIDs []string
}
type BridgeCompletionRequest struct {
Operation BridgeOperation
ClientOperation string
OperationSubType string
Messages []BridgeMessage
JSONOutputFormat map[string]any
UserID string
ChannelID string
}
type AgentsBridge interface {
Status(rctx request.CTX) (bool, string)
GetAgents(sessionUserID, userID string) ([]model.BridgeAgentInfo, error)
GetServices(sessionUserID, userID string) ([]model.BridgeServiceInfo, error)
AgentCompletion(sessionUserID, agentID string, req BridgeCompletionRequest) (string, error)
ServiceCompletion(sessionUserID, serviceID string, req BridgeCompletionRequest) (string, error)
}
type liveAgentsBridge struct {
ch *Channels
}
func newLiveAgentsBridge(ch *Channels) AgentsBridge {
return &liveAgentsBridge{ch: ch}
}
func (b *liveAgentsBridge) Status(rctx request.CTX) (bool, string) {
return b.getLiveStatus(rctx)
}
func (b *liveAgentsBridge) GetAgents(sessionUserID, userID string) ([]model.BridgeAgentInfo, error) {
if available, _ := b.getLiveStatus(request.EmptyContext(b.ch.srv.Log())); !available {
return []model.BridgeAgentInfo{}, nil
}
client := agentclient.NewClientFromApp(New(ServerConnector(b.ch)), sessionUserID)
agents, err := client.GetAgents(userID)
if err != nil {
return nil, err
}
return toModelBridgeAgents(agents), nil
}
func (b *liveAgentsBridge) GetServices(sessionUserID, userID string) ([]model.BridgeServiceInfo, error) {
if available, _ := b.getLiveStatus(request.EmptyContext(b.ch.srv.Log())); !available {
return []model.BridgeServiceInfo{}, nil
}
client := agentclient.NewClientFromApp(New(ServerConnector(b.ch)), sessionUserID)
services, err := client.GetServices(userID)
if err != nil {
return nil, err
}
return toModelBridgeServices(services), nil
}
func (b *liveAgentsBridge) AgentCompletion(sessionUserID, agentID string, req BridgeCompletionRequest) (string, error) {
client := agentclient.NewClientFromApp(New(ServerConnector(b.ch)), sessionUserID)
return client.AgentCompletion(agentID, toClientCompletionRequest(req))
}
func (b *liveAgentsBridge) ServiceCompletion(sessionUserID, serviceID string, req BridgeCompletionRequest) (string, error) {
client := agentclient.NewClientFromApp(New(ServerConnector(b.ch)), sessionUserID)
return client.ServiceCompletion(serviceID, toClientCompletionRequest(req))
}
func (b *liveAgentsBridge) getLiveStatus(rctx request.CTX) (bool, string) {
pluginsEnvironment := b.ch.GetPluginsEnvironment()
if pluginsEnvironment == nil {
rctx.Logger().Debug("AI plugin bridge not available - plugin environment not initialized")
return false, "app.agents.bridge.not_available.plugin_env_not_initialized"
}
if !pluginsEnvironment.IsActive(aiPluginID) {
rctx.Logger().Debug("AI plugin bridge not available - plugin is not active or not installed",
mlog.String("plugin_id", aiPluginID),
)
return false, "app.agents.bridge.not_available.plugin_not_active"
}
plugins := pluginsEnvironment.Active()
for _, plugin := range plugins {
if plugin.Manifest == nil || plugin.Manifest.Id != aiPluginID {
continue
}
pluginVersion, err := semver.Parse(plugin.Manifest.Version)
if err != nil {
rctx.Logger().Debug("AI plugin bridge not available - failed to parse plugin version",
mlog.String("plugin_id", aiPluginID),
mlog.String("version", plugin.Manifest.Version),
mlog.Err(err),
)
return false, "app.agents.bridge.not_available.plugin_version_parse_failed"
}
minVersion, err := semver.Parse(minAIPluginVersionForBridge)
if err != nil {
return false, "app.agents.bridge.not_available.min_version_parse_failed"
}
if pluginVersion.LT(minVersion) {
rctx.Logger().Debug("AI plugin bridge not available - plugin version is too old",
mlog.String("plugin_id", aiPluginID),
mlog.String("current_version", plugin.Manifest.Version),
mlog.String("minimum_version", minAIPluginVersionForBridge),
)
return false, "app.agents.bridge.not_available.plugin_version_too_old"
}
return true, ""
}
return false, "app.agents.bridge.not_available.plugin_not_registered"
}
func toModelBridgeAgents(agents []agentclient.BridgeAgentInfo) []model.BridgeAgentInfo {
if len(agents) == 0 {
return []model.BridgeAgentInfo{}
}
converted := make([]model.BridgeAgentInfo, 0, len(agents))
for _, agent := range agents {
converted = append(converted, model.BridgeAgentInfo{
ID: agent.ID,
DisplayName: agent.DisplayName,
Username: agent.Username,
ServiceID: agent.ServiceID,
ServiceType: agent.ServiceType,
IsDefault: agent.IsDefault,
})
}
return converted
}
func toModelBridgeServices(services []agentclient.BridgeServiceInfo) []model.BridgeServiceInfo {
if len(services) == 0 {
return []model.BridgeServiceInfo{}
}
converted := make([]model.BridgeServiceInfo, 0, len(services))
for _, service := range services {
converted = append(converted, model.BridgeServiceInfo{
ID: service.ID,
Name: service.Name,
Type: service.Type,
})
}
return converted
}
func toBridgeClientPosts(messages []BridgeMessage) []agentclient.Post {
posts := make([]agentclient.Post, 0, len(messages))
for _, message := range messages {
posts = append(posts, agentclient.Post{
Role: message.Role,
Message: message.Message,
FileIDs: append([]string(nil), message.FileIDs...),
})
}
return posts
}
func toClientCompletionRequest(req BridgeCompletionRequest) agentclient.CompletionRequest {
return agentclient.CompletionRequest{
Posts: toBridgeClientPosts(req.Messages),
JSONOutputFormat: cloneJSONOutputFormat(req.JSONOutputFormat),
UserID: req.UserID,
ChannelID: req.ChannelID,
Operation: req.ClientOperation,
OperationSubType: req.OperationSubType,
}
}
func cloneJSONOutputFormat(jsonOutputFormat map[string]any) map[string]any {
if jsonOutputFormat == nil {
return nil
}
cloned := make(map[string]any, len(jsonOutputFormat))
for key, value := range jsonOutputFormat {
cloned[key] = cloneJSONValue(value)
}
return cloned
}
func cloneJSONValue(value any) any {
switch v := value.(type) {
case map[string]any:
cloned := make(map[string]any, len(v))
for key, child := range v {
cloned[key] = cloneJSONValue(child)
}
return cloned
case []any:
cloned := make([]any, len(v))
for i, child := range v {
cloned[i] = cloneJSONValue(child)
}
return cloned
case []string:
return append([]string(nil), v...)
default:
return v
}
}
var _ AgentsBridge = (*liveAgentsBridge)(nil)

View File

@@ -0,0 +1,186 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type bridgeCompleteCall struct {
sessionUserID string
agentID string
serviceID string
request BridgeCompletionRequest
}
type testAgentsBridge struct {
statusAvailable bool
statusReason string
statusFn func(rctx request.CTX) (bool, string)
getAgentsFn func(sessionUserID, userID string) ([]model.BridgeAgentInfo, error)
getServicesFn func(sessionUserID, userID string) ([]model.BridgeServiceInfo, error)
completeFn func(sessionUserID, agentID string, req BridgeCompletionRequest) (string, error)
completeSvcFn func(sessionUserID, serviceID string, req BridgeCompletionRequest) (string, error)
completeCalls []bridgeCompleteCall
}
func (b *testAgentsBridge) Status(rctx request.CTX) (bool, string) {
if b.statusFn != nil {
return b.statusFn(rctx)
}
return b.statusAvailable, b.statusReason
}
func (b *testAgentsBridge) GetAgents(sessionUserID, userID string) ([]model.BridgeAgentInfo, error) {
if b.getAgentsFn != nil {
return b.getAgentsFn(sessionUserID, userID)
}
return []model.BridgeAgentInfo{}, nil
}
func (b *testAgentsBridge) GetServices(sessionUserID, userID string) ([]model.BridgeServiceInfo, error) {
if b.getServicesFn != nil {
return b.getServicesFn(sessionUserID, userID)
}
return []model.BridgeServiceInfo{}, nil
}
func (b *testAgentsBridge) AgentCompletion(sessionUserID, agentID string, req BridgeCompletionRequest) (string, error) {
b.completeCalls = append(b.completeCalls, bridgeCompleteCall{
sessionUserID: sessionUserID,
agentID: agentID,
request: req,
})
if b.completeFn != nil {
return b.completeFn(sessionUserID, agentID, req)
}
return "", nil
}
func (b *testAgentsBridge) ServiceCompletion(sessionUserID, serviceID string, req BridgeCompletionRequest) (string, error) {
b.completeCalls = append(b.completeCalls, bridgeCompleteCall{
sessionUserID: sessionUserID,
serviceID: serviceID,
request: req,
})
if b.completeSvcFn != nil {
return b.completeSvcFn(sessionUserID, serviceID, req)
}
return "", nil
}
func TestServiceCompletionUsesAgentsBridge(t *testing.T) {
bridge := &testAgentsBridge{
completeSvcFn: func(sessionUserID, serviceID string, req BridgeCompletionRequest) (string, error) {
return "translated", nil
},
}
th := Setup(t, WithAgentsBridge(bridge)).InitBasic(t)
completion, err := th.App.ServiceCompletion("", "service-id", BridgeCompletionRequest{
Operation: BridgeOperationAutoTranslate,
ClientOperation: string(BridgeOperationAutoTranslate),
OperationSubType: "translate",
Messages: []BridgeMessage{
{Role: "user", Message: "hola"},
},
})
require.NoError(t, err)
assert.Equal(t, "translated", completion)
require.Len(t, bridge.completeCalls, 1)
assert.Equal(t, "service-id", bridge.completeCalls[0].serviceID)
assert.Equal(t, BridgeOperationAutoTranslate, bridge.completeCalls[0].request.Operation)
assert.Equal(t, "translate", bridge.completeCalls[0].request.OperationSubType)
}
func TestSetAIBridgeTestHelperSwapsBridge(t *testing.T) {
th := Setup(t).InitBasic(t)
recordRequests := true
config := &model.AIBridgeTestHelperConfig{
Status: &model.AIBridgeTestHelperStatus{Available: true},
Agents: []model.BridgeAgentInfo{{
ID: "agent-1", DisplayName: "Test Agent", Username: "test.agent",
ServiceID: "svc-1", ServiceType: "openai", IsDefault: true,
}},
Services: []model.BridgeServiceInfo{{
ID: "svc-1", Name: "Test Service", Type: "openai",
}},
AgentCompletions: map[string][]model.AIBridgeTestHelperCompletion{
string(BridgeOperationRewrite): {{Completion: "rewritten"}},
},
RecordRequests: &recordRequests,
}
appErr := th.App.SetAIBridgeTestHelperConfig(config)
require.Nil(t, appErr)
_, ok := th.App.Channels().agentsBridge.(*e2eAgentsBridge)
assert.True(t, ok, "bridge should be e2eAgentsBridge after SetAIBridgeTestHelperConfig")
rctx := request.EmptyContext(th.App.Srv().Log())
available, _ := th.App.Channels().agentsBridge.Status(rctx)
assert.True(t, available)
agents, err := th.App.Channels().agentsBridge.GetAgents("", "")
require.NoError(t, err)
require.Len(t, agents, 1)
assert.Equal(t, "agent-1", agents[0].ID)
services, err := th.App.Channels().agentsBridge.GetServices("", "")
require.NoError(t, err)
require.Len(t, services, 1)
assert.Equal(t, "svc-1", services[0].ID)
completion, err := th.App.Channels().agentsBridge.AgentCompletion("", "agent-1", BridgeCompletionRequest{
Operation: BridgeOperationRewrite,
})
require.NoError(t, err)
assert.Equal(t, "rewritten", completion)
state := th.App.GetAIBridgeTestHelperState()
require.Len(t, state.RecordedRequests, 1)
assert.Equal(t, string(BridgeOperationRewrite), state.RecordedRequests[0].Operation)
assert.Equal(t, "agent-1", state.RecordedRequests[0].AgentID)
}
func TestResetAIBridgeTestHelperRestoresLiveBridge(t *testing.T) {
th := Setup(t).InitBasic(t)
config := &model.AIBridgeTestHelperConfig{
Status: &model.AIBridgeTestHelperStatus{Available: true},
}
appErr := th.App.SetAIBridgeTestHelperConfig(config)
require.Nil(t, appErr)
_, ok := th.App.Channels().agentsBridge.(*e2eAgentsBridge)
require.True(t, ok)
th.App.ResetAIBridgeTestHelper()
_, ok = th.App.Channels().agentsBridge.(*e2eAgentsBridge)
assert.False(t, ok, "bridge should be restored to live after reset")
state := th.App.GetAIBridgeTestHelperState()
assert.Nil(t, state.Status)
assert.Empty(t, state.Agents)
assert.Empty(t, state.RecordedRequests)
}
var _ AgentsBridge = (*testAgentsBridge)(nil)

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"net/http"
"github.com/mattermost/mattermost/server/public/model"
)
func (a *App) SetAIBridgeTestHelperConfig(config *model.AIBridgeTestHelperConfig) *model.AppError {
bridge, err := newE2EAgentsBridge(config)
if err != nil {
return model.NewAppError("SetAIBridgeTestHelperConfig", "app.ai_bridge_test_helper.invalid_config", nil, err.Error(), http.StatusBadRequest)
}
a.ch.SetAgentsBridge(bridge)
if config.FeatureFlags != nil {
if appErr := a.setAIBridgeTestHelperFeatureFlags(config.FeatureFlags); appErr != nil {
return appErr
}
}
return nil
}
func (a *App) GetAIBridgeTestHelperState() *model.AIBridgeTestHelperState {
var state *model.AIBridgeTestHelperState
if e2e, ok := a.ch.agentsBridge.(*e2eAgentsBridge); ok {
state = e2e.GetState()
} else {
state = &model.AIBridgeTestHelperState{
RecordedRequests: []model.AIBridgeTestHelperRecordedRequest{},
}
}
state.FeatureFlags = &model.AIBridgeTestHelperFeatureFlags{
EnableAIPluginBridge: new(a.Config().FeatureFlags.EnableAIPluginBridge),
EnableAIRecaps: new(a.Config().FeatureFlags.EnableAIRecaps),
}
return state
}
func (a *App) ResetAIBridgeTestHelper() {
a.ch.SetAgentsBridge(newLiveAgentsBridge(a.ch))
}
func (a *App) setAIBridgeTestHelperFeatureFlags(featureFlags *model.AIBridgeTestHelperFeatureFlags) *model.AppError {
configStore := a.Srv().Platform().GetConfigStore()
configStore.SetReadOnlyFF(false)
defer configStore.SetReadOnlyFF(true)
a.UpdateConfig(func(cfg *model.Config) {
if featureFlags.EnableAIPluginBridge != nil {
cfg.FeatureFlags.EnableAIPluginBridge = *featureFlags.EnableAIPluginBridge
}
if featureFlags.EnableAIRecaps != nil {
cfg.FeatureFlags.EnableAIRecaps = *featureFlags.EnableAIRecaps
}
})
return nil
}

View File

@@ -0,0 +1,350 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"net/http"
"golang.org/x/sync/errgroup"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
const (
DayMilliseconds = 24 * 60 * 60 * 1000
MonthMilliseconds = 31 * DayMilliseconds
)
func (a *App) GetAnalytics(rctx request.CTX, name string, teamID string) (model.AnalyticsRows, *model.AppError) {
return a.getAnalytics(rctx, name, teamID, false)
}
func (a *App) GetAnalyticsForSupportPacket(rctx request.CTX) (model.AnalyticsRows, *model.AppError) {
return a.getAnalytics(rctx, "standard", "", true)
}
func (a *App) getAnalytics(rctx request.CTX, name string, teamID string, forSupportPacket bool) (model.AnalyticsRows, *model.AppError) {
systemUserCount, err := a.Srv().Store().User().Count(model.UserCountOptions{})
if err != nil {
return nil, model.NewAppError("GetAnalytics", "app.user.get_total_users_count.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
skipIntensiveQueries := false
// When generating a Support Packet, always run intensive queries.
if !forSupportPacket && systemUserCount > int64(*a.Config().AnalyticsSettings.MaxUsersForStatistics) {
rctx.Logger().Warn("Number of users in the system is higher than the configured limit. Skipping intensive SQL queries.", mlog.Int("limit", *a.Config().AnalyticsSettings.MaxUsersForStatistics))
skipIntensiveQueries = true
}
switch name {
case "standard":
return a.getStandardAnalytics(rctx, teamID, systemUserCount)
case "bot_post_counts_day":
return a.getBotPostCountsAnalytics(rctx, teamID)
case "post_counts_day":
return a.getPostCountsAnalytics(rctx, teamID)
case "user_counts_with_posts_day":
return a.getUserCountsWithPostsAnalytics(rctx, teamID, skipIntensiveQueries)
case "extra_counts":
return a.getExtraCountsAnalytics(rctx, teamID)
default:
return nil, nil
}
}
func (a *App) getStandardAnalytics(rctx request.CTX, teamID string, systemUserCount int64) (model.AnalyticsRows, *model.AppError) {
var rows model.AnalyticsRows = make([]*model.AnalyticsRow, 12)
rows[0] = &model.AnalyticsRow{Name: "channel_open_count", Value: 0}
rows[1] = &model.AnalyticsRow{Name: "channel_private_count", Value: 0}
rows[2] = &model.AnalyticsRow{Name: "post_count", Value: 0}
rows[3] = &model.AnalyticsRow{Name: "unique_user_count", Value: 0}
rows[4] = &model.AnalyticsRow{Name: "team_count", Value: 0}
rows[5] = &model.AnalyticsRow{Name: "total_websocket_connections", Value: 0}
rows[6] = &model.AnalyticsRow{Name: "total_master_db_connections", Value: 0}
rows[7] = &model.AnalyticsRow{Name: "total_read_db_connections", Value: 0}
rows[8] = &model.AnalyticsRow{Name: "daily_active_users", Value: 0}
rows[9] = &model.AnalyticsRow{Name: "monthly_active_users", Value: 0}
rows[10] = &model.AnalyticsRow{Name: "inactive_user_count", Value: 0}
rows[11] = &model.AnalyticsRow{Name: "single_channel_guest_count", Value: 0}
var g errgroup.Group
g.SetLimit(2)
var channelCounts map[model.ChannelType]int64
g.Go(func() error {
var err error
if channelCounts, err = a.Srv().Store().Channel().AnalyticsCountAll(teamID); err != nil {
return model.NewAppError("GetAnalytics", "app.channel.analytics_type_count.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
})
var usersCount int64
var inactiveUsersCount int64
if teamID == "" {
g.Go(func() error {
var err error
if inactiveUsersCount, err = a.Srv().Store().User().AnalyticsGetInactiveUsersCount(); err != nil {
return model.NewAppError("GetAnalytics", "app.user.analytics_get_inactive_users_count.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
})
} else {
g.Go(func() error {
var err error
if usersCount, err = a.Srv().Store().User().Count(model.UserCountOptions{TeamId: teamID}); err != nil {
return model.NewAppError("GetAnalytics", "app.user.get_total_users_count.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
})
}
var postsCount int64
g.Go(func() error {
var err error
if postsCount, err = a.Srv().Store().Post().AnalyticsPostCountByTeam(teamID); err != nil {
return model.NewAppError("GetAnalytics", "app.post.analytics_posts_count.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
})
var teamsCount int64
g.Go(func() error {
var err error
if teamsCount, err = a.Srv().Store().Team().AnalyticsTeamCount(nil); err != nil {
return model.NewAppError("GetAnalytics", "app.team.analytics_team_count.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
})
var dailyActiveUsersCount int64
g.Go(func() error {
var err error
if dailyActiveUsersCount, err = a.Srv().Store().User().AnalyticsActiveCount(DayMilliseconds, model.UserCountOptions{IncludeBotAccounts: false, IncludeDeleted: false}); err != nil {
return model.NewAppError("GetAnalytics", "app.user.analytics_daily_active_users.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
})
var monthlyActiveUsersCount int64
g.Go(func() error {
var err error
if monthlyActiveUsersCount, err = a.Srv().Store().User().AnalyticsActiveCount(MonthMilliseconds, model.UserCountOptions{IncludeBotAccounts: false, IncludeDeleted: false}); err != nil {
return model.NewAppError("GetAnalytics", "app.user.analytics_daily_active_users.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
})
var singleChannelGuestCount int64
if a.shouldTrackSingleChannelGuests() {
g.Go(func() error {
var err error
if singleChannelGuestCount, err = a.Srv().Store().User().AnalyticsGetSingleChannelGuestCount(); err != nil {
return model.NewAppError("GetAnalytics", "app.user.analytics_get_single_channel_guest_count.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err.(*model.AppError)
}
rows[0].Value = float64(channelCounts[model.ChannelTypeOpen])
rows[1].Value = float64(channelCounts[model.ChannelTypePrivate])
rows[2].Value = float64(postsCount)
if teamID == "" {
rows[3].Value = float64(systemUserCount)
rows[10].Value = float64(inactiveUsersCount)
} else {
rows[3].Value = float64(usersCount)
rows[10].Value = -1
}
rows[4].Value = float64(teamsCount)
// If in HA mode then aggregate all the stats
if a.Cluster() != nil && *a.Config().ClusterSettings.Enable {
stats, err2 := a.Cluster().GetClusterStats(rctx)
if err2 != nil {
return nil, err2
}
totalSockets := a.TotalWebsocketConnections()
totalMasterDb := a.Srv().Store().TotalMasterDbConnections()
totalReadDb := a.Srv().Store().TotalReadDbConnections()
for _, stat := range stats {
totalSockets = totalSockets + stat.TotalWebsocketConnections
totalMasterDb = totalMasterDb + stat.TotalMasterDbConnections
totalReadDb = totalReadDb + stat.TotalReadDbConnections
}
rows[5].Value = float64(totalSockets)
rows[6].Value = float64(totalMasterDb)
rows[7].Value = float64(totalReadDb)
} else {
rows[5].Value = float64(a.TotalWebsocketConnections())
rows[6].Value = float64(a.Srv().Store().TotalMasterDbConnections())
rows[7].Value = float64(a.Srv().Store().TotalReadDbConnections())
}
rows[8].Value = float64(dailyActiveUsersCount)
rows[9].Value = float64(monthlyActiveUsersCount)
rows[11].Value = float64(singleChannelGuestCount)
return rows, nil
}
func (a *App) getBotPostCountsAnalytics(rctx request.CTX, teamID string) (model.AnalyticsRows, *model.AppError) {
analyticsRows, nErr := a.Srv().Store().Post().AnalyticsPostCountsByDay(&model.AnalyticsPostCountsOptions{
TeamId: teamID,
BotsOnly: true,
})
if nErr != nil {
return nil, model.NewAppError("GetAnalytics", "app.post.analytics_posts_count_by_day.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
return analyticsRows, nil
}
func (a *App) getPostCountsAnalytics(rctx request.CTX, teamID string) (model.AnalyticsRows, *model.AppError) {
analyticsRows, nErr := a.Srv().Store().Post().AnalyticsPostCountsByDay(&model.AnalyticsPostCountsOptions{
TeamId: teamID,
BotsOnly: false,
})
if nErr != nil {
return nil, model.NewAppError("GetAnalytics", "app.post.analytics_posts_count_by_day.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
return analyticsRows, nil
}
func (a *App) getUserCountsWithPostsAnalytics(rctx request.CTX, teamID string, skipIntensiveQueries bool) (model.AnalyticsRows, *model.AppError) {
if skipIntensiveQueries {
rows := model.AnalyticsRows{&model.AnalyticsRow{Name: "", Value: -1}}
return rows, nil
}
analyticsRows, nErr := a.Srv().Store().Post().AnalyticsUserCountsWithPostsByDay(teamID)
if nErr != nil {
return nil, model.NewAppError("GetAnalytics", "app.post.analytics_user_counts_posts_by_day.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
return analyticsRows, nil
}
func (a *App) getExtraCountsAnalytics(rctx request.CTX, teamID string) (model.AnalyticsRows, *model.AppError) {
var rows model.AnalyticsRows = make([]*model.AnalyticsRow, 6)
rows[0] = &model.AnalyticsRow{Name: "incoming_webhook_count", Value: 0}
rows[1] = &model.AnalyticsRow{Name: "outgoing_webhook_count", Value: 0}
rows[2] = &model.AnalyticsRow{Name: "command_count", Value: 0}
rows[3] = &model.AnalyticsRow{Name: "session_count", Value: 0}
rows[4] = &model.AnalyticsRow{Name: "total_file_count", Value: 0}
rows[5] = &model.AnalyticsRow{Name: "total_file_size", Value: 0}
var incomingWebhookCount int64
var g errgroup.Group
g.SetLimit(2)
g.Go(func() error {
var err error
if incomingWebhookCount, err = a.Srv().Store().Webhook().AnalyticsIncomingCount(teamID, ""); err != nil {
return model.NewAppError("GetAnalytics", "app.webhooks.analytics_incoming_count.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
})
var outgoingWebhookCount int64
g.Go(func() error {
var err error
if outgoingWebhookCount, err = a.Srv().Store().Webhook().AnalyticsOutgoingCount(teamID); err != nil {
return model.NewAppError("GetAnalytics", "app.webhooks.analytics_outgoing_count.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
})
var commandsCount int64
g.Go(func() error {
var err error
if commandsCount, err = a.Srv().Store().Command().AnalyticsCommandCount(teamID); err != nil {
return model.NewAppError("GetAnalytics", "app.analytics.getanalytics.internal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
})
var sessionsCount int64
g.Go(func() error {
var err error
if sessionsCount, err = a.Srv().Store().Session().AnalyticsSessionCount(); err != nil {
return model.NewAppError("GetAnalytics", "app.session.analytics_session_count.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
})
var fileCount int64
g.Go(func() error {
var err error
if fileCount, err = a.Srv().Store().FileInfo().CountAll(); err != nil {
return model.NewAppError("GetAnalytics", "app.file_info.get_count.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
})
var fileSize int64
g.Go(func() error {
var err error
if fileSize, err = a.Srv().Store().FileInfo().GetStorageUsage(false, false); err != nil {
return model.NewAppError("GetAnalytics", "app.file_info.get_storage_usage.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
})
if err := g.Wait(); err != nil {
return nil, err.(*model.AppError)
}
rows[0].Value = float64(incomingWebhookCount)
rows[1].Value = float64(outgoingWebhookCount)
rows[2].Value = float64(commandsCount)
rows[3].Value = float64(sessionsCount)
rows[4].Value = float64(fileCount)
rows[5].Value = float64(fileSize)
return rows, nil
}
func (a *App) GetRecentlyActiveUsersForTeam(rctx request.CTX, teamID string) (map[string]*model.User, *model.AppError) {
users, err := a.Srv().Store().User().GetRecentlyActiveUsersForTeam(teamID, 0, 100, nil)
if err != nil {
return nil, model.NewAppError("GetRecentlyActiveUsersForTeam", "app.user.get_recently_active_users.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
userMap := make(map[string]*model.User)
for _, user := range users {
userMap[user.Id] = user
}
return userMap, nil
}
func (a *App) GetRecentlyActiveUsersForTeamPage(rctx request.CTX, teamID string, page, perPage int, asAdmin bool, viewRestrictions *model.ViewUsersRestrictions) ([]*model.User, *model.AppError) {
users, err := a.Srv().Store().User().GetRecentlyActiveUsersForTeam(teamID, page*perPage, perPage, viewRestrictions)
if err != nil {
return nil, model.NewAppError("GetRecentlyActiveUsersForTeamPage", "app.user.get_recently_active_users.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return a.sanitizeProfiles(users, asAdmin), nil
}
func (a *App) GetNewUsersForTeamPage(rctx request.CTX, teamID string, page, perPage int, asAdmin bool, viewRestrictions *model.ViewUsersRestrictions) ([]*model.User, *model.AppError) {
users, err := a.Srv().Store().User().GetNewUsersForTeam(teamID, page*perPage, perPage, viewRestrictions)
if err != nil {
return nil, model.NewAppError("GetNewUsersForTeamPage", "app.user.get_new_users.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return a.sanitizeProfiles(users, asAdmin), nil
}

162
server/channels/app/app.go Normal file
View File

@@ -0,0 +1,162 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"fmt"
"net/http"
"strconv"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/httpservice"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/timezones"
"github.com/mattermost/mattermost/server/v8/einterfaces"
"github.com/mattermost/mattermost/server/v8/platform/services/imageproxy"
"github.com/mattermost/mattermost/server/v8/platform/services/searchengine"
"github.com/mattermost/mattermost/server/v8/platform/shared/templates"
)
// App is a pure functional component that does not have any fields, except Server.
// It is a request-scoped struct constructed every time a request hits the server,
// and its only purpose is to provide business logic to Server via its methods.
type App struct {
ch *Channels
}
func New(options ...AppOption) *App {
app := &App{}
for _, option := range options {
option(app)
}
return app
}
func (a *App) ServerId() string {
return a.Srv().ServerId()
}
func (s *Server) TemplatesContainer() *templates.Container {
return s.htmlTemplates
}
func (s *Server) getFirstServerRunTimestamp() (int64, *model.AppError) {
systemData, err := s.Store().System().GetByName(model.SystemFirstServerRunTimestampKey)
if err != nil {
return 0, model.NewAppError("getFirstServerRunTimestamp", "app.system.get_by_name.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
value, err := strconv.ParseInt(systemData.Value, 10, 64)
if err != nil {
return 0, model.NewAppError("getFirstServerRunTimestamp", "app.system_install_date.parse_int.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return value, nil
}
func (a *App) Channels() *Channels {
return a.ch
}
func (a *App) Srv() *Server {
return a.ch.srv
}
func (a *App) Log() *mlog.Logger {
return a.ch.srv.Log()
}
func (a *App) AccountMigration() einterfaces.AccountMigrationInterface {
return a.ch.AccountMigration
}
func (a *App) Cluster() einterfaces.ClusterInterface {
return a.ch.srv.platform.Cluster()
}
func (a *App) Compliance() einterfaces.ComplianceInterface {
return a.ch.Compliance
}
func (a *App) DataRetention() einterfaces.DataRetentionInterface {
return a.ch.DataRetention
}
func (a *App) SearchEngine() *searchengine.Broker {
return a.ch.srv.platform.SearchEngine
}
func (a *App) Ldap() einterfaces.LdapInterface {
return a.ch.Ldap
}
func (a *App) LdapDiagnostic() einterfaces.LdapDiagnosticInterface {
return a.ch.srv.platform.LdapDiagnostic()
}
func (a *App) MessageExport() einterfaces.MessageExportInterface {
return a.ch.MessageExport
}
func (a *App) Metrics() einterfaces.MetricsInterface {
return a.ch.srv.GetMetrics()
}
func (a *App) Notification() einterfaces.NotificationInterface {
return a.ch.Notification
}
func (a *App) AutoTranslation() einterfaces.AutoTranslationInterface {
return a.Srv().AutoTranslation
}
func (a *App) Saml() einterfaces.SamlInterface {
return a.ch.Saml
}
func (a *App) Intune() einterfaces.IntuneInterface {
return a.ch.Intune
}
func (a *App) Cloud() einterfaces.CloudInterface {
return a.ch.srv.Cloud
}
func (a *App) IPFiltering() einterfaces.IPFilteringInterface {
return a.ch.srv.IPFiltering
}
func (a *App) OutgoingOAuthConnections() einterfaces.OutgoingOAuthConnectionInterface {
return a.ch.srv.OutgoingOAuthConnection
}
func (a *App) HTTPService() httpservice.HTTPService {
return a.ch.srv.httpService
}
func (a *App) ImageProxy() *imageproxy.ImageProxy {
return a.ch.imageProxy
}
func (a *App) Timezones() *timezones.Timezones {
return a.ch.srv.timezones
}
func (a *App) License() *model.License {
return a.Srv().License()
}
func (a *App) DBHealthCheckWrite() error {
currentTime := strconv.FormatInt(time.Now().Unix(), 10)
return a.Srv().Store().System().SaveOrUpdate(&model.System{
Name: a.dbHealthCheckKey(),
Value: currentTime,
})
}
func (a *App) DBHealthCheckDelete() error {
_, err := a.Srv().Store().System().PermanentDeleteByName(a.dbHealthCheckKey())
return err
}
func (a *App) dbHealthCheckKey() string {
return fmt.Sprintf("health_check_%s", a.GetClusterId())
}
func (a *App) CheckIntegrity() <-chan model.IntegrityCheckResult {
return a.Srv().Store().CheckIntegrity()
}
func (a *App) SetChannels(ch *Channels) {
a.ch = ch
}
func (a *App) SetServer(srv *Server) {
a.ch.srv = srv
}
func (a *App) UpdateExpiredDNDStatuses() ([]*model.Status, error) {
return a.Srv().Store().Status().UpdateExpiredDNDStatuses()
}

View File

@@ -0,0 +1,316 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"fmt"
"sync/atomic"
"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"
)
/* TODO: Temporarily comment out until MM-11108
func TestAppRace(t *testing.T) {
for i := 0; i < 10; i++ {
a, err := New()
require.NoError(t, err)
a.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.ListenAddress = "localhost:0" })
serverErr := a.StartServer()
require.NoError(t, serverErr)
a.Srv().Shutdown()
}
}
*/
var allPermissionIDs []string
func init() {
for _, perm := range model.AllPermissions {
allPermissionIDs = append(allPermissionIDs, perm.Id)
}
}
func TestUnitUpdateConfig(t *testing.T) {
mainHelper.Parallel(t)
th := SetupWithStoreMock(t)
mockStore := th.App.Srv().Store().(*mocks.Store)
mockUserStore := mocks.UserStore{}
mockUserStore.On("Count", mock.Anything).Return(int64(10), nil)
mockPostStore := mocks.PostStore{}
mockPostStore.On("GetMaxPostSize").Return(model.PostMessageMaxBytesV2, nil)
mockSystemStore := mocks.SystemStore{}
mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil)
mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil)
mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil)
mockLicenseStore := mocks.LicenseStore{}
mockLicenseStore.On("Get", "").Return(&model.LicenseRecord{}, nil)
mockStore.On("User").Return(&mockUserStore)
mockStore.On("Post").Return(&mockPostStore)
mockStore.On("System").Return(&mockSystemStore)
mockStore.On("License").Return(&mockLicenseStore)
mockStore.On("GetDBSchemaVersion").Return(1, nil)
prev := *th.App.Config().ServiceSettings.SiteURL
require.False(t, th.App.IsConfigReadOnly())
var called atomic.Int32
th.App.AddConfigListener(func(old, current *model.Config) {
called.Add(1)
assert.Equal(t, prev, *old.ServiceSettings.SiteURL)
assert.Equal(t, "http://foo.com", *current.ServiceSettings.SiteURL)
})
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.SiteURL = "http://foo.com"
})
// callback should be called once
assert.Equal(t, int32(1), called.Load())
}
func TestDoAdvancedPermissionsMigration(t *testing.T) {
th := Setup(t)
th.ResetRoleMigration(t)
err := th.App.DoAdvancedPermissionsMigration()
require.NoError(t, err)
roleNames := []string{
"system_user",
"system_admin",
"team_user",
"team_admin",
"channel_user",
"channel_admin",
"system_post_all",
"system_post_all_public",
"system_user_access_token",
"team_post_all",
"team_post_all_public",
"playbook_admin",
"playbook_member",
"run_admin",
"run_member",
}
roles1, err1 := th.App.GetRolesByNames(roleNames)
assert.Nil(t, err1)
assert.Equal(t, len(roles1), len(roleNames))
expected1 := map[string][]string{
"channel_user": {
model.PermissionReadChannel.Id,
model.PermissionReadChannelContent.Id,
model.PermissionAddReaction.Id,
model.PermissionRemoveReaction.Id,
model.PermissionManagePublicChannelMembers.Id,
model.PermissionUploadFile.Id,
model.PermissionGetPublicLink.Id,
model.PermissionCreatePost.Id,
model.PermissionUseChannelMentions.Id,
model.PermissionManagePublicChannelProperties.Id,
model.PermissionDeletePublicChannel.Id,
model.PermissionManagePrivateChannelProperties.Id,
model.PermissionDeletePrivateChannel.Id,
model.PermissionManagePrivateChannelMembers.Id,
model.PermissionDeletePost.Id,
model.PermissionEditPost.Id,
model.PermissionEditFileAttachment.Id,
model.PermissionAddBookmarkPublicChannel.Id,
model.PermissionEditBookmarkPublicChannel.Id,
model.PermissionDeleteBookmarkPublicChannel.Id,
model.PermissionOrderBookmarkPublicChannel.Id,
model.PermissionAddBookmarkPrivateChannel.Id,
model.PermissionEditBookmarkPrivateChannel.Id,
model.PermissionDeleteBookmarkPrivateChannel.Id,
model.PermissionOrderBookmarkPrivateChannel.Id,
},
"channel_admin": {
model.PermissionManageChannelRoles.Id,
model.PermissionUseGroupMentions.Id,
model.PermissionAddBookmarkPublicChannel.Id,
model.PermissionEditBookmarkPublicChannel.Id,
model.PermissionDeleteBookmarkPublicChannel.Id,
model.PermissionOrderBookmarkPublicChannel.Id,
model.PermissionAddBookmarkPrivateChannel.Id,
model.PermissionEditBookmarkPrivateChannel.Id,
model.PermissionDeleteBookmarkPrivateChannel.Id,
model.PermissionOrderBookmarkPrivateChannel.Id,
model.PermissionManagePublicChannelBanner.Id,
model.PermissionManagePrivateChannelBanner.Id,
model.PermissionManageChannelAccessRules.Id,
model.PermissionManagePublicChannelAutoTranslation.Id,
model.PermissionManagePrivateChannelAutoTranslation.Id,
model.PermissionManagePrivateChannelDiscoverability.Id,
model.PermissionManageChannelJoinRequests.Id,
},
"team_user": {
model.PermissionListTeamChannels.Id,
model.PermissionJoinPublicChannels.Id,
model.PermissionReadPublicChannel.Id,
model.PermissionViewTeam.Id,
model.PermissionCreatePublicChannel.Id,
model.PermissionCreatePrivateChannel.Id,
model.PermissionInviteUser.Id,
model.PermissionAddUserToTeam.Id,
},
"team_post_all": {
model.PermissionCreatePost.Id,
model.PermissionUseChannelMentions.Id,
},
"team_post_all_public": {
model.PermissionCreatePostPublic.Id,
model.PermissionUseChannelMentions.Id,
},
"team_admin": {
model.PermissionRemoveUserFromTeam.Id,
model.PermissionManageTeam.Id,
model.PermissionManageTeamRoles.Id,
model.PermissionManageTeamAccessRules.Id,
model.PermissionManageChannelRoles.Id,
model.PermissionManageOwnIncomingWebhooks.Id,
model.PermissionManageOthersIncomingWebhooks.Id,
model.PermissionManageOwnOutgoingWebhooks.Id,
model.PermissionManageOthersOutgoingWebhooks.Id,
model.PermissionManageOwnSlashCommands.Id,
model.PermissionManageOthersSlashCommands.Id,
model.PermissionBypassIncomingWebhookChannelLock.Id,
model.PermissionConvertPublicChannelToPrivate.Id,
model.PermissionConvertPrivateChannelToPublic.Id,
model.PermissionDeletePost.Id,
model.PermissionDeleteOthersPosts.Id,
model.PermissionAddBookmarkPublicChannel.Id,
model.PermissionEditBookmarkPublicChannel.Id,
model.PermissionDeleteBookmarkPublicChannel.Id,
model.PermissionOrderBookmarkPublicChannel.Id,
model.PermissionAddBookmarkPrivateChannel.Id,
model.PermissionEditBookmarkPrivateChannel.Id,
model.PermissionDeleteBookmarkPrivateChannel.Id,
model.PermissionOrderBookmarkPrivateChannel.Id,
model.PermissionManagePublicChannelBanner.Id,
model.PermissionManagePrivateChannelBanner.Id,
model.PermissionManageChannelAccessRules.Id,
},
"system_user": {
model.PermissionListPublicTeams.Id,
model.PermissionJoinPublicTeams.Id,
model.PermissionCreateDirectChannel.Id,
model.PermissionCreateGroupChannel.Id,
model.PermissionViewMembers.Id,
model.PermissionCreateTeam.Id,
model.PermissionCreateCustomGroup.Id,
model.PermissionEditCustomGroup.Id,
model.PermissionDeleteCustomGroup.Id,
model.PermissionRestoreCustomGroup.Id,
model.PermissionManageCustomGroupMembers.Id,
model.PermissionManageOwnAgent.Id,
},
"system_post_all": {
model.PermissionCreatePost.Id,
model.PermissionUseChannelMentions.Id,
},
"system_post_all_public": {
model.PermissionCreatePostPublic.Id,
model.PermissionUseChannelMentions.Id,
},
"system_user_access_token": {
model.PermissionCreateUserAccessToken.Id,
model.PermissionReadUserAccessToken.Id,
model.PermissionRevokeUserAccessToken.Id,
},
"system_admin": allPermissionIDs,
}
assert.Contains(t, allPermissionIDs, model.PermissionManageSharedChannels.Id, "manage_shared_channels permission not found")
assert.Contains(t, allPermissionIDs, model.PermissionManageSecureConnections.Id, "manage_secure_connections permission not found")
// Check the migration matches what's expected.
for name, permissions := range expected1 {
role, err := th.App.GetRoleByName(th.Context, name)
assert.Nil(t, err)
assert.Equal(t, role.Permissions, permissions, fmt.Sprintf("role %q didn't match", name))
}
th.App.Srv().SetLicense(model.NewTestLicense())
// Check the migration doesn't change anything if run again.
err = th.App.DoAdvancedPermissionsMigration()
require.NoError(t, err)
roles2, err2 := th.App.GetRolesByNames(roleNames)
assert.Nil(t, err2)
assert.Equal(t, len(roles2), len(roleNames))
for name, permissions := range expected1 {
role, err := th.App.GetRoleByName(th.Context, name)
assert.Nil(t, err)
assert.Equal(t, permissions, role.Permissions)
}
}
func TestDoEmojisPermissionsMigration(t *testing.T) {
th := SetupWithoutPreloadMigrations(t)
expectedSystemAdmin := allPermissionIDs
th.ResetEmojisMigration(t)
err := th.App.DoEmojisPermissionsMigration()
require.NoError(t, err)
role3, err3 := th.App.GetRoleByName(th.Context, model.SystemUserRoleId)
assert.Nil(t, err3)
expected3 := []string{
model.PermissionCreateCustomGroup.Id,
model.PermissionEditCustomGroup.Id,
model.PermissionDeleteCustomGroup.Id,
model.PermissionManageCustomGroupMembers.Id,
model.PermissionRestoreCustomGroup.Id,
model.PermissionListPublicTeams.Id,
model.PermissionJoinPublicTeams.Id,
model.PermissionCreateDirectChannel.Id,
model.PermissionCreateGroupChannel.Id,
model.PermissionCreateTeam.Id,
model.PermissionCreateEmojis.Id,
model.PermissionDeleteEmojis.Id,
model.PermissionViewMembers.Id,
model.PermissionManageOwnAgent.Id,
}
assert.ElementsMatch(t, expected3, role3.Permissions, fmt.Sprintf("'%v' did not have expected permissions", model.SystemUserRoleId))
systemAdmin2, systemAdminErr2 := th.App.GetRoleByName(th.Context, model.SystemAdminRoleId)
assert.Nil(t, systemAdminErr2)
assert.ElementsMatch(t, expectedSystemAdmin, systemAdmin2.Permissions, fmt.Sprintf("'%v' did not have expected permissions", model.SystemAdminRoleId))
}
func TestDBHealthCheckWriteAndDelete(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
expectedKey := "health_check_" + th.App.GetClusterId()
assert.Equal(t, expectedKey, th.App.dbHealthCheckKey())
_, err := th.App.Srv().Store().System().GetByName(expectedKey)
assert.Error(t, err)
err = th.App.DBHealthCheckWrite()
assert.NoError(t, err)
systemVal, err := th.App.Srv().Store().System().GetByName(expectedKey)
assert.NoError(t, err)
assert.NotNil(t, systemVal)
err = th.App.DBHealthCheckDelete()
assert.NoError(t, err)
_, err = th.App.Srv().Store().System().GetByName(expectedKey)
assert.Error(t, err)
}

View File

@@ -0,0 +1,237 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"os/user"
"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/public/utils"
"github.com/mattermost/mattermost/server/v8/channels/audit"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/config"
)
// Audit level aliases for convenient access within the app package.
// These map directly to the audit levels defined in mlog.
//
// See [github.com/mattermost/mattermost/server/public/shared/mlog.LvlAuditAPI],
// [github.com/mattermost/mattermost/server/public/shared/mlog.LvlAuditContent],
// [github.com/mattermost/mattermost/server/public/shared/mlog.LvlAuditPerms],
// [github.com/mattermost/mattermost/server/public/shared/mlog.LvlAuditCLI],
// [github.com/mattermost/mattermost/server/public/shared/mlog.LvlAuditDelivery]
// for detailed documentation on when to use each level.
var (
LevelAPI = mlog.LvlAuditAPI
LevelContent = mlog.LvlAuditContent
LevelPerms = mlog.LvlAuditPerms
LevelCLI = mlog.LvlAuditCLI
LevelDelivery = mlog.LvlAuditDelivery
)
const (
AuditCertificateFilename = "audit_certificate.pem"
)
func (a *App) GetAudits(rctx request.CTX, userID string, limit int) (model.Audits, *model.AppError) {
audits, err := a.Srv().Store().Audit().Get(userID, 0, limit)
if err != nil {
var outErr *store.ErrOutOfBounds
switch {
case errors.As(err, &outErr):
return nil, model.NewAppError("GetAudits", "app.audit.get.limit.app_error", nil, "", http.StatusBadRequest).Wrap(err)
default:
return nil, model.NewAppError("GetAudits", "app.audit.get.finding.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
return audits, nil
}
func (a *App) GetAuditsPage(rctx request.CTX, userID string, page int, perPage int) (model.Audits, *model.AppError) {
audits, err := a.Srv().Store().Audit().Get(userID, page*perPage, perPage)
if err != nil {
var outErr *store.ErrOutOfBounds
switch {
case errors.As(err, &outErr):
return nil, model.NewAppError("GetAuditsPage", "app.audit.get.limit.app_error", nil, "", http.StatusBadRequest).Wrap(err)
default:
return nil, model.NewAppError("GetAuditsPage", "app.audit.get.finding.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
return audits, nil
}
// LogAuditRec logs an audit record using default LvlAuditCLI.
func (a *App) LogAuditRec(rctx request.CTX, rec *model.AuditRecord, err error) {
a.LogAuditRecWithLevel(rctx, rec, mlog.LvlAuditCLI, err)
}
// LogAuditRecWithLevel logs an audit record using specified Level.
func (a *App) LogAuditRecWithLevel(rctx request.CTX, rec *model.AuditRecord, level mlog.Level, err error) {
if rec == nil {
return
}
if err != nil {
appErr, ok := err.(*model.AppError)
if ok {
rec.AddErrorCode(appErr.StatusCode)
}
rec.AddErrorDesc(appErr.Error())
rec.Fail()
}
a.Srv().Audit.LogRecord(level, *rec)
}
// MakeAuditRecord creates a audit record pre-populated with defaults.
func (a *App) MakeAuditRecord(rctx request.CTX, event string, initialStatus string) *model.AuditRecord {
var userID string
user, err := user.Current()
if err == nil {
userID = fmt.Sprintf("%s:%s", user.Uid, user.Username)
}
rec := &model.AuditRecord{
EventName: event,
Status: initialStatus,
Meta: map[string]any{
model.AuditKeyAPIPath: "",
model.AuditKeyClusterID: a.GetClusterId(),
},
Actor: model.AuditEventActor{
UserId: userID,
SessionId: "",
Client: fmt.Sprintf("server %s-%s", model.BuildNumber, model.BuildHash),
IpAddress: "",
XForwardedFor: "",
},
EventData: model.AuditEventData{
Parameters: map[string]any{},
PriorState: map[string]any{},
ResultState: map[string]any{},
ObjectType: "",
},
}
return rec
}
func (s *Server) configureAudit(adt *audit.Audit, bAllowAdvancedLogging bool) error {
adt.OnQueueFull = s.onAuditTargetQueueFull
adt.OnError = s.onAuditError
var logConfigSrc config.LogConfigSrc
dsn := s.platform.Config().ExperimentalAuditSettings.GetAdvancedLoggingConfig()
if bAllowAdvancedLogging {
if !utils.IsEmptyJSON(dsn) {
var err error
logConfigSrc, err = config.NewLogConfigSrc(dsn, s.platform.GetConfigStore())
if err != nil {
return fmt.Errorf("invalid config source for audit, %w", err)
}
s.Log().Debug("Loaded audit configuration", mlog.String("source", dsn))
} else {
s.Log().Debug("Advanced logging config not provided for audit")
}
}
// ExperimentalAuditSettings provides basic file audit (E0, E10); logConfigSrc provides advanced config (E20).
cfg, err := config.MloggerConfigFromAuditConfig(s.platform.Config().ExperimentalAuditSettings, logConfigSrc)
if err != nil {
return fmt.Errorf("invalid config for audit, %w", err)
}
// Append additional config from env var; any target name collisions will be overwritten.
additionalJSON := strings.TrimSpace(os.Getenv("MM_EXPERIMENTALAUDITSETTINGS_ADDITIONAL"))
if additionalJSON != "" {
cfgAdditional := make(mlog.LoggerConfiguration)
if err := json.Unmarshal([]byte(additionalJSON), &cfgAdditional); err != nil {
return fmt.Errorf("invalid additional config for audit, %w", err)
}
cfg.Append(cfgAdditional)
}
return adt.Configure(cfg)
}
func (s *Server) onAuditTargetQueueFull(qname string, maxQSize int) bool {
s.Log().Error("Audit queue full, dropping record.", mlog.String("qname", qname), mlog.Int("queue_size", maxQSize))
return true // drop it
}
func (s *Server) onAuditError(err error) {
s.Log().Error("Audit Error", mlog.Err(err))
}
func (a *App) AddAuditLogCertificate(rctx request.CTX, fileData *multipart.FileHeader) *model.AppError {
file, err := fileData.Open()
if err != nil {
return model.NewAppError("AddAuditLogCertificate", "api.admin.add_certificate.open.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
return model.NewAppError("AddAuditLogCertificate", "api.admin.add_certificate.saving.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
err = a.Srv().platform.SetConfigFile(AuditCertificateFilename, data)
if err != nil {
return model.NewAppError("AddAuditLogCertificate", "api.admin.add_certificate.saving.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
cfg := a.Config().Clone()
*cfg.ExperimentalAuditSettings.Certificate = AuditCertificateFilename
if err := cfg.IsValid(); err != nil {
return err
}
a.UpdateConfig(func(dest *model.Config) { *dest = *cfg })
if a.License().IsCloud() {
err = a.Cloud().CreateAuditLoggingCert(rctx.Session().UserId, fileData)
if err != nil {
return model.NewAppError("AddAuditLogCertificate", "api.admin.add_certificate.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
return nil
}
func (a *App) RemoveAuditLogCertificate(rctx request.CTX) *model.AppError {
err := a.Srv().platform.RemoveConfigFile(AuditCertificateFilename)
if err != nil {
return model.NewAppError("RemoveAuditLogCertificate", "api.admin.remove_certificate.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
cfg := a.Config().Clone()
*cfg.ExperimentalAuditSettings.Certificate = ""
if err := cfg.IsValid(); err != nil {
return model.NewAppError("RemoveAuditLogCertificate", "api.admin.remove_certificate.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
a.UpdateConfig(func(dest *model.Config) { *dest = *cfg })
if a.License().IsCloud() {
err = a.Cloud().RemoveAuditLoggingCert(rctx.Session().UserId)
if err != nil {
return model.NewAppError("RemoveAuditLogCertificate", "api.admin.remove_certificate.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
return nil
}

View File

@@ -0,0 +1,534 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"errors"
"net/http"
"path"
"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/app/password/hashers"
"github.com/mattermost/mattermost/server/v8/channels/app/users"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/mattermost/mattermost/server/v8/platform/shared/mfa"
)
type TokenLocation int
const (
TokenLocationNotFound TokenLocation = iota
TokenLocationHeader
TokenLocationCookie
TokenLocationQueryString
TokenLocationCloudHeader
TokenLocationRemoteClusterHeader
)
func (tl TokenLocation) String() string {
switch tl {
case TokenLocationNotFound:
return "Not Found"
case TokenLocationHeader:
return "Header"
case TokenLocationCookie:
return "Cookie"
case TokenLocationQueryString:
return "QueryString"
case TokenLocationCloudHeader:
return "CloudHeader"
case TokenLocationRemoteClusterHeader:
return "RemoteClusterHeader"
default:
return "Unknown"
}
}
func (a *App) IsPasswordValid(rctx request.CTX, password string) *model.AppError {
if err := users.IsPasswordValidWithSettings(password, &a.Config().PasswordSettings); err != nil {
var invErr *users.ErrInvalidPassword
switch {
case errors.As(err, &invErr):
return model.NewAppError("User.IsValid", invErr.Id(), map[string]any{"Min": *a.Config().PasswordSettings.MinimumLength}, "", http.StatusBadRequest).Wrap(err)
default:
return model.NewAppError("User.IsValid", "app.valid_password_generic.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
return nil
}
func (a *App) checkUserPassword(user *model.User, password string) *model.AppError {
if user.Password == "" || password == "" {
return model.NewAppError("checkUserPassword", "api.user.check_user_password.invalid.app_error", nil, "user_id="+user.Id, http.StatusUnauthorized)
}
// Get the hasher and parsed PHC
hasher, phc, err := hashers.GetHasherFromPHCString(user.Password)
if err != nil {
return model.NewAppError("checkUserPassword", "api.user.check_user_password.invalid_hash.app_error", nil, "user_id="+user.Id, http.StatusUnauthorized)
}
// Compare the password using the hasher that generated it
err = hasher.CompareHashAndPassword(phc, password)
if err != nil && errors.Is(err, hashers.ErrMismatchedHashAndPassword) {
return model.NewAppError("checkUserPassword", "api.user.check_user_password.invalid.app_error", nil, "user_id="+user.Id, http.StatusUnauthorized).Wrap(err)
} else if err != nil {
return model.NewAppError("checkUserPassword", "app.valid_password_generic.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Migrate the password if needed
if !hashers.IsLatestHasher(hasher) {
return a.migratePassword(user, password)
}
return nil
}
// migratePassword updates the database with the user's password hashed with the
// latest hashing method. It assumes that the password has been already validated.
func (a *App) migratePassword(user *model.User, password string) *model.AppError {
// Compute the new hash with the latest hashing method
newHash, err := hashers.Hash(password)
if err != nil {
return model.NewAppError("migratePassword", "app.user.check_user_password.failed_migration", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Update the password
if err := a.Srv().Store().User().UpdatePassword(user.Id, newHash); err != nil {
return model.NewAppError("migratePassword", "app.user.check_user_password.failed_update", nil, "", http.StatusInternalServerError).Wrap(err)
}
a.InvalidateCacheForUser(user.Id)
return nil
}
func (a *App) CheckPasswordAndAllCriteria(rctx request.CTX, userID string, password string, mfaToken string) *model.AppError {
user, err := a.GetUser(rctx, userID)
if err != nil {
if err.Id != MissingAccountError {
err.StatusCode = http.StatusInternalServerError
return err
}
err.StatusCode = http.StatusBadRequest
return err
}
if err := a.CheckUserPreflightAuthenticationCriteria(rctx, user, mfaToken); err != nil {
return err
}
maxAttempts := *a.Config().ServiceSettings.MaximumLoginAttempts
claimed, claimErr := a.Srv().Store().User().TryIncrementFailedPasswordAttempts(user.Id, maxAttempts)
if claimErr != nil {
return model.NewAppError("CheckPasswordAndAllCriteria", "app.user.update_failed_pwd_attempts.app_error", nil, "", http.StatusInternalServerError).Wrap(claimErr)
}
if !claimed {
return model.NewAppError("checkUserLoginAttempts", "api.user.check_user_login_attempts.too_many.app_error", nil, "user_id="+user.Id, http.StatusUnauthorized)
}
if err := a.checkUserPassword(user, password); err != nil {
// Only keep the claimed slot when the failure is an actual
// credential mismatch; backend errors (hasher failures, migration
// failures, malformed stored hash) must not consume a slot or a
// transient infra issue could lock out a user with valid creds.
if err.Id != "api.user.check_user_password.invalid.app_error" {
if passErr := a.Srv().Store().User().DecrementFailedPasswordAttempts(user.Id); passErr != nil {
rctx.Logger().Warn("failed to refund login attempt slot", mlog.String("user_id", user.Id), mlog.Err(passErr))
}
}
return err
}
if err := a.CheckUserMfa(rctx, user, mfaToken); err != nil {
// The slot we claimed already counts this as a failed attempt;
// the only special case is when no mfaToken was provided, which
// is treated as a pre-flight MFA-state probe rather than a real
// attempt — refund the slot so the probe is not counted.
if mfaToken == "" {
if passErr := a.Srv().Store().User().DecrementFailedPasswordAttempts(user.Id); passErr != nil {
rctx.Logger().Warn("failed to refund MFA probe slot", mlog.String("user_id", user.Id), mlog.Err(passErr))
}
}
return err
}
if passErr := a.Srv().Store().User().UpdateFailedPasswordAttempts(user.Id, 0); passErr != nil {
return model.NewAppError("CheckPasswordAndAllCriteria", "app.user.update_failed_pwd_attempts.app_error", nil, "", http.StatusInternalServerError).Wrap(passErr)
}
if err := a.CheckUserPostflightAuthenticationCriteria(rctx, user); err != nil {
return err
}
return nil
}
// This to be used for places we check the users password when they are already logged in
func (a *App) DoubleCheckPassword(rctx request.CTX, user *model.User, password string) *model.AppError {
maxAttempts := *a.Config().ServiceSettings.MaximumLoginAttempts
claimed, claimErr := a.Srv().Store().User().TryIncrementFailedPasswordAttempts(user.Id, maxAttempts)
if claimErr != nil {
return model.NewAppError("DoubleCheckPassword", "app.user.update_failed_pwd_attempts.app_error", nil, "", http.StatusInternalServerError).Wrap(claimErr)
}
if !claimed {
return model.NewAppError("checkUserLoginAttempts", "api.user.check_user_login_attempts.too_many.app_error", nil, "user_id="+user.Id, http.StatusUnauthorized)
}
if err := a.checkUserPassword(user, password); err != nil {
if err.Id != "api.user.check_user_password.invalid.app_error" {
if passErr := a.Srv().Store().User().DecrementFailedPasswordAttempts(user.Id); passErr != nil {
rctx.Logger().Warn("failed to refund login attempt slot", mlog.String("user_id", user.Id), mlog.Err(passErr))
}
}
return err
}
if passErr := a.Srv().Store().User().UpdateFailedPasswordAttempts(user.Id, 0); passErr != nil {
return model.NewAppError("DoubleCheckPassword", "app.user.update_failed_pwd_attempts.app_error", nil, "", http.StatusInternalServerError).Wrap(passErr)
}
a.InvalidateCacheForUser(user.Id)
return nil
}
func (a *App) checkLdapUserPasswordAndAllCriteria(rctx request.CTX, user *model.User, password, mfaToken string) (*model.User, *model.AppError) {
// We need to get the latest value of the user from the database. user.Id is empty for first-time LDAP users.
if user.Id != "" {
var err *model.AppError
user, err = a.GetUser(rctx, user.Id)
if err != nil {
if err.Id != MissingAccountError {
err.StatusCode = http.StatusInternalServerError
return nil, err
}
err.StatusCode = http.StatusBadRequest
return nil, err
}
}
ldapID := user.AuthData
if a.Ldap() == nil || ldapID == nil {
err := model.NewAppError("doLdapAuthentication", "api.user.login_ldap.not_available.app_error", nil, "", http.StatusNotImplemented)
return nil, err
}
maxAttempts := *a.Config().LdapSettings.MaximumLoginAttempts
// First-time LDAP users have no local row yet to pre-claim against.
if user.Id != "" {
claimed, claimErr := a.Srv().Store().User().TryIncrementFailedPasswordAttempts(user.Id, maxAttempts)
if claimErr != nil {
return nil, model.NewAppError("checkLdapUserPasswordAndAllCriteria", "app.user.update_failed_pwd_attempts.app_error", nil, "", http.StatusInternalServerError).Wrap(claimErr)
}
if !claimed {
return nil, model.NewAppError("checkUserLoginAttempts", "api.user.check_user_login_attempts.too_many_ldap.app_error", nil, "user_id="+user.Id, http.StatusUnauthorized)
}
}
ldapUser, err := a.Ldap().DoLogin(rctx, *ldapID, password)
if err != nil {
// If this is a new LDAP user, we need to get the user from the database because DoLogin will have created the user.
if user.Id == "" {
var getUserByAuthErr *model.AppError
ldapUser, getUserByAuthErr = a.GetUserByAuth(ldapID, model.UserAuthServiceLdap)
if getUserByAuthErr != nil {
return nil, getUserByAuthErr
}
} else {
ldapUser = user
}
// Log a info to make it easier to admin to spot that a user tried to log in with a legitimate user name.
if err.Id == "ent.ldap.do_login.invalid_password.app_error" {
rctx.Logger().LogM(mlog.MlvlLDAPInfo, "A user tried to sign in, which matched an LDAP account, but the password was incorrect.", mlog.String("ldap_id", *ldapID))
// For existing users we already claimed the slot above, so the
// counter has already been bumped. For first-time users (the
// row was just created by DoLogin) we still need to count the
// failed attempt explicitly, using the atomic primitive so
// concurrent first-attempt requests cannot overwrite each
// other's increments.
if user.Id == "" {
if _, passErr := a.Srv().Store().User().TryIncrementFailedPasswordAttempts(ldapUser.Id, maxAttempts); passErr != nil {
rctx.Logger().Warn("failed to record failed attempt for first-time LDAP user", mlog.String("user_id", ldapUser.Id), mlog.Err(passErr))
}
}
} else if user.Id != "" {
// Non-credential failure (LDAP unreachable, transient error,
// etc.) on an existing user must not consume the slot we
// pre-claimed, or an LDAP outage could lock out everyone.
if passErr := a.Srv().Store().User().DecrementFailedPasswordAttempts(user.Id); passErr != nil {
rctx.Logger().Warn("failed to refund LDAP login attempt slot", mlog.String("user_id", user.Id), mlog.Err(passErr))
}
}
err.StatusCode = http.StatusUnauthorized
return nil, err
}
if err = a.CheckUserMfa(rctx, ldapUser, mfaToken); err != nil {
// For existing LDAP users we pre-claimed a slot, so it already
// counts as a failed attempt. The only special case is when no
// mfaToken was provided, which is treated as a pre-flight
// MFA-state probe rather than a real attempt — refund the slot
// so the probe is not counted.
//
// For first-time LDAP users we did not pre-claim (no row to
// claim against), so a real MFA attempt with a non-empty token
// still needs to be counted explicitly against the freshly
// created row.
switch {
case user.Id == "" && mfaToken != "":
if _, passErr := a.Srv().Store().User().TryIncrementFailedPasswordAttempts(ldapUser.Id, maxAttempts); passErr != nil {
rctx.Logger().Warn("failed to record failed MFA attempt for first-time LDAP user", mlog.String("user_id", ldapUser.Id), mlog.Err(passErr))
}
case user.Id != "" && mfaToken == "":
if passErr := a.Srv().Store().User().DecrementFailedPasswordAttempts(ldapUser.Id); passErr != nil {
rctx.Logger().Warn("failed to refund LDAP MFA probe slot", mlog.String("user_id", ldapUser.Id), mlog.Err(passErr))
}
}
return nil, err
}
if err = checkUserNotDisabled(ldapUser); err != nil {
// Existing LDAP users had a slot pre-claimed; a disabled-account
// rejection is not a credential failure, so refund the slot so a
// reactivated user is not immediately rate-limited.
if user.Id != "" {
if passErr := a.Srv().Store().User().DecrementFailedPasswordAttempts(ldapUser.Id); passErr != nil {
rctx.Logger().Warn("failed to refund disabled LDAP login attempt slot", mlog.String("user_id", ldapUser.Id), mlog.Err(passErr))
}
}
return nil, err
}
if passErr := a.Srv().Store().User().UpdateFailedPasswordAttempts(ldapUser.Id, 0); passErr != nil {
return nil, model.NewAppError("checkLdapUserPasswordAndAllCriteria", "app.user.update_failed_pwd_attempts.app_error", nil, "", http.StatusInternalServerError).Wrap(passErr)
}
// user successfully authenticated
return ldapUser, nil
}
func (a *App) CheckUserAllAuthenticationCriteria(rctx request.CTX, user *model.User, mfaToken string) *model.AppError {
if err := a.CheckUserPreflightAuthenticationCriteria(rctx, user, mfaToken); err != nil {
return err
}
if err := a.CheckUserPostflightAuthenticationCriteria(rctx, user); err != nil {
return err
}
return nil
}
func (a *App) CheckUserPreflightAuthenticationCriteria(rctx request.CTX, user *model.User, mfaToken string) *model.AppError {
if err := checkUserNotDisabled(user); err != nil {
return err
}
if err := checkUserNotBot(user); err != nil {
return err
}
if err := checkUserLoginAttempts(user, *a.Config().ServiceSettings.MaximumLoginAttempts); err != nil {
return err
}
return nil
}
func (a *App) CheckUserPostflightAuthenticationCriteria(rctx request.CTX, user *model.User) *model.AppError {
if !user.EmailVerified && *a.Config().EmailSettings.RequireEmailVerification {
return model.NewAppError("Login", "api.user.login.not_verified.app_error", nil, "user_id="+user.Id, http.StatusUnauthorized)
}
return nil
}
func (a *App) CheckUserMfa(rctx request.CTX, user *model.User, token string) *model.AppError {
if !user.MfaActive || !*a.Config().ServiceSettings.EnableMultifactorAuthentication {
return nil
}
if !*a.Config().ServiceSettings.EnableMultifactorAuthentication {
return model.NewAppError("CheckUserMfa", "mfa.mfa_disabled.app_error", nil, "", http.StatusNotImplemented)
}
ok, err := mfa.New(a.Srv().Store().User()).ValidateToken(user, token)
if err != nil {
return model.NewAppError("CheckUserMfa", "mfa.validate_token.authenticate.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
if !ok {
return model.NewAppError("checkUserMfa", "api.user.check_user_mfa.bad_code.app_error", nil, "", http.StatusUnauthorized)
}
return nil
}
func (a *App) MFARequired(rctx request.CTX, method string) *model.AppError {
if license := a.Channels().License(); license == nil || !*license.Features.MFA || !*a.Config().ServiceSettings.EnableMultifactorAuthentication || !*a.Config().ServiceSettings.EnforceMultifactorAuthentication {
return nil
}
session := rctx.Session()
// Session cannot be nil or empty if MFA is to be enforced.
if session == nil || session.Id == "" {
return model.NewAppError("MfaRequired", "api.context.get_session.app_error", nil, "", http.StatusUnauthorized)
}
// OAuth integrations are excepted
if session.IsOAuth {
return nil
}
user, err := a.GetUser(rctx, session.UserId)
if err != nil {
return model.NewAppError("MfaRequired", "api.context.get_user.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if user.IsGuest() && !*a.Config().GuestAccountsSettings.EnforceMultifactorAuthentication {
return nil
}
// Only required for email and ldap accounts
if user.AuthService != "" &&
user.AuthService != model.UserAuthServiceEmail &&
user.AuthService != model.UserAuthServiceLdap {
return nil
}
// Special case to let user get (but not modify or delete) themself
subpath, _ := utils.GetSubpathFromConfig(a.Config())
if method == http.MethodGet && rctx.Path() == path.Join(subpath, "/api/v4/users/me") {
return nil
}
// Bots are exempt
if user.IsBot {
return nil
}
if !user.MfaActive {
return model.NewAppError("MfaRequired", "api.context.mfa_required.app_error", nil, "", http.StatusForbidden)
}
return nil
}
func checkUserLoginAttempts(user *model.User, maxAttempts int) *model.AppError {
if user.FailedAttempts >= maxAttempts {
if user.AuthService == model.UserAuthServiceLdap {
return model.NewAppError("checkUserLoginAttempts", "api.user.check_user_login_attempts.too_many_ldap.app_error", nil, "user_id="+user.Id, http.StatusUnauthorized)
}
return model.NewAppError("checkUserLoginAttempts", "api.user.check_user_login_attempts.too_many.app_error", nil, "user_id="+user.Id, http.StatusUnauthorized)
}
return nil
}
func checkUserNotDisabled(user *model.User) *model.AppError {
if user.DeleteAt > 0 {
return model.NewAppError("Login", "api.user.login.inactive.app_error", nil, "user_id="+user.Id, http.StatusUnauthorized)
}
return nil
}
func checkUserNotBot(user *model.User) *model.AppError {
if user.IsBot {
return model.NewAppError("Login", "api.user.login.bot_login_forbidden.app_error", nil, "user_id="+user.Id, http.StatusUnauthorized)
}
return nil
}
func (a *App) authenticateUser(rctx request.CTX, user *model.User, password, mfaToken string) (*model.User, *model.AppError) {
license := a.Srv().License()
ldapAvailable := *a.Config().LdapSettings.Enable && a.Ldap() != nil && license != nil && *license.Features.LDAP
if user.AuthService == model.UserAuthServiceLdap {
if !ldapAvailable {
err := model.NewAppError("login", "api.user.login_ldap.not_available.app_error", nil, "", http.StatusNotImplemented)
return user, err
}
ldapUser, err := a.checkLdapUserPasswordAndAllCriteria(rctx, user, password, mfaToken)
if err != nil {
err.StatusCode = http.StatusUnauthorized
return user, err
}
// slightly redundant to get the user again, but we need to get it from the LDAP server
return ldapUser, nil
}
if user.AuthService != "" {
authService := user.AuthService
if authService == model.UserAuthServiceSaml {
authService = strings.ToUpper(authService)
}
err := model.NewAppError("login", "api.user.login.use_auth_service.app_error", map[string]any{"AuthService": authService}, "", http.StatusBadRequest)
return user, err
}
if err := a.CheckPasswordAndAllCriteria(rctx, user.Id, password, mfaToken); err != nil {
if err.Id == "api.user.check_user_password.invalid.app_error" {
rctx.Logger().LogM(mlog.MlvlLDAPInfo, "A user tried to sign in, which matched a Mattermost account, but the password was incorrect.", mlog.String("username", user.Username))
}
err.StatusCode = http.StatusUnauthorized
return user, err
}
return user, nil
}
func ParseAuthTokenFromRequest(r *http.Request) (token string, loc TokenLocation) {
defer func() {
// Stripping off tokens of large sizes
// to prevent logging a large string.
if len(token) > 50 {
token = token[:50]
}
}()
authHeader := r.Header.Get(model.HeaderAuth)
// Attempt to parse the token from the cookie
if cookie, err := r.Cookie(model.SessionCookieToken); err == nil {
return cookie.Value, TokenLocationCookie
}
// Parse the token from the header
if len(authHeader) > 6 && strings.ToUpper(authHeader[0:6]) == model.HeaderBearer {
// Default session token
return authHeader[7:], TokenLocationHeader
}
if len(authHeader) > 5 && strings.ToLower(authHeader[0:5]) == model.HeaderToken {
// OAuth token
return authHeader[6:], TokenLocationHeader
}
// Attempt to parse token out of the query string
if token := r.URL.Query().Get("access_token"); token != "" {
return token, TokenLocationQueryString
}
if token := r.Header.Get(model.HeaderCloudToken); token != "" {
return token, TokenLocationCloudHeader
}
if token := r.Header.Get(model.HeaderRemoteclusterToken); token != "" {
return token, TokenLocationRemoteClusterHeader
}
return "", TokenLocationNotFound
}

View File

@@ -0,0 +1,818 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"sync"
"testing"
"time"
"github.com/dgryski/dgoogauth"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
"golang.org/x/sync/errgroup"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/app/password/hashers"
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
)
func TestParseAuthTokenFromRequest(t *testing.T) {
mainHelper.Parallel(t)
cases := []struct {
header string
cookie string
query string
expectedToken string
expectedLocation TokenLocation
}{
{"", "", "", "", TokenLocationNotFound},
{"token mytoken", "", "", "mytoken", TokenLocationHeader},
{"BEARER mytoken", "", "", "mytoken", TokenLocationHeader},
{"", "mytoken", "", "mytoken", TokenLocationCookie},
{"", "a very large token to test out tokentokentokentokentokentokentokentokentokentokentokentokentoken", "", "a very large token to test out tokentokentokentoke", TokenLocationCookie},
{"", "", "mytoken", "mytoken", TokenLocationQueryString},
{"mytoken", "", "", "mytoken", TokenLocationCloudHeader},
}
for testnum, tc := range cases {
pathname := "/test/here"
if tc.query != "" {
pathname += "?access_token=" + tc.query
}
req := httptest.NewRequest("GET", pathname, nil)
switch tc.expectedLocation {
case TokenLocationHeader:
req.Header.Add(model.HeaderAuth, tc.header)
case TokenLocationCloudHeader:
req.Header.Add(model.HeaderCloudToken, tc.header)
case TokenLocationCookie:
req.AddCookie(&http.Cookie{
Name: model.SessionCookieToken,
Value: tc.cookie,
})
}
token, location := ParseAuthTokenFromRequest(req)
require.Equal(t, tc.expectedToken, token, "Wrong token on test "+strconv.Itoa(testnum))
require.Equal(t, tc.expectedLocation, location, "Wrong location on test "+strconv.Itoa(testnum))
}
}
func TestCheckPasswordAndAllCriteria(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
const maxFailedLoginAttempts = 3
const concurrentAttempts = maxFailedLoginAttempts + 1
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.MaximumLoginAttempts = maxFailedLoginAttempts
*cfg.ServiceSettings.EnableMultifactorAuthentication = true
})
password := model.NewTestPassword()
appErr := th.App.UpdatePassword(th.Context, th.BasicUser, password)
require.Nil(t, appErr)
// setup MFA
secret, appErr := th.App.GenerateMfaSecret(th.Context, th.BasicUser.Id)
require.Nil(t, appErr)
err := th.Server.Store().User().UpdateMfaActive(th.BasicUser.Id, true)
require.NoError(t, err)
err = th.Server.Store().User().UpdateMfaSecret(th.BasicUser.Id, secret.Secret)
require.NoError(t, err)
t.Run("should run successfully when attempts are available", func(t *testing.T) {
err = th.App.Srv().Store().User().UpdateFailedPasswordAttempts(th.BasicUser.Id, maxFailedLoginAttempts-1)
require.NoError(t, err)
code := dgoogauth.ComputeCode(secret.Secret, time.Now().UTC().Unix()/30)
token := fmt.Sprintf("%06d", code)
appErr = th.App.CheckPasswordAndAllCriteria(th.Context, th.BasicUser.Id, password, token)
require.Nil(t, appErr)
updatedUser, appErr := th.App.GetUser(th.Context, th.BasicUser.Id)
require.Nil(t, appErr)
require.Equal(t, 0, updatedUser.FailedAttempts, "successful login must reset FailedAttempts")
})
t.Run("MFA pre-flight probe does not consume a slot", func(t *testing.T) {
// An empty mfaToken on an MFA-enabled user is a pre-flight probe
// (the client is checking whether MFA is required) and must not
// count as a failed attempt.
err := th.App.Srv().Store().User().UpdateFailedPasswordAttempts(th.BasicUser.Id, 0)
require.NoError(t, err)
appErr := th.App.CheckPasswordAndAllCriteria(th.Context, th.BasicUser.Id, password, "")
require.NotNil(t, appErr)
require.Equal(t, "mfa.validate_token.authenticate.app_error", appErr.Id)
updatedUser, appErr := th.App.GetUser(th.Context, th.BasicUser.Id)
require.Nil(t, appErr)
require.Equal(t, 0, updatedUser.FailedAttempts, "MFA probe must not consume a slot")
})
t.Run("MFA real attempt with a wrong token consumes a slot", func(t *testing.T) {
// A non-empty bad mfaToken is a real attempt, not a probe; the
// slot the pre-claim consumed stays consumed.
err := th.App.Srv().Store().User().UpdateFailedPasswordAttempts(th.BasicUser.Id, 0)
require.NoError(t, err)
appErr := th.App.CheckPasswordAndAllCriteria(th.Context, th.BasicUser.Id, password, "123456")
require.NotNil(t, appErr)
require.Equal(t, "api.user.check_user_mfa.bad_code.app_error", appErr.Id)
updatedUser, appErr := th.App.GetUser(th.Context, th.BasicUser.Id)
require.Nil(t, appErr)
require.Equal(t, 1, updatedUser.FailedAttempts, "real MFA failure must consume a slot")
})
t.Run("backend error refunds the slot", func(t *testing.T) {
// Backend errors during the password check (malformed stored hash,
// hasher misc failure, migration failure) must not consume a slot
// or a transient infra issue could lock out a user with valid
// credentials. We trigger this via an unparseable PHC string,
// which surfaces as invalid_hash.app_error.
badHashUser := th.CreateUser(t)
err := th.Server.Store().User().UpdatePassword(badHashUser.Id, "$pbkdf2$bogus")
require.NoError(t, err)
th.App.InvalidateCacheForUser(badHashUser.Id)
err = th.App.Srv().Store().User().UpdateFailedPasswordAttempts(badHashUser.Id, 0)
require.NoError(t, err)
appErr := th.App.CheckPasswordAndAllCriteria(th.Context, badHashUser.Id, "any-password", "")
require.NotNil(t, appErr)
require.Equal(t, "api.user.check_user_password.invalid_hash.app_error", appErr.Id)
updatedUser, appErr := th.App.GetUser(th.Context, badHashUser.Id)
require.Nil(t, appErr)
require.Equal(t, 0, updatedUser.FailedAttempts, "backend error must not consume a slot")
})
t.Run("validate concurrent failed attempts to bypass checks", func(t *testing.T) {
testCases := []struct {
name string
password string
mfaToken string
expectedErrID string
}{
{
name: "should not breach max. login attempts when password is wrong",
password: model.NewTestPassword(),
expectedErrID: "api.user.check_user_password.invalid.app_error",
},
{
name: "should not breach max. login attempts when MFA is wrong",
password: password,
mfaToken: "123456",
expectedErrID: "api.user.check_user_mfa.bad_code.app_error",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Reset login attempts
err := th.App.Srv().Store().User().UpdateFailedPasswordAttempts(th.BasicUser.Id, 0)
require.NoError(t, err)
// Capture all concurrent errors
appErrs := make([]*model.AppError, concurrentAttempts)
// Wait to complete the test
var completeWG sync.WaitGroup
completeWG.Add(concurrentAttempts)
for i := range concurrentAttempts {
go func(i int) {
defer completeWG.Done()
// Simulate concurrent failed login checks by same user
appErrs[i] = th.App.CheckPasswordAndAllCriteria(th.Context, th.BasicUser.Id, tc.password, tc.mfaToken)
}(i)
}
completeWG.Wait()
expectedErrsCount := 0
for i := range concurrentAttempts {
if appErrs[i].Id == tc.expectedErrID {
expectedErrsCount++
continue
}
require.Equal(t, "api.user.check_user_login_attempts.too_many.app_error", appErrs[i].Id, "All other errors should be of too many login attempts only.")
}
// Password/MFA failure attempts should not breach the maxFailedAttempts
// even during concurrent access by the same user.
require.Equal(t, maxFailedLoginAttempts, expectedErrsCount)
})
}
})
}
func TestDoubleCheckPassword(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
const maxFailedLoginAttempts = 3
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.MaximumLoginAttempts = maxFailedLoginAttempts
})
password := model.NewTestPassword()
appErr := th.App.UpdatePassword(th.Context, th.BasicUser, password)
require.Nil(t, appErr)
// DoubleCheckPassword does not re-fetch the user; it inspects user.Password
// directly. Pull a fresh struct that reflects the hash we just wrote.
user, appErr := th.App.GetUser(th.Context, th.BasicUser.Id)
require.Nil(t, appErr)
t.Run("correct password succeeds and resets the counter", func(t *testing.T) {
err := th.App.Srv().Store().User().UpdateFailedPasswordAttempts(user.Id, maxFailedLoginAttempts-1)
require.NoError(t, err)
appErr := th.App.DoubleCheckPassword(th.Context, user, password)
require.Nil(t, appErr)
updatedUser, appErr := th.App.GetUser(th.Context, user.Id)
require.Nil(t, appErr)
require.Equal(t, 0, updatedUser.FailedAttempts)
})
t.Run("rate limit is enforced once max attempts is reached", func(t *testing.T) {
err := th.App.Srv().Store().User().UpdateFailedPasswordAttempts(user.Id, maxFailedLoginAttempts)
require.NoError(t, err)
appErr := th.App.DoubleCheckPassword(th.Context, user, password)
require.NotNil(t, appErr)
require.Equal(t, "api.user.check_user_login_attempts.too_many.app_error", appErr.Id)
})
t.Run("backend error refunds the slot", func(t *testing.T) {
badHashUser := th.CreateUser(t)
err := th.Server.Store().User().UpdatePassword(badHashUser.Id, "$pbkdf2$bogus")
require.NoError(t, err)
th.App.InvalidateCacheForUser(badHashUser.Id)
err = th.App.Srv().Store().User().UpdateFailedPasswordAttempts(badHashUser.Id, 0)
require.NoError(t, err)
user, appErr := th.App.GetUser(th.Context, badHashUser.Id)
require.Nil(t, appErr)
appErr = th.App.DoubleCheckPassword(th.Context, user, "any-password")
require.NotNil(t, appErr)
require.Equal(t, "api.user.check_user_password.invalid_hash.app_error", appErr.Id)
updatedUser, appErr := th.App.GetUser(th.Context, badHashUser.Id)
require.Nil(t, appErr)
require.Equal(t, 0, updatedUser.FailedAttempts, "backend error must not consume a slot")
})
}
func TestCheckLdapUserPasswordAndAllCriteria(t *testing.T) {
th := SetupEnterprise(t).InitBasic(t)
// update config
const maxFailedLoginAttempts = 3
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.LdapSettings.MaximumLoginAttempts = maxFailedLoginAttempts
*cfg.ServiceSettings.EnableMultifactorAuthentication = true
})
mockLdap := &mocks.LdapInterface{}
th.App.Channels().Ldap = mockLdap
authData := model.NewRandomString(32)
// create an ldap user by calling createUser
ldapUser := &model.User{
Email: "ldapuser@mattermost-customer.com",
Username: "ldapuser",
AuthService: model.UserAuthServiceLdap,
AuthData: &authData,
EmailVerified: true,
}
user, appErr := th.App.CreateUser(th.Context, ldapUser)
require.Nil(t, appErr)
user.AuthData = &authData
validPassword := model.NewTestPassword()
wrongPassword := model.NewTestPassword()
testCases := []struct {
name string
password string
expectedErrID string
mockDoLogin func()
}{
{
name: "valid password",
password: validPassword,
expectedErrID: "",
mockDoLogin: func() {
mockLdap.Mock.On("DoLogin", th.Context, authData, validPassword).Return(user, nil)
},
},
{
name: "invalid password",
password: wrongPassword,
expectedErrID: "api.user.check_user_password.invalid.app_error",
mockDoLogin: func() {
mockLdap.Mock.On("DoLogin", th.Context, authData, wrongPassword).Return(nil, &model.AppError{Id: "ent.ldap.do_login.invalid_password.app_error"})
},
},
{
name: "too many login attempts",
password: wrongPassword,
expectedErrID: "api.user.check_user_login_attempts.too_many_ldap.app_error",
mockDoLogin: func() {
mockLdap.Mock.On("DoLogin", th.Context, authData, wrongPassword).Return(nil, &model.AppError{Id: "ent.ldap.do_login.invalid_password.app_error"}).Once()
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Reset login attempts
err := th.App.Srv().Store().User().UpdateFailedPasswordAttempts(user.Id, 0)
require.NoError(t, err)
tc.mockDoLogin()
ldapUser := user
// Simulate failed login attempts if necessary
if tc.expectedErrID == "api.user.check_user_login_attempts.too_many_ldap.app_error" {
for range maxFailedLoginAttempts - 1 {
_, appErr = th.App.checkLdapUserPasswordAndAllCriteria(th.Context, ldapUser, wrongPassword, "")
require.NotNil(t, appErr)
require.Equal(t, "ent.ldap.do_login.invalid_password.app_error", appErr.Id)
}
}
// Call the method with the test case parameters
_, appErr := th.App.checkLdapUserPasswordAndAllCriteria(th.Context, ldapUser, tc.password, "")
// Verify the returned error matches the expected error
if tc.expectedErrID == "" {
require.Nil(t, appErr)
} else {
require.NotNil(t, appErr)
}
if tc.expectedErrID == "api.user.check_user_login_attempts.too_many_ldap.app_error" {
updatedUser, err := th.App.GetUser(th.Context, ldapUser.Id)
require.Nil(t, err)
require.Equal(t, maxFailedLoginAttempts, updatedUser.FailedAttempts)
}
})
}
// The cases below cover paths the table loop above does not exercise:
// first-time LDAP users (user.Id == ""), LDAP backend errors that are
// not credential failures, and the MFA pre-flight probe refund. Each
// subtest builds its own mockLdap so expectations from previous
// subtests cannot match the wrong call.
createLdapUserWithMFA := func(t *testing.T, emailLocal string) (*model.User, *string) {
t.Helper()
userAuthData := model.NewRandomString(32)
created, appErr := th.App.CreateUser(th.Context, &model.User{
Email: emailLocal + "@mattermost-customer.com",
Username: emailLocal,
AuthService: model.UserAuthServiceLdap,
AuthData: &userAuthData,
EmailVerified: true,
})
require.Nil(t, appErr)
secret, appErr := th.App.GenerateMfaSecret(th.Context, created.Id)
require.Nil(t, appErr)
require.NoError(t, th.Server.Store().User().UpdateMfaActive(created.Id, true))
require.NoError(t, th.Server.Store().User().UpdateMfaSecret(created.Id, secret.Secret))
require.NoError(t, th.App.Srv().Store().User().UpdateFailedPasswordAttempts(created.Id, 0))
created, appErr = th.App.GetUser(th.Context, created.Id)
require.Nil(t, appErr)
created.AuthData = &userAuthData
return created, &userAuthData
}
t.Run("first-time LDAP user with wrong password increments counter", func(t *testing.T) {
// DoLogin in production creates the row before reporting a bad
// password; we pre-create it here so GetUserByAuth can resolve it.
firstAuthData := model.NewRandomString(32)
preCreated, appErr := th.App.CreateUser(th.Context, &model.User{
Email: "ldapuser-first-bad-pwd@mattermost-customer.com",
Username: "ldapuser-first-bad-pwd",
AuthService: model.UserAuthServiceLdap,
AuthData: &firstAuthData,
EmailVerified: true,
})
require.Nil(t, appErr)
require.NoError(t, th.App.Srv().Store().User().UpdateFailedPasswordAttempts(preCreated.Id, 0))
freshMock := &mocks.LdapInterface{}
th.App.Channels().Ldap = freshMock
t.Cleanup(func() { th.App.Channels().Ldap = mockLdap })
freshMock.Mock.On("DoLogin", th.Context, firstAuthData, wrongPassword).Return(nil, &model.AppError{Id: "ent.ldap.do_login.invalid_password.app_error"})
_, appErr = th.App.checkLdapUserPasswordAndAllCriteria(th.Context, &model.User{
AuthService: model.UserAuthServiceLdap,
AuthData: &firstAuthData,
}, wrongPassword, "")
require.NotNil(t, appErr)
require.Equal(t, "ent.ldap.do_login.invalid_password.app_error", appErr.Id)
updatedUser, appErr := th.App.GetUser(th.Context, preCreated.Id)
require.Nil(t, appErr)
require.Equal(t, 1, updatedUser.FailedAttempts, "first-time LDAP wrong password must be counted")
})
t.Run("first-time LDAP user with wrong MFA token increments counter", func(t *testing.T) {
// DoLogin returns the freshly created user struct; the function
// then calls CheckUserMfa, which fails on a wrong non-empty token.
preCreated, authDataPtr := createLdapUserWithMFA(t, "ldapuser-first-bad-mfa")
freshMock := &mocks.LdapInterface{}
th.App.Channels().Ldap = freshMock
t.Cleanup(func() { th.App.Channels().Ldap = mockLdap })
freshMock.Mock.On("DoLogin", th.Context, *authDataPtr, validPassword).Return(preCreated, nil)
_, appErr := th.App.checkLdapUserPasswordAndAllCriteria(th.Context, &model.User{
AuthService: model.UserAuthServiceLdap,
AuthData: authDataPtr,
}, validPassword, "123456")
require.NotNil(t, appErr)
require.Equal(t, "api.user.check_user_mfa.bad_code.app_error", appErr.Id)
updatedUser, appErr := th.App.GetUser(th.Context, preCreated.Id)
require.Nil(t, appErr)
require.Equal(t, 1, updatedUser.FailedAttempts, "first-time LDAP wrong MFA must be counted")
})
t.Run("existing LDAP user with LDAP backend error refunds the slot", func(t *testing.T) {
// A non-credential LDAP error (server unreachable, transient
// failure) on an existing user must not consume the pre-claimed
// slot, or an LDAP outage could lock out everyone.
require.NoError(t, th.App.Srv().Store().User().UpdateFailedPasswordAttempts(user.Id, 0))
freshMock := &mocks.LdapInterface{}
th.App.Channels().Ldap = freshMock
t.Cleanup(func() { th.App.Channels().Ldap = mockLdap })
freshMock.Mock.On("DoLogin", th.Context, authData, wrongPassword).Return(nil, &model.AppError{Id: "ent.ldap.do_login.unable_to_connect.app_error"})
_, appErr := th.App.checkLdapUserPasswordAndAllCriteria(th.Context, user, wrongPassword, "")
require.NotNil(t, appErr)
require.Equal(t, "ent.ldap.do_login.unable_to_connect.app_error", appErr.Id)
updatedUser, appErr := th.App.GetUser(th.Context, user.Id)
require.Nil(t, appErr)
require.Equal(t, 0, updatedUser.FailedAttempts, "LDAP backend error must refund the slot")
})
t.Run("existing LDAP user MFA pre-flight probe refunds the slot", func(t *testing.T) {
// Empty mfaToken on an MFA-enabled LDAP user is a probe; the slot
// the pre-claim consumed is refunded.
preCreated, authDataPtr := createLdapUserWithMFA(t, "ldapuser-existing-mfa-probe")
freshMock := &mocks.LdapInterface{}
th.App.Channels().Ldap = freshMock
t.Cleanup(func() { th.App.Channels().Ldap = mockLdap })
freshMock.Mock.On("DoLogin", th.Context, *authDataPtr, validPassword).Return(preCreated, nil)
_, appErr := th.App.checkLdapUserPasswordAndAllCriteria(th.Context, preCreated, validPassword, "")
require.NotNil(t, appErr)
require.Equal(t, "mfa.validate_token.authenticate.app_error", appErr.Id)
updatedUser, appErr := th.App.GetUser(th.Context, preCreated.Id)
require.Nil(t, appErr)
require.Equal(t, 0, updatedUser.FailedAttempts, "MFA probe on existing LDAP user must not consume a slot")
})
t.Run("concurrent first-time LDAP wrong password caps at maxAttempts", func(t *testing.T) {
// A first-time LDAP user has no local row yet, so the slot is
// not pre-claimed. The fallback counter bump must use the atomic
// TryIncrement primitive: a previous implementation used an
// absolute UPDATE Users SET FailedAttempts = ldapUser.FailedAttempts + 1
// based on an in-memory snapshot, which lost increments when
// concurrent first-attempt requests all read FailedAttempts = 0
// and all wrote 1. Under the atomic primitive the counter caps
// at maxFailedLoginAttempts regardless of contention.
concurrentAuthData := model.NewRandomString(32)
preCreated, appErr := th.App.CreateUser(th.Context, &model.User{
Email: "ldapuser-first-bad-pwd-conc@mattermost-customer.com",
Username: "ldapuser-first-bad-pwd-conc",
AuthService: model.UserAuthServiceLdap,
AuthData: &concurrentAuthData,
EmailVerified: true,
})
require.Nil(t, appErr)
require.NoError(t, th.App.Srv().Store().User().UpdateFailedPasswordAttempts(preCreated.Id, 0))
freshMock := &mocks.LdapInterface{}
th.App.Channels().Ldap = freshMock
t.Cleanup(func() { th.App.Channels().Ldap = mockLdap })
freshMock.Mock.On("DoLogin", th.Context, concurrentAuthData, wrongPassword).Return(nil, &model.AppError{Id: "ent.ldap.do_login.invalid_password.app_error"})
const goroutines = maxFailedLoginAttempts * 3
var g errgroup.Group
start := make(chan struct{})
for range goroutines {
g.Go(func() error {
<-start
_, _ = th.App.checkLdapUserPasswordAndAllCriteria(th.Context, &model.User{
AuthService: model.UserAuthServiceLdap,
AuthData: &concurrentAuthData,
}, wrongPassword, "")
return nil
})
}
close(start)
require.NoError(t, g.Wait())
updatedUser, appErr := th.App.GetUser(th.Context, preCreated.Id)
require.Nil(t, appErr)
require.Equal(t, maxFailedLoginAttempts, updatedUser.FailedAttempts, "concurrent first-time attempts must not lose increments and must cap at maxAttempts")
})
}
func TestCheckLdapUserPasswordConcurrency(t *testing.T) {
th := SetupEnterprise(t).InitBasic(t)
// update config
const maxFailedLoginAttempts = 1
const concurrentAttempts = 10
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.LdapSettings.MaximumLoginAttempts = maxFailedLoginAttempts
*cfg.ServiceSettings.EnableMultifactorAuthentication = true
})
authData := model.NewRandomString(32)
// create an ldap user by calling createUser
ldapUser := &model.User{
Email: "ldapuser@mattermost-customer.com",
Username: "ldapuser",
AuthService: model.UserAuthServiceLdap,
AuthData: &authData,
EmailVerified: true,
}
user, appErr := th.App.CreateUser(th.Context, ldapUser)
require.Nil(t, appErr)
// setup MFA
secret, appErr := th.App.GenerateMfaSecret(th.Context, user.Id)
require.Nil(t, appErr)
err := th.Server.Store().User().UpdateMfaActive(user.Id, true)
require.NoError(t, err)
err = th.Server.Store().User().UpdateMfaSecret(user.Id, secret.Secret)
require.NoError(t, err)
user, appErr = th.App.GetUser(th.Context, user.Id)
require.Nil(t, appErr)
user.AuthData = &authData
wrongPassword := model.NewTestPassword()
validPassword := model.NewTestPassword()
t.Run("validate concurrent failed attempts to bypass checks", func(t *testing.T) {
testCases := []struct {
name string
password string
mfaToken string
expectedErrID string
doLoginExpectedErrID string
}{
{
name: "should not breach max. login attempts when password is wrong",
password: wrongPassword,
mfaToken: "",
doLoginExpectedErrID: "ent.ldap.do_login.invalid_password.app_error",
expectedErrID: "ent.ldap.do_login.invalid_password.app_error",
},
{
name: "should not breach max. login attempts when MFA is wrong",
password: validPassword,
mfaToken: "123456",
doLoginExpectedErrID: "",
expectedErrID: "api.user.check_user_mfa.bad_code.app_error",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
mockLdap := &mocks.LdapInterface{}
th.App.Channels().Ldap = mockLdap
// Reset login attempts
err := th.App.Srv().Store().User().UpdateFailedPasswordAttempts(user.Id, 0)
require.NoError(t, err)
// Capture all concurrent errors
appErrs := make([]*model.AppError, concurrentAttempts)
// Wait to complete the test
var completeWG sync.WaitGroup
completeWG.Add(concurrentAttempts)
for i := range concurrentAttempts {
go func(i int) {
defer completeWG.Done()
if tc.doLoginExpectedErrID == "ent.ldap.do_login.invalid_password.app_error" {
mockLdap.Mock.On("DoLogin", mock.AnythingOfType("*request.Context"), mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(nil, &model.AppError{Id: tc.doLoginExpectedErrID})
} else {
mockLdap.Mock.On("DoLogin", mock.AnythingOfType("*request.Context"), mock.AnythingOfType("string"), tc.password).Return(user, nil)
}
_, appErrs[i] = th.App.checkLdapUserPasswordAndAllCriteria(th.Context, user, tc.password, tc.mfaToken)
}(i)
}
completeWG.Wait()
expectedErrsCount := 0
for i := range concurrentAttempts {
if appErrs[i].Id == tc.expectedErrID {
expectedErrsCount++
continue
}
if appErrs[i] != nil {
require.Equal(t, "api.user.check_user_login_attempts.too_many_ldap.app_error", appErrs[i].Id, "All other errors should be of too many login attempts only.")
}
}
// Password/MFA failure attempts should not breach the maxFailedAttempts
// even during concurrent access by the same user.
require.Equal(t, maxFailedLoginAttempts, expectedErrsCount)
})
}
})
}
func TestCheckUserPassword(t *testing.T) {
th := Setup(t).InitBasic(t)
pwd := model.NewTestPassword()
pwdBcryptBytes, err := bcrypt.GenerateFromPassword([]byte(pwd), 10)
require.NoError(t, err)
pwdBcrypt := string(pwdBcryptBytes)
pwdPBKDF2, err := hashers.Hash(pwd)
require.NoError(t, err)
createUserWithHash := func(hash string) *model.User {
t.Helper()
user := th.CreateUser(t)
// Update the hash directly in the store (otherwise the app hashes it)
err := th.Server.Store().User().UpdatePassword(user.Id, hash)
require.NoError(t, err)
th.App.InvalidateCacheForUser(user.Id)
updatedUser, appErr := th.App.GetUser(th.Context, user.Id)
require.Nil(t, appErr)
require.Equal(t, hash, updatedUser.Password)
return updatedUser
}
t.Run("valid password with current hashing", func(t *testing.T) {
user := createUserWithHash(pwdPBKDF2)
err := th.App.checkUserPassword(user, pwd)
require.Nil(t, err)
})
wrongPassword := model.NewTestPassword()
t.Run("invalid password", func(t *testing.T) {
user := createUserWithHash(pwdPBKDF2)
err := th.App.checkUserPassword(user, wrongPassword)
require.NotNil(t, err)
require.Equal(t, "api.user.check_user_password.invalid.app_error", err.Id)
})
t.Run("password migration from outdated hash", func(t *testing.T) {
user := createUserWithHash(pwdBcrypt)
require.Contains(t, user.Password, "$2a$10")
require.NotContains(t, user.Password, "pbkdf2")
err := th.App.checkUserPassword(user, pwd)
require.Nil(t, err)
updatedUser, err := th.App.GetUser(th.Context, user.Id)
require.Nil(t, err)
require.NotEqual(t, pwdBcrypt, updatedUser.Password)
require.Contains(t, updatedUser.Password, "$pbkdf2")
// Re-check with updated password
err = th.App.checkUserPassword(updatedUser, pwd)
require.Nil(t, err)
})
t.Run("password migration fails with invalid password", func(t *testing.T) {
user := createUserWithHash(pwdBcrypt)
err := th.App.checkUserPassword(user, wrongPassword)
require.NotNil(t, err)
require.Equal(t, "api.user.check_user_password.invalid.app_error", err.Id)
})
t.Run("empty password", func(t *testing.T) {
user := createUserWithHash(pwdPBKDF2)
user, err := th.App.GetUser(th.Context, user.Id)
require.Nil(t, err)
err = th.App.checkUserPassword(user, "")
require.NotNil(t, err)
require.Equal(t, "api.user.check_user_password.invalid.app_error", err.Id)
})
t.Run("user with empty password hash", func(t *testing.T) {
user := createUserWithHash("")
user, err := th.App.GetUser(th.Context, user.Id)
require.Nil(t, err)
err = th.App.checkUserPassword(user, pwd)
require.NotNil(t, err)
require.Equal(t, "api.user.check_user_password.invalid.app_error", err.Id)
})
t.Run("successful migration from PBKDF2 with old parameter to new parameter", func(t *testing.T) {
// Create a PBKDF2 hasher with work factor = 10000 instead of the default
oldParamPBKDF2, err := hashers.NewPBKDF2(10000, 32)
require.NoError(t, err)
pwdOldParamPBKDF2, err := oldParamPBKDF2.Hash(pwd)
require.NoError(t, err)
user := createUserWithHash(pwdOldParamPBKDF2)
require.Contains(t, user.Password, "$pbkdf2")
// The user hash contains the old parameter
require.Contains(t, user.Password, "w=10000")
appErr := th.App.checkUserPassword(user, pwd)
require.Nil(t, appErr)
updatedUser, appErr := th.App.GetUser(th.Context, user.Id)
require.Nil(t, appErr)
require.NotEqual(t, pwdBcrypt, updatedUser.Password)
require.Contains(t, updatedUser.Password, "$pbkdf2")
// The new user hash should NOT contain the old parameter
require.NotContains(t, updatedUser.Password, "w=10000")
// Re-check with updated password
appErr = th.App.checkUserPassword(updatedUser, pwd)
require.Nil(t, appErr)
})
}
func TestMigratePassword(t *testing.T) {
th := Setup(t).InitBasic(t)
pwd := model.NewTestPassword()
pwdBcryptBytes, err := bcrypt.GenerateFromPassword([]byte(pwd), 10)
require.NoError(t, err)
pwdBcrypt := string(pwdBcryptBytes)
createUserWithHash := func(hash string) *model.User {
t.Helper()
user := th.CreateUser(t)
// Update the hash directly in the store (otherwise the app hashes it)
err := th.Server.Store().User().UpdatePassword(user.Id, hash)
require.NoError(t, err)
th.App.InvalidateCacheForUser(user.Id)
updatedUser, appErr := th.App.GetUser(th.Context, user.Id)
require.Nil(t, appErr)
require.Equal(t, hash, updatedUser.Password)
return updatedUser
}
t.Run("successful migration from BCrypt to PBKDF2", func(t *testing.T) {
user := createUserWithHash(pwdBcrypt)
err := th.App.migratePassword(user, pwd)
require.Nil(t, err)
updatedUser, err := th.App.GetUser(th.Context, user.Id)
require.Nil(t, err)
require.NotEqual(t, pwdBcrypt, updatedUser.Password)
require.Contains(t, updatedUser.Password, "$pbkdf2")
// Re-check with updated password
err = th.App.checkUserPassword(updatedUser, pwd)
require.Nil(t, err)
})
}

View File

@@ -0,0 +1,806 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"database/sql"
"errors"
"net/http"
"slices"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
func (a *App) SessionHasPermissionTo(session model.Session, permission *model.Permission) bool {
if session.IsUnrestricted() {
return true
}
return a.RolesGrantPermission(session.GetUserRoles(), permission.Id)
}
// SessionHasPermissionToAndNotRestrictedAdmin is a variant of [App.SessionHasPermissionTo] that
// denies access to restricted system admins. Note that a local session is always unrestricted.
func (a *App) SessionHasPermissionToAndNotRestrictedAdmin(session model.Session, permission *model.Permission) bool {
if session.IsUnrestricted() {
return true
}
if *a.Config().ExperimentalSettings.RestrictSystemAdmin {
return false
}
return a.RolesGrantPermission(session.GetUserRoles(), permission.Id)
}
func (a *App) SessionHasPermissionToAny(session model.Session, permissions []*model.Permission) bool {
for _, perm := range permissions {
if a.SessionHasPermissionTo(session, perm) {
return true
}
}
return false
}
func (a *App) SessionHasPermissionToTeam(session model.Session, teamID string, permission *model.Permission) bool {
if teamID == "" {
return false
}
if session.IsUnrestricted() {
return true
}
teamMember := session.GetTeamByTeamId(teamID)
if teamMember != nil {
if a.RolesGrantPermission(teamMember.GetRoles(), permission.Id) {
return true
}
}
return a.RolesGrantPermission(session.GetUserRoles(), permission.Id)
}
// SessionHasPermissionToTeams returns true only if user has access to all teams.
func (a *App) SessionHasPermissionToTeams(rctx request.CTX, session model.Session, teamIDs []string, permission *model.Permission) bool {
if len(teamIDs) == 0 {
return true
}
if slices.Contains(teamIDs, "") {
return false
}
// Check session permission, if it allows access, no need to check teams.
if a.SessionHasPermissionTo(session, permission) {
return true
}
for _, teamID := range teamIDs {
tm := session.GetTeamByTeamId(teamID)
if tm != nil {
// If a team member has permission, then no need to check further.
if a.RolesGrantPermission(tm.GetRoles(), permission.Id) {
continue
}
}
return false
}
return true
}
// SessionHasPermissionToChannel checks if the session has permission to the given channel.
//
// Returns:
//
// (hasPermission, isMember)
//
// hasPermission: true if the user has the specified permission for the channel, otherwise false.
// isMember: used for auditing access without membership. True if the user is a member of the channel, otherwise false.
func (a *App) SessionHasPermissionToChannel(rctx request.CTX, session model.Session, channelID string, permission *model.Permission) (hasPermission bool, isMember bool) {
if channelID == "" {
return false, false
}
channel, appErr := a.GetChannel(rctx, channelID)
if appErr != nil && appErr.StatusCode == http.StatusNotFound {
return false, false
} else if appErr != nil {
rctx.Logger().Warn("Failed to get channel", mlog.String("channel_id", channelID), mlog.Err(appErr))
return false, false
}
if session.IsUnrestricted() {
return true, false
}
isMember = false
ids, err := a.Srv().Store().Channel().GetAllChannelMembersForUser(rctx, session.UserId, true, true)
var channelRoles []string
if err == nil {
if roles, ok := ids[channelID]; ok {
isMember = true
channelRoles = strings.Fields(roles)
if a.RolesGrantPermission(channelRoles, permission.Id) {
return true, isMember
}
}
}
if a.RolesGrantPermission(session.GetUserRoles(), model.PermissionManageSystem.Id) {
return true, isMember
}
if channel.TeamId != "" {
return a.SessionHasPermissionToTeam(session, channel.TeamId, permission), isMember
}
return a.SessionHasPermissionTo(session, permission), isMember
}
// SessionHasPermissionToChannels returns true only if user has access to all channels.
func (a *App) SessionHasPermissionToChannels(rctx request.CTX, session model.Session, channelIDs []string, permission *model.Permission) bool {
if len(channelIDs) == 0 {
return true
}
if session.IsUnrestricted() || a.RolesGrantPermission(session.GetUserRoles(), model.PermissionManageSystem.Id) {
return true
}
// make sure all channels exist, otherwise return false.
for _, channelID := range channelIDs {
if channelID == "" {
return false
}
_, appErr := a.GetChannel(rctx, channelID)
if appErr != nil {
return false
}
}
// if System Roles (i.e. Admin, TeamAdmin) allow permissions
// if so, no reason to check team
if a.SessionHasPermissionTo(session, permission) {
return true
}
ids, err := a.Srv().Store().Channel().GetAllChannelMembersForUser(rctx, session.UserId, true, true)
var channelRoles []string
for _, channelID := range channelIDs {
if err == nil {
// If a channel member has permission, then no need to check further.
if roles, ok := ids[channelID]; ok {
channelRoles = strings.Fields(roles)
if a.RolesGrantPermission(channelRoles, permission.Id) {
continue
}
}
}
return false
}
return true
}
func (a *App) SessionHasPermissionToGroup(session model.Session, groupID string, permission *model.Permission) bool {
groupMember, err := a.Srv().Store().Group().GetMember(groupID, session.UserId)
// don't reject immediately on ErrNoRows error because there's further authz logic below for non-groupmembers
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return false
}
// each member of a group is implicitly considered to have the 'custom_group_user' role in that group, so if the user is a member of the
// group and custom_group_user on their system has the requested permission then return true
if groupMember != nil && a.RolesGrantPermission([]string{model.CustomGroupUserRoleId}, permission.Id) {
return true
}
// Not implemented: group-override schemes.
// ...otherwise check their system roles to see if they have the requested permission system-wide
return a.SessionHasPermissionTo(session, permission)
}
func (a *App) SessionHasPermissionToChannelByPost(session model.Session, postID string, permission *model.Permission) bool {
if postID == "" {
return false
}
if channelMember, err := a.Srv().Store().Channel().GetMemberForPost(postID, session.UserId); err == nil {
if a.RolesGrantPermission(channelMember.GetRoles(), permission.Id) {
return true
}
}
if channel, err := a.Srv().Store().Channel().GetForPost(postID); err == nil {
if channel.TeamId != "" {
return a.SessionHasPermissionToTeam(session, channel.TeamId, permission)
}
}
return a.SessionHasPermissionTo(session, permission)
}
func (a *App) SessionHasPermissionToReadPost(rctx request.CTX, session model.Session, postID string) (hasPErmission bool, isMember bool) {
if postID == "" {
return false, false
}
channel, err := a.Srv().Store().Channel().GetForPost(postID)
if err != nil {
// Original implementation (SessionHasPermissionToChannelByPost) still checks for
// general permissions even if the channel is not found, and some tests rely on this behavior.
return a.SessionHasPermissionTo(session, model.PermissionReadChannelContent), false
}
return a.SessionHasPermissionToReadChannel(rctx, session, channel)
}
func (a *App) SessionHasPermissionToCategory(rctx request.CTX, session model.Session, userID, teamID, categoryId string) bool {
if a.SessionHasPermissionTo(session, model.PermissionEditOtherUsers) {
return true
}
category, err := a.GetSidebarCategory(rctx, categoryId)
return err == nil && category != nil && category.UserId == session.UserId && category.UserId == userID && category.TeamId == teamID
}
func (a *App) SessionHasPermissionToUser(rctx request.CTX, session model.Session, userID string) bool {
if userID == "" {
return false
}
if session.IsUnrestricted() || a.SessionHasPermissionTo(session, model.PermissionManageSystem) {
return true
}
if session.UserId == userID {
return true
}
if !a.SessionHasPermissionTo(session, model.PermissionEditOtherUsers) {
return false
}
user, err := a.GetUser(rctx, userID)
if err != nil {
return false
}
if user.IsSystemAdmin() {
return false
}
return true
}
func (a *App) SessionHasPermissionToUserOrBot(rctx request.CTX, session model.Session, userID string) bool {
if session.IsUnrestricted() {
return true
}
err := a.SessionHasPermissionToManageBot(rctx, session, userID)
if err == nil {
return true
}
if err.Id == "store.sql_bot.get.missing.app_error" && err.Where == "SqlBotStore.Get" {
if a.SessionHasPermissionToUser(rctx, session, userID) {
return true
}
}
return false
}
func (a *App) HasPermissionTo(rctx request.CTX, askingUserId string, permission *model.Permission) bool {
user, err := a.GetUser(rctx, askingUserId)
if err != nil {
return false
}
roles := user.GetRoles()
return a.RolesGrantPermission(roles, permission.Id)
}
func (a *App) HasPermissionToTeam(rctx request.CTX, askingUserId string, teamID string, permission *model.Permission) bool {
if teamID == "" || askingUserId == "" {
return false
}
teamMember, _ := a.GetTeamMember(rctx, teamID, askingUserId)
if teamMember != nil && teamMember.DeleteAt == 0 {
if a.RolesGrantPermission(teamMember.GetRoles(), permission.Id) {
return true
}
}
return a.HasPermissionTo(rctx, askingUserId, permission)
}
// HasPermissionToChannel determines if the specified user has the given permission on the provided channel.
//
// Returns:
//
// (hasPermission, isMember)
//
// hasPermission: true if the user has the specified permission for the channel, otherwise false.
// isMember: used for auditing access without membership. True if the user is a member of the channel, otherwise false.
func (a *App) HasPermissionToChannel(rctx request.CTX, askingUserId string, channelID string, permission *model.Permission) (hasPermission bool, isMember bool) {
if channelID == "" || askingUserId == "" {
return false, false
}
isMember = false
// We call GetAllChannelMembersForUser instead of just getting
// a single member from the DB, because it's cache backed
// and this is a very frequent call.
ids, err := a.Srv().Store().Channel().GetAllChannelMembersForUser(rctx, askingUserId, true, true)
var channelRoles []string
if err == nil {
if roles, ok := ids[channelID]; ok {
isMember = true
channelRoles = strings.Fields(roles)
if a.RolesGrantPermission(channelRoles, permission.Id) {
return true, isMember
}
}
}
channel, appErr := a.GetChannel(rctx, channelID)
if appErr == nil && channel.TeamId != "" {
return a.HasPermissionToTeam(rctx, askingUserId, channel.TeamId, permission), isMember
}
return a.HasPermissionTo(rctx, askingUserId, permission), isMember
}
func (a *App) HasPermissionToChannelByPost(rctx request.CTX, askingUserId string, postID string, permission *model.Permission) bool {
if channelMember, err := a.Srv().Store().Channel().GetMemberForPost(postID, askingUserId); err == nil {
if a.RolesGrantPermission(channelMember.GetRoles(), permission.Id) {
return true
}
}
if channel, err := a.Srv().Store().Channel().GetForPost(postID); err == nil {
return a.HasPermissionToTeam(rctx, askingUserId, channel.TeamId, permission)
}
return a.HasPermissionTo(rctx, askingUserId, permission)
}
func (a *App) HasPermissionToUser(rctx request.CTX, askingUserId string, userID string) bool {
if askingUserId == userID {
return true
}
if a.HasPermissionTo(rctx, askingUserId, model.PermissionEditOtherUsers) {
return true
}
return false
}
func (a *App) RolesGrantPermission(roleNames []string, permissionId string) bool {
roles, err := a.GetRolesByNames(roleNames)
if err != nil {
// This should only happen if something is very broken. We can't realistically
// recover the situation, so deny permission and log an error.
mlog.Error("Failed to get roles from database with role names: "+strings.Join(roleNames, ",")+" ", mlog.Err(err))
return false
}
for _, role := range roles {
if role.DeleteAt != 0 {
continue
}
permissions := role.Permissions
if slices.Contains(permissions, permissionId) {
return true
}
}
return false
}
// SessionHasPermissionToManageBot returns nil if the session has access to manage the given bot.
// This function deviates from other authorization checks in returning an error instead of just
// a boolean, allowing the permission failure to be exposed with more granularity.
func (a *App) SessionHasPermissionToManageBot(rctx request.CTX, session model.Session, botUserId string) *model.AppError {
existingBot, err := a.GetBot(rctx, botUserId, true)
if err != nil {
return err
}
if session.IsUnrestricted() {
return nil
}
if existingBot.OwnerId == session.UserId {
if !a.SessionHasPermissionTo(session, model.PermissionManageBots) {
if !a.SessionHasPermissionTo(session, model.PermissionReadBots) {
// If the user doesn't have permission to read bots, pretend as if
// the bot doesn't exist at all.
return model.MakeBotNotFoundError("permissions", botUserId)
}
return model.MakePermissionError(&session, []*model.Permission{model.PermissionManageBots})
}
} else {
if !a.SessionHasPermissionTo(session, model.PermissionManageOthersBots) {
if !a.SessionHasPermissionTo(session, model.PermissionReadOthersBots) {
// If the user doesn't have permission to read others' bots,
// pretend as if the bot doesn't exist at all.
return model.MakeBotNotFoundError("permissions", botUserId)
}
return model.MakePermissionError(&session, []*model.Permission{model.PermissionManageOthersBots})
}
}
return nil
}
// SessionHasPermissionToReadChannel checks whether the given session has permission
// to read the specified channel.
//
// Returns:
//
// (hasPermission, isMember)
//
// hasPermission: true if the user has permission to read the channel, false otherwise
// isMember: used for auditing access without membership. True if the user is a member of the channel, false otherwise
func (a *App) SessionHasPermissionToReadChannel(rctx request.CTX, session model.Session, channel *model.Channel) (hasPermission bool, isMember bool) {
if session.IsUnrestricted() {
return true, false
}
return a.HasPermissionToReadChannel(rctx, session.UserId, channel)
}
// HasPermissionToReadChannel determines if the specified user has permission to read the given channel.
//
// Returns:
//
// (hasPermission, isMember)
//
// hasPermission: true if the user has permission to read the channel, false otherwise
// isMember: used for auditing access without membership. True if the user is a member of the channel, false otherwise
func (a *App) HasPermissionToReadChannel(rctx request.CTX, userID string, channel *model.Channel) (hasPermission bool, isMember bool) {
if ok, member := a.HasPermissionToChannel(rctx, userID, channel.Id, model.PermissionReadChannelContent); ok {
return true, member
}
if (channel.Type == model.ChannelTypeOpen || channel.Type == model.ChannelTypeOpenBoard) && !*a.Config().ComplianceSettings.Enable {
return a.HasPermissionToTeam(rctx, userID, channel.TeamId, model.PermissionReadPublicChannel), false
}
return false, false
}
// HasPermissionToResolveChannelMention determines whether the specified user may have a
// ~channel mention resolved to its display name and link.
//
// Unlike HasPermissionToReadChannel, this exposes only the public channel NAME and link —
// never post/file content — so it is intentionally INDEPENDENT of ComplianceSettings.
// A public channel name is already discoverable within its team via browse/search/autocomplete,
// and following the link still requires joining the channel (which creates the compliance trail).
//
// Private/DM/GM channels resolve only for members (via the content-read check). Public channels
// on a team the user does not belong to stay unresolved, preventing cross-team disclosure.
func (a *App) HasPermissionToResolveChannelMention(rctx request.CTX, userID string, channel *model.Channel) bool {
if ok, _ := a.HasPermissionToChannel(rctx, userID, channel.Id, model.PermissionReadChannelContent); ok {
return true
}
if channel.Type == model.ChannelTypeOpen || channel.Type == model.ChannelTypeOpenBoard {
return a.HasPermissionToTeam(rctx, userID, channel.TeamId, model.PermissionReadPublicChannel)
}
return false
}
func (a *App) HasPermissionToChannelMemberCount(rctx request.CTX, userID string, channel *model.Channel) bool {
if ok, _ := a.HasPermissionToChannel(rctx, userID, channel.Id, model.PermissionReadChannelContent); ok {
return true
}
if channel.Type == model.ChannelTypeOpen || channel.Type == model.ChannelTypeOpenBoard {
return a.HasPermissionToTeam(rctx, userID, channel.TeamId, model.PermissionListTeamChannels)
}
return false
}
// SessionHasPermissionToEditPropertyField checks if the session has permission to edit the field definition.
// Returns false if the field is nil, protected, or if PermissionField is nil (legacy fields).
func (a *App) SessionHasPermissionToEditPropertyField(rctx request.CTX, session model.Session, field *model.PropertyField) bool {
if field == nil {
return false
}
if field.Protected {
return false
}
if field.PermissionField == nil {
return false
}
if session.IsUnrestricted() {
return true
}
return a.hasPropertyFieldPermissionLevel(rctx, session.UserId, field, *field.PermissionField)
}
// SessionHasPermissionToSetPropertyFieldValues checks if the session has
// permission to set the given value on the field. The valueTargetID is the
// specific object the value is attached to (the channel/post/user/team ID
// for that ObjectType); admin and member levels are evaluated against it.
// Returns false if the field is nil or if PermissionValues is nil (legacy fields).
func (a *App) SessionHasPermissionToSetPropertyFieldValues(rctx request.CTX, session model.Session, field *model.PropertyField, valueTargetID string) bool {
if field == nil {
return false
}
if field.PermissionValues == nil {
return false
}
if session.IsUnrestricted() {
return true
}
return a.hasPropertyFieldValuePermissionLevel(rctx, session.UserId, field, valueTargetID, *field.PermissionValues)
}
// SessionHasPermissionToManagePropertyFieldOptions checks if the session has permission to manage field options.
// Returns false if the field is nil or if PermissionOptions is nil (legacy fields).
func (a *App) SessionHasPermissionToManagePropertyFieldOptions(rctx request.CTX, session model.Session, field *model.PropertyField) bool {
if field == nil {
return false
}
if field.PermissionOptions == nil {
return false
}
if session.IsUnrestricted() {
return true
}
return a.hasPropertyFieldPermissionLevel(rctx, session.UserId, field, *field.PermissionOptions)
}
// HasPermissionToEditPropertyField checks if the user has permission to edit the field definition.
// Returns false if the field is nil, protected, userID is empty, or if PermissionField is nil (legacy fields).
func (a *App) HasPermissionToEditPropertyField(rctx request.CTX, userID string, field *model.PropertyField) bool {
if field == nil || userID == "" {
return false
}
if field.Protected {
return false
}
if field.PermissionField == nil {
return false
}
return a.hasPropertyFieldPermissionLevel(rctx, userID, field, *field.PermissionField)
}
// HasPermissionToSetPropertyFieldValues checks if the user has permission to
// set the given value on the field. The valueTargetID is the specific object
// the value is attached to (the channel/post/user/team ID for that
// ObjectType); admin and member levels are evaluated against it.
// Returns false if the field is nil, userID is empty, or if PermissionValues is nil (legacy fields).
func (a *App) HasPermissionToSetPropertyFieldValues(rctx request.CTX, userID string, field *model.PropertyField, valueTargetID string) bool {
if field == nil || userID == "" {
return false
}
if field.PermissionValues == nil {
return false
}
return a.hasPropertyFieldValuePermissionLevel(rctx, userID, field, valueTargetID, *field.PermissionValues)
}
// HasPermissionToManagePropertyFieldOptions checks if the user has permission to manage field options.
// Returns false if the field is nil, userID is empty, or if PermissionOptions is nil (legacy fields).
func (a *App) HasPermissionToManagePropertyFieldOptions(rctx request.CTX, userID string, field *model.PropertyField) bool {
if field == nil || userID == "" {
return false
}
if field.PermissionOptions == nil {
return false
}
return a.hasPropertyFieldPermissionLevel(rctx, userID, field, *field.PermissionOptions)
}
// hasPropertyFieldPermissionLevel checks if the user has the specified permission level for the field.
// "admin" resolves against the field's target: manage_system on system targets,
// manage_team on team targets, manage_channel_roles on channel targets — i.e.
// the permission that the corresponding built-in admin role grants. Note this
// is a stricter check than hasTargetAccess (which uses manage_*_channel_properties
// for channel writes): hasTargetAccess is the outer "may write anything here"
// gate, and PermissionLevelAdmin is the inner "is a channel admin" tier above it.
func (a *App) hasPropertyFieldPermissionLevel(rctx request.CTX, userID string, field *model.PropertyField, level model.PermissionLevel) bool {
switch level {
case model.PermissionLevelNone:
return false
case model.PermissionLevelSysadmin:
return a.HasPermissionTo(rctx, userID, model.PermissionManageSystem)
case model.PermissionLevelMember:
return a.hasPropertyFieldScopeAccess(rctx, userID, field)
case model.PermissionLevelAdmin:
switch field.TargetType {
case string(model.PropertyFieldTargetLevelSystem):
return a.HasPermissionTo(rctx, userID, model.PermissionManageSystem)
case string(model.PropertyFieldTargetLevelTeam):
return a.HasPermissionToTeam(rctx, userID, field.TargetID, model.PermissionManageTeam)
case string(model.PropertyFieldTargetLevelChannel):
hasPermission, _ := a.HasPermissionToChannel(rctx, userID, field.TargetID, model.PermissionManageChannelRoles)
return hasPermission
}
}
return false
}
// hasPropertyFieldValuePermissionLevel evaluates a permission level against
// the value's specific target rather than the field's target. The "admin"
// and "member" levels dispatch on field.ObjectType against valueTargetID —
// so a value on a channel-object field is gated by the user's role on that
// channel, regardless of how the field itself is scoped. "sysadmin" and
// "none" behave identically to the field-level dispatch.
func (a *App) hasPropertyFieldValuePermissionLevel(rctx request.CTX, userID string, field *model.PropertyField, valueTargetID string, level model.PermissionLevel) bool {
switch level {
case model.PermissionLevelSysadmin:
return a.HasPermissionTo(rctx, userID, model.PermissionManageSystem)
case model.PermissionLevelAdmin:
return a.hasPropertyFieldValueAdmin(rctx, userID, field, valueTargetID)
case model.PermissionLevelMember:
return a.hasPropertyFieldValueScopeAccess(rctx, userID, field, valueTargetID)
case model.PermissionLevelNone:
return false
}
return false
}
// hasPropertyFieldValueAdmin reports whether the user administers the
// value's target. For channel/post-object fields, this is channel admin
// (manage_channel_roles) on the value's channel (or the post's channel).
// For user/system/template fields the value's target has no admin concept,
// so the check defers to the field's TargetType (sysadmin / team admin /
// channel admin) via the field-level dispatch.
func (a *App) hasPropertyFieldValueAdmin(rctx request.CTX, userID string, field *model.PropertyField, valueTargetID string) bool {
switch field.ObjectType {
case model.PropertyFieldObjectTypeChannel:
ok, _ := a.HasPermissionToChannel(rctx, userID, valueTargetID, model.PermissionManageChannelRoles)
return ok
case model.PropertyFieldObjectTypePost:
post, err := a.Srv().Store().Post().GetSingle(rctx, valueTargetID, false)
if err != nil {
rctx.Logger().Warn("Failed to look up post for property value admin check",
mlog.String("post_id", valueTargetID),
mlog.String("user_id", userID),
mlog.String("field_id", field.ID),
mlog.Err(err),
)
return false
}
ok, _ := a.HasPermissionToChannel(rctx, userID, post.ChannelId, model.PermissionManageChannelRoles)
return ok
case model.PropertyFieldObjectTypeUser,
model.PropertyFieldObjectTypeSystem,
model.PropertyFieldObjectTypeTemplate:
return a.hasPropertyFieldPermissionLevel(rctx, userID, field, model.PermissionLevelAdmin)
}
return false
}
// hasPropertyFieldValueScopeAccess reports whether the user can write the
// value's target as a regular member. For channel-object fields this is
// membership in the value's channel. For post-object fields this is
// membership in the post's channel — any channel member can set values on
// any post in that channel. Both are checked via HasPermissionToChannel so
// sysadmins and team admins cascade through. User/system/template fields
// have no per-object membership and defer to the field's TargetType-based scope.
func (a *App) hasPropertyFieldValueScopeAccess(rctx request.CTX, userID string, field *model.PropertyField, valueTargetID string) bool {
switch field.ObjectType {
case model.PropertyFieldObjectTypeChannel:
channel, err := a.GetChannel(rctx, valueTargetID)
if err != nil {
rctx.Logger().Warn("Failed to look up channel for property value scope check",
mlog.String("channel_id", valueTargetID),
mlog.String("user_id", userID),
mlog.String("field_id", field.ID),
mlog.Err(err),
)
return false
}
// DM/GM values are meant to be derived from the participants' own
// attributes rather than typed in, so participation alone doesn't earn
// the write. Only the system-level path may set them.
if channel.IsGroupOrDirect() {
return a.HasPermissionTo(rctx, userID, model.PermissionManageSystem)
}
ok, _ := a.HasPermissionToChannel(rctx, userID, valueTargetID, model.PermissionReadChannel)
return ok
case model.PropertyFieldObjectTypePost:
post, err := a.Srv().Store().Post().GetSingle(rctx, valueTargetID, false)
if err != nil {
rctx.Logger().Warn("Failed to look up post for property value scope check",
mlog.String("post_id", valueTargetID),
mlog.String("user_id", userID),
mlog.String("field_id", field.ID),
mlog.Err(err),
)
return false
}
ok, _ := a.HasPermissionToChannel(rctx, userID, post.ChannelId, model.PermissionReadChannel)
return ok
case model.PropertyFieldObjectTypeUser,
model.PropertyFieldObjectTypeSystem,
model.PropertyFieldObjectTypeTemplate:
return a.hasPropertyFieldScopeAccess(rctx, userID, field)
}
return false
}
// hasPropertyFieldScopeAccess checks if the user has access to the property
// field's scope. System-level properties are open to any authenticated user.
// Team- and channel-level properties go through HasPermissionToTeam /
// HasPermissionToChannel so sysadmins (and team-admins for channel scopes)
// cascade through — matching the value-level scope check.
func (a *App) hasPropertyFieldScopeAccess(rctx request.CTX, userID string, field *model.PropertyField) bool {
switch field.TargetType {
case string(model.PropertyFieldTargetLevelSystem):
return true
case string(model.PropertyFieldTargetLevelTeam):
return a.HasPermissionToTeam(rctx, userID, field.TargetID, model.PermissionViewTeam)
case string(model.PropertyFieldTargetLevelChannel):
ok, _ := a.HasPermissionToChannel(rctx, userID, field.TargetID, model.PermissionReadChannel)
return ok
}
return false
}
// HasPermissionToFileAction evaluates whether the user is allowed to perform the
// given file action (e.g. upload_file_attachment, download_file_attachment) on
// a channel, based on ABAC permission policies.
// Returns true if allowed (or if ABAC is not active), false if denied.
func (a *App) HasPermissionToFileAction(rctx request.CTX, userID string, roles string, channelID string, action string) bool {
acs := a.Srv().Channels().AccessControl
if acs == nil {
return true
}
if !*a.Config().AccessControlSettings.EnableAttributeBasedAccessControl {
return true
}
if !a.Config().FeatureFlags.PermissionPolicies {
return true
}
var subject *model.Subject
var appErr *model.AppError
if rctx.Session().UserId == userID {
subject, appErr = a.BuildAccessControlSubjectForSession(rctx, channelID)
} else {
subject, appErr = a.BuildAccessControlSubject(rctx, userID, roles, channelID)
}
if appErr != nil {
rctx.Logger().Info("Failed to build ABAC subject for file action evaluation",
mlog.String("user_id", userID),
mlog.String("action", action),
mlog.Err(appErr),
)
return false
}
decision, evalErr := acs.AccessEvaluation(rctx, model.AccessRequest{
Subject: *subject,
Resource: model.Resource{
Type: model.AccessControlPolicyTypeChannel,
ID: channelID,
},
Action: action,
})
if evalErr != nil {
rctx.Logger().Debug("ABAC file action evaluation failed, denying by default",
mlog.String("user_id", userID),
mlog.String("action", action),
mlog.String("channel_id", channelID),
mlog.Err(evalErr),
)
return false
}
return decision.Decision
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,121 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"net/http"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
)
// check if there is any auto_response type post in channel by the user in a calender day
func (a *App) checkIfRespondedToday(createdAt int64, channelId, userId string) (bool, error) {
y, m, d := model.GetTimeForMillis(createdAt).Date()
since := model.GetMillisForTime(time.Date(y, m, d, 0, 0, 0, 0, time.UTC))
return a.Srv().Store().Post().HasAutoResponsePostByUserSince(
model.GetPostsSinceOptions{ChannelId: channelId, Time: since},
userId,
)
}
func (a *App) SendAutoResponseIfNecessary(rctx request.CTX, channel *model.Channel, sender *model.User, post *model.Post) (bool, *model.AppError) {
if channel.Type != model.ChannelTypeDirect {
return false, nil
}
if sender.IsBot {
return false, nil
}
receiverId := channel.GetOtherUserIdForDM(sender.Id)
if receiverId == "" {
// User direct messaged themself, let them test their auto-responder.
receiverId = sender.Id
}
receiver, aErr := a.GetUser(rctx, receiverId)
if aErr != nil {
return false, aErr
}
autoResponded, err := a.checkIfRespondedToday(post.CreateAt, post.ChannelId, receiverId)
if err != nil {
return false, model.NewAppError("SendAutoResponseIfNecessary", "app.user.send_auto_response.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if autoResponded {
return false, nil
}
return a.SendAutoResponse(rctx, channel, receiver, post)
}
func (a *App) SendAutoResponse(rctx request.CTX, channel *model.Channel, receiver *model.User, post *model.Post) (bool, *model.AppError) {
if receiver == nil || receiver.NotifyProps == nil {
return false, nil
}
active := receiver.NotifyProps[model.AutoResponderActiveNotifyProp] == "true"
message := receiver.NotifyProps[model.AutoResponderMessageNotifyProp]
if !active || message == "" {
return false, nil
}
rootID := post.Id
if post.RootId != "" {
rootID = post.RootId
}
autoResponderPost := &model.Post{
ChannelId: channel.Id,
Message: message,
RootId: rootID,
Type: model.PostTypeAutoResponder,
UserId: receiver.Id,
}
if _, _, err := a.CreatePost(rctx, autoResponderPost, channel, model.CreatePostFlags{}); err != nil {
return false, err
}
return true, nil
}
func (a *App) SetAutoResponderStatus(rctx request.CTX, user *model.User, oldNotifyProps model.StringMap) {
active := user.NotifyProps[model.AutoResponderActiveNotifyProp] == "true"
oldActive := oldNotifyProps[model.AutoResponderActiveNotifyProp] == "true"
autoResponderEnabled := !oldActive && active
autoResponderDisabled := oldActive && !active
if autoResponderEnabled {
a.SetStatusOutOfOffice(user.Id)
} else if autoResponderDisabled {
a.SetStatusOnline(user.Id, true)
}
}
func (a *App) DisableAutoResponder(rctx request.CTX, userID string, asAdmin bool) *model.AppError {
user, err := a.GetUser(rctx, userID)
if err != nil {
return err
}
active := user.NotifyProps[model.AutoResponderActiveNotifyProp] == "true"
if active {
patch := &model.UserPatch{}
patch.NotifyProps = user.NotifyProps
patch.NotifyProps[model.AutoResponderActiveNotifyProp] = "false"
_, err := a.PatchUser(rctx, userID, patch, asAdmin)
if err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,390 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestSetAutoResponderStatus(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
user := th.CreateUser(t)
defer func() {
err := th.App.PermanentDeleteUser(th.Context, user)
require.Nil(t, err)
}()
th.App.SetStatusOnline(user.Id, true)
patch := &model.UserPatch{}
patch.NotifyProps = make(map[string]string)
patch.NotifyProps["auto_responder_active"] = "true"
patch.NotifyProps["auto_responder_message"] = "Hello, I'm unavailable today."
userUpdated1, _ := th.App.PatchUser(th.Context, user.Id, patch, true)
// autoResponder is enabled, status should be OOO
th.App.SetAutoResponderStatus(th.Context, userUpdated1, user.NotifyProps)
status, err := th.App.GetStatus(userUpdated1.Id)
require.Nil(t, err)
assert.Equal(t, model.StatusOutOfOffice, status.Status)
patch2 := &model.UserPatch{}
patch2.NotifyProps = make(map[string]string)
patch2.NotifyProps["auto_responder_active"] = "false"
patch2.NotifyProps["auto_responder_message"] = "Hello, I'm unavailable today."
userUpdated2, _ := th.App.PatchUser(th.Context, user.Id, patch2, true)
// autoResponder is disabled, status should be ONLINE
th.App.SetAutoResponderStatus(th.Context, userUpdated2, userUpdated1.NotifyProps)
status, err = th.App.GetStatus(userUpdated2.Id)
require.Nil(t, err)
assert.Equal(t, model.StatusOnline, status.Status)
}
func TestDisableAutoResponder(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
user := th.CreateUser(t)
defer func() {
err := th.App.PermanentDeleteUser(th.Context, user)
require.Nil(t, err)
}()
th.App.SetStatusOnline(user.Id, true)
patch := &model.UserPatch{}
patch.NotifyProps = make(map[string]string)
patch.NotifyProps["auto_responder_active"] = "true"
patch.NotifyProps["auto_responder_message"] = "Hello, I'm unavailable today."
user, appErr := th.App.PatchUser(th.Context, user.Id, patch, true)
require.Nil(t, appErr)
err := th.App.DisableAutoResponder(th.Context, user.Id, true)
require.Nil(t, err)
userUpdated1, err := th.App.GetUser(th.Context, user.Id)
require.Nil(t, err)
assert.Equal(t, userUpdated1.NotifyProps["auto_responder_active"], "false")
err = th.App.DisableAutoResponder(th.Context, user.Id, true)
require.Nil(t, err)
userUpdated2, err := th.App.GetUser(th.Context, user.Id)
require.Nil(t, err)
assert.Equal(t, userUpdated2.NotifyProps["auto_responder_active"], "false")
}
func TestSendAutoResponseIfNecessary(t *testing.T) {
mainHelper.Parallel(t)
t.Run("should send auto response when enabled", func(t *testing.T) {
th := Setup(t).InitBasic(t)
receiver := th.CreateUser(t)
patch := &model.UserPatch{
NotifyProps: map[string]string{
"auto_responder_active": "true",
"auto_responder_message": "Hello, I'm unavailable today.",
},
}
receiver, err := th.App.PatchUser(th.Context, receiver.Id, patch, true)
require.Nil(t, err)
channel := th.CreateDmChannel(t, receiver)
savedPost, _, _ := th.App.CreatePost(th.Context, &model.Post{
ChannelId: channel.Id,
Message: NewTestId(),
UserId: th.BasicUser.Id,
},
th.BasicChannel,
model.CreatePostFlags{SetOnline: true})
sent, err := th.App.SendAutoResponseIfNecessary(th.Context, channel, th.BasicUser, savedPost)
assert.Nil(t, err)
assert.True(t, sent)
})
t.Run("should not send auto response when disabled", func(t *testing.T) {
th := Setup(t).InitBasic(t)
receiver := th.CreateUser(t)
patch := &model.UserPatch{
NotifyProps: map[string]string{
"auto_responder_active": "false",
"auto_responder_message": "Hello, I'm unavailable today.",
},
}
receiver, err := th.App.PatchUser(th.Context, receiver.Id, patch, true)
require.Nil(t, err)
channel := th.CreateDmChannel(t, receiver)
savedPost, _, _ := th.App.CreatePost(th.Context, &model.Post{
ChannelId: channel.Id,
Message: NewTestId(),
UserId: th.BasicUser.Id,
},
th.BasicChannel,
model.CreatePostFlags{SetOnline: true})
sent, err := th.App.SendAutoResponseIfNecessary(th.Context, channel, th.BasicUser, savedPost)
assert.Nil(t, err)
assert.False(t, sent)
})
t.Run("should not send auto response for non-DM channel", func(t *testing.T) {
th := Setup(t).InitBasic(t)
savedPost, _, _ := th.App.CreatePost(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
Message: NewTestId(),
UserId: th.BasicUser.Id,
},
th.BasicChannel,
model.CreatePostFlags{SetOnline: true})
sent, err := th.App.SendAutoResponseIfNecessary(th.Context, th.BasicChannel, th.BasicUser, savedPost)
assert.Nil(t, err)
assert.False(t, sent)
})
t.Run("should not send auto response for bot", func(t *testing.T) {
th := Setup(t).InitBasic(t)
receiver := th.CreateUser(t)
patch := &model.UserPatch{
NotifyProps: map[string]string{
"auto_responder_active": "true",
"auto_responder_message": "Hello, I'm unavailable today.",
},
}
receiver, err := th.App.PatchUser(th.Context, receiver.Id, patch, true)
require.Nil(t, err)
channel := th.CreateDmChannel(t, receiver)
bot, err := th.App.CreateBot(th.Context, &model.Bot{
Username: "botusername",
Description: "bot",
OwnerId: th.BasicUser.Id,
})
assert.Nil(t, err)
botUser, err := th.App.GetUser(th.Context, bot.UserId)
assert.Nil(t, err)
savedPost, _, _ := th.App.CreatePost(th.Context, &model.Post{
ChannelId: channel.Id,
Message: NewTestId(),
UserId: botUser.Id,
},
th.BasicChannel,
model.CreatePostFlags{SetOnline: true})
sent, err := th.App.SendAutoResponseIfNecessary(th.Context, channel, botUser, savedPost)
assert.Nil(t, err)
assert.False(t, sent)
})
t.Run("should send auto response in dm channel if not already sent today", func(t *testing.T) {
th := Setup(t).InitBasic(t)
receiver := th.CreateUser(t)
patch := &model.UserPatch{
NotifyProps: map[string]string{
"auto_responder_active": "true",
"auto_responder_message": "Hello, I'm unavailable today.",
},
}
receiver, err := th.App.PatchUser(th.Context, receiver.Id, patch, true)
require.Nil(t, err)
channel := th.CreateDmChannel(t, receiver)
// Clean up all posts from this user.
// There are some dummy messages like "user joined team" etc.
// which needs to be cleaned up.
require.NoError(t, th.GetSqlStore().Post().PermanentDeleteByUser(th.Context, th.BasicUser.Id))
savedPost, _, err := th.App.CreatePost(th.Context, &model.Post{
ChannelId: channel.Id,
Message: patch.NotifyProps["auto_responder_message"],
UserId: receiver.Id,
CreateAt: model.GetMillisForTime(time.Now().Add(-48 * time.Hour)),
Type: model.PostTypeAutoResponder,
},
th.BasicChannel,
model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
savedPost.CreateAt = model.GetMillisForTime(time.Now())
sent, err := th.App.SendAutoResponseIfNecessary(th.Context, channel, th.BasicUser, savedPost)
require.Nil(t, err)
assert.True(t, sent)
})
}
func TestSendAutoResponseSuccess(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
user := th.CreateUser(t)
defer func() {
err := th.App.PermanentDeleteUser(th.Context, user)
require.Nil(t, err)
}()
patch := &model.UserPatch{}
patch.NotifyProps = make(map[string]string)
patch.NotifyProps["auto_responder_active"] = "true"
patch.NotifyProps["auto_responder_message"] = "Hello, I'm unavailable today."
userUpdated1, err := th.App.PatchUser(th.Context, user.Id, patch, true)
require.Nil(t, err)
savedPost, _, _ := th.App.CreatePost(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "zz" + model.NewId() + "a",
UserId: th.BasicUser.Id,
},
th.BasicChannel,
model.CreatePostFlags{SetOnline: true})
sent, err := th.App.SendAutoResponse(th.Context, th.BasicChannel, userUpdated1, savedPost)
assert.Nil(t, err)
assert.True(t, sent)
list, err := th.App.GetPosts(th.Context, th.BasicChannel.Id, 0, 1)
require.Nil(t, err)
autoResponderPostFound := false
for _, post := range list.Posts {
if post.Type == model.PostTypeAutoResponder {
autoResponderPostFound = true
assert.Equal(t, savedPost.Id, post.RootId)
}
}
assert.True(t, autoResponderPostFound)
}
func TestSendAutoResponseSuccessOnThread(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
user := th.CreateUser(t)
defer func() {
err := th.App.PermanentDeleteUser(th.Context, user)
require.Nil(t, err)
}()
patch := &model.UserPatch{}
patch.NotifyProps = make(map[string]string)
patch.NotifyProps["auto_responder_active"] = "true"
patch.NotifyProps["auto_responder_message"] = "Hello, I'm unavailable today."
userUpdated1, err := th.App.PatchUser(th.Context, user.Id, patch, true)
require.Nil(t, err)
parentPost, _, _ := th.App.CreatePost(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "zz" + model.NewId() + "a",
UserId: th.BasicUser.Id,
},
th.BasicChannel,
model.CreatePostFlags{SetOnline: true})
savedPost, _, _ := th.App.CreatePost(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "zz" + model.NewId() + "a",
UserId: th.BasicUser.Id,
RootId: parentPost.Id,
},
th.BasicChannel,
model.CreatePostFlags{SetOnline: true})
sent, err := th.App.SendAutoResponse(th.Context, th.BasicChannel, userUpdated1, savedPost)
assert.Nil(t, err)
assert.True(t, sent)
list, err := th.App.GetPosts(th.Context, th.BasicChannel.Id, 0, 1)
require.Nil(t, err)
autoResponderPostFound := false
for _, post := range list.Posts {
if post.Type == model.PostTypeAutoResponder {
autoResponderPostFound = true
assert.Equal(t, savedPost.RootId, post.RootId)
}
}
assert.True(t, autoResponderPostFound)
}
func TestSendAutoResponseFailure(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
user := th.CreateUser(t)
defer func() {
err := th.App.PermanentDeleteUser(th.Context, user)
require.Nil(t, err)
}()
patch := &model.UserPatch{}
patch.NotifyProps = make(map[string]string)
patch.NotifyProps["auto_responder_active"] = "false"
patch.NotifyProps["auto_responder_message"] = "Hello, I'm unavailable today."
userUpdated1, err := th.App.PatchUser(th.Context, user.Id, patch, true)
require.Nil(t, err)
savedPost, _, _ := th.App.CreatePost(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "zz" + model.NewId() + "a",
UserId: th.BasicUser.Id,
},
th.BasicChannel,
model.CreatePostFlags{SetOnline: true})
sent, err := th.App.SendAutoResponse(th.Context, th.BasicChannel, userUpdated1, savedPost)
assert.Nil(t, err)
assert.False(t, sent)
if list, err := th.App.GetPosts(th.Context, th.BasicChannel.Id, 0, 1); err != nil {
require.Nil(t, err)
} else {
autoResponderPostFound := false
for _, post := range list.Posts {
if post.Type == model.PostTypeAutoResponder {
autoResponderPostFound = true
}
}
assert.False(t, autoResponderPostFound)
}
}

View File

@@ -0,0 +1,157 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"errors"
"net/http"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
func (a *App) CreateBoardChannel(rctx request.CTX, channel *model.Channel) (*model.Channel, *model.AppError) {
// Validate feature flag
if !a.Config().FeatureFlags.IntegratedBoards {
return nil, model.NewAppError("CreateBoardChannel", "app.channel.create_board_channel.boards_not_enabled.app_error", nil, "The Integrated Boards feature is not enabled.", http.StatusForbidden)
}
channel.DisplayName = strings.TrimSpace(channel.DisplayName)
if appErr := channel.IsValidBoard(); appErr != nil {
return nil, appErr
}
// Look up boards property fields by name
boardsGroup, appErr := a.GetPropertyGroup(rctx, model.BoardsPropertyGroupName)
if appErr != nil {
return nil, model.NewAppError("CreateBoardChannel", "app.channel.create_board_channel.internal_error", nil, "boards property group not found", http.StatusInternalServerError).Wrap(appErr)
}
assigneeField, appErr := a.GetPropertyFieldByNameForObjectType(rctx, boardsGroup.ID, "", model.PropertyFieldObjectTypePost, model.BoardsPropertyFieldAssignee)
if appErr != nil {
return nil, model.NewAppError("CreateBoardChannel", "app.channel.create_board_channel.internal_error", nil, "assignee property field not found", http.StatusInternalServerError).Wrap(appErr)
}
statusField, appErr := a.GetPropertyFieldByNameForObjectType(rctx, boardsGroup.ID, "", model.PropertyFieldObjectTypePost, model.BoardsPropertyFieldStatus)
if appErr != nil {
return nil, model.NewAppError("CreateBoardChannel", "app.channel.create_board_channel.internal_error", nil, "status property field not found", http.StatusInternalServerError).Wrap(appErr)
}
// Set linked properties on channel
if channel.Props == nil {
channel.Props = make(map[string]any)
}
channel.Props[model.ChannelPropsBoardLinkedProperties] = []string{statusField.ID, assigneeField.ID}
view, appErr := buildBoardKanbanView(channel.CreatorId, statusField)
if appErr != nil {
return nil, appErr
}
// Atomically save channel + view
sc, savedView, nErr := a.Srv().Store().Channel().SaveBoardChannel(rctx, channel, *a.Config().TeamSettings.MaxChannelsPerTeam, view)
if nErr != nil {
var appError *model.AppError
var invErr *store.ErrInvalidInput
var cErr *store.ErrConflict
var ltErr *store.ErrLimitExceeded
switch {
case errors.As(nErr, &invErr):
return nil, model.NewAppError("CreateBoardChannel", "app.channel.create_board_channel.invalid.app_error", nil, "", http.StatusBadRequest).Wrap(nErr)
case errors.As(nErr, &cErr):
return nil, model.NewAppError("CreateBoardChannel", "store.sql_channel.save_channel.exists.app_error", nil, "", http.StatusBadRequest).Wrap(nErr)
case errors.As(nErr, &ltErr):
return nil, model.NewAppError("CreateBoardChannel", "app.channel.create_board_channel.limit.app_error", nil, "", http.StatusBadRequest).Wrap(nErr)
case errors.As(nErr, &appError):
return nil, appError
default:
return nil, model.NewAppError("CreateBoardChannel", "app.channel.create_board_channel.internal_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
}
// Add creator as admin member
user, nErr := a.Srv().Store().User().Get(rctx, channel.CreatorId)
if nErr != nil {
return nil, model.NewAppError("CreateBoardChannel", "app.user.get.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
cm := &model.ChannelMember{
ChannelId: sc.Id,
UserId: user.Id,
SchemeGuest: user.IsGuest(),
SchemeUser: !user.IsGuest(),
SchemeAdmin: true,
NotifyProps: model.GetDefaultChannelNotifyProps(),
}
if _, nErr := a.Srv().Store().Channel().SaveMember(rctx, cm); nErr != nil {
return nil, model.NewAppError("CreateBoardChannel", "app.channel.save_member.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
if err := a.Srv().Store().ChannelMemberHistory().LogJoinEvent(channel.CreatorId, sc.Id, model.GetMillis()); err != nil {
return nil, model.NewAppError("CreateBoardChannel", "app.channel_member_history.log_join_event.internal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
a.Srv().Platform().InvalidateChannelCacheForUser(channel.CreatorId)
// Publish board_created event (NOT channel_created)
boardMessage := model.NewWebSocketEvent(model.WebsocketEventBoardCreated, "", "", channel.CreatorId, nil, "")
boardMessage.Add("channel_id", sc.Id)
boardMessage.Add("team_id", sc.TeamId)
a.Publish(boardMessage)
// Publish view_created event
a.publishViewEvent(rctx, model.WebsocketEventViewCreated, savedView, "")
// Do NOT add to sidebar categories — boards don't appear in sidebar
return sc, nil
}
// buildBoardKanbanView constructs the default kanban view for a new board
// channel, with one column per option on the boards "status" property field.
func buildBoardKanbanView(creatorID string, statusField *model.PropertyField) (*model.View, *model.AppError) {
statusOptions, ok := statusField.Attrs["options"].([]any)
if !ok || len(statusOptions) == 0 {
return nil, model.NewAppError("CreateBoardChannel", "app.channel.create_board_channel.internal_error", nil, "status field has no options", http.StatusInternalServerError)
}
var columns []model.KanbanColumn
for _, opt := range statusOptions {
optMap, ok := opt.(map[string]any)
if !ok {
continue
}
optID, _ := optMap["id"].(string)
optName, _ := optMap["name"].(string)
if optID != "" && optName != "" {
columns = append(columns, model.KanbanColumn{
ID: model.NewId(),
Name: optName,
OptionIDs: []string{optID},
})
}
}
kanbanProps := &model.KanbanProps{
GroupBy: model.KanbanGroupBy{
FieldID: statusField.ID,
Columns: columns,
},
}
viewProps, err := kanbanProps.ToProps()
if err != nil {
return nil, model.NewAppError("CreateBoardChannel", "app.channel.create_board_channel.internal_error", nil, "failed to serialize kanban props", http.StatusInternalServerError).Wrap(err)
}
return &model.View{
CreatorId: creatorID,
Type: model.ViewTypeKanban,
Title: "Board",
Props: viewProps,
}, nil
}

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestBuildBoardKanbanView(t *testing.T) {
t.Run("rejects status field with no options attribute", func(t *testing.T) {
statusField := &model.PropertyField{
ID: model.NewId(),
Attrs: map[string]any{},
}
view, err := buildBoardKanbanView(model.NewId(), statusField)
require.Nil(t, view)
require.NotNil(t, err)
require.Contains(t, err.DetailedError, "status field has no options")
})
t.Run("rejects status field with empty options", func(t *testing.T) {
statusField := &model.PropertyField{
ID: model.NewId(),
Attrs: map[string]any{"options": []any{}},
}
view, err := buildBoardKanbanView(model.NewId(), statusField)
require.Nil(t, view)
require.NotNil(t, err)
})
t.Run("builds a column per valid option", func(t *testing.T) {
creatorID := model.NewId()
statusField := &model.PropertyField{
ID: model.NewId(),
Attrs: map[string]any{
"options": []any{
map[string]any{"id": "opt-todo", "name": "Todo"},
map[string]any{"id": "opt-doing", "name": "Doing"},
map[string]any{"id": "opt-done", "name": "Done"},
},
},
}
view, err := buildBoardKanbanView(creatorID, statusField)
require.Nil(t, err)
require.NotNil(t, view)
assert.Equal(t, model.ViewTypeKanban, view.Type)
assert.Equal(t, creatorID, view.CreatorId)
assert.Equal(t, "Board", view.Title)
kanban, kErr := model.KanbanPropsFromProps(view.Props)
require.NoError(t, kErr)
assert.Equal(t, statusField.ID, kanban.GroupBy.FieldID)
require.Len(t, kanban.GroupBy.Columns, 3)
assert.Equal(t, "Todo", kanban.GroupBy.Columns[0].Name)
assert.Equal(t, []string{"opt-todo"}, kanban.GroupBy.Columns[0].OptionIDs)
assert.Equal(t, "Doing", kanban.GroupBy.Columns[1].Name)
assert.Equal(t, "Done", kanban.GroupBy.Columns[2].Name)
for _, col := range kanban.GroupBy.Columns {
assert.NotEmpty(t, col.ID, "column should have a generated ID")
}
})
t.Run("skips options that are not maps or have missing fields", func(t *testing.T) {
statusField := &model.PropertyField{
ID: model.NewId(),
Attrs: map[string]any{
"options": []any{
"not-a-map", // skipped
map[string]any{"id": "", "name": "blank-id"}, // skipped (empty id)
map[string]any{"id": "opt-x", "name": ""}, // skipped (empty name)
map[string]any{"id": "opt-todo", "name": "Todo"},
},
},
}
view, err := buildBoardKanbanView(model.NewId(), statusField)
require.Nil(t, err)
require.NotNil(t, view)
kanban, kErr := model.KanbanPropsFromProps(view.Props)
require.NoError(t, kErr)
require.Len(t, kanban.GroupBy.Columns, 1)
assert.Equal(t, "Todo", kanban.GroupBy.Columns[0].Name)
})
}

705
server/channels/app/bot.go Normal file
View File

@@ -0,0 +1,705 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"errors"
"fmt"
"net/http"
"github.com/mattermost/mattermost/server/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"
)
const (
internalKeyPrefix = "mmi_"
botUserKey = internalKeyPrefix + "botid"
)
// EnsureBot provides similar functionality with the plugin-api BotService. It doesn't accept
// any ensureBotOptions hence it is not required for now.
func (a *App) EnsureBot(rctx request.CTX, pluginID string, bot *model.Bot) (string, error) {
if bot == nil {
return "", errors.New("passed a nil bot")
}
if bot.Username == "" {
return "", errors.New("passed a bot with no username")
}
botIDBytes, appErr := a.GetPluginKey(pluginID, botUserKey)
if appErr != nil {
return "", appErr
}
// If the bot has already been created, check whether it still exists and use it
if botIDBytes != nil {
botID := string(botIDBytes)
if _, appErr = a.GetBot(rctx, botID, true); appErr != nil {
rctx.Logger().Debug("Unable to get bot.", mlog.String("bot_id", botID), mlog.Err(appErr))
} else {
// ensure existing bot is synced with what is being created
botPatch := &model.BotPatch{
Username: &bot.Username,
DisplayName: &bot.DisplayName,
Description: &bot.Description,
}
if _, appErr = a.PatchBot(rctx, botID, botPatch); appErr != nil {
return "", fmt.Errorf("failed to patch bot: %w", appErr)
}
return botID, nil
}
}
// Check for an existing bot user with that username. If one exists, then use that.
if user, appErr := a.GetUserByUsername(bot.Username); appErr == nil && user != nil {
if user.IsBot {
if appErr := a.SetPluginKey(pluginID, botUserKey, []byte(user.Id)); appErr != nil {
return "", fmt.Errorf("failed to set plugin key: %w", appErr)
}
return user.Id, nil
}
rctx.Logger().Error("Plugin attempted to use an account that already exists. Convert user to a bot "+
"account in the CLI by running 'mattermost user convert <username> --bot'. If the user is an "+
"existing user account you want to preserve, change its username and restart the Mattermost server, "+
"after which the plugin will create a bot account with that name. For more information about bot "+
"accounts, see https://mattermost.com/pl/default-bot-accounts", mlog.String("username",
bot.Username),
mlog.String("user_id",
user.Id),
)
return "", fmt.Errorf("username %q is already taken by a non-bot user", bot.Username)
}
createdBot, err := a.CreateBot(rctx, bot)
if err != nil {
return "", fmt.Errorf("failed to create bot: %w", err)
}
if appErr := a.SetPluginKey(pluginID, botUserKey, []byte(createdBot.UserId)); appErr != nil {
return "", fmt.Errorf("failed to set plugin key: %w", appErr)
}
return createdBot.UserId, nil
}
// CreateBot creates the given bot and corresponding user.
func (a *App) CreateBot(rctx request.CTX, bot *model.Bot) (*model.Bot, *model.AppError) {
vErr := bot.IsValidCreate()
if vErr != nil {
return nil, vErr
}
user, nErr := a.Srv().Store().User().Save(rctx, model.UserFromBot(bot))
if nErr != nil {
var appErr *model.AppError
var invErr *store.ErrInvalidInput
switch {
case errors.As(nErr, &appErr):
return nil, appErr
case errors.As(nErr, &invErr):
code := ""
switch invErr.Field {
case "email":
code = "app.user.save.email_exists.app_error"
case "username":
code = "app.user.save.username_exists.app_error"
default:
code = "app.user.save.existing.app_error"
}
return nil, model.NewAppError("CreateBot", code, nil, "", http.StatusBadRequest).Wrap(nErr)
default:
return nil, model.NewAppError("CreateBot", "app.user.save.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
}
bot.UserId = user.Id
savedBot, nErr := a.Srv().Store().Bot().Save(bot)
if nErr != nil {
if err := a.Srv().Store().User().PermanentDelete(rctx, bot.UserId); err != nil {
rctx.Logger().Error("Failed to permanently delete the user after bot save failure", mlog.Err(err))
}
var appErr *model.AppError
switch {
case errors.As(nErr, &appErr): // in case we haven't converted to plain error.
return nil, appErr
default: // last fallback in case it doesn't map to an existing app error.
return nil, model.NewAppError("CreateBot", "app.bot.createbot.internal_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
}
// Get the owner of the bot, if one exists. If not, don't send a message
ownerUser, err := a.Srv().Store().User().Get(rctx, bot.OwnerId)
var nfErr *store.ErrNotFound
if err != nil && !errors.As(err, &nfErr) {
return nil, model.NewAppError("CreateBot", "app.user.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
} else if ownerUser != nil {
// Send a message to the bot's creator to inform them that the bot needs to be added
// to a team and channel after it's created
channel, err := a.getOrCreateDirectChannelWithUser(rctx, user, ownerUser)
if err != nil {
return nil, err
}
T := i18n.GetUserTranslations(ownerUser.Locale)
botAddPost := &model.Post{
Type: model.PostTypeAddBotTeamsChannels,
UserId: savedBot.UserId,
ChannelId: channel.Id,
Message: T("api.bot.teams_channels.add_message_mobile"),
}
if _, _, err := a.CreatePostAsUser(rctx, botAddPost, rctx.Session().Id, true); err != nil {
return nil, err
}
}
return savedBot, nil
}
func (a *App) GetSystemBot(rctx request.CTX) (*model.Bot, *model.AppError) {
return a.GetOrCreateSystemOwnedBot(rctx, model.BotSystemBotUsername, i18n.T("app.system.system_bot.bot_displayname"))
}
func (a *App) GetOrCreateSystemOwnedBot(rctx request.CTX, botUsername, botDisplayName string) (*model.Bot, *model.AppError) {
perPage := 1
userOptions := &model.UserGetOptions{
Page: 0,
PerPage: perPage,
Role: model.SystemAdminRoleId,
Inactive: false,
}
sysAdminList, err := a.GetUsersFromProfiles(userOptions)
if err != nil {
return nil, err
}
if len(sysAdminList) == 0 {
return nil, model.NewAppError("GetSystemBot", "app.bot.get_system_bot.empty_admin_list.app_error", nil, "", http.StatusInternalServerError)
}
botDef := &model.Bot{
Username: botUsername,
DisplayName: botDisplayName,
Description: "",
OwnerId: sysAdminList[0].Id,
}
botUser, appErr := a.GetUserByUsername(botDef.Username)
if appErr != nil {
if appErr.StatusCode != http.StatusNotFound {
return nil, appErr
}
// cannot find this bot user, save the user
user, nErr := a.Srv().Store().User().Save(rctx, model.UserFromBot(botDef))
if nErr != nil {
var appError *model.AppError
var invErr *store.ErrInvalidInput
switch {
case errors.As(nErr, &appError):
return nil, appError
case errors.As(nErr, &invErr):
code := ""
switch invErr.Field {
case "email":
code = "app.user.save.email_exists.app_error"
case "username":
code = "app.user.save.username_exists.app_error"
default:
code = "app.user.save.existing.app_error"
}
return nil, model.NewAppError("GetOrCreateSystemOwnedBot", code, nil, "", http.StatusBadRequest).Wrap(nErr)
default:
return nil, model.NewAppError("GetOrCreateSystemOwnedBot", "app.user.save.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
}
botDef.UserId = user.Id
//save the bot
savedBot, nErr := a.Srv().Store().Bot().Save(botDef)
if nErr != nil {
if err := a.Srv().Store().User().PermanentDelete(rctx, savedBot.UserId); err != nil {
rctx.Logger().Error("Failed to permanently delete the user after bot save failure", mlog.Err(err))
}
var nAppErr *model.AppError
switch {
case errors.As(nErr, &nAppErr): // in case we haven't converted to plain error.
return nil, nAppErr
default: // last fallback in case it doesn't map to an existing app error.
return nil, model.NewAppError("GetOrCreateSystemOwnedBot", "app.bot.createbot.internal_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
}
return savedBot, nil
}
if botUser == nil {
return nil, model.NewAppError("GetOrCreateSystemOwnedBot", "app.bot.createbot.internal_error", nil, "", http.StatusInternalServerError)
}
// Bot user exists; fetch including disabled.
bot, appErr := a.GetBot(rctx, botUser.Id, true)
if appErr != nil {
return nil, appErr
}
// Auto-heal: re-enable the bot if it was disabled (e.g. its owner was deactivated).
if bot.DeleteAt != 0 {
return a.UpdateBotActive(rctx, bot.UserId, true)
}
return bot, nil
}
// PatchBot applies the given patch to the bot and corresponding user.
func (a *App) PatchBot(rctx request.CTX, botUserId string, botPatch *model.BotPatch) (*model.Bot, *model.AppError) {
bot, err := a.GetBot(rctx, botUserId, true)
if err != nil {
return nil, err
}
if !bot.WouldPatch(botPatch) {
return bot, nil
}
bot.Patch(botPatch)
user, nErr := a.Srv().Store().User().Get(rctx, botUserId)
if nErr != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(nErr, &nfErr):
return nil, model.NewAppError("PatchBot", MissingAccountError, nil, "", http.StatusNotFound).Wrap(nErr)
default:
return nil, model.NewAppError("PatchBot", "app.user.get.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
}
patchedUser := model.UserFromBot(bot)
user.Id = patchedUser.Id
user.Username = patchedUser.Username
user.Email = patchedUser.Email
user.FirstName = patchedUser.FirstName
userUpdate, nErr := a.Srv().Store().User().Update(rctx, user, true)
if nErr != nil {
var appErr *model.AppError
var invErr *store.ErrInvalidInput
var conErr *store.ErrConflict
switch {
case errors.As(nErr, &appErr):
return nil, appErr
case errors.As(nErr, &invErr):
return nil, model.NewAppError("PatchBot", "app.user.update.find.app_error", nil, "", http.StatusBadRequest).Wrap(nErr)
case errors.As(nErr, &conErr):
if conErr.Resource == "Username" {
return nil, model.NewAppError("PatchBot", "app.user.save.username_exists.app_error", nil, "", http.StatusBadRequest).Wrap(nErr)
}
return nil, model.NewAppError("PatchBot", "app.user.save.email_exists.app_error", nil, "", http.StatusBadRequest).Wrap(nErr)
default:
return nil, model.NewAppError("PatchBot", "app.user.update.finding.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
}
a.InvalidateCacheForUser(user.Id)
ruser := userUpdate.New
a.sendUpdatedUserEvent(ruser)
bot, nErr = a.Srv().Store().Bot().Update(bot)
if nErr != nil {
var nfErr *store.ErrNotFound
var appErr *model.AppError
switch {
case errors.As(nErr, &nfErr):
return nil, model.MakeBotNotFoundError("SqlBotStore.Get", nfErr.ID).Wrap(nErr)
case errors.As(nErr, &appErr): // in case we haven't converted to plain error.
return nil, appErr
default: // last fallback in case it doesn't map to an existing app error.
return nil, model.NewAppError("PatchBot", "app.bot.patchbot.internal_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
}
return bot, nil
}
// GetBot returns the given bot.
func (a *App) GetBot(rctx request.CTX, botUserId string, includeDeleted bool) (*model.Bot, *model.AppError) {
bot, err := a.Srv().Store().Bot().Get(botUserId, includeDeleted)
if err != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(err, &nfErr):
return nil, model.MakeBotNotFoundError("SqlBotStore.Get", nfErr.ID).Wrap(err)
default: // last fallback in case it doesn't map to an existing app error.
return nil, model.NewAppError("GetBot", "app.bot.getbot.internal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
return bot, nil
}
// GetBots returns the requested page of bots.
func (a *App) GetBots(rctx request.CTX, options *model.BotGetOptions) (model.BotList, *model.AppError) {
bots, err := a.Srv().Store().Bot().GetAll(options)
if err != nil {
return nil, model.NewAppError("GetBots", "app.bot.getbots.internal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return bots, nil
}
// IsBotExemptFromDMRestrictions checks if the given user ID is a bot that is
// exempt from the RestrictDirectMessage=team enforcement. This includes the
// system bot, bots owned by the current session's user, and plugin-owned bots.
func (a *App) IsBotExemptFromDMRestrictions(rctx request.CTX, userID string) (bool, *model.AppError) {
bot, appErr := a.GetBot(rctx, userID, false)
if appErr != nil {
return false, appErr
}
// The system bot must be able to send messages to any user regardless of
// team membership (e.g. push notification tests, post reminders, etc.)
if bot.Username == model.BotSystemBotUsername {
return true, nil
}
if session := rctx.Session(); session != nil && bot.OwnerId == session.UserId {
return true, nil
}
pluginsEnvironment := a.GetPluginsEnvironment()
if pluginsEnvironment == nil {
return false, nil
}
availablePlugins, err := pluginsEnvironment.Available()
if err != nil {
return false, model.NewAppError("IsBotExemptFromDMRestrictions", "app.plugin.get_plugins.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
pluginIDs := make(map[string]bool, len(availablePlugins))
for _, plugin := range availablePlugins {
if plugin.Manifest != nil {
pluginIDs[plugin.Manifest.Id] = true
}
}
return pluginIDs[bot.OwnerId], nil
}
// UpdateBotActive marks a bot as active or inactive, along with its corresponding user.
func (a *App) UpdateBotActive(rctx request.CTX, botUserId string, active bool) (*model.Bot, *model.AppError) {
bot, nErr := a.Srv().Store().Bot().Get(botUserId, true)
if nErr != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(nErr, &nfErr):
return nil, model.MakeBotNotFoundError("SqlBotStore.Get", nfErr.ID).Wrap(nErr)
default:
return nil, model.NewAppError("UpdateBotActive", "app.bot.getbot.internal_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
}
// System-owned bots must never be disabled. This is the single choke point
// for both direct API calls and the disableUserBots owner-deactivation path.
if bot.IsSystemOwned() && !active {
return nil, model.NewAppError("UpdateBotActive", "app.bot.update_bot_active.protected_bot.app_error", nil, "", http.StatusForbidden)
}
user, nErr := a.Srv().Store().User().Get(rctx, botUserId)
if nErr != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(nErr, &nfErr):
return nil, model.NewAppError("PatchBot", MissingAccountError, nil, "", http.StatusNotFound).Wrap(nErr)
default:
return nil, model.NewAppError("PatchBot", "app.user.get.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
}
if _, err := a.UpdateActive(rctx, user, active); err != nil {
return nil, err
}
changed := true
if active && bot.DeleteAt != 0 {
bot.DeleteAt = 0
} else if !active && bot.DeleteAt == 0 {
bot.DeleteAt = model.GetMillis()
} else {
changed = false
}
if changed {
bot, nErr = a.Srv().Store().Bot().Update(bot)
if nErr != nil {
var nfErr *store.ErrNotFound
var appErr *model.AppError
switch {
case errors.As(nErr, &nfErr):
return nil, model.MakeBotNotFoundError("SqlBotStore.Get", nfErr.ID).Wrap(nErr)
case errors.As(nErr, &appErr): // in case we haven't converted to plain error.
return nil, appErr
default: // last fallback in case it doesn't map to an existing app error.
return nil, model.NewAppError("PatchBot", "app.bot.patchbot.internal_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
}
}
return bot, nil
}
// PermanentDeleteBot permanently deletes a bot and its corresponding user.
func (a *App) PermanentDeleteBot(rctx request.CTX, botUserId string) *model.AppError {
if err := a.Srv().Store().Bot().PermanentDelete(botUserId); err != nil {
var invErr *store.ErrInvalidInput
switch {
case errors.As(err, &invErr):
return model.NewAppError("PermanentDeleteBot", "app.bot.permenent_delete.bad_id", map[string]any{"user_id": invErr.Value}, "", http.StatusBadRequest).Wrap(err)
default: // last fallback in case it doesn't map to an existing app error.
return model.NewAppError("PatchBot", "app.bot.permanent_delete.internal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
if err := a.Srv().Store().Session().PermanentDeleteSessionsByUser(botUserId); err != nil {
return model.NewAppError("PermanentDeleteBot", "app.session.permanent_delete_sessions_by_user.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if err := a.Srv().Store().UserAccessToken().DeleteAllForUser(botUserId); err != nil {
return model.NewAppError("PermanentDeleteBot", "app.user_access_token.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Sessions are cached in memory by token, so clearing the cache is required
// to stop the deleted bot's access tokens from continuing to authenticate.
a.ClearSessionCacheForUser(botUserId)
if err := a.Srv().Store().User().PermanentDelete(rctx, botUserId); err != nil {
return model.NewAppError("PermanentDeleteBot", "app.user.permanent_delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}
// UpdateBotOwner changes a bot's owner to the given value.
func (a *App) UpdateBotOwner(rctx request.CTX, botUserId, newOwnerId string) (*model.Bot, *model.AppError) {
bot, err := a.Srv().Store().Bot().Get(botUserId, true)
if err != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(err, &nfErr):
return nil, model.MakeBotNotFoundError("SqlBotStore.Get", nfErr.ID).Wrap(err)
default: // last fallback in case it doesn't map to an existing app error.
return nil, model.NewAppError("UpdateBotOwner", "app.bot.getbot.internal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
bot.OwnerId = newOwnerId
bot, err = a.Srv().Store().Bot().Update(bot)
if err != nil {
var nfErr *store.ErrNotFound
var appErr *model.AppError
switch {
case errors.As(err, &nfErr):
return nil, model.MakeBotNotFoundError("SqlBotStore.Get", nfErr.ID).Wrap(err)
case errors.As(err, &appErr): // in case we haven't converted to plain error.
return nil, appErr
default: // last fallback in case it doesn't map to an existing app error.
return nil, model.NewAppError("PatchBot", "app.bot.patchbot.internal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
return bot, nil
}
// disableUserBots disables all bots owned by the given user.
func (a *App) disableUserBots(rctx request.CTX, userID string) *model.AppError {
perPage := 20
for {
options := &model.BotGetOptions{
OwnerId: userID,
IncludeDeleted: false,
OnlyOrphaned: false,
Page: 0,
PerPage: perPage,
}
userBots, err := a.GetBots(rctx, options)
if err != nil {
return err
}
for _, bot := range userBots {
_, err := a.UpdateBotActive(rctx, bot.UserId, false)
if err != nil {
rctx.Logger().Warn("Unable to deactivate bot.", mlog.String("bot_user_id", bot.UserId), mlog.Err(err))
}
}
// Get next set of bots if we got the max number of bots
if len(userBots) == perPage {
options.Page += 1
continue
}
break
}
return nil
}
func (a *App) notifySysadminsBotOwnerDeactivated(rctx request.CTX, userID string) *model.AppError {
perPage := 25
botOptions := &model.BotGetOptions{
OwnerId: userID,
IncludeDeleted: false,
OnlyOrphaned: false,
Page: 0,
PerPage: perPage,
}
// get owner bots
var userBots []*model.Bot
for {
bots, err := a.GetBots(rctx, botOptions)
if err != nil {
return err
}
userBots = append(userBots, bots...)
if len(bots) < perPage {
break
}
botOptions.Page += 1
}
// user does not own bots
if len(userBots) == 0 {
return nil
}
userOptions := &model.UserGetOptions{
Page: 0,
PerPage: perPage,
Role: model.SystemAdminRoleId,
Inactive: false,
}
// get sysadmins
var sysAdmins []*model.User
for {
sysAdminsList, err := a.GetUsersFromProfiles(userOptions)
if err != nil {
return err
}
sysAdmins = append(sysAdmins, sysAdminsList...)
if len(sysAdminsList) < perPage {
break
}
userOptions.Page += 1
}
// user being disabled
user, err := a.GetUser(rctx, userID)
if err != nil {
return err
}
// for each sysadmin, notify user that owns bots was disabled
for _, sysAdmin := range sysAdmins {
channel, appErr := a.GetOrCreateDirectChannel(rctx, sysAdmin.Id, sysAdmin.Id)
if appErr != nil {
return appErr
}
post := &model.Post{
UserId: sysAdmin.Id,
ChannelId: channel.Id,
Message: a.getDisableBotSysadminMessage(user, userBots),
Type: model.PostTypeSystemGeneric,
}
_, _, appErr = a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true})
if appErr != nil {
return appErr
}
}
return nil
}
func (a *App) getDisableBotSysadminMessage(user *model.User, userBots model.BotList) string {
disableBotsSetting := *a.Config().ServiceSettings.DisableBotsWhenOwnerIsDeactivated
var printAllBots = true
numBotsToPrint := len(userBots)
if numBotsToPrint > 10 {
numBotsToPrint = 10
printAllBots = false
}
var message, botList string
for _, bot := range userBots[:numBotsToPrint] {
botList += fmt.Sprintf("* %v\n", bot.Username)
}
T := i18n.GetUserTranslations(user.Locale)
message = T("app.bot.get_disable_bot_sysadmin_message",
map[string]any{
"UserName": user.Username,
"NumBots": len(userBots),
"BotNames": botList,
"disableBotsSetting": disableBotsSetting,
"printAllBots": printAllBots,
})
return message
}
// ConvertUserToBot converts a user to bot.
func (a *App) ConvertUserToBot(rctx request.CTX, user *model.User) (*model.Bot, *model.AppError) {
// Clear OAuth credentials before converting to bot
if user.AuthService != "" {
emptyString := ""
userAuth := &model.UserAuth{
AuthService: "",
AuthData: &emptyString,
}
_, err := a.UpdateUserAuth(rctx, user.Id, userAuth)
if err != nil {
return nil, err
}
// Refresh user data
updatedUser, err := a.GetUser(rctx, user.Id)
if err != nil {
return nil, err
}
user = updatedUser
}
bot, err := a.Srv().Store().Bot().Save(model.BotFromUser(user))
if err != nil {
var appErr *model.AppError
switch {
case errors.As(err, &appErr): // in case we haven't converted to plain error.
return nil, appErr
default: // last fallback in case it doesn't map to an existing app error.
return nil, model.NewAppError("CreateBot", "app.bot.createbot.internal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
if err := a.RevokeAllSessions(rctx, user.Id); err != nil {
return nil, err
}
a.InvalidateCacheForUser(user.Id)
return bot, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,105 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"bytes"
"mime/multipart"
"net/http"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
const (
BrandFilePath = "brand/"
BrandFileName = "image.png"
)
func (a *App) SaveBrandImage(rctx request.CTX, imageData *multipart.FileHeader) *model.AppError {
if *a.Config().FileSettings.DriverName == "" {
return model.NewAppError("SaveBrandImage", "api.admin.upload_brand_image.storage.app_error", nil, "", http.StatusNotImplemented)
}
file, err := imageData.Open()
if err != nil {
return model.NewAppError("SaveBrandImage", "brand.save_brand_image.open.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
defer file.Close()
if err = checkImageLimits(file, *a.Config().FileSettings.MaxImageResolution); err != nil {
return model.NewAppError("SaveBrandImage", "brand.save_brand_image.check_image_limits.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
img, _, err := a.ch.imgDecoder.Decode(file)
if err != nil {
return model.NewAppError("SaveBrandImage", "brand.save_brand_image.decode.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
buf := new(bytes.Buffer)
err = a.ch.imgEncoder.EncodePNG(buf, img)
if err != nil {
return model.NewAppError("SaveBrandImage", "brand.save_brand_image.encode.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
t := time.Now()
// Try to backup the old brand image if it exists
oldPath := BrandFilePath + BrandFileName
newPath := BrandFilePath + t.Format("2006-01-02T15:04:05") + ".png"
fileExists, appErr := a.FileExists(oldPath)
if appErr != nil {
rctx.Logger().Warn("Failed to check if brand image exists before backup", mlog.String("path", oldPath), mlog.Err(appErr))
}
if fileExists {
if err := a.MoveFile(oldPath, newPath); err != nil {
// Log the error but continue since this is a non-critical operation - we're just trying to
// backup the old brand image, but it's not a problem if we can't
rctx.Logger().Warn(
"Failed to backup old brand image",
mlog.Err(err),
mlog.String("old_path", oldPath),
mlog.String("new_path", newPath),
)
}
}
if _, err := a.WriteFile(buf, BrandFilePath+BrandFileName); err != nil {
return model.NewAppError("SaveBrandImage", "brand.save_brand_image.save_image.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}
func (a *App) GetBrandImage(rctx request.CTX) ([]byte, *model.AppError) {
if *a.Config().FileSettings.DriverName == "" {
return nil, model.NewAppError("GetBrandImage", "api.admin.get_brand_image.storage.app_error", nil, "", http.StatusNotImplemented)
}
img, err := a.ReadFile(BrandFilePath + BrandFileName)
if err != nil {
return nil, err
}
return img, nil
}
func (a *App) DeleteBrandImage(rctx request.CTX) *model.AppError {
filePath := BrandFilePath + BrandFileName
fileExists, err := a.FileExists(filePath)
if err != nil {
return err
}
if !fileExists {
return model.NewAppError("DeleteBrandImage", "api.admin.delete_brand_image.storage.not_found", nil, "", http.StatusNotFound)
}
return a.RemoveFile(filePath)
}

155
server/channels/app/busy.go Normal file
View File

@@ -0,0 +1,155 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/einterfaces"
)
const (
TimestampFormat = "Mon Jan 2 15:04:05 -0700 MST 2006"
)
// Busy represents the busy state of the server. A server marked busy
// will have non-critical services disabled. If a Cluster is provided
// any changes will be propagated to each node.
type Busy struct {
busy int32 // protected via atomic for fast IsBusy calls
mux sync.RWMutex
timer *time.Timer
expires time.Time
cluster einterfaces.ClusterInterface
}
// NewBusy creates a new Busy instance with optional cluster which will
// be notified of busy state changes.
func NewBusy(cluster einterfaces.ClusterInterface) *Busy {
return &Busy{cluster: cluster}
}
// IsBusy returns true if the server has been marked as busy.
func (b *Busy) IsBusy() bool {
if b == nil {
return false
}
return atomic.LoadInt32(&b.busy) != 0
}
// Set marks the server as busy for dur duration and notifies cluster nodes.
func (b *Busy) Set(dur time.Duration) {
b.mux.Lock()
defer b.mux.Unlock()
// minimum 1 second
if dur < (time.Second * 1) {
dur = time.Second * 1
}
b.setWithoutNotify(dur)
if b.cluster != nil {
sbs := &model.ServerBusyState{Busy: true, Expires: b.expires.Unix(), ExpiresTS: b.expires.UTC().Format(TimestampFormat)}
b.notifyServerBusyChange(sbs)
}
}
// must hold mutex
func (b *Busy) setWithoutNotify(dur time.Duration) {
b.clearWithoutNotify()
atomic.StoreInt32(&b.busy, 1)
b.expires = time.Now().Add(dur)
b.timer = time.AfterFunc(dur, func() {
b.mux.Lock()
b.clearWithoutNotify()
b.mux.Unlock()
})
}
// ClearBusy marks the server as not busy and notifies cluster nodes.
func (b *Busy) Clear() {
b.mux.Lock()
defer b.mux.Unlock()
b.clearWithoutNotify()
if b.cluster != nil {
sbs := &model.ServerBusyState{Busy: false, Expires: time.Time{}.Unix(), ExpiresTS: ""}
b.notifyServerBusyChange(sbs)
}
}
// must hold mutex
func (b *Busy) clearWithoutNotify() {
if b.timer != nil {
b.timer.Stop() // don't drain timer.C channel for AfterFunc timers.
}
b.timer = nil
b.expires = time.Time{}
atomic.StoreInt32(&b.busy, 0)
}
// Expires returns the expected time that the server
// will be marked not busy. This expiry can be extended
// via additional calls to SetBusy.
func (b *Busy) Expires() time.Time {
b.mux.RLock()
defer b.mux.RUnlock()
return b.expires
}
// notifyServerBusyChange informs all cluster members of a server busy state change.
func (b *Busy) notifyServerBusyChange(sbs *model.ServerBusyState) {
if b.cluster == nil {
return
}
buf, _ := json.Marshal(sbs)
msg := &model.ClusterMessage{
Event: model.ClusterEventBusyStateChanged,
SendType: model.ClusterSendReliable,
WaitForAllToSend: true,
Data: buf,
}
b.cluster.SendClusterMessage(msg)
}
// ClusterEventChanged is called when a CLUSTER_EVENT_BUSY_STATE_CHANGED is received.
func (b *Busy) ClusterEventChanged(sbs *model.ServerBusyState) {
b.mux.Lock()
defer b.mux.Unlock()
if sbs.Busy {
expires := time.Unix(sbs.Expires, 0)
dur := time.Until(expires)
if dur > 0 {
b.setWithoutNotify(dur)
}
} else {
b.clearWithoutNotify()
}
}
func (b *Busy) ToJSON() ([]byte, error) {
b.mux.RLock()
defer b.mux.RUnlock()
sbs := &model.ServerBusyState{
Busy: atomic.LoadInt32(&b.busy) != 0,
Expires: b.expires.Unix(),
ExpiresTS: b.expires.UTC().Format(TimestampFormat),
}
sbsJSON, jsonErr := json.Marshal(sbs)
if jsonErr != nil {
return []byte{}, fmt.Errorf("failed to encode server busy state to JSON: %w", jsonErr)
}
return sbsJSON, nil
}

View File

@@ -0,0 +1,180 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/einterfaces"
)
func TestBusySet(t *testing.T) {
mainHelper.Parallel(t)
cluster := &ClusterMock{Busy: &Busy{}, t: t}
busy := NewBusy(cluster)
isNotBusy := func() bool {
return !busy.IsBusy()
}
require.False(t, busy.IsBusy())
busy.Set(time.Second * 5)
require.True(t, busy.IsBusy())
require.True(t, compareBusyState(t, busy, cluster.Busy))
// should automatically expire after 5s.
require.Eventually(t, isNotBusy, time.Second*15, time.Millisecond*100)
// allow a moment for cluster to sync.
require.Eventually(t, func() bool { return compareBusyState(t, busy, cluster.Busy) }, time.Second*15, time.Millisecond*20)
// test set after auto expiry.
busy.Set(time.Second * 30)
require.True(t, busy.IsBusy())
require.True(t, compareBusyState(t, busy, cluster.Busy))
expire := busy.Expires()
require.Greater(t, expire.Unix(), time.Now().Add(time.Second*10).Unix())
// test extending existing expiry
busy.Set(time.Minute * 5)
require.True(t, busy.IsBusy())
require.True(t, compareBusyState(t, busy, cluster.Busy))
expire = busy.Expires()
require.Greater(t, expire.Unix(), time.Now().Add(time.Minute*2).Unix())
busy.Clear()
require.False(t, busy.IsBusy())
require.True(t, compareBusyState(t, busy, cluster.Busy))
}
func TestBusyExpires(t *testing.T) {
mainHelper.Parallel(t)
cluster := &ClusterMock{Busy: &Busy{}, t: t}
busy := NewBusy(cluster)
isNotBusy := func() bool {
return !busy.IsBusy()
}
// get expiry before it is set
expire := busy.Expires()
// should be time.Time zero value
require.Equal(t, time.Time{}.Unix(), expire.Unix())
// get expiry after it is set
busy.Set(time.Minute * 5)
expire = busy.Expires()
require.Greater(t, expire.Unix(), time.Now().Add(time.Minute*2).Unix())
require.True(t, compareBusyState(t, busy, cluster.Busy))
// get expiry after clear
busy.Clear()
expire = busy.Expires()
// should be time.Time zero value
require.Equal(t, time.Time{}.Unix(), expire.Unix())
require.True(t, compareBusyState(t, busy, cluster.Busy))
// get expiry after auto-expire
busy.Set(time.Millisecond * 100)
require.Eventually(t, isNotBusy, time.Second*5, time.Millisecond*20)
expire = busy.Expires()
// should be time.Time zero value
require.Equal(t, time.Time{}.Unix(), expire.Unix())
// allow a moment for cluster to sync
require.Eventually(t, func() bool { return compareBusyState(t, busy, cluster.Busy) }, time.Second*15, time.Millisecond*20)
}
func TestBusyRace(t *testing.T) {
mainHelper.Parallel(t)
cluster := &ClusterMock{Busy: &Busy{}, t: t}
busy := NewBusy(cluster)
busy.Set(500 * time.Millisecond)
// We are sleeping in order to let the race trigger.
time.Sleep(time.Second)
}
func compareBusyState(t *testing.T, busy1 *Busy, busy2 *Busy) bool {
t.Helper()
if busy1.IsBusy() != busy2.IsBusy() {
busy1JSON, _ := busy1.ToJSON()
busy2JSON, _ := busy2.ToJSON()
t.Logf("IsBusy() is not equal: busy1:%s; busy2:%s\n", busy1JSON, busy2JSON)
return false
}
// busy2 is the cluster expiry which could potentially be later
// than busy1 because of the recalculation of new time at ClusterEventChanged
// and then again at setWithoutNotify.
if busy1.Expires().Unix() < busy2.Expires().Unix() {
busy1JSON, _ := busy1.ToJSON()
busy2JSON, _ := busy2.ToJSON()
t.Logf("busy1.Expires().Unix():%s is not less than busy2Expires().Unix():%s\n", busy1JSON, busy2JSON)
return false
}
return true
}
// ClusterMock simulates the busy state of a cluster.
type ClusterMock struct {
Busy *Busy
t *testing.T
}
func (c *ClusterMock) SendClusterMessage(msg *model.ClusterMessage) {
var sbs model.ServerBusyState
err := json.Unmarshal(msg.Data, &sbs)
require.NoError(c.t, err)
c.Busy.ClusterEventChanged(&sbs)
}
func (c *ClusterMock) SendClusterMessageToNode(nodeID string, msg *model.ClusterMessage) error {
return nil
}
func (c *ClusterMock) StartInterNodeCommunication() {}
func (c *ClusterMock) StopInterNodeCommunication() {}
func (c *ClusterMock) RegisterClusterMessageHandler(event model.ClusterEvent, crm einterfaces.ClusterMessageHandler) {
}
func (c *ClusterMock) GetClusterId() string { return "cluster_mock" }
func (c *ClusterMock) IsLeader() bool { return false }
func (c *ClusterMock) GetMyClusterInfo() *model.ClusterInfo { return nil }
func (c *ClusterMock) GetClusterInfos() ([]*model.ClusterInfo, error) { return nil, nil }
func (c *ClusterMock) NotifyMsg(buf []byte) {}
func (c *ClusterMock) GetClusterStats(rctx request.CTX) ([]*model.ClusterStats, *model.AppError) {
return nil, nil
}
func (c *ClusterMock) GetLogs(rctx request.CTX, page, perPage int) ([]string, *model.AppError) {
return nil, nil
}
func (c *ClusterMock) QueryLogs(rctx request.CTX, page, perPage int) (map[string][]string, *model.AppError) {
return nil, nil
}
func (c *ClusterMock) GenerateSupportPacket(rctx request.CTX, options *model.SupportPacketOptions) (map[string][]model.FileData, error) {
return nil, nil
}
func (c *ClusterMock) GetPluginStatuses() (model.PluginStatuses, *model.AppError) { return nil, nil }
func (c *ClusterMock) ConfigChanged(previousConfig *model.Config, newConfig *model.Config, sendToOtherServer bool) *model.AppError {
return nil
}
func (c *ClusterMock) HealthScore() int { return 0 }
func (c *ClusterMock) WebConnCountForUser(userID string) (int, *model.AppError) {
return 0, nil
}
func (c *ClusterMock) GetWSQueues(userID, connectionID string, seqNum int64) (map[string]*model.WSQueues, error) {
return nil, nil
}
func (c *ClusterMock) Shutdown() {}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,152 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"errors"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
func (a *App) GetChannelBookmarks(channelId string, since int64) ([]*model.ChannelBookmarkWithFileInfo, *model.AppError) {
bookmarks, err := a.Srv().Store().ChannelBookmark().GetBookmarksForChannelSince(channelId, since)
if err != nil {
return nil, model.NewAppError("GetChannelBookmarks", "app.channel.bookmark.get.app_error", nil, "", http.StatusNotFound).Wrap(err)
}
return bookmarks, nil
}
func (a *App) GetBookmark(bookmarkId string, includeDeleted bool) (*model.ChannelBookmarkWithFileInfo, *model.AppError) {
bookmark, err := a.Srv().Store().ChannelBookmark().Get(bookmarkId, includeDeleted)
if err != nil {
return nil, model.NewAppError("GetBookmark", "app.channel.bookmark.get.app_error", nil, "", http.StatusNotFound).Wrap(err)
}
return bookmark, nil
}
func (a *App) CreateChannelBookmark(rctx request.CTX, newBookmark *model.ChannelBookmark, connectionId string) (*model.ChannelBookmarkWithFileInfo, *model.AppError) {
newBookmark.OwnerId = rctx.Session().UserId //ensure that the bookmark is being created by the user who owns the session
newBookmark.Id = "" // ensure that creating a new bookmark generates a new ID
bookmark, err := a.Srv().Store().ChannelBookmark().Save(newBookmark, true)
if err != nil {
return nil, model.NewAppError("CreateChannelBookmark", "app.channel.bookmark.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
message := model.NewWebSocketEvent(model.WebsocketEventChannelBookmarkCreated, "", bookmark.ChannelId, "", nil, connectionId)
bookmarkJSON, jsonErr := json.Marshal(bookmark)
if jsonErr != nil {
return nil, model.NewAppError("CreateChannelBookmark", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
}
message.Add("bookmark", string(bookmarkJSON))
a.Publish(message)
return bookmark, nil
}
func (a *App) UpdateChannelBookmark(rctx request.CTX, updateBookmark *model.ChannelBookmarkWithFileInfo, connectionId string) (*model.UpdateChannelBookmarkResponse, *model.AppError) {
response := &model.UpdateChannelBookmarkResponse{}
if updateBookmark.OwnerId == rctx.Session().UserId {
isAnotherFile := updateBookmark.FileInfo != nil && updateBookmark.FileId != "" && updateBookmark.FileId != updateBookmark.FileInfo.Id
if isAnotherFile {
if fileAlreadyAttachedErr := a.Srv().Store().ChannelBookmark().ErrorIfBookmarkFileInfoAlreadyAttached(updateBookmark.FileId, updateBookmark.ChannelId); fileAlreadyAttachedErr != nil {
return nil, model.NewAppError("UpdateChannelBookmark", "app.channel.bookmark.update.app_error", nil, "", http.StatusInternalServerError).Wrap(fileAlreadyAttachedErr)
}
}
if err := a.Srv().Store().ChannelBookmark().Update(updateBookmark.ChannelBookmark); err != nil {
return nil, model.NewAppError("UpdateChannelBookmark", "app.channel.bookmark.update.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if isAnotherFile {
fileInfo, fileErr := a.Srv().Store().FileInfo().Get(updateBookmark.FileId)
if fileErr != nil {
return nil, model.NewAppError("UpdateChannelBookmark", "app.channel.bookmark.get_existing.app_err", nil, "", http.StatusNotFound).Wrap(fileErr)
}
response.Updated = updateBookmark.ToBookmarkWithFileInfo(fileInfo)
} else {
response.Updated = updateBookmark.ToBookmarkWithFileInfo(updateBookmark.FileInfo)
}
} else {
existingBookmark, ebErr := a.Srv().Store().ChannelBookmark().Get(updateBookmark.Id, false)
if ebErr != nil {
return nil, model.NewAppError("UpdateChannelBookmark", "app.channel.bookmark.get_existing.app_err", nil, "", http.StatusNotFound).Wrap(ebErr)
}
existingBookmark.DeleteAt = model.GetMillis()
if err := a.Srv().Store().ChannelBookmark().Delete(updateBookmark.Id, false); err != nil {
return nil, model.NewAppError("UpdateChannelBookmark", "app.channel.bookmark.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
newBookmark := updateBookmark.SetOriginal(rctx.Session().UserId)
bookmark, err := a.Srv().Store().ChannelBookmark().Save(newBookmark, false)
if err != nil {
return nil, model.NewAppError("UpdateChannelBookmark", "app.channel.bookmark.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
response.Updated = bookmark
response.Deleted = existingBookmark.ToBookmarkWithFileInfo(nil)
}
message := model.NewWebSocketEvent(model.WebsocketEventChannelBookmarkUpdated, "", updateBookmark.ChannelId, "", nil, connectionId)
bookmarkJSON, jsonErr := json.Marshal(response)
if jsonErr != nil {
return nil, model.NewAppError("UpdateChannelBookmark", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
}
message.Add("bookmarks", string(bookmarkJSON))
a.Publish(message)
return response, nil
}
func (a *App) DeleteChannelBookmark(bookmarkId, connectionId string) (*model.ChannelBookmarkWithFileInfo, *model.AppError) {
if err := a.Srv().Store().ChannelBookmark().Delete(bookmarkId, true); err != nil {
return nil, model.NewAppError("DeleteChannelBookmark", "app.channel.bookmark.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
bookmark, err := a.GetBookmark(bookmarkId, true)
if err != nil {
return nil, model.NewAppError("DeleteChannelBookmark", "app.channel.bookmark.get.app_error", nil, "", http.StatusNotFound).Wrap(err)
}
message := model.NewWebSocketEvent(model.WebsocketEventChannelBookmarkDeleted, "", bookmark.ChannelId, "", nil, connectionId)
bookmarkJSON, jsonErr := json.Marshal(bookmark)
if jsonErr != nil {
return nil, model.NewAppError("DeleteChannelBookmark", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
}
message.Add("bookmark", string(bookmarkJSON))
a.Publish(message)
return bookmark, nil
}
func (a *App) UpdateChannelBookmarkSortOrder(bookmarkId, channelId string, newIndex int64, connectionId string) ([]*model.ChannelBookmarkWithFileInfo, *model.AppError) {
bookmarks, err := a.Srv().Store().ChannelBookmark().UpdateSortOrder(bookmarkId, channelId, newIndex)
if err != nil {
var iiErr *store.ErrInvalidInput
var nfErr *store.ErrNotFound
switch {
case errors.As(err, &iiErr):
return nil, model.NewAppError("UpdateSortOrder", "app.channel.bookmark.update_sort.invalid_input.app_error", nil, "", http.StatusBadRequest).Wrap(err)
case errors.As(err, &nfErr):
return nil, model.NewAppError("UpdateSortOrder", "app.channel.bookmark.update_sort.missing_bookmark.app_error", nil, "", http.StatusNotFound).Wrap(err)
default:
return nil, model.NewAppError("UpdateSortOrder", "app.channel.bookmark.update_sort.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
message := model.NewWebSocketEvent(model.WebsocketEventChannelBookmarkSorted, "", channelId, "", nil, connectionId)
bookmarkJSON, jsonErr := json.Marshal(bookmarks)
if jsonErr != nil {
return nil, model.NewAppError("UpdateSortOrder", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
}
message.Add("bookmarks", string(bookmarkJSON))
a.Publish(message)
return bookmarks, nil
}

View File

@@ -0,0 +1,630 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"fmt"
"testing"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func find_bookmark(slice []*model.ChannelBookmarkWithFileInfo, id string) *model.ChannelBookmarkWithFileInfo {
for _, element := range slice {
if element.Id == id {
return element
}
}
return nil
}
func createBookmark(name string, bookmarkType model.ChannelBookmarkType, channelId string, fileId string) *model.ChannelBookmark {
bookmark := &model.ChannelBookmark{
ChannelId: channelId,
DisplayName: name,
Type: bookmarkType,
Emoji: ":smile:",
}
if bookmarkType == model.ChannelBookmarkLink {
bookmark.LinkUrl = "https://mattermost.com"
}
if bookmarkType == model.ChannelBookmarkFile {
bookmark.FileId = fileId
}
return bookmark
}
func TestCreateBookmark(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
t.Run("create a channel bookmark", func(t *testing.T) {
th.Context.Session().UserId = th.BasicUser.Id // set the user for the session
bookmark1 := createBookmark("Link bookmark test", model.ChannelBookmarkLink, th.BasicChannel.Id, "")
bookmarkResp, err := th.App.CreateChannelBookmark(th.Context, bookmark1, "")
require.Nil(t, err)
require.NotNil(t, bookmarkResp)
assert.Equal(t, bookmarkResp.ChannelId, th.BasicChannel.Id)
assert.NotEmpty(t, bookmarkResp.Id)
bookmark2 := createBookmark("File bookmark test", model.ChannelBookmarkFile, th.BasicChannel.Id, "")
bookmarkResp, err = th.App.CreateChannelBookmark(th.Context, bookmark2, "")
assert.Nil(t, bookmarkResp)
assert.NotNil(t, err)
})
t.Run("Cannot create more than MaxBookmarksPerChannel", func(t *testing.T) {
th.Context.Session().UserId = th.BasicUser.Id // set the user for the session
for i := 1; i < model.MaxBookmarksPerChannel; i++ {
bookmark := createBookmark(fmt.Sprintf("Link bookmark test %d", i), model.ChannelBookmarkLink, th.BasicChannel.Id, "")
bookmarkResp, err := th.App.CreateChannelBookmark(th.Context, bookmark, "")
require.Nil(t, err)
require.NotNil(t, bookmarkResp)
assert.Equal(t, bookmarkResp.ChannelId, th.BasicChannel.Id)
assert.NotEmpty(t, bookmarkResp.Id)
}
bookmark := createBookmark("Bookmark that should not be added", model.ChannelBookmarkLink, th.BasicChannel.Id, "")
bookmarkResp, err := th.App.CreateChannelBookmark(th.Context, bookmark, "")
assert.Nil(t, bookmarkResp)
assert.NotNil(t, err)
})
}
func TestUpdateBookmark(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
var updateBookmark *model.ChannelBookmarkWithFileInfo
testUpdateAnotherFile := func(th *TestHelper, t *testing.T) {
file := &model.FileInfo{
Id: model.NewId(),
ChannelId: th.BasicChannel.Id,
CreatorId: model.BookmarkFileOwner,
Path: "somepath",
ThumbnailPath: "thumbpath",
PreviewPath: "prevPath",
Name: "test file",
Extension: "png",
MimeType: "images/png",
Size: 873182,
Width: 3076,
Height: 2200,
HasPreviewImage: true,
}
_, err := th.App.Srv().Store().FileInfo().Save(th.Context, file)
assert.NoError(t, err)
defer func() {
err = th.App.Srv().Store().FileInfo().PermanentDelete(th.Context, file.Id)
assert.NoError(t, err)
}()
bookmark2 := createBookmark("File to be updated", model.ChannelBookmarkFile, th.BasicChannel.Id, file.Id)
bookmarkResp, appErr := th.App.CreateChannelBookmark(th.Context, bookmark2, "")
require.Nil(t, appErr)
require.NotNil(t, bookmarkResp)
file2 := &model.FileInfo{
Id: model.NewId(),
ChannelId: th.BasicChannel.Id,
CreatorId: model.BookmarkFileOwner,
Path: "somepath",
ThumbnailPath: "thumbpath",
PreviewPath: "prevPath",
Name: "test file",
Extension: "png",
MimeType: "images/png",
Size: 873182,
Width: 3076,
Height: 2200,
HasPreviewImage: true,
}
_, err = th.App.Srv().Store().FileInfo().Save(th.Context, file2)
assert.NoError(t, err)
err = th.App.Srv().Store().FileInfo().AttachToPost(th.Context, file2.Id, model.NewId(), th.BasicChannel.Id, model.BookmarkFileOwner)
assert.NoError(t, err)
defer func() {
err = th.App.Srv().Store().FileInfo().PermanentDelete(th.Context, file2.Id)
require.NoError(t, err)
}()
bookmark2.FileId = file2.Id
bookmarkResp, appErr = th.App.CreateChannelBookmark(th.Context, bookmark2, "")
require.NotNil(t, appErr)
require.Nil(t, bookmarkResp)
}
var testUpdateInvalidFiles = func(th *TestHelper, t *testing.T, creatingUserId string, updatingUserId string) {
file := &model.FileInfo{
Id: model.NewId(),
ChannelId: th.BasicChannel.Id,
CreatorId: model.BookmarkFileOwner,
Path: "somepath",
ThumbnailPath: "thumbpath",
PreviewPath: "prevPath",
Name: "test file",
Extension: "png",
MimeType: "images/png",
Size: 873182,
Width: 3076,
Height: 2200,
HasPreviewImage: true,
}
_, err := th.App.Srv().Store().FileInfo().Save(th.Context, file)
assert.NoError(t, err)
defer func() {
err = th.App.Srv().Store().FileInfo().PermanentDelete(th.Context, file.Id)
assert.NoError(t, err)
}()
th.Context.Session().UserId = creatingUserId
bookmark := createBookmark("File to be updated", model.ChannelBookmarkFile, th.BasicChannel.Id, file.Id)
bookmarkToEdit, appErr := th.App.CreateChannelBookmark(th.Context, bookmark, "")
require.Nil(t, appErr)
require.NotNil(t, bookmarkToEdit)
otherChannel := th.CreateChannel(t, th.BasicTeam)
createAt := time.Now().Add(-1 * time.Minute)
deleteAt := createAt.Add(1 * time.Second)
deletedFile := &model.FileInfo{
Id: model.NewId(),
ChannelId: th.BasicChannel.Id,
CreatorId: model.BookmarkFileOwner,
Path: "somepath",
ThumbnailPath: "thumbpath",
PreviewPath: "prevPath",
Name: "test file",
Extension: "png",
MimeType: "images/png",
Size: 873182,
Width: 3076,
Height: 2200,
HasPreviewImage: true,
CreateAt: createAt.UnixMilli(),
UpdateAt: createAt.UnixMilli(),
DeleteAt: deleteAt.UnixMilli(),
}
_, err = th.App.Srv().Store().FileInfo().Save(th.Context, deletedFile)
assert.NoError(t, err)
defer func() {
err = th.App.Srv().Store().FileInfo().PermanentDelete(th.Context, deletedFile.Id)
assert.NoError(t, err)
}()
th.Context.Session().UserId = updatingUserId
updateBookmarkPending := bookmarkToEdit.Clone()
updateBookmarkPending.FileId = deletedFile.Id
bookmarkEdited, appErr := th.App.UpdateChannelBookmark(th.Context, updateBookmarkPending, "")
assert.NotNil(t, appErr)
require.Nil(t, bookmarkEdited)
anotherChannelFile := &model.FileInfo{
Id: model.NewId(),
ChannelId: otherChannel.Id,
CreatorId: model.BookmarkFileOwner,
Path: "somepath",
ThumbnailPath: "thumbpath",
PreviewPath: "prevPath",
Name: "test file",
Extension: "png",
MimeType: "images/png",
Size: 873182,
Width: 3076,
Height: 2200,
HasPreviewImage: true,
}
_, err = th.App.Srv().Store().FileInfo().Save(th.Context, anotherChannelFile)
assert.NoError(t, err)
defer func() {
err = th.App.Srv().Store().FileInfo().PermanentDelete(th.Context, anotherChannelFile.Id)
require.NoError(t, err)
}()
updateBookmarkPending = bookmarkToEdit.Clone()
updateBookmarkPending.FileId = anotherChannelFile.Id
bookmarkEdited, appErr = th.App.UpdateChannelBookmark(th.Context, updateBookmarkPending, "")
assert.NotNil(t, appErr)
require.Nil(t, bookmarkEdited)
}
t.Run("same user update a channel bookmark", func(t *testing.T) {
bookmark1 := &model.ChannelBookmark{
ChannelId: th.BasicChannel.Id,
DisplayName: "Link bookmark test",
LinkUrl: "https://mattermost.com",
Type: model.ChannelBookmarkLink,
Emoji: ":smile:",
}
th.Context.Session().UserId = th.BasicUser.Id // set the user for the session
bookmarkResp, err := th.App.CreateChannelBookmark(th.Context, bookmark1, "")
require.Nil(t, err)
require.NotNil(t, bookmarkResp)
updateBookmark = bookmarkResp.Clone()
updateBookmark.DisplayName = "New name"
time.Sleep(1 * time.Millisecond) // to avoid collisions
response, _ := th.App.UpdateChannelBookmark(th.Context, updateBookmark, "")
require.NotNil(t, response)
assert.Greater(t, response.Updated.UpdateAt, response.Updated.CreateAt)
testUpdateAnotherFile(th, t)
testUpdateInvalidFiles(th, t, th.BasicUser.Id, th.BasicUser.Id)
})
t.Run("another user update a channel bookmark", func(t *testing.T) {
updateBookmark2 := updateBookmark.Clone()
updateBookmark2.DisplayName = "Another new name"
th.Context.Session().UserId = th.BasicUser2.Id
response, _ := th.App.UpdateChannelBookmark(th.Context, updateBookmark2, "")
require.NotNil(t, response)
assert.Equal(t, response.Updated.OriginalId, response.Deleted.Id)
assert.Equal(t, response.Updated.DeleteAt, int64(0))
assert.Greater(t, response.Deleted.DeleteAt, int64(0))
assert.Equal(t, "Another new name", response.Updated.DisplayName)
assert.Equal(t, "New name", response.Deleted.DisplayName)
testUpdateAnotherFile(th, t)
testUpdateInvalidFiles(th, t, th.BasicUser.Id, th.BasicUser.Id)
})
t.Run("update an already deleted channel bookmark", func(t *testing.T) {
bookmark1 := &model.ChannelBookmark{
ChannelId: th.BasicChannel.Id,
DisplayName: "Link bookmark test",
LinkUrl: "https://mattermost.com",
Type: model.ChannelBookmarkLink,
Emoji: ":smile:",
}
th.Context.Session().UserId = th.BasicUser.Id // set the user for the session
bookmarkResp, err := th.App.CreateChannelBookmark(th.Context, bookmark1, "")
require.Nil(t, err)
require.NotNil(t, bookmarkResp)
updateBookmark = bookmarkResp.Clone()
_, err = th.App.DeleteChannelBookmark(updateBookmark.Id, "")
assert.Nil(t, err)
updateBookmark.DisplayName = "New name"
_, err = th.App.UpdateChannelBookmark(th.Context, updateBookmark, "")
assert.NotNil(t, err)
})
t.Run("update a nonexisting channel bookmark", func(t *testing.T) {
updateBookmark := &model.ChannelBookmark{
Id: model.NewId(),
ChannelId: th.BasicChannel.Id,
DisplayName: "Link bookmark test",
LinkUrl: "https://mattermost.com",
Type: model.ChannelBookmarkLink,
Emoji: ":smile:",
}
_, err := th.App.UpdateChannelBookmark(th.Context, updateBookmark.ToBookmarkWithFileInfo(nil), "")
assert.NotNil(t, err)
assert.Equal(t, "app.channel.bookmark.get_existing.app_err", err.Id)
})
}
func TestDeleteBookmark(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
t.Run("delete a channel bookmark", func(t *testing.T) {
bookmark1 := &model.ChannelBookmark{
ChannelId: th.BasicChannel.Id,
DisplayName: "Link bookmark test",
LinkUrl: "https://mattermost.com",
Type: model.ChannelBookmarkLink,
Emoji: ":smile:",
}
th.Context.Session().UserId = th.BasicUser.Id // set the user for the session
bookmarkResp, err := th.App.CreateChannelBookmark(th.Context, bookmark1, "")
require.Nil(t, err)
require.NotNil(t, bookmarkResp)
bookmarkResp, err = th.App.DeleteChannelBookmark(bookmarkResp.Id, "")
require.Nil(t, err)
require.NotNil(t, bookmarkResp)
assert.Greater(t, bookmarkResp.DeleteAt, int64(0))
})
}
func TestGetChannelBookmarks(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.Context.Session().UserId = th.BasicUser.Id // set the user for the session
bookmark1 := &model.ChannelBookmark{
ChannelId: th.BasicChannel.Id,
DisplayName: "Bookmark 1",
LinkUrl: "https://mattermost.com",
Type: model.ChannelBookmarkLink,
Emoji: ":smile:",
}
_, appErr := th.App.CreateChannelBookmark(th.Context, bookmark1, "")
assert.Nil(t, appErr)
file := &model.FileInfo{
Id: model.NewId(),
ChannelId: th.BasicChannel.Id,
CreatorId: model.BookmarkFileOwner,
Path: "somepath",
ThumbnailPath: "thumbpath",
PreviewPath: "prevPath",
Name: "test file",
Extension: "png",
MimeType: "images/png",
Size: 873182,
Width: 3076,
Height: 2200,
HasPreviewImage: true,
}
_, err := th.App.Srv().Store().FileInfo().Save(th.Context, file)
assert.NoError(t, err)
defer func() {
err := th.App.Srv().Store().FileInfo().PermanentDelete(th.Context, file.Id)
assert.NoError(t, err)
}()
bookmark2 := &model.ChannelBookmark{
ChannelId: th.BasicChannel.Id,
DisplayName: "Bookmark 2",
FileId: file.Id,
Type: model.ChannelBookmarkFile,
Emoji: ":smile:",
}
_, appErr = th.App.CreateChannelBookmark(th.Context, bookmark2, "")
assert.Nil(t, appErr)
t.Run("get bookmarks of a channel", func(t *testing.T) {
bookmarks, err := th.App.GetChannelBookmarks(th.BasicChannel.Id, 0)
require.Nil(t, err)
require.NotNil(t, bookmarks)
assert.Len(t, bookmarks, 2)
})
t.Run("get bookmarks of a channel after one is deleted (aka only return the changed bookmarks)", func(t *testing.T) {
now := model.GetMillis()
_, appErr := th.App.DeleteChannelBookmark(bookmark1.Id, "")
assert.Nil(t, appErr)
bookmarks, err := th.App.GetChannelBookmarks(th.BasicChannel.Id, 0)
require.Nil(t, err)
require.NotNil(t, bookmarks)
assert.Len(t, bookmarks, 1)
bookmarks, err = th.App.GetChannelBookmarks(th.BasicChannel.Id, now)
require.Nil(t, err)
require.NotNil(t, bookmarks)
assert.Len(t, bookmarks, 1)
deleted := false
for _, b := range bookmarks {
if b.DeleteAt > 0 {
deleted = true
break
}
}
assert.Equal(t, deleted, true)
})
}
func TestUpdateChannelBookmarkSortOrder(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
channelId := th.BasicChannel.Id
th.Context.Session().UserId = th.BasicUser.Id // set the user for the session
bookmark0 := &model.ChannelBookmark{
ChannelId: channelId,
DisplayName: "Bookmark 0",
LinkUrl: "https://mattermost.com",
Type: model.ChannelBookmarkLink,
Emoji: ":smile:",
}
file := &model.FileInfo{
Id: model.NewId(),
ChannelId: th.BasicChannel.Id,
CreatorId: model.BookmarkFileOwner,
Path: "somepath",
ThumbnailPath: "thumbpath",
PreviewPath: "prevPath",
Name: "test file",
Extension: "png",
MimeType: "images/png",
Size: 873182,
Width: 3076,
Height: 2200,
HasPreviewImage: true,
}
bookmark1 := &model.ChannelBookmark{
ChannelId: channelId,
DisplayName: "Bookmark 1",
FileId: file.Id,
Type: model.ChannelBookmarkFile,
Emoji: ":smile:",
}
_, err := th.App.Srv().Store().FileInfo().Save(th.Context, file)
require.NoError(t, err)
defer func() {
err = th.App.Srv().Store().FileInfo().PermanentDelete(th.Context, file.Id)
require.NoError(t, err)
}()
bookmark2 := &model.ChannelBookmark{
ChannelId: channelId,
DisplayName: "Bookmark 2",
LinkUrl: "https://mattermost.com",
Type: model.ChannelBookmarkLink,
}
bookmark3 := &model.ChannelBookmark{
ChannelId: channelId,
DisplayName: "Bookmark 3",
LinkUrl: "https://mattermost.com",
Type: model.ChannelBookmarkLink,
}
bookmark4 := &model.ChannelBookmark{
ChannelId: channelId,
DisplayName: "Bookmark 4",
LinkUrl: "https://mattermost.com",
Type: model.ChannelBookmarkLink,
}
bookmarkResp, appErr := th.App.CreateChannelBookmark(th.Context, bookmark0, "")
require.Nil(t, appErr)
require.NotNil(t, bookmarkResp)
bookmark0 = bookmarkResp.ChannelBookmark.Clone()
bookmarkResp, appErr = th.App.CreateChannelBookmark(th.Context, bookmark1, "")
require.Nil(t, appErr)
require.NotNil(t, bookmarkResp)
bookmark1 = bookmarkResp.ChannelBookmark.Clone()
bookmarkResp, appErr = th.App.CreateChannelBookmark(th.Context, bookmark2, "")
require.Nil(t, appErr)
require.NotNil(t, bookmarkResp)
bookmark2 = bookmarkResp.ChannelBookmark.Clone()
bookmarkResp, appErr = th.App.CreateChannelBookmark(th.Context, bookmark3, "")
require.Nil(t, appErr)
require.NotNil(t, bookmarkResp)
bookmark3 = bookmarkResp.ChannelBookmark.Clone()
bookmarkResp, appErr = th.App.CreateChannelBookmark(th.Context, bookmark4, "")
require.Nil(t, appErr)
require.NotNil(t, bookmarkResp)
bookmark4 = bookmarkResp.ChannelBookmark.Clone()
t.Run("change order of bookmarks first to last", func(t *testing.T) {
bookmarks, sortErr := th.App.UpdateChannelBookmarkSortOrder(bookmark0.Id, channelId, int64(4), "")
require.Nil(t, sortErr)
require.NotNil(t, bookmarks)
assert.Equal(t, find_bookmark(bookmarks, bookmark1.Id).SortOrder, int64(0))
assert.Equal(t, find_bookmark(bookmarks, bookmark2.Id).SortOrder, int64(1))
assert.Equal(t, find_bookmark(bookmarks, bookmark3.Id).SortOrder, int64(2))
assert.Equal(t, find_bookmark(bookmarks, bookmark4.Id).SortOrder, int64(3))
assert.Equal(t, find_bookmark(bookmarks, bookmark0.Id).SortOrder, int64(4))
})
t.Run("change order of bookmarks last to first", func(t *testing.T) {
bookmarks, sortErr := th.App.UpdateChannelBookmarkSortOrder(bookmark0.Id, channelId, int64(0), "")
require.Nil(t, sortErr)
require.NotNil(t, bookmarks)
assert.Equal(t, find_bookmark(bookmarks, bookmark0.Id).SortOrder, int64(0))
assert.Equal(t, find_bookmark(bookmarks, bookmark1.Id).SortOrder, int64(1))
assert.Equal(t, find_bookmark(bookmarks, bookmark2.Id).SortOrder, int64(2))
assert.Equal(t, find_bookmark(bookmarks, bookmark3.Id).SortOrder, int64(3))
assert.Equal(t, find_bookmark(bookmarks, bookmark4.Id).SortOrder, int64(4))
})
t.Run("change order of bookmarks first to third", func(t *testing.T) {
bookmarks, sortErr := th.App.UpdateChannelBookmarkSortOrder(bookmark0.Id, channelId, int64(2), "")
require.Nil(t, sortErr)
require.NotNil(t, bookmarks)
assert.Equal(t, find_bookmark(bookmarks, bookmark1.Id).SortOrder, int64(0))
assert.Equal(t, find_bookmark(bookmarks, bookmark2.Id).SortOrder, int64(1))
assert.Equal(t, find_bookmark(bookmarks, bookmark0.Id).SortOrder, int64(2))
assert.Equal(t, find_bookmark(bookmarks, bookmark3.Id).SortOrder, int64(3))
assert.Equal(t, find_bookmark(bookmarks, bookmark4.Id).SortOrder, int64(4))
// now reset order
_, appErr = th.App.UpdateChannelBookmarkSortOrder(bookmark0.Id, channelId, int64(0), "")
assert.Nil(t, appErr)
})
t.Run("change order of bookmarks second to third", func(t *testing.T) {
bookmarks, sortErr := th.App.UpdateChannelBookmarkSortOrder(bookmark1.Id, channelId, int64(2), "")
require.Nil(t, sortErr)
require.NotNil(t, bookmarks)
assert.Equal(t, find_bookmark(bookmarks, bookmark0.Id).SortOrder, int64(0))
assert.Equal(t, find_bookmark(bookmarks, bookmark2.Id).SortOrder, int64(1))
assert.Equal(t, find_bookmark(bookmarks, bookmark1.Id).SortOrder, int64(2))
assert.Equal(t, find_bookmark(bookmarks, bookmark3.Id).SortOrder, int64(3))
assert.Equal(t, find_bookmark(bookmarks, bookmark4.Id).SortOrder, int64(4))
})
t.Run("change order of bookmarks third to second", func(t *testing.T) {
bookmarks, sortErr := th.App.UpdateChannelBookmarkSortOrder(bookmark1.Id, channelId, int64(1), "")
require.Nil(t, sortErr)
require.NotNil(t, bookmarks)
assert.Equal(t, find_bookmark(bookmarks, bookmark0.Id).SortOrder, int64(0))
assert.Equal(t, find_bookmark(bookmarks, bookmark1.Id).SortOrder, int64(1))
assert.Equal(t, find_bookmark(bookmarks, bookmark2.Id).SortOrder, int64(2))
assert.Equal(t, find_bookmark(bookmarks, bookmark3.Id).SortOrder, int64(3))
assert.Equal(t, find_bookmark(bookmarks, bookmark4.Id).SortOrder, int64(4))
})
t.Run("change order of bookmarks last to previous last", func(t *testing.T) {
bookmarks, sortErr := th.App.UpdateChannelBookmarkSortOrder(bookmark4.Id, channelId, int64(3), "")
require.Nil(t, sortErr)
require.NotNil(t, bookmarks)
assert.Equal(t, find_bookmark(bookmarks, bookmark0.Id).SortOrder, int64(0))
assert.Equal(t, find_bookmark(bookmarks, bookmark1.Id).SortOrder, int64(1))
assert.Equal(t, find_bookmark(bookmarks, bookmark2.Id).SortOrder, int64(2))
assert.Equal(t, find_bookmark(bookmarks, bookmark4.Id).SortOrder, int64(3))
assert.Equal(t, find_bookmark(bookmarks, bookmark3.Id).SortOrder, int64(4))
})
t.Run("change order of bookmarks last to second", func(t *testing.T) {
bookmarks, sortErr := th.App.UpdateChannelBookmarkSortOrder(bookmark3.Id, channelId, int64(1), "")
require.Nil(t, sortErr)
require.NotNil(t, bookmarks)
assert.Equal(t, find_bookmark(bookmarks, bookmark0.Id).SortOrder, int64(0))
assert.Equal(t, find_bookmark(bookmarks, bookmark3.Id).SortOrder, int64(1))
assert.Equal(t, find_bookmark(bookmarks, bookmark1.Id).SortOrder, int64(2))
assert.Equal(t, find_bookmark(bookmarks, bookmark2.Id).SortOrder, int64(3))
assert.Equal(t, find_bookmark(bookmarks, bookmark4.Id).SortOrder, int64(4))
})
t.Run("change order of bookmarks error when new index is out of bounds", func(t *testing.T) {
_, appErr = th.App.UpdateChannelBookmarkSortOrder(bookmark3.Id, channelId, int64(-1), "")
assert.NotNil(t, appErr)
_, appErr = th.App.UpdateChannelBookmarkSortOrder(bookmark3.Id, channelId, int64(5), "")
assert.NotNil(t, appErr)
})
t.Run("change order of bookmarks error when bookmark not found", func(t *testing.T) {
_, appErr = th.App.UpdateChannelBookmarkSortOrder(model.NewId(), channelId, int64(0), "")
assert.NotNil(t, appErr)
})
}

View File

@@ -0,0 +1,383 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"errors"
"net/http"
"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"
)
func (a *App) createInitialSidebarCategories(rctx request.CTX, userID string, teamID string) (*model.OrderedSidebarCategories, *model.AppError) {
categories, nErr := a.Srv().Store().Channel().CreateInitialSidebarCategories(rctx, userID, teamID)
if nErr != nil {
return nil, model.NewAppError("createInitialSidebarCategories", "app.channel.create_initial_sidebar_categories.internal_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
return categories, nil
}
func (a *App) GetSidebarCategoriesForTeamForUser(rctx request.CTX, userID, teamID string) (*model.OrderedSidebarCategories, *model.AppError) {
var appErr *model.AppError
categories, err := a.Srv().Store().Channel().GetSidebarCategoriesForTeamForUser(userID, teamID)
if err == nil && len(categories.Categories) == 0 {
// A user must always have categories, so migration must not have happened yet, and we should run it ourselves
categories, appErr = a.createInitialSidebarCategories(rctx, userID, teamID)
if appErr != nil {
return nil, appErr
}
}
if err != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(err, &nfErr):
return nil, model.NewAppError("GetSidebarCategoriesForTeamForUser", "app.channel.sidebar_categories.app_error", nil, "", http.StatusNotFound).Wrap(err)
default:
return nil, model.NewAppError("GetSidebarCategoriesForTeamForUser", "app.channel.sidebar_categories.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
return categories, nil
}
func (a *App) GetSidebarCategories(rctx request.CTX, userID string, teamID string) (*model.OrderedSidebarCategories, *model.AppError) {
var appErr *model.AppError
categories, err := a.Srv().Store().Channel().GetSidebarCategories(userID, teamID)
if err == nil && len(categories.Categories) == 0 {
// A user must always have categories, so migration must not have happened yet, and we should run it ourselves
categories, appErr = a.createInitialSidebarCategories(rctx, userID, teamID)
if appErr != nil {
return nil, appErr
}
}
if err != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(err, &nfErr):
return nil, model.NewAppError("GetSidebarCategories", "app.channel.sidebar_categories.app_error", nil, "", http.StatusNotFound).Wrap(err)
default:
return nil, model.NewAppError("GetSidebarCategories", "app.channel.sidebar_categories.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
return categories, nil
}
func (a *App) GetSidebarCategoryOrder(rctx request.CTX, userID, teamID string) ([]string, *model.AppError) {
categories, err := a.Srv().Store().Channel().GetSidebarCategoryOrder(userID, teamID)
if err != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(err, &nfErr):
return nil, model.NewAppError("GetSidebarCategoryOrder", "app.channel.sidebar_categories.app_error", nil, "", http.StatusNotFound).Wrap(err)
default:
return nil, model.NewAppError("GetSidebarCategoryOrder", "app.channel.sidebar_categories.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
return categories, nil
}
func (a *App) GetSidebarCategory(rctx request.CTX, categoryId string) (*model.SidebarCategoryWithChannels, *model.AppError) {
category, err := a.Srv().Store().Channel().GetSidebarCategory(categoryId)
if err != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(err, &nfErr):
return nil, model.NewAppError("GetSidebarCategory", "app.channel.sidebar_categories.app_error", nil, "", http.StatusNotFound).Wrap(err)
default:
return nil, model.NewAppError("GetSidebarCategory", "app.channel.sidebar_categories.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
return category, nil
}
func (a *App) CreateSidebarCategory(rctx request.CTX, userID, teamID string, newCategory *model.SidebarCategoryWithChannels) (*model.SidebarCategoryWithChannels, *model.AppError) {
category, err := a.Srv().Store().Channel().CreateSidebarCategory(userID, teamID, newCategory)
if err != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(err, &nfErr):
return nil, model.NewAppError("CreateSidebarCategory", "app.channel.sidebar_categories.app_error", nil, "", http.StatusNotFound).Wrap(err)
default:
return nil, model.NewAppError("CreateSidebarCategory", "app.channel.sidebar_categories.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
message := model.NewWebSocketEvent(model.WebsocketEventSidebarCategoryCreated, teamID, "", userID, nil, "")
message.Add("category_id", category.Id)
a.Publish(message)
return category, nil
}
func (a *App) UpdateSidebarCategoryOrder(rctx request.CTX, userID, teamID string, categoryOrder []string) *model.AppError {
err := a.Srv().Store().Channel().UpdateSidebarCategoryOrder(userID, teamID, categoryOrder)
if err != nil {
var nfErr *store.ErrNotFound
var invErr *store.ErrInvalidInput
switch {
case errors.As(err, &nfErr):
return model.NewAppError("UpdateSidebarCategoryOrder", "app.channel.sidebar_categories.app_error", nil, "", http.StatusNotFound).Wrap(err)
case errors.As(err, &invErr):
return model.NewAppError("UpdateSidebarCategoryOrder", "app.channel.sidebar_categories.app_error", nil, "", http.StatusBadRequest).Wrap(err)
default:
return model.NewAppError("UpdateSidebarCategoryOrder", "app.channel.sidebar_categories.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
message := model.NewWebSocketEvent(model.WebsocketEventSidebarCategoryOrderUpdated, teamID, "", userID, nil, "")
message.Add("order", categoryOrder)
a.Publish(message)
return nil
}
func (a *App) UpdateSidebarCategories(rctx request.CTX, userID, teamID string, categories []*model.SidebarCategoryWithChannels) ([]*model.SidebarCategoryWithChannels, *model.AppError) {
updatedCategories, originalCategories, err := a.Srv().Store().Channel().UpdateSidebarCategories(userID, teamID, categories)
if err != nil {
return nil, model.NewAppError("UpdateSidebarCategories", "app.channel.sidebar_categories.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
message := model.NewWebSocketEvent(model.WebsocketEventSidebarCategoryUpdated, teamID, "", userID, nil, "")
updatedCategoriesJSON, jsonErr := json.Marshal(updatedCategories)
if jsonErr != nil {
return nil, model.NewAppError("UpdateSidebarCategories", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
}
message.Add("updatedCategories", string(updatedCategoriesJSON))
a.Publish(message)
a.muteChannelsForUpdatedCategories(rctx, userID, updatedCategories, originalCategories)
return updatedCategories, nil
}
func (a *App) muteChannelsForUpdatedCategories(rctx request.CTX, userID string, updatedCategories []*model.SidebarCategoryWithChannels, originalCategories []*model.SidebarCategoryWithChannels) {
var channelsToMute []string
var channelsToUnmute []string
// Mute or unmute all channels in categories that were muted or unmuted
for i, updatedCategory := range updatedCategories {
if i > len(originalCategories)-1 {
// The two slices should be the same length, but double check that to be safe
continue
}
originalCategory := originalCategories[i]
if updatedCategory.Muted && !originalCategory.Muted {
channelsToMute = append(channelsToMute, updatedCategory.Channels...)
} else if !updatedCategory.Muted && originalCategory.Muted {
channelsToUnmute = append(channelsToUnmute, updatedCategory.Channels...)
}
}
// Mute any channels moved from an unmuted category into a muted one and vice versa
channelsDiff := diffChannelsBetweenCategories(updatedCategories, originalCategories)
if len(channelsDiff) != 0 {
makeCategoryMap := func(categories []*model.SidebarCategoryWithChannels) map[string]*model.SidebarCategoryWithChannels {
result := make(map[string]*model.SidebarCategoryWithChannels)
for _, category := range categories {
result[category.Id] = category
}
return result
}
updatedCategoriesById := makeCategoryMap(updatedCategories)
originalCategoriesById := makeCategoryMap(originalCategories)
for channelID, diff := range channelsDiff {
fromCategory := originalCategoriesById[diff.fromCategoryId]
toCategory := updatedCategoriesById[diff.toCategoryId]
if toCategory.Muted && !fromCategory.Muted {
channelsToMute = append(channelsToMute, channelID)
} else if !toCategory.Muted && fromCategory.Muted {
channelsToUnmute = append(channelsToUnmute, channelID)
}
}
}
if len(channelsToMute) > 0 {
_, err := a.setChannelsMuted(rctx, channelsToMute, userID, true)
if err != nil {
rctx.Logger().Error(
"Failed to mute channels to match category",
mlog.String("user_id", userID),
mlog.Err(err),
)
}
}
if len(channelsToUnmute) > 0 {
_, err := a.setChannelsMuted(rctx, channelsToUnmute, userID, false)
if err != nil {
rctx.Logger().Error(
"Failed to unmute channels to match category",
mlog.String("user_id", userID),
mlog.Err(err),
)
}
}
}
type categoryChannelDiff struct {
fromCategoryId string
toCategoryId string
}
func diffChannelsBetweenCategories(updatedCategories []*model.SidebarCategoryWithChannels, originalCategories []*model.SidebarCategoryWithChannels) map[string]*categoryChannelDiff {
// mapChannelIdsToCategories returns a map of channel IDs to the IDs of the categories that they're a member of.
mapChannelIdsToCategories := func(categories []*model.SidebarCategoryWithChannels) map[string]string {
result := make(map[string]string)
for _, category := range categories {
for _, channelID := range category.Channels {
result[channelID] = category.Id
}
}
return result
}
updatedChannelIdsMap := mapChannelIdsToCategories(updatedCategories)
originalChannelIdsMap := mapChannelIdsToCategories(originalCategories)
// Check for any channels that have changed categories. Note that we don't worry about any channels that have moved
// outside of these categories since that heavily complicates things and doesn't currently happen in our apps.
channelsDiff := make(map[string]*categoryChannelDiff)
for channelID, originalCategoryId := range originalChannelIdsMap {
updatedCategoryId := updatedChannelIdsMap[channelID]
if originalCategoryId != updatedCategoryId && updatedCategoryId != "" {
channelsDiff[channelID] = &categoryChannelDiff{originalCategoryId, updatedCategoryId}
}
}
return channelsDiff
}
func (a *App) DeleteSidebarCategory(rctx request.CTX, userID, teamID, categoryId string) *model.AppError {
err := a.Srv().Store().Channel().DeleteSidebarCategory(categoryId)
if err != nil {
var invErr *store.ErrInvalidInput
switch {
case errors.As(err, &invErr):
return model.NewAppError("DeleteSidebarCategory", "app.channel.sidebar_categories.app_error", nil, "", http.StatusBadRequest).Wrap(err)
default:
return model.NewAppError("DeleteSidebarCategory", "app.channel.sidebar_categories.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
message := model.NewWebSocketEvent(model.WebsocketEventSidebarCategoryDeleted, teamID, "", userID, nil, "")
message.Add("category_id", categoryId)
a.Publish(message)
return nil
}
func (a *App) SetChannelManagedCategory(rctx request.CTX, channelID, categoryName string) *model.AppError {
categoryName = strings.TrimSpace(categoryName)
if categoryName == "" {
return model.NewAppError("SetChannelManagedCategory", "app.managed_category.empty_name.app_error", nil, "", http.StatusBadRequest)
}
channel, appErr := a.GetChannel(rctx, channelID)
if appErr != nil {
return appErr
}
if channel.Type == model.ChannelTypeDirect || channel.Type == model.ChannelTypeGroup {
return model.NewAppError("SetChannelManagedCategory", "app.managed_category.dm_gm_not_allowed.app_error", nil, "", http.StatusBadRequest)
}
nameJSON, _ := json.Marshal(categoryName)
value := &model.PropertyValue{
GroupID: a.Channels().managedCategoryGroupID,
FieldID: a.Channels().managedCategoryFieldID,
TargetID: channelID,
TargetType: model.PropertyValueTargetTypeChannel,
Value: nameJSON,
}
if _, appErr := a.UpsertPropertyValues(rctx, []*model.PropertyValue{value}, model.PropertyFieldObjectTypeChannel, channelID, ""); appErr != nil {
return model.NewAppError("SetChannelManagedCategory", "app.managed_category.set.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
return nil
}
func (a *App) ClearChannelManagedCategory(rctx request.CTX, channelID string) *model.AppError {
values, appErr := a.SearchPropertyValues(rctx, a.Channels().managedCategoryGroupID, model.PropertyValueSearchOpts{
FieldID: a.Channels().managedCategoryFieldID,
TargetIDs: []string{channelID},
PerPage: 1,
})
if appErr != nil {
return model.NewAppError("ClearChannelManagedCategory", "app.managed_category.search.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
if len(values) == 0 {
return nil
}
if appErr := a.DeletePropertyValue(rctx, a.Channels().managedCategoryGroupID, values[0].ID); appErr != nil {
return model.NewAppError("ClearChannelManagedCategory", "app.managed_category.clear.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
return nil
}
// GetVisibleManagedCategoryMappings returns a map of channelID -> categoryName for all channels
// the user is a member of (within the given team) that have a managed category assigned.
func (a *App) GetVisibleManagedCategoryMappings(rctx request.CTX, teamID string) (map[string]string, *model.AppError) {
channels, appErr := a.GetChannelsForTeamForUser(rctx, teamID, rctx.Session().UserId, &model.ChannelSearchOpts{})
if appErr != nil {
if appErr.StatusCode == http.StatusNotFound {
return map[string]string{}, nil
}
return nil, appErr
}
if len(channels) == 0 {
return map[string]string{}, nil
}
channelIDs := make([]string, 0, len(channels))
for _, ch := range channels {
channelIDs = append(channelIDs, ch.Id)
}
values, appErr := a.SearchPropertyValues(rctx, a.Channels().managedCategoryGroupID, model.PropertyValueSearchOpts{
FieldID: a.Channels().managedCategoryFieldID,
TargetIDs: channelIDs,
PerPage: len(channelIDs),
})
if appErr != nil {
return nil, model.NewAppError("GetVisibleManagedCategoryMappings", "app.managed_category.get_mappings.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
result := make(map[string]string, len(values))
for _, v := range values {
var name string
if err := json.Unmarshal(v.Value, &name); err != nil {
rctx.Logger().Warn("Failed to unmarshal managed category name",
mlog.String("channel_id", v.TargetID),
mlog.Err(err),
)
continue
}
result[v.TargetID] = name
}
return result, nil
}

View File

@@ -0,0 +1,792 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestSidebarCategory(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
basicChannel2 := th.CreateChannel(t, th.BasicTeam)
defer func() {
err := th.App.PermanentDeleteChannel(th.Context, basicChannel2)
require.Nil(t, err)
}()
user := th.CreateUser(t)
defer func() {
err := th.App.Srv().Store().User().PermanentDelete(th.Context, user.Id)
require.NoError(t, err)
}()
th.LinkUserToTeam(t, user, th.BasicTeam)
th.AddUserToChannel(t, user, basicChannel2)
var createdCategory *model.SidebarCategoryWithChannels
t.Run("CreateSidebarCategory", func(t *testing.T) {
catData := model.SidebarCategoryWithChannels{
SidebarCategory: model.SidebarCategory{
DisplayName: "TEST",
},
Channels: []string{th.BasicChannel.Id, basicChannel2.Id, basicChannel2.Id},
}
_, err := th.App.CreateSidebarCategory(th.Context, user.Id, th.BasicTeam.Id, &catData)
require.NotNil(t, err, "Should return error due to duplicate IDs")
catData.Channels = []string{th.BasicChannel.Id, basicChannel2.Id}
cat, err := th.App.CreateSidebarCategory(th.Context, user.Id, th.BasicTeam.Id, &catData)
require.Nil(t, err, "Expected no error")
require.NotNil(t, cat, "Expected category object, got nil")
createdCategory = cat
})
t.Run("UpdateSidebarCategories", func(t *testing.T) {
require.NotNil(t, createdCategory)
createdCategory.Channels = []string{th.BasicChannel.Id}
updatedCat, err := th.App.UpdateSidebarCategories(th.Context, user.Id, th.BasicTeam.Id, []*model.SidebarCategoryWithChannels{createdCategory})
require.Nil(t, err, "Expected no error")
require.NotNil(t, updatedCat, "Expected category object, got nil")
require.Len(t, updatedCat, 1)
require.Len(t, updatedCat[0].Channels, 1)
require.Equal(t, updatedCat[0].Channels[0], th.BasicChannel.Id)
})
t.Run("UpdateSidebarCategoryOrder", func(t *testing.T) {
err := th.App.UpdateSidebarCategoryOrder(th.Context, user.Id, th.BasicTeam.Id, []string{th.BasicChannel.Id, basicChannel2.Id})
require.NotNil(t, err, "Should return error due to invalid order")
actualOrder, err := th.App.GetSidebarCategoryOrder(th.Context, user.Id, th.BasicTeam.Id)
require.Nil(t, err, "Should fetch order successfully")
actualOrder[2], actualOrder[3] = actualOrder[3], actualOrder[2]
err = th.App.UpdateSidebarCategoryOrder(th.Context, user.Id, th.BasicTeam.Id, actualOrder)
require.Nil(t, err, "Should update order successfully")
// We create a copy of actualOrder to prevent racy read
// of the slice when the broadcast message is sent from webhub.
newOrder := make([]string, len(actualOrder))
copy(newOrder, actualOrder)
newOrder[2] = "asd"
err = th.App.UpdateSidebarCategoryOrder(th.Context, user.Id, th.BasicTeam.Id, newOrder)
require.NotNil(t, err, "Should return error due to invalid id")
})
t.Run("GetSidebarCategoryOrder", func(t *testing.T) {
catOrder, err := th.App.GetSidebarCategoryOrder(th.Context, user.Id, th.BasicTeam.Id)
require.Nil(t, err, "Expected no error")
require.Len(t, catOrder, 4)
require.Equal(t, catOrder[1], createdCategory.Id, "the newly created category should be after favorites")
})
}
func TestGetSidebarCategories(t *testing.T) {
mainHelper.Parallel(t)
t.Run("should return the sidebar categories for the given user/team", func(t *testing.T) {
th := Setup(t).InitBasic(t)
_, err := th.App.CreateSidebarCategory(th.Context, th.BasicUser.Id, th.BasicTeam.Id, &model.SidebarCategoryWithChannels{
SidebarCategory: model.SidebarCategory{
UserId: th.BasicUser.Id,
TeamId: th.BasicTeam.Id,
DisplayName: "new category",
},
})
require.Nil(t, err)
categories, err := th.App.GetSidebarCategoriesForTeamForUser(th.Context, th.BasicUser.Id, th.BasicTeam.Id)
assert.Nil(t, err)
assert.Len(t, categories.Categories, 4)
})
t.Run("should create the initial categories even if migration hasn't ran yet", func(t *testing.T) {
th := Setup(t).InitBasic(t)
// Manually add the user to the team without going through the app layer to simulate a pre-existing user/team
// relationship that hasn't been migrated yet
team := th.CreateTeam(t)
_, err := th.App.Srv().Store().Team().SaveMember(th.Context, &model.TeamMember{
TeamId: team.Id,
UserId: th.BasicUser.Id,
SchemeUser: true,
}, 100)
require.NoError(t, err)
categories, appErr := th.App.GetSidebarCategoriesForTeamForUser(th.Context, th.BasicUser.Id, team.Id)
assert.Nil(t, appErr)
assert.Len(t, categories.Categories, 3)
})
t.Run("should return a store error if a db table is missing", func(t *testing.T) {
th := Setup(t).InitBasic(t)
// Temporarily renaming a table to force a DB error.
_, err := th.SQLStore.GetMaster().Exec("ALTER TABLE SidebarCategories RENAME TO SidebarCategoriesTest")
require.NoError(t, err)
defer func() {
_, err := th.SQLStore.GetMaster().Exec("ALTER TABLE SidebarCategoriesTest RENAME TO SidebarCategories")
require.NoError(t, err)
}()
categories, appErr := th.App.GetSidebarCategoriesForTeamForUser(th.Context, th.BasicUser.Id, th.BasicTeam.Id)
assert.Nil(t, categories)
assert.NotNil(t, appErr)
assert.Equal(t, "app.channel.sidebar_categories.app_error", appErr.Id)
})
}
func TestUpdateSidebarCategories(t *testing.T) {
mainHelper.Parallel(t)
t.Run("should mute and unmute all channels in a category when it is muted or unmuted", func(t *testing.T) {
th := Setup(t).InitBasic(t)
categories, err := th.App.GetSidebarCategoriesForTeamForUser(th.Context, th.BasicUser.Id, th.BasicTeam.Id)
require.Nil(t, err)
channelsCategory := categories.Categories[1]
// Create some channels to be part of the channels category
channel1 := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, th.BasicUser, channel1)
channel2 := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, th.BasicUser, channel2)
// Mute the category
updated, err := th.App.UpdateSidebarCategories(th.Context, th.BasicUser.Id, th.BasicTeam.Id, []*model.SidebarCategoryWithChannels{
{
SidebarCategory: model.SidebarCategory{
Id: channelsCategory.Id,
Muted: true,
},
Channels: []string{channel1.Id, channel2.Id},
},
})
require.Nil(t, err)
assert.True(t, updated[0].Muted)
// Confirm that the channels are now muted
member1, err := th.App.GetChannelMember(th.Context, channel1.Id, th.BasicUser.Id)
require.Nil(t, err)
assert.True(t, member1.IsChannelMuted())
member2, err := th.App.GetChannelMember(th.Context, channel2.Id, th.BasicUser.Id)
require.Nil(t, err)
assert.True(t, member2.IsChannelMuted())
// Unmute the category
updated, err = th.App.UpdateSidebarCategories(th.Context, th.BasicUser.Id, th.BasicTeam.Id, []*model.SidebarCategoryWithChannels{
{
SidebarCategory: model.SidebarCategory{
Id: channelsCategory.Id,
Muted: false,
},
Channels: []string{channel1.Id, channel2.Id},
},
})
require.Nil(t, err)
assert.False(t, updated[0].Muted)
// Confirm that the channels are now unmuted
member1, err = th.App.GetChannelMember(th.Context, channel1.Id, th.BasicUser.Id)
require.Nil(t, err)
assert.False(t, member1.IsChannelMuted())
member2, err = th.App.GetChannelMember(th.Context, channel2.Id, th.BasicUser.Id)
require.Nil(t, err)
assert.False(t, member2.IsChannelMuted())
})
t.Run("should mute and unmute channels moved from an unmuted category to a muted one and back", func(t *testing.T) {
th := Setup(t).InitBasic(t)
// Create some channels
channel1 := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, th.BasicUser, channel1)
channel2 := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, th.BasicUser, channel2)
// And some categories
mutedCategory, err := th.App.CreateSidebarCategory(th.Context, th.BasicUser.Id, th.BasicTeam.Id, &model.SidebarCategoryWithChannels{
SidebarCategory: model.SidebarCategory{
DisplayName: "muted",
Muted: true,
},
})
require.Nil(t, err)
require.True(t, mutedCategory.Muted)
unmutedCategory, err := th.App.CreateSidebarCategory(th.Context, th.BasicUser.Id, th.BasicTeam.Id, &model.SidebarCategoryWithChannels{
SidebarCategory: model.SidebarCategory{
DisplayName: "unmuted",
Muted: false,
},
Channels: []string{channel1.Id, channel2.Id},
})
require.Nil(t, err)
require.False(t, unmutedCategory.Muted)
// Move the channels
_, err = th.App.UpdateSidebarCategories(th.Context, th.BasicUser.Id, th.BasicTeam.Id, []*model.SidebarCategoryWithChannels{
{
SidebarCategory: model.SidebarCategory{
Id: mutedCategory.Id,
DisplayName: mutedCategory.DisplayName,
Muted: mutedCategory.Muted,
},
Channels: []string{channel1.Id, channel2.Id},
},
{
SidebarCategory: model.SidebarCategory{
Id: unmutedCategory.Id,
DisplayName: unmutedCategory.DisplayName,
Muted: unmutedCategory.Muted,
},
Channels: []string{},
},
})
require.Nil(t, err)
// Confirm that the channels are now muted
member1, err := th.App.GetChannelMember(th.Context, channel1.Id, th.BasicUser.Id)
require.Nil(t, err)
assert.True(t, member1.IsChannelMuted())
member2, err := th.App.GetChannelMember(th.Context, channel2.Id, th.BasicUser.Id)
require.Nil(t, err)
assert.True(t, member2.IsChannelMuted())
// Move the channels back
_, err = th.App.UpdateSidebarCategories(th.Context, th.BasicUser.Id, th.BasicTeam.Id, []*model.SidebarCategoryWithChannels{
{
SidebarCategory: model.SidebarCategory{
Id: mutedCategory.Id,
DisplayName: mutedCategory.DisplayName,
Muted: mutedCategory.Muted,
},
Channels: []string{},
},
{
SidebarCategory: model.SidebarCategory{
Id: unmutedCategory.Id,
DisplayName: unmutedCategory.DisplayName,
Muted: unmutedCategory.Muted,
},
Channels: []string{channel1.Id, channel2.Id},
},
})
require.Nil(t, err)
// Confirm that the channels are now unmuted
member1, err = th.App.GetChannelMember(th.Context, channel1.Id, th.BasicUser.Id)
require.Nil(t, err)
assert.False(t, member1.IsChannelMuted())
member2, err = th.App.GetChannelMember(th.Context, channel2.Id, th.BasicUser.Id)
require.Nil(t, err)
assert.False(t, member2.IsChannelMuted())
})
t.Run("should not mute or unmute channels moved between muted categories", func(t *testing.T) {
th := Setup(t).InitBasic(t)
// Create some channels
channel1 := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, th.BasicUser, channel1)
channel2 := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, th.BasicUser, channel2)
// And some categories
category1, err := th.App.CreateSidebarCategory(th.Context, th.BasicUser.Id, th.BasicTeam.Id, &model.SidebarCategoryWithChannels{
SidebarCategory: model.SidebarCategory{
DisplayName: "category1",
Muted: true,
},
})
require.Nil(t, err)
require.True(t, category1.Muted)
category2, err := th.App.CreateSidebarCategory(th.Context, th.BasicUser.Id, th.BasicTeam.Id, &model.SidebarCategoryWithChannels{
SidebarCategory: model.SidebarCategory{
DisplayName: "category2",
Muted: true,
},
Channels: []string{channel1.Id, channel2.Id},
})
require.Nil(t, err)
require.True(t, category2.Muted)
// Move the unmuted channels
_, err = th.App.UpdateSidebarCategories(th.Context, th.BasicUser.Id, th.BasicTeam.Id, []*model.SidebarCategoryWithChannels{
{
SidebarCategory: model.SidebarCategory{
Id: category1.Id,
DisplayName: category1.DisplayName,
Muted: category1.Muted,
},
Channels: []string{channel1.Id, channel2.Id},
},
{
SidebarCategory: model.SidebarCategory{
Id: category2.Id,
DisplayName: category2.DisplayName,
Muted: category2.Muted,
},
Channels: []string{},
},
})
require.Nil(t, err)
// Confirm that the channels are still unmuted
member1, err := th.App.GetChannelMember(th.Context, channel1.Id, th.BasicUser.Id)
require.Nil(t, err)
assert.False(t, member1.IsChannelMuted())
member2, err := th.App.GetChannelMember(th.Context, channel2.Id, th.BasicUser.Id)
require.Nil(t, err)
assert.False(t, member2.IsChannelMuted())
// Mute the channels manually
_, err = th.App.ToggleMuteChannel(th.Context, channel1.Id, th.BasicUser.Id)
require.Nil(t, err)
_, err = th.App.ToggleMuteChannel(th.Context, channel2.Id, th.BasicUser.Id)
require.Nil(t, err)
// Move the muted channels back
_, err = th.App.UpdateSidebarCategories(th.Context, th.BasicUser.Id, th.BasicTeam.Id, []*model.SidebarCategoryWithChannels{
{
SidebarCategory: model.SidebarCategory{
Id: category1.Id,
DisplayName: category1.DisplayName,
Muted: category1.Muted,
},
Channels: []string{},
},
{
SidebarCategory: model.SidebarCategory{
Id: category2.Id,
DisplayName: category2.DisplayName,
Muted: category2.Muted,
},
Channels: []string{channel1.Id, channel2.Id},
},
})
require.Nil(t, err)
// Confirm that the channels are still muted
member1, err = th.App.GetChannelMember(th.Context, channel1.Id, th.BasicUser.Id)
require.Nil(t, err)
assert.True(t, member1.IsChannelMuted())
member2, err = th.App.GetChannelMember(th.Context, channel2.Id, th.BasicUser.Id)
require.Nil(t, err)
assert.True(t, member2.IsChannelMuted())
})
t.Run("should not mute or unmute channels moved between unmuted categories", func(t *testing.T) {
th := Setup(t).InitBasic(t)
// Create some channels
channel1 := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, th.BasicUser, channel1)
channel2 := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, th.BasicUser, channel2)
// And some categories
category1, err := th.App.CreateSidebarCategory(th.Context, th.BasicUser.Id, th.BasicTeam.Id, &model.SidebarCategoryWithChannels{
SidebarCategory: model.SidebarCategory{
DisplayName: "category1",
Muted: false,
},
})
require.Nil(t, err)
require.False(t, category1.Muted)
category2, err := th.App.CreateSidebarCategory(th.Context, th.BasicUser.Id, th.BasicTeam.Id, &model.SidebarCategoryWithChannels{
SidebarCategory: model.SidebarCategory{
DisplayName: "category2",
Muted: false,
},
Channels: []string{channel1.Id, channel2.Id},
})
require.Nil(t, err)
require.False(t, category2.Muted)
// Move the unmuted channels
_, err = th.App.UpdateSidebarCategories(th.Context, th.BasicUser.Id, th.BasicTeam.Id, []*model.SidebarCategoryWithChannels{
{
SidebarCategory: model.SidebarCategory{
Id: category1.Id,
DisplayName: category1.DisplayName,
Muted: category1.Muted,
},
Channels: []string{channel1.Id, channel2.Id},
},
{
SidebarCategory: model.SidebarCategory{
Id: category2.Id,
DisplayName: category2.DisplayName,
Muted: category2.Muted,
},
Channels: []string{},
},
})
require.Nil(t, err)
// Confirm that the channels are still unmuted
member1, err := th.App.GetChannelMember(th.Context, channel1.Id, th.BasicUser.Id)
require.Nil(t, err)
assert.False(t, member1.IsChannelMuted())
member2, err := th.App.GetChannelMember(th.Context, channel2.Id, th.BasicUser.Id)
require.Nil(t, err)
assert.False(t, member2.IsChannelMuted())
// Mute the channels manually
_, err = th.App.ToggleMuteChannel(th.Context, channel1.Id, th.BasicUser.Id)
require.Nil(t, err)
_, err = th.App.ToggleMuteChannel(th.Context, channel2.Id, th.BasicUser.Id)
require.Nil(t, err)
// Move the muted channels back
_, err = th.App.UpdateSidebarCategories(th.Context, th.BasicUser.Id, th.BasicTeam.Id, []*model.SidebarCategoryWithChannels{
{
SidebarCategory: model.SidebarCategory{
Id: category1.Id,
DisplayName: category1.DisplayName,
Muted: category1.Muted,
},
Channels: []string{},
},
{
SidebarCategory: model.SidebarCategory{
Id: category2.Id,
DisplayName: category2.DisplayName,
Muted: category2.Muted,
},
Channels: []string{channel1.Id, channel2.Id},
},
})
require.Nil(t, err)
// Confirm that the channels are still muted
member1, err = th.App.GetChannelMember(th.Context, channel1.Id, th.BasicUser.Id)
require.Nil(t, err)
assert.True(t, member1.IsChannelMuted())
member2, err = th.App.GetChannelMember(th.Context, channel2.Id, th.BasicUser.Id)
require.Nil(t, err)
assert.True(t, member2.IsChannelMuted())
})
}
func TestSetChannelManagedCategory(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
session := &model.Session{UserId: th.BasicUser.Id, Props: model.StringMap{}}
rctx := th.Context.WithSession(session)
t.Run("should set a managed category on a public channel", func(t *testing.T) {
channel := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, th.BasicUser, channel)
err := th.App.SetChannelManagedCategory(rctx, channel.Id, "Operations")
require.Nil(t, err)
mappings, err := th.App.GetVisibleManagedCategoryMappings(rctx, th.BasicTeam.Id)
require.Nil(t, err)
assert.Equal(t, "Operations", mappings[channel.Id])
})
t.Run("should overwrite an existing managed category", func(t *testing.T) {
channel := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, th.BasicUser, channel)
err := th.App.SetChannelManagedCategory(rctx, channel.Id, "Alpha")
require.Nil(t, err)
err = th.App.SetChannelManagedCategory(rctx, channel.Id, "Bravo")
require.Nil(t, err)
mappings, err := th.App.GetVisibleManagedCategoryMappings(rctx, th.BasicTeam.Id)
require.Nil(t, err)
assert.Equal(t, "Bravo", mappings[channel.Id])
})
}
func TestClearChannelManagedCategory(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
session := &model.Session{UserId: th.BasicUser.Id, Props: model.StringMap{}}
rctx := th.Context.WithSession(session)
t.Run("should clear an existing managed category", func(t *testing.T) {
channel := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, th.BasicUser, channel)
err := th.App.SetChannelManagedCategory(rctx, channel.Id, "Operations")
require.Nil(t, err)
err = th.App.ClearChannelManagedCategory(rctx, channel.Id)
require.Nil(t, err)
mappings, err := th.App.GetVisibleManagedCategoryMappings(rctx, th.BasicTeam.Id)
require.Nil(t, err)
_, exists := mappings[channel.Id]
assert.False(t, exists, "expected channel.Id to be absent from mappings")
})
t.Run("should not error when clearing a channel with no managed category", func(t *testing.T) {
channel := th.CreateChannel(t, th.BasicTeam)
err := th.App.ClearChannelManagedCategory(rctx, channel.Id)
require.Nil(t, err)
})
}
func TestGetVisibleManagedCategoryMappings(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
session := &model.Session{UserId: th.BasicUser.Id, Props: model.StringMap{}}
rctx := th.Context.WithSession(session)
t.Run("should return empty map when no managed categories exist", func(t *testing.T) {
team := th.CreateTeam(t)
th.LinkUserToTeam(t, th.BasicUser, team)
mappings, err := th.App.GetVisibleManagedCategoryMappings(rctx, team.Id)
require.Nil(t, err)
assert.Empty(t, mappings)
})
t.Run("should return mappings only for channels the user is a member of", func(t *testing.T) {
team := th.CreateTeam(t)
th.LinkUserToTeam(t, th.BasicUser, team)
channel1 := th.CreateChannel(t, team)
otherUser := th.CreateUser(t)
th.LinkUserToTeam(t, otherUser, team)
otherSession := &model.Session{UserId: otherUser.Id, Props: model.StringMap{}}
otherCtx := th.Context.WithSession(otherSession)
channel2, appErr := th.App.CreateChannel(otherCtx, &model.Channel{
DisplayName: "other_channel",
Name: "other_" + model.NewId(),
Type: model.ChannelTypeOpen,
TeamId: team.Id,
CreatorId: otherUser.Id,
}, true)
require.Nil(t, appErr)
err := th.App.SetChannelManagedCategory(rctx, channel1.Id, "Alpha")
require.Nil(t, err)
err = th.App.SetChannelManagedCategory(otherCtx, channel2.Id, "Bravo")
require.Nil(t, err)
mappings, err := th.App.GetVisibleManagedCategoryMappings(rctx, team.Id)
require.Nil(t, err)
assert.Equal(t, "Alpha", mappings[channel1.Id])
_, hasCh2 := mappings[channel2.Id]
assert.False(t, hasCh2, "should not include channel user is not a member of")
})
t.Run("should return multiple category mappings", func(t *testing.T) {
team := th.CreateTeam(t)
th.LinkUserToTeam(t, th.BasicUser, team)
channel1 := th.CreateChannel(t, team)
th.AddUserToChannel(t, th.BasicUser, channel1)
channel2 := th.CreateChannel(t, team)
th.AddUserToChannel(t, th.BasicUser, channel2)
err := th.App.SetChannelManagedCategory(rctx, channel1.Id, "Ops")
require.Nil(t, err)
err = th.App.SetChannelManagedCategory(rctx, channel2.Id, "Intel")
require.Nil(t, err)
mappings, err := th.App.GetVisibleManagedCategoryMappings(rctx, team.Id)
require.Nil(t, err)
assert.Equal(t, "Ops", mappings[channel1.Id])
assert.Equal(t, "Intel", mappings[channel2.Id])
})
}
func TestDiffChannelsBetweenCategories(t *testing.T) {
mainHelper.Parallel(t)
t.Run("should return nothing when the categories contain identical channels", func(t *testing.T) {
originalCategories := []*model.SidebarCategoryWithChannels{
{
SidebarCategory: model.SidebarCategory{
Id: "category1",
DisplayName: "Category One",
},
Channels: []string{"channel1", "channel2", "channel3"},
},
{
SidebarCategory: model.SidebarCategory{
Id: "category2",
DisplayName: "Category Two",
},
Channels: []string{"channel4", "channel5"},
},
{
SidebarCategory: model.SidebarCategory{
Id: "category3",
DisplayName: "Category Three",
},
Channels: []string{},
},
}
updatedCategories := []*model.SidebarCategoryWithChannels{
{
SidebarCategory: model.SidebarCategory{
Id: "category1",
DisplayName: "Category Won",
},
Channels: []string{"channel1", "channel2", "channel3"},
},
{
SidebarCategory: model.SidebarCategory{
Id: "category2",
DisplayName: "Category Too",
},
Channels: []string{"channel4", "channel5"},
},
{
SidebarCategory: model.SidebarCategory{
Id: "category3",
DisplayName: "Category 🌲",
},
Channels: []string{},
},
}
channelsDiff := diffChannelsBetweenCategories(updatedCategories, originalCategories)
assert.Equal(t, map[string]*categoryChannelDiff{}, channelsDiff)
})
t.Run("should return nothing when the categories contain identical channels", func(t *testing.T) {
originalCategories := []*model.SidebarCategoryWithChannels{
{
SidebarCategory: model.SidebarCategory{
Id: "category1",
DisplayName: "Category One",
},
Channels: []string{"channel1", "channel2", "channel3"},
},
{
SidebarCategory: model.SidebarCategory{
Id: "category2",
DisplayName: "Category Two",
},
Channels: []string{"channel4", "channel5"},
},
{
SidebarCategory: model.SidebarCategory{
Id: "category3",
DisplayName: "Category Three",
},
Channels: []string{},
},
}
updatedCategories := []*model.SidebarCategoryWithChannels{
{
SidebarCategory: model.SidebarCategory{
Id: "category1",
DisplayName: "Category Won",
},
Channels: []string{},
},
{
SidebarCategory: model.SidebarCategory{
Id: "category2",
DisplayName: "Category Too",
},
Channels: []string{"channel5", "channel2"},
},
{
SidebarCategory: model.SidebarCategory{
Id: "category3",
DisplayName: "Category 🌲",
},
Channels: []string{"channel4", "channel1", "channel3"},
},
}
channelsDiff := diffChannelsBetweenCategories(updatedCategories, originalCategories)
assert.Equal(
t,
map[string]*categoryChannelDiff{
"channel1": {
fromCategoryId: "category1",
toCategoryId: "category3",
},
"channel2": {
fromCategoryId: "category1",
toCategoryId: "category2",
},
"channel3": {
fromCategoryId: "category1",
toCategoryId: "category3",
},
"channel4": {
fromCategoryId: "category2",
toCategoryId: "category3",
},
},
channelsDiff,
)
})
t.Run("should not return channels that are moved in our out of the categories implicitly", func(t *testing.T) {
// This case could change to actually return the channels in the future, but we don't need to handle it right now
originalCategories := []*model.SidebarCategoryWithChannels{
{
SidebarCategory: model.SidebarCategory{
Id: "category1",
DisplayName: "Category One",
},
Channels: []string{"channel1", "channel2"},
},
{
SidebarCategory: model.SidebarCategory{
Id: "category2",
DisplayName: "Category Two",
},
Channels: []string{"channel3"},
},
}
updatedCategories := []*model.SidebarCategoryWithChannels{
{
SidebarCategory: model.SidebarCategory{
Id: "category1",
DisplayName: "Category Won",
},
Channels: []string{"channel1", "channel3"},
},
{
SidebarCategory: model.SidebarCategory{
Id: "category2",
DisplayName: "Category Too",
},
Channels: []string{"channel4"},
},
}
channelsDiff := diffChannelsBetweenCategories(updatedCategories, originalCategories)
assert.Equal(
t,
map[string]*categoryChannelDiff{
"channel3": {
fromCategoryId: "category2",
toCategoryId: "category1",
},
},
channelsDiff,
)
})
}

View File

@@ -0,0 +1,384 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"context"
"sync"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
// channelVisibilityCacheKey is the per-request request.CTX value key used to
// memoise PDP membership decisions across N+1 channel filtering work in a
// single Browse Channels load.
type channelVisibilityCacheKey struct{}
type channelVisibilityCache struct {
mu sync.Mutex
decisions map[string]bool
}
func getChannelVisibilityCache(rctx request.CTX) *channelVisibilityCache {
if v := rctx.Context().Value(channelVisibilityCacheKey{}); v != nil {
if cache, ok := v.(*channelVisibilityCache); ok {
return cache
}
}
return nil
}
// withChannelVisibilityCache returns a request context that memoises PDP
// membership decisions across the visibility filter calls in a single request.
// It's safe to call this multiple times — only the outermost installation
// allocates a cache.
func withChannelVisibilityCache(rctx request.CTX) request.CTX {
if getChannelVisibilityCache(rctx) != nil {
return rctx
}
cache := &channelVisibilityCache{decisions: map[string]bool{}}
return rctx.WithContext(context.WithValue(rctx.Context(), channelVisibilityCacheKey{}, cache))
}
func (c *channelVisibilityCache) get(channelID string) (bool, bool) {
c.mu.Lock()
defer c.mu.Unlock()
v, ok := c.decisions[channelID]
return v, ok
}
func (c *channelVisibilityCache) set(channelID string, allow bool) {
c.mu.Lock()
defer c.mu.Unlock()
c.decisions[channelID] = allow
}
// FilterDiscoverableChannelsByPolicy removes from `channels` any
// policy-enforced private channel that the user fails to satisfy — the
// security-critical visibility invariant in plan §6c. Channels without an
// active policy are returned untouched. Callers that need the additional
// "non-member private must be discoverable" gate should use
// FilterChannelsForUserVisibility instead.
//
// Failure modes are fail-secure: a missing AccessControl service, a
// subject-build failure, or any PDP error drops the offending channel from
// the result so a non-qualifying user can never be inadvertently shown a
// gated channel. Decisions are cached per-request via the request.CTX value
// bag installed by withChannelVisibilityCache.
func (a *App) FilterDiscoverableChannelsByPolicy(rctx request.CTX, channels []*model.Channel, userID string) ([]*model.Channel, *model.AppError) {
if len(channels) == 0 {
return channels, nil
}
if !a.Config().FeatureFlags.DiscoverableChannels {
return channels, nil
}
rctx = withChannelVisibilityCache(rctx)
cache := getChannelVisibilityCache(rctx)
var (
user *model.User
userErr *model.AppError
userOnce sync.Once
filtered = make([]*model.Channel, 0, len(channels))
dropCount int
)
for _, channel := range channels {
if channel == nil {
continue
}
if !channel.PolicyEnforced || channel.Type != model.ChannelTypePrivate || !channel.Discoverable {
filtered = append(filtered, channel)
continue
}
if cached, ok := cache.get(channel.Id); ok {
if cached {
filtered = append(filtered, channel)
} else {
dropCount++
}
continue
}
userOnce.Do(func() {
user, userErr = a.GetUser(rctx, userID)
})
if userErr != nil {
return nil, userErr
}
// Guests are never permitted to see discoverable private channels.
if user.IsGuest() {
cache.set(channel.Id, false)
dropCount++
continue
}
decision, evalErr := a.evaluateChannelMembership(rctx, user, channel)
if evalErr != nil {
rctx.Logger().Warn("FilterDiscoverableChannelsByPolicy: PDP error, hiding channel (fail-secure)",
mlog.String("user_id", userID),
mlog.String("channel_id", channel.Id),
mlog.Err(evalErr),
)
cache.set(channel.Id, false)
dropCount++
continue
}
cache.set(channel.Id, decision)
if decision {
filtered = append(filtered, channel)
} else {
dropCount++
}
}
return filtered, nil
}
// FilterChannelsForUserVisibility wraps FilterDiscoverableChannelsByPolicy with
// the secondary invariant: a non-member private channel must be discoverable
// to be visible at all. The caller is expected to scope `channels` to results
// where the user is a non-member; member channels should not be passed
// through this filter (their visibility is governed by membership alone).
//
// In practice the search/autocomplete store paths return a mix of member and
// non-member rows; callers should pass the full list because the helper
// detects membership-implying fields. The current implementation only checks
// the discoverability gate (the SQL-level membership join already excluded
// unaffiliated channels).
func (a *App) FilterChannelsForUserVisibility(rctx request.CTX, channels []*model.Channel, userID string) ([]*model.Channel, *model.AppError) {
return a.FilterDiscoverableChannelsByPolicy(rctx, channels, userID)
}
// FilterChannelListForUserVisibility is the convenience overload for
// model.ChannelList callers (the standard list shape returned by app-layer
// search functions).
func (a *App) FilterChannelListForUserVisibility(rctx request.CTX, channels model.ChannelList, userID string) (model.ChannelList, *model.AppError) {
filtered, err := a.FilterChannelsForUserVisibility(rctx, channels, userID)
if err != nil {
return nil, err
}
return model.ChannelList(filtered), nil
}
// FilterChannelListWithTeamDataForUserVisibility filters the team-data list
// shape used by Autocomplete and SearchAllChannels. The function preserves
// the embedded TeamDisplayName / TeamName fields. Returns the post-filter
// total adjustment so paginated callers can shrink TotalCount alongside the
// trimmed result set.
func (a *App) FilterChannelListWithTeamDataForUserVisibility(rctx request.CTX, channels model.ChannelListWithTeamData, userID string) (model.ChannelListWithTeamData, int, *model.AppError) {
if len(channels) == 0 {
return channels, 0, nil
}
if !a.Config().FeatureFlags.DiscoverableChannels {
return channels, 0, nil
}
rctx = withChannelVisibilityCache(rctx)
cache := getChannelVisibilityCache(rctx)
var (
user *model.User
userErr *model.AppError
userOnce sync.Once
out = make(model.ChannelListWithTeamData, 0, len(channels))
dropped int
)
for i := range channels {
ch := channels[i]
if !ch.PolicyEnforced || ch.Type != model.ChannelTypePrivate || !ch.Discoverable {
out = append(out, ch)
continue
}
if cached, ok := cache.get(ch.Id); ok {
if cached {
out = append(out, ch)
} else {
dropped++
}
continue
}
userOnce.Do(func() {
user, userErr = a.GetUser(rctx, userID)
})
if userErr != nil {
return nil, 0, userErr
}
if user.IsGuest() {
cache.set(ch.Id, false)
dropped++
continue
}
decision, evalErr := a.evaluateChannelMembership(rctx, user, &ch.Channel)
if evalErr != nil {
rctx.Logger().Warn("FilterChannelListWithTeamDataForUserVisibility: PDP error, hiding channel (fail-secure)",
mlog.String("user_id", userID),
mlog.String("channel_id", ch.Id),
mlog.Err(evalErr),
)
cache.set(ch.Id, false)
dropped++
continue
}
cache.set(ch.Id, decision)
if decision {
out = append(out, ch)
} else {
dropped++
}
}
return out, dropped, nil
}
// IsDiscoverableJoinAllowed reports whether `user` may view `channel` as a
// non-member through the discoverable-channels surface. Returns 404 (mapped
// by callers) when the channel is hidden from this user — matching the
// "indistinguishable from a non-existent channel" requirement so the policy
// cannot act as an existence oracle.
func (a *App) IsDiscoverableJoinAllowed(rctx request.CTX, user *model.User, channel *model.Channel) (bool, *model.AppError) {
if channel == nil {
return false, nil
}
if channel.Type != model.ChannelTypePrivate || !channel.Discoverable {
return false, nil
}
if user == nil || user.IsGuest() || user.DeleteAt != 0 {
return false, nil
}
if channel.DeleteAt != 0 || channel.IsShared() {
return false, nil
}
if !channel.PolicyEnforced {
return true, nil
}
decision, evalErr := a.evaluateChannelMembership(rctx, user, channel)
if evalErr != nil {
// Fail-secure: PDP failure hides the channel rather than leak it.
rctx.Logger().Warn("IsDiscoverableJoinAllowed: PDP error, hiding channel (fail-secure)",
mlog.String("user_id", user.Id),
mlog.String("channel_id", channel.Id),
mlog.Err(evalErr),
)
return false, nil
}
return decision, nil
}
// CancelPendingChannelJoinRequestsOnConvert transitions every pending request
// for a channel to the withdrawn state — used when the channel is converted
// to public (open channels are inherently joinable, so a pending queue is
// nonsensical) and when the channel is archived. Failures are logged because
// the conversion / archive must not be blocked.
func (a *App) CancelPendingChannelJoinRequestsOnConvert(rctx request.CTX, channel *model.Channel) {
if channel == nil {
return
}
const (
pageSize = 200
maxIterations = 50 // hard cap at ~10k requests per channel
)
for range maxIterations {
opts := model.GetChannelJoinRequestsOpts{
Status: model.ChannelJoinRequestStatusPending,
Page: 0,
PerPage: pageSize,
}
rows, _, err := a.Srv().Store().ChannelJoinRequest().GetForChannel(channel.Id, opts)
if err != nil {
rctx.Logger().Warn("CancelPendingChannelJoinRequestsOnConvert: failed to list pending requests",
mlog.String("channel_id", channel.Id),
mlog.Err(err),
)
return
}
if len(rows) == 0 {
return
}
failed := 0
for _, row := range rows {
row.Status = model.ChannelJoinRequestStatusWithdrawn
row.Message = ""
updated, updateErr := a.Srv().Store().ChannelJoinRequest().Update(row)
if updateErr != nil {
failed++
rctx.Logger().Warn("CancelPendingChannelJoinRequestsOnConvert: failed to withdraw pending request",
mlog.String("channel_id", channel.Id),
mlog.String("request_id", row.Id),
mlog.Err(updateErr),
)
continue
}
a.broadcastChannelJoinRequestUpdated(rctx, channel, updated)
}
// If every row in the batch failed to update, the next iteration
// would re-fetch the same rows and loop forever. Break out and
// surface the situation in the log — the operator can re-run the
// cleanup manually after addressing the underlying store error.
if failed == len(rows) {
rctx.Logger().Warn("CancelPendingChannelJoinRequestsOnConvert: every row in batch failed to update, aborting to avoid infinite loop",
mlog.String("channel_id", channel.Id),
mlog.Int("failed", failed),
)
return
}
// Standard exit when the last page is partial: every remaining
// pending row was successfully withdrawn (or logged as failed).
if len(rows) < pageSize {
return
}
}
// maxIterations safety net — this should be effectively unreachable
// because the per-batch all-failed check above already aborts on
// systemic update failures. Fire a higher-severity log if we hit it.
rctx.Logger().Error("CancelPendingChannelJoinRequestsOnConvert: hit maxIterations, aborting",
mlog.String("channel_id", channel.Id),
mlog.Int("max_iterations", maxIterations),
)
}
// IsDiscoverableSelfAddBlocked reports whether a user trying to self-add to
// `channel` via POST /channels/{id}/members must instead go through the
// request flow. The block applies only when:
// - the channel is private,
// - it is discoverable but does NOT have an active ABAC policy
// (channels with a policy use the existing PDP gate inside
// addUserToChannel — admins can still add others by policy),
// - the user is not yet a member,
// - and the requester is the user themselves.
//
// Other paths (admin invites, API by reviewer ID) are unaffected: the request
// flow exists to give admins a queue, not to block invites.
func (a *App) IsDiscoverableSelfAddBlocked(rctx request.CTX, channel *model.Channel, requesterUserID, targetUserID string) bool {
if channel == nil || channel.Type != model.ChannelTypePrivate {
return false
}
if !channel.Discoverable {
return false
}
if channel.PolicyEnforced {
return false
}
if requesterUserID != targetUserID {
return false
}
if !a.Config().FeatureFlags.DiscoverableChannels {
return false
}
return true
}

View File

@@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestDiscoverableVisibilityInvariant_NonGuestSeesNoPolicy verifies that a
// discoverable + no-policy private channel is returned through the
// non-member autocomplete path for a non-guest user.
//
// The complementary policy-enforced + non-qualifying user case is covered
// by TestFilterDiscoverableChannelsByPolicy_PolicyEnforcedFailSecure (which
// checks the fail-secure path) and the dedicated guest case is in
// TestFilterDiscoverableChannelsByPolicy_GuestHidden.
func TestDiscoverableVisibilityInvariant_NonGuestSeesNoPolicy(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
// BasicUser2 is a member of the team but NOT of `channel`. The
// autocomplete query must still surface the channel because of the
// discoverable OR-branch (post-query ABAC filter is a no-op since the
// channel has no policy).
results, appErr := th.App.AutocompleteChannelsForTeam(th.Context, th.BasicTeam.Id, th.BasicUser2.Id, channel.Name)
require.Nil(t, appErr)
found := false
for _, c := range results {
if c.Id == channel.Id {
found = true
break
}
}
assert.True(t, found, "discoverable + no-policy private channel must appear in autocomplete for a non-member non-guest")
}
// TestDiscoverableVisibilityInvariant_NonDiscoverableHidden ensures that the
// store-level OR-branch we added does not inadvertently leak private
// channels with discoverable=false to non-members. The new OR clause must be
// gated on `Discoverable=true`.
func TestDiscoverableVisibilityInvariant_NonDiscoverableHidden(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
plain := th.CreatePrivateChannel(t, th.BasicTeam)
results, appErr := th.App.AutocompleteChannelsForTeam(th.Context, th.BasicTeam.Id, th.BasicUser2.Id, plain.Name)
require.Nil(t, appErr)
for _, c := range results {
assert.NotEqual(t, plain.Id, c.Id, "non-discoverable private channel must remain hidden from non-members")
}
}
// TestDiscoverableVisibilityInvariant_GuestHidden re-verifies the guest path
// at the autocomplete level (the unit-level guest case lives in
// TestFilterDiscoverableChannelsByPolicy_GuestHidden, but this test exercises
// the full app+store integration so we don't accidentally rely on the
// in-memory filter alone).
func TestDiscoverableVisibilityInvariant_GuestHidden(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
guest := th.CreateGuest(t)
th.LinkUserToTeam(t, guest, th.BasicTeam)
results, appErr := th.App.AutocompleteChannelsForTeam(th.Context, th.BasicTeam.Id, guest.Id, channel.Name)
require.Nil(t, appErr)
for _, c := range results {
assert.NotEqual(t, channel.Id, c.Id, "guests must never see discoverable private channels in autocomplete")
}
}

View File

@@ -0,0 +1,216 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"net/http"
"sync"
"time"
"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"
)
// Backoff bounds for the guard-cache reload retry. Package vars (not consts) so tests can shrink
// them via t.Cleanup-restored override.
var (
guardCacheRetryInitialDelay = 1 * time.Second
guardCacheRetryMaxDelay = 5 * time.Minute
)
const clusterEventInvalidateChannelGuardCache = model.ClusterEvent("inv_channel_guards")
// reloadGuardCache scans the ChannelGuards table and atomically replaces the in-memory cache with
// the result. Used both at startup (from NewChannels) and from the cluster invalidation handler.
// Forces a master read because all callers (post-write reload, cluster invalidation) can race with
// replica lag.
func (ch *Channels) reloadGuardCache(rctx request.CTX, s store.Store) error {
guards, err := s.ChannelGuard().GetAll(store.RequestContextWithMaster(rctx))
if err != nil {
return err
}
fresh := &sync.Map{}
grouped := map[string][]*store.ChannelGuard{}
for _, g := range guards {
grouped[g.ChannelId] = append(grouped[g.ChannelId], g)
}
for channelID, slice := range grouped {
fresh.Store(channelID, slice)
}
ch.guardCache.Store(fresh)
return nil
}
// getGuardsForChannel returns the cached guard slice for a channel, or nil if none.
func (ch *Channels) getGuardsForChannel(channelID string) []*store.ChannelGuard {
m := ch.guardCache.Load()
if m == nil {
return nil
}
v, ok := m.Load(channelID)
if !ok {
return nil
}
guards, _ := v.([]*store.ChannelGuard)
return guards
}
// clusterInvalidateGuardCacheHandler is registered as the receive-side handler for
// clusterEventInvalidateChannelGuardCache. The handler refetches the entire table.
func (ch *Channels) clusterInvalidateGuardCacheHandler(msg *model.ClusterMessage) {
rctx := request.EmptyContext(ch.srv.Log())
if err := ch.reloadGuardCache(rctx, ch.srv.Store()); err != nil {
ch.srv.Log().Warn(
"Failed to reload channel guard cache after cluster invalidation; retry scheduled",
mlog.String("event", string(msg.Event)),
mlog.Err(err),
)
ch.scheduleGuardCacheReloadRetry()
}
}
// broadcastChannelGuardInvalidation tells the rest of the cluster to refetch their guard caches.
// The payload is intentionally empty.
func (ch *Channels) broadcastChannelGuardInvalidation() {
cluster := ch.srv.platform.Cluster()
if cluster == nil {
return
}
msg := &model.ClusterMessage{
Event: clusterEventInvalidateChannelGuardCache,
SendType: model.ClusterSendReliable,
WaitForAllToSend: true,
}
cluster.SendClusterMessage(msg)
}
// RegisterChannelGuard records that pluginID claims channelID. The caller's pluginID is expected to
// be lowercased.
func (a *App) RegisterChannelGuard(rctx request.CTX, channelID, pluginID string) *model.AppError {
if channelID == "" {
return model.NewAppError("RegisterChannelGuard", "app.channel_guard.register.empty_channel.app_error", nil, "", http.StatusBadRequest)
}
if !model.IsValidId(channelID) {
return model.NewAppError("RegisterChannelGuard", "app.channel_guard.invalid_channel.app_error", nil, "", http.StatusBadRequest)
}
guard := &store.ChannelGuard{
ChannelId: channelID,
PluginId: pluginID,
CreatedAt: model.GetMillis(),
}
if err := a.Srv().Store().ChannelGuard().Save(rctx, guard); err != nil {
return model.NewAppError("RegisterChannelGuard", "app.channel_guard.register.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
}
ch := a.Channels()
if err := ch.reloadGuardCache(rctx, a.Srv().Store()); err != nil {
a.Srv().Log().Warn(
"Failed to reload channel guard cache after Register; retry scheduled",
mlog.String("channel_id", channelID),
mlog.String("plugin_id", pluginID),
mlog.Err(err),
)
ch.scheduleGuardCacheReloadRetry()
}
ch.broadcastChannelGuardInvalidation()
return nil
}
// UnregisterChannelGuard removes pluginID's claim on channelID. If pluginID has no claim on the
// channel, this is a no-op (returns nil). The store-level DELETE matches by both ChannelId and
// PluginId, so other plugins' claims on the same channel are left untouched.
func (a *App) UnregisterChannelGuard(rctx request.CTX, channelID, pluginID string) *model.AppError {
if channelID == "" {
return model.NewAppError("UnregisterChannelGuard", "app.channel_guard.unregister.empty_channel.app_error", nil, "", http.StatusBadRequest)
}
if !model.IsValidId(channelID) {
return model.NewAppError("UnregisterChannelGuard", "app.channel_guard.invalid_channel.app_error", nil, "", http.StatusBadRequest)
}
rowsAffected, err := a.Srv().Store().ChannelGuard().Delete(rctx, channelID, pluginID)
if err != nil {
return model.NewAppError("UnregisterChannelGuard", "app.channel_guard.unregister.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
}
if rowsAffected == 0 {
a.Srv().Log().Warn(
"UnregisterChannelGuard removed no rows; pluginID does not match any guard for this channel",
mlog.String("error_id", "unregister_no_matching_guard"),
mlog.String("channel_id", channelID),
mlog.String("plugin_id", pluginID),
)
}
ch := a.Channels()
if err := ch.reloadGuardCache(rctx, a.Srv().Store()); err != nil {
a.Srv().Log().Warn(
"Failed to reload channel guard cache after Unregister; retry scheduled",
mlog.String("channel_id", channelID),
mlog.String("plugin_id", pluginID),
mlog.Err(err),
)
ch.scheduleGuardCacheReloadRetry()
}
ch.broadcastChannelGuardInvalidation()
return nil
}
// scheduleGuardCacheReloadRetry kicks off a single in-flight retry goroutine that calls
// reloadGuardCache with exponential backoff until success or until the server is shutting down.
// Multiple concurrent calls collapse to a single retry — useful when Register, Unregister, the
// cluster handler, and the startup loader can all see the same DB outage simultaneously.
//
// Returns true if a new retry goroutine was scheduled, false if one was already in flight. Call
// sites can ignore the return value; tests use it to assert single-flight semantics.
func (ch *Channels) scheduleGuardCacheReloadRetry() bool {
if !ch.guardCacheRetryInFlight.CompareAndSwap(false, true) {
return false
}
go ch.runGuardCacheReloadRetry()
return true
}
func (ch *Channels) runGuardCacheReloadRetry() {
defer ch.guardCacheRetryInFlight.Store(false)
rctx := request.EmptyContext(ch.srv.Log())
delay := guardCacheRetryInitialDelay
for attempt := 1; ; attempt++ {
timer := time.NewTimer(delay)
select {
case <-ch.interruptQuitChan:
timer.Stop()
ch.srv.Log().Info(
"Channel guard cache reload retry cancelled by shutdown",
mlog.Int("attempt", attempt),
)
return
case <-timer.C:
}
if err := ch.reloadGuardCache(rctx, ch.srv.Store()); err != nil {
ch.srv.Log().Info(
"Channel guard cache reload retry attempt failed; will retry",
mlog.Int("attempt", attempt),
mlog.Err(err),
)
delay *= 2
if delay > guardCacheRetryMaxDelay {
delay = guardCacheRetryMaxDelay
}
continue
}
ch.srv.Log().Info(
"Channel guard cache reload retry succeeded",
mlog.Int("attempt", attempt),
)
return
}
}

View File

@@ -0,0 +1,383 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"sync"
"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/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/einterfaces"
)
// captureClusterMock records every SendClusterMessage call made during a test
// so the test can assert what was broadcast.
type captureClusterMock struct {
mu sync.Mutex
captured []*model.ClusterMessage
}
func (c *captureClusterMock) SendClusterMessage(msg *model.ClusterMessage) {
c.mu.Lock()
defer c.mu.Unlock()
c.captured = append(c.captured, msg)
}
func (c *captureClusterMock) SendClusterMessageToNode(nodeID string, msg *model.ClusterMessage) error {
return nil
}
func (c *captureClusterMock) snapshot() []*model.ClusterMessage {
c.mu.Lock()
defer c.mu.Unlock()
out := make([]*model.ClusterMessage, len(c.captured))
copy(out, c.captured)
return out
}
// reset drops everything captured so far. TestHelper setup produces many unrelated
// cluster messages, and some setup work can finish asynchronously, so callers
// must still filter for the event produced by the code under test.
func (c *captureClusterMock) reset() {
c.mu.Lock()
defer c.mu.Unlock()
c.captured = nil
}
func (c *captureClusterMock) StartInterNodeCommunication() {}
func (c *captureClusterMock) StopInterNodeCommunication() {}
func (c *captureClusterMock) RegisterClusterMessageHandler(event model.ClusterEvent, crm einterfaces.ClusterMessageHandler) {
}
func (c *captureClusterMock) GetClusterId() string { return "capture_cluster_mock" }
func (c *captureClusterMock) IsLeader() bool { return false }
func (c *captureClusterMock) GetMyClusterInfo() *model.ClusterInfo { return nil }
func (c *captureClusterMock) GetClusterInfos() ([]*model.ClusterInfo, error) { return nil, nil }
func (c *captureClusterMock) NotifyMsg(buf []byte) {}
func (c *captureClusterMock) GetClusterStats(rctx request.CTX) ([]*model.ClusterStats, *model.AppError) {
return nil, nil
}
func (c *captureClusterMock) GetLogs(rctx request.CTX, page, perPage int) ([]string, *model.AppError) {
return nil, nil
}
func (c *captureClusterMock) QueryLogs(rctx request.CTX, page, perPage int) (map[string][]string, *model.AppError) {
return nil, nil
}
func (c *captureClusterMock) GenerateSupportPacket(rctx request.CTX, options *model.SupportPacketOptions) (map[string][]model.FileData, error) {
return nil, nil
}
func (c *captureClusterMock) GetPluginStatuses() (model.PluginStatuses, *model.AppError) {
return nil, nil
}
func (c *captureClusterMock) ConfigChanged(previousConfig *model.Config, newConfig *model.Config, sendToOtherServer bool) *model.AppError {
return nil
}
func (c *captureClusterMock) HealthScore() int { return 0 }
func (c *captureClusterMock) WebConnCountForUser(userID string) (int, *model.AppError) {
return 0, nil
}
func (c *captureClusterMock) GetWSQueues(userID, connectionID string, seqNum int64) (map[string]*model.WSQueues, error) {
return nil, nil
}
func (c *captureClusterMock) Shutdown() {}
func TestChannelGuardCacheBroadcastShape(t *testing.T) {
mainHelper.Parallel(t)
cluster := &captureClusterMock{}
th := SetupWithClusterMock(t, cluster)
cluster.reset() // drop init-time noise; only inspect messages from code under test
th.App.Channels().broadcastChannelGuardInvalidation()
guardEvents := filterGuardCacheEvents(cluster.snapshot())
require.Len(t, guardEvents, 1, "broadcast must produce exactly one guard-cache invalidation")
msg := guardEvents[0]
assert.Equal(t, clusterEventInvalidateChannelGuardCache, msg.Event)
assert.Equal(t, model.ClusterSendReliable, msg.SendType)
assert.Empty(t, msg.Data, "broadcast payload should be empty (D9: receiver does a full reload)")
assert.True(t, msg.WaitForAllToSend, "guard invalidation must wait for cluster ack (matches access_control precedent)")
}
func TestChannelGuardRegisterTriggersBroadcast(t *testing.T) {
mainHelper.Parallel(t)
cluster := &captureClusterMock{}
th := SetupWithClusterMock(t, cluster)
cluster.reset() // drop init-time noise; only inspect messages from code under test
channelID := model.NewId()
pluginID := "com.example.register-broadcast"
rctx := request.EmptyContext(th.App.Srv().Log())
require.Nil(t, th.App.RegisterChannelGuard(rctx, channelID, pluginID))
guardEvents := filterGuardCacheEvents(cluster.snapshot())
require.Len(t, guardEvents, 1, "Register must produce exactly one guard-cache invalidation")
}
func filterGuardCacheEvents(msgs []*model.ClusterMessage) []*model.ClusterMessage {
out := []*model.ClusterMessage{}
for _, m := range msgs {
if m.Event == clusterEventInvalidateChannelGuardCache {
out = append(out, m)
}
}
return out
}
func TestChannelGuardUnregisterTriggersBroadcast(t *testing.T) {
mainHelper.Parallel(t)
cluster := &captureClusterMock{}
th := SetupWithClusterMock(t, cluster)
channelID := model.NewId()
pluginID := "com.example.unregister-broadcast"
rctx := request.EmptyContext(th.App.Srv().Log())
// Register first (this also broadcasts), then drop captured noise so we
// only see the Unregister-side broadcast.
require.Nil(t, th.App.RegisterChannelGuard(rctx, channelID, pluginID))
cluster.reset()
require.Nil(t, th.App.UnregisterChannelGuard(rctx, channelID, pluginID))
guardEvents := filterGuardCacheEvents(cluster.snapshot())
require.Len(t, guardEvents, 1, "Unregister must produce exactly one guard-cache invalidation")
}
func TestChannelGuardCacheMultiChannelRefetch(t *testing.T) {
mainHelper.Parallel(t)
cluster := &captureClusterMock{}
th := SetupWithClusterMock(t, cluster)
channelA := model.NewId()
channelB := model.NewId()
pluginA := "com.example.multi-a"
pluginB := "com.example.multi-b"
rctx := request.EmptyContext(th.App.Srv().Log())
require.NoError(t, th.App.Srv().Store().ChannelGuard().Save(rctx, &store.ChannelGuard{ChannelId: channelA, PluginId: pluginA, CreatedAt: 1}))
require.NoError(t, th.App.Srv().Store().ChannelGuard().Save(rctx, &store.ChannelGuard{ChannelId: channelA, PluginId: pluginB, CreatedAt: 2}))
require.NoError(t, th.App.Srv().Store().ChannelGuard().Save(rctx, &store.ChannelGuard{ChannelId: channelB, PluginId: pluginA, CreatedAt: 3}))
// Force the cache to be empty (simulate a node that just started or had its cache cleared).
th.App.Channels().guardCache.Store(&sync.Map{})
th.App.Channels().clusterInvalidateGuardCacheHandler(&model.ClusterMessage{
Event: clusterEventInvalidateChannelGuardCache,
})
gotA := th.App.Channels().getGuardsForChannel(channelA)
gotB := th.App.Channels().getGuardsForChannel(channelB)
assert.Len(t, gotA, 2, "channel A should have two claims after refetch")
assert.Len(t, gotB, 1, "channel B should have one claim after refetch")
}
// TestChannelGuardRegisterUnregisterNilClusterIsSafe verifies that the
// App-level Register/Unregister methods don't panic when Cluster() is nil.
// They reach broadcastChannelGuardInvalidation, so this also covers the nil
// guard inside that helper.
func TestChannelGuardRegisterUnregisterNilClusterIsSafe(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
require.Nil(t, th.App.Srv().platform.Cluster(), "expected nil cluster in a single-node test setup")
channelID := th.BasicChannel.Id
pluginID := "com.example.nil-cluster-rt"
rctx := request.EmptyContext(th.App.Srv().Log())
require.Nil(t, th.App.RegisterChannelGuard(rctx, channelID, pluginID))
got := th.App.Channels().getGuardsForChannel(channelID)
require.Len(t, got, 1)
assert.Equal(t, pluginID, got[0].PluginId)
require.Nil(t, th.App.UnregisterChannelGuard(rctx, channelID, pluginID))
assert.Empty(t, th.App.Channels().getGuardsForChannel(channelID))
}
func TestChannelGuardLowercaseNormalization(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
channelID := th.BasicChannel.Id
mixedCaseID := "MixedCase.Plugin.ID"
expectedID := "mixedcase.plugin.id"
// Build a PluginAPI directly with a mixed-case manifest. This bypasses the
// real plugin activation path (which we don't need for the lowercasing
// check) and exercises only the api.id -> App.RegisterChannelGuard handoff.
rctx := request.EmptyContext(th.App.Srv().Log())
api := &PluginAPI{
id: mixedCaseID,
app: th.App,
ctx: rctx,
}
require.Nil(t, api.RegisterChannelGuard(channelID))
guards, err := th.App.Srv().Store().ChannelGuard().GetForChannel(rctx, channelID)
require.NoError(t, err)
require.Len(t, guards, 1)
assert.Equal(t, expectedID, guards[0].PluginId, "PluginId must be normalized to lowercase before reaching the store")
require.Nil(t, api.UnregisterChannelGuard(channelID))
guards, err = th.App.Srv().Store().ChannelGuard().GetForChannel(rctx, channelID)
require.NoError(t, err)
assert.Empty(t, guards, "Unregister with the same mixed-case id must hit the lowercased row")
}
func TestChannelGuardEmptyChannelIDRejected(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
rctx := request.EmptyContext(th.App.Srv().Log())
appErr := th.App.RegisterChannelGuard(rctx, "", "com.example.plugin")
require.NotNil(t, appErr)
assert.Equal(t, "app.channel_guard.register.empty_channel.app_error", appErr.Id)
assert.Equal(t, 400, appErr.StatusCode)
appErr = th.App.UnregisterChannelGuard(rctx, "", "com.example.plugin")
require.NotNil(t, appErr)
assert.Equal(t, "app.channel_guard.unregister.empty_channel.app_error", appErr.Id)
assert.Equal(t, 400, appErr.StatusCode)
}
// TestUnregisterChannelGuardWarnsOnNoMatchingRow verifies that calling UnregisterChannelGuard with
// a pluginID that has no claim on the channel returns nil (no error) and leaves the existing guard
// row untouched. The Warn log emitted when rowsAffected==0 is operator-facing and is not asserted
// here; the behavioral contract (nil return + row unchanged) is the check.
func TestUnregisterChannelGuardWarnsOnNoMatchingRow(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
channelID := th.BasicChannel.Id
pluginA := "com.example.plugin-a"
pluginB := "com.example.plugin-b"
rctx := request.EmptyContext(th.App.Srv().Log())
// Register pluginA's guard on the channel.
require.Nil(t, th.App.RegisterChannelGuard(rctx, channelID, pluginA))
// Unregister with a different pluginID — must return nil (no-op).
appErr := th.App.UnregisterChannelGuard(rctx, channelID, pluginB)
require.Nil(t, appErr, "cross-plugin Unregister must return nil")
// pluginA's guard row must be untouched.
guards, err := th.App.Srv().Store().ChannelGuard().GetForChannel(rctx, channelID)
require.NoError(t, err)
require.Len(t, guards, 1, "pluginA guard row must remain after cross-plugin Unregister")
assert.Equal(t, pluginA, guards[0].PluginId)
}
// failingGuardStore wraps a real ChannelGuardStore but forces GetAll to error,
// so tests can exercise reload-failure branches deterministically.
type failingGuardStore struct {
store.ChannelGuardStore
err error
}
func (f *failingGuardStore) GetAll(rctx request.CTX) ([]*store.ChannelGuard, error) {
return nil, f.err
}
// guardFailingStoreWrapper decorates a real Store, swapping ChannelGuard() for
// a failing implementation. All other store calls pass through to the embedded
// Store so the rest of the app stays functional.
type guardFailingStoreWrapper struct {
store.Store
failing *failingGuardStore
}
func (w *guardFailingStoreWrapper) ChannelGuard() store.ChannelGuardStore {
return w.failing
}
func TestChannelGuardCacheClusterInvalidationHandlesStoreFailure(t *testing.T) {
// No t.Parallel(): mutates package-level guardCacheRetryInitialDelay.
originalInitial := guardCacheRetryInitialDelay
guardCacheRetryInitialDelay = 30 * time.Second
t.Cleanup(func() { guardCacheRetryInitialDelay = originalInitial })
th := Setup(t)
ch := th.App.Channels()
// Pre-populate the cache with a known row by writing through the real store
// then doing a successful reload.
channelID := model.NewId()
pluginID := "com.example.cluster-fail-test"
rctx := request.EmptyContext(th.App.Srv().Log())
require.NoError(t, th.App.Srv().Store().ChannelGuard().Save(rctx, &store.ChannelGuard{
ChannelId: channelID,
PluginId: pluginID,
CreatedAt: 1,
}))
require.NoError(t, ch.reloadGuardCache(rctx, th.App.Srv().Store()))
require.Len(t, ch.getGuardsForChannel(channelID), 1, "precondition: cache should hold the seeded row")
// Swap in a wrapped store that fails on GetAll.
originalStore := th.App.Srv().Store()
wrapped := &guardFailingStoreWrapper{
Store: originalStore,
failing: &failingGuardStore{ChannelGuardStore: originalStore.ChannelGuard(), err: assert.AnError},
}
th.App.Srv().SetStore(wrapped)
t.Cleanup(func() { th.App.Srv().SetStore(originalStore) })
// Sanity: confirm the wrapped store actually fails, otherwise the test is meaningless.
_, err := th.App.Srv().Store().ChannelGuard().GetAll(rctx)
require.Error(t, err, "test wrapper must surface GetAll failure")
// Calling the handler with a failing store must:
// - not panic
// - leave the existing cache untouched
// - schedule a retry (atomic.Bool flips to true)
require.NotPanics(t, func() {
ch.clusterInvalidateGuardCacheHandler(&model.ClusterMessage{
Event: clusterEventInvalidateChannelGuardCache,
})
})
assert.Len(t, ch.getGuardsForChannel(channelID), 1, "cache must be unchanged when reload fails")
assert.True(t, ch.guardCacheRetryInFlight.Load(), "failed reload from cluster handler must schedule a retry")
}
// TestScheduleGuardCacheReloadRetrySingleFlight verifies that concurrent calls to
// scheduleGuardCacheReloadRetry collapse to a single in-flight retry goroutine. The retry goroutine
// is parked in its initial timer wait by shrinking nothing — instead we override the initial delay
// to a very long value so the test window stays inside the timer wait, then verify the second call
// returns false (no new goroutine scheduled). Test cleanup tears down the server which closes
// interruptQuitChan and lets the parked goroutine exit cleanly. No t.Parallel() because it mutates
// a package-level var.
func TestScheduleGuardCacheReloadRetrySingleFlight(t *testing.T) {
originalInitial := guardCacheRetryInitialDelay
guardCacheRetryInitialDelay = 30 * time.Second
t.Cleanup(func() { guardCacheRetryInitialDelay = originalInitial })
th := Setup(t)
ch := th.App.Channels()
require.True(t, ch.scheduleGuardCacheReloadRetry(), "first call should schedule a retry")
require.False(t, ch.scheduleGuardCacheReloadRetry(), "second call should be a no-op while one is in flight")
require.False(t, ch.scheduleGuardCacheReloadRetry(), "additional concurrent calls should also be no-ops")
}
func TestChannelGuardInvalidChannelIDRejected(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
rctx := request.EmptyContext(th.App.Srv().Log())
appErr := th.App.RegisterChannelGuard(rctx, "not-a-real-id", "com.example.plugin")
require.NotNil(t, appErr)
assert.Equal(t, "app.channel_guard.invalid_channel.app_error", appErr.Id)
assert.Equal(t, 400, appErr.StatusCode)
appErr = th.App.UnregisterChannelGuard(rctx, "not-a-real-id", "com.example.plugin")
require.NotNil(t, appErr)
assert.Equal(t, "app.channel_guard.invalid_channel.app_error", appErr.Id)
assert.Equal(t, 400, appErr.StatusCode)
}

View File

@@ -0,0 +1,447 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"errors"
"net/http"
"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"
)
// channelJoinRequestPaginationDefaultPerPage matches the public /api/v4 default
// for paginated endpoints.
const channelJoinRequestPaginationDefaultPerPage = 60
// channelJoinRequestPaginationMaxPerPage caps a single page's size; mirrors the
// 200 cap shared by other public list endpoints.
const channelJoinRequestPaginationMaxPerPage = 200
// requestJoinChannelGuard validates that a user is allowed to express interest
// in joining `channel` and returns a sanitized result for `channel`. Callers
// are expected to look up `channel` via the store before calling this helper.
func (a *App) requestJoinChannelGuard(rctx request.CTX, user *model.User, channel *model.Channel) *model.AppError {
if channel == nil {
return model.NewAppError("RequestJoinChannel", "app.channel.get.existing.app_error", nil, "", http.StatusNotFound)
}
if channel.DeleteAt != 0 {
return model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.archived.app_error", nil, "channel_id="+channel.Id, http.StatusBadRequest)
}
if channel.Type != model.ChannelTypePrivate {
return model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.not_private.app_error", nil, "channel_id="+channel.Id, http.StatusBadRequest)
}
if !channel.Discoverable {
return model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.not_discoverable.app_error", nil, "channel_id="+channel.Id, http.StatusForbidden)
}
// Shared channels join through their own remote-cluster sync mechanism.
if channel.IsShared() {
return model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.shared.app_error", nil, "channel_id="+channel.Id, http.StatusBadRequest)
}
if user.IsGuest() {
return model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.guest.app_error", nil, "user_id="+user.Id, http.StatusForbidden)
}
if user.DeleteAt != 0 {
return model.NewAppError("RequestJoinChannel", "app.channel.add_member.deleted_user.app_error", nil, "", http.StatusForbidden)
}
return nil
}
// RequestJoinChannel decides between an immediate ABAC-gated auto-join and an
// asynchronous request-to-join row.
//
// Returns the persisted ChannelJoinRequest when the user must wait for an
// admin review, or nil when the user was added directly to the channel (the
// caller can detect this via the `joined` return value).
func (a *App) RequestJoinChannel(rctx request.CTX, userID, channelID, message string) (joined bool, req *model.ChannelJoinRequest, appErr *model.AppError) {
user, appErr := a.GetUser(rctx, userID)
if appErr != nil {
return false, nil, appErr
}
channel, appErr := a.GetChannel(rctx, channelID)
if appErr != nil {
return false, nil, appErr
}
if guardErr := a.requestJoinChannelGuard(rctx, user, channel); guardErr != nil {
return false, nil, guardErr
}
_, memberErr := a.Srv().Store().Channel().GetMember(rctx, channel.Id, user.Id)
if memberErr == nil {
return false, nil, model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.already_member.app_error", nil, "channel_id="+channel.Id, http.StatusBadRequest)
}
var nfErr *store.ErrNotFound
if !errors.As(memberErr, &nfErr) {
return false, nil, model.NewAppError("RequestJoinChannel", "app.channel.get_member.app_error", nil, "", http.StatusInternalServerError).Wrap(memberErr)
}
enforced, appErr := a.ChannelAccessControlled(rctx, channel.Id)
if appErr != nil {
return false, nil, appErr
}
// ABAC gate: when an active policy is attached and the user qualifies, add
// the member directly. AddChannelMember re-runs the PDP gate inside
// addUserToChannel, so a denial here is authoritative; a non-allow result
// falls through to the request-row path below ONLY when there is no policy.
if enforced {
decision, evalErr := a.evaluateChannelMembership(rctx, user, channel)
if evalErr != nil {
return false, nil, evalErr
}
if !decision {
return false, nil, model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.policy_denied.app_error", nil, "channel_id="+channel.Id, http.StatusForbidden)
}
if _, err := a.AddChannelMember(rctx, user.Id, channel, ChannelMemberOpts{UserRequestorID: user.Id}); err != nil {
return false, nil, err
}
return true, nil, nil
}
pending := &model.ChannelJoinRequest{
ChannelId: channel.Id,
UserId: user.Id,
Message: message,
}
saved, err := a.Srv().Store().ChannelJoinRequest().Save(pending)
if err != nil {
var conflict *store.ErrConflict
if errors.As(err, &conflict) {
existing, getErr := a.Srv().Store().ChannelJoinRequest().GetPendingForChannelAndUser(channel.Id, user.Id)
if getErr == nil {
return false, existing, nil
}
return false, nil, model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.duplicate.app_error", nil, "channel_id="+channel.Id, http.StatusConflict)
}
if appErr, ok := err.(*model.AppError); ok {
return false, nil, appErr
}
return false, nil, model.NewAppError("RequestJoinChannel", "app.channel.join_request.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
a.broadcastChannelJoinRequestCreated(rctx, channel, saved)
return false, saved, nil
}
// WithdrawChannelJoinRequest flips a pending request the calling user owns to
// the withdrawn state. Non-owners receive a 404 (no oracle on existence) and
// already-terminal rows return 409.
func (a *App) WithdrawChannelJoinRequest(rctx request.CTX, requestID, userID string) (*model.ChannelJoinRequest, *model.AppError) {
current, err := a.Srv().Store().ChannelJoinRequest().Get(requestID)
if err != nil {
var nfErr *store.ErrNotFound
if errors.As(err, &nfErr) {
return nil, model.NewAppError("WithdrawChannelJoinRequest", "app.channel.join_request.not_found.app_error", nil, "request_id="+requestID, http.StatusNotFound)
}
return nil, model.NewAppError("WithdrawChannelJoinRequest", "app.channel.join_request.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if current.UserId != userID {
// Hide the row from non-owners by returning the same not-found
// response. The reviewer flow uses different endpoints.
return nil, model.NewAppError("WithdrawChannelJoinRequest", "app.channel.join_request.not_found.app_error", nil, "request_id="+requestID, http.StatusNotFound)
}
if current.Status != model.ChannelJoinRequestStatusPending {
return nil, model.NewAppError("WithdrawChannelJoinRequest", "api.channel.discoverable_join_request.not_pending.app_error", nil, "request_id="+requestID, http.StatusConflict)
}
current.Status = model.ChannelJoinRequestStatusWithdrawn
current.Message = ""
updated, err := a.Srv().Store().ChannelJoinRequest().Update(current)
if err != nil {
if appErr, ok := err.(*model.AppError); ok {
return nil, appErr
}
return nil, model.NewAppError("WithdrawChannelJoinRequest", "app.channel.join_request.update.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
channel, channelErr := a.GetChannel(rctx, updated.ChannelId)
if channelErr != nil {
// Channel went away mid-flight — still report the update; we just
// can't broadcast to the admin queue.
rctx.Logger().Warn("WithdrawChannelJoinRequest: failed to load channel for broadcast", mlog.String("channel_id", updated.ChannelId), mlog.Err(channelErr))
return updated, nil
}
a.broadcastChannelJoinRequestUpdated(rctx, channel, updated)
return updated, nil
}
// GetMyChannelJoinRequest returns the calling user's active pending request for
// `channelID`, or nil if none exists. It never returns an error for a missing
// row — that's the non-pending state and is expected.
func (a *App) GetMyChannelJoinRequest(rctx request.CTX, userID, channelID string) (*model.ChannelJoinRequest, *model.AppError) {
req, err := a.Srv().Store().ChannelJoinRequest().GetPendingForChannelAndUser(channelID, userID)
if err != nil {
var nfErr *store.ErrNotFound
if errors.As(err, &nfErr) {
return nil, nil
}
return nil, model.NewAppError("GetMyChannelJoinRequest", "app.channel.join_request.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return req, nil
}
// GetMyChannelJoinRequests lists the calling user's join requests across all
// channels. The "My Pending Requests" tab filters by `Status="pending"` (the
// default when opts.Status is empty).
func (a *App) GetMyChannelJoinRequests(rctx request.CTX, userID string, opts model.GetChannelJoinRequestsOpts) (*model.ChannelJoinRequestList, *model.AppError) {
opts = sanitizeJoinRequestListOpts(opts)
rows, total, err := a.Srv().Store().ChannelJoinRequest().GetForUser(userID, opts)
if err != nil {
return nil, model.NewAppError("GetMyChannelJoinRequests", "app.channel.join_request.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return &model.ChannelJoinRequestList{Requests: rows, TotalCount: total}, nil
}
// GetChannelJoinRequests lists the join requests targeting `channelID` for the
// admin queue UI. The visibility check is performed by the API layer via the
// PermissionManageChannelJoinRequests permission.
func (a *App) GetChannelJoinRequests(rctx request.CTX, channelID string, opts model.GetChannelJoinRequestsOpts) (*model.ChannelJoinRequestList, *model.AppError) {
opts = sanitizeJoinRequestListOpts(opts)
rows, total, err := a.Srv().Store().ChannelJoinRequest().GetForChannel(channelID, opts)
if err != nil {
return nil, model.NewAppError("GetChannelJoinRequests", "app.channel.join_request.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return &model.ChannelJoinRequestList{Requests: rows, TotalCount: total}, nil
}
// CountPendingChannelJoinRequests returns the number of pending join requests
// for `channelID`, used by the channel-header badge.
func (a *App) CountPendingChannelJoinRequests(rctx request.CTX, channelID string) (int64, *model.AppError) {
count, err := a.Srv().Store().ChannelJoinRequest().CountPending(channelID)
if err != nil {
return 0, model.NewAppError("CountPendingChannelJoinRequests", "app.channel.join_request.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return count, nil
}
// UpdateChannelJoinRequest applies an admin review (approve / deny) to a
// pending request. When approving, the user is added via AddChannelMember so
// the existing PDP gate inside addUserToChannel re-runs — admins cannot bypass
// an active ABAC policy. The store row is only updated after a successful add
// to keep the audit trail consistent.
func (a *App) UpdateChannelJoinRequest(rctx request.CTX, requestID, channelID string, patch *model.ChannelJoinRequestPatch, reviewerID string) (*model.ChannelJoinRequest, *model.AppError) {
if patch == nil {
return nil, model.NewAppError("UpdateChannelJoinRequest", "api.channel.discoverable_join_request.invalid_patch.app_error", nil, "", http.StatusBadRequest)
}
switch patch.Status {
case model.ChannelJoinRequestStatusApproved, model.ChannelJoinRequestStatusDenied:
default:
return nil, model.NewAppError("UpdateChannelJoinRequest", "api.channel.discoverable_join_request.invalid_patch.app_error", nil, "status="+patch.Status, http.StatusBadRequest)
}
current, err := a.Srv().Store().ChannelJoinRequest().Get(requestID)
if err != nil {
var nfErr *store.ErrNotFound
if errors.As(err, &nfErr) {
return nil, model.NewAppError("UpdateChannelJoinRequest", "app.channel.join_request.not_found.app_error", nil, "request_id="+requestID, http.StatusNotFound)
}
return nil, model.NewAppError("UpdateChannelJoinRequest", "app.channel.join_request.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Defense in depth: refuse cross-channel updates so a forged request id
// can't be reviewed against a channel the admin happens to own.
if current.ChannelId != channelID {
return nil, model.NewAppError("UpdateChannelJoinRequest", "app.channel.join_request.not_found.app_error", nil, "request_id="+requestID, http.StatusNotFound)
}
if current.Status != model.ChannelJoinRequestStatusPending {
return nil, model.NewAppError("UpdateChannelJoinRequest", "api.channel.discoverable_join_request.not_pending.app_error", nil, "request_id="+requestID, http.StatusConflict)
}
channel, appErr := a.GetChannel(rctx, current.ChannelId)
if appErr != nil {
return nil, appErr
}
if patch.Status == model.ChannelJoinRequestStatusApproved {
if _, err := a.AddChannelMember(rctx, current.UserId, channel, ChannelMemberOpts{UserRequestorID: reviewerID}); err != nil {
return nil, err
}
}
current.Status = patch.Status
current.ReviewedBy = reviewerID
current.ReviewedAt = model.GetMillis()
current.DenialReason = ""
if patch.Status == model.ChannelJoinRequestStatusDenied && patch.DenialReason != nil {
current.DenialReason = *patch.DenialReason
}
// Drop the original message from the response; it served its purpose
// during review and keeping it would leak free-text into the audit trail.
current.Message = ""
updated, err := a.Srv().Store().ChannelJoinRequest().Update(current)
if err != nil {
if appErr, ok := err.(*model.AppError); ok {
return nil, appErr
}
return nil, model.NewAppError("UpdateChannelJoinRequest", "app.channel.join_request.update.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
a.broadcastChannelJoinRequestUpdated(rctx, channel, updated)
return updated, nil
}
// sanitizeJoinRequestListOpts clamps user-provided pagination + status options
// so the store sees a normalized request.
func sanitizeJoinRequestListOpts(opts model.GetChannelJoinRequestsOpts) model.GetChannelJoinRequestsOpts {
if opts.Status == "" {
opts.Status = model.ChannelJoinRequestStatusPending
} else if !model.IsValidChannelJoinRequestStatus(opts.Status) {
opts.Status = model.ChannelJoinRequestStatusPending
}
if opts.Page < 0 {
opts.Page = 0
}
if opts.PerPage <= 0 {
opts.PerPage = channelJoinRequestPaginationDefaultPerPage
} else if opts.PerPage > channelJoinRequestPaginationMaxPerPage {
opts.PerPage = channelJoinRequestPaginationMaxPerPage
}
return opts
}
// evaluateChannelMembership runs the access-control PDP for `user` against the
// `membership` action on `channel`, returning the boolean decision. Errors
// from the PDP are returned to callers so they can choose between the
// "channel is invisible" (visibility filter) or "channel cannot be joined"
// (request flow) fail-secure semantics. Callers must have already verified
// that `channel.PolicyEnforced` is true before invoking the PDP.
func (a *App) evaluateChannelMembership(rctx request.CTX, user *model.User, channel *model.Channel) (bool, *model.AppError) {
acs := a.Srv().Channels().AccessControl
if acs == nil {
// No ABAC service → fail-secure. The channel acts as if the user did
// not satisfy the policy.
return false, nil
}
subject, appErr := a.BuildAccessControlSubject(rctx, user.Id, user.Roles, channel.Id)
if appErr != nil {
return false, appErr
}
decision, evalErr := acs.AccessEvaluation(rctx, model.AccessRequest{
Subject: *subject,
Resource: model.Resource{
Type: model.AccessControlPolicyTypeChannel,
ID: channel.Id,
},
Action: "membership",
})
if evalErr != nil {
return false, evalErr
}
return decision.Decision, nil
}
// channelAdminUserIDs returns the user ids of channel members with the
// scheme-admin role on `channelID`. Used to scope WS broadcasts of join-request
// events to the queue audience. Failures bubble up because broadcasting to no
// one would silently break the admin UI.
func (a *App) channelAdminUserIDs(rctx request.CTX, channelID string) ([]string, *model.AppError) {
const channelMembersPageSize = 200
admins := []string{}
page := 0
for {
members, err := a.Srv().Store().Channel().GetMembers(model.ChannelMembersGetOptions{
ChannelID: channelID,
Offset: page * channelMembersPageSize,
Limit: channelMembersPageSize,
})
if err != nil {
return nil, model.NewAppError("channelAdminUserIDs", "app.channel.get_members.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
for _, m := range members {
if m.SchemeAdmin {
admins = append(admins, m.UserId)
}
}
if len(members) < channelMembersPageSize {
break
}
page++
}
return admins, nil
}
// broadcastChannelJoinRequestCreated fires a channel_join_request_created event
// scoped to the channel admin set, using the OnlyChannelAdmins broadcast hook
// to filter out non-admin members the channel-id broadcast would otherwise
// reach.
func (a *App) broadcastChannelJoinRequestCreated(rctx request.CTX, channel *model.Channel, req *model.ChannelJoinRequest) {
a.publishChannelJoinRequestEvent(rctx, channel, req, model.WebsocketEventChannelJoinRequestCreated, true /* adminsOnly */)
}
// broadcastChannelJoinRequestUpdated fires a channel_join_request_updated event
// to the channel admin set + the requesting user (so their My Pending Requests
// list reacts in real-time).
func (a *App) broadcastChannelJoinRequestUpdated(rctx request.CTX, channel *model.Channel, req *model.ChannelJoinRequest) {
// Send a dedicated copy to the requester so an offline-but-then-reconnected
// requester gets their own row update even when they are not a channel
// member yet (the channel-id broadcast wouldn't reach them otherwise).
if req.UserId != "" {
userMessage := model.NewWebSocketEvent(model.WebsocketEventChannelJoinRequestUpdated, "", "", req.UserId, nil, "")
userMessage.Add("request", marshalChannelJoinRequest(rctx, req))
userMessage.Add("channel_id", channel.Id)
a.Publish(userMessage)
}
a.publishChannelJoinRequestEvent(rctx, channel, req, model.WebsocketEventChannelJoinRequestUpdated, true /* adminsOnly */)
}
func (a *App) publishChannelJoinRequestEvent(rctx request.CTX, channel *model.Channel, req *model.ChannelJoinRequest, event model.WebsocketEventType, adminsOnly bool) {
message := model.NewWebSocketEvent(event, "", channel.Id, "", nil, "")
message.Add("request", marshalChannelJoinRequest(rctx, req))
message.Add("channel_id", channel.Id)
if adminsOnly {
admins, appErr := a.channelAdminUserIDs(rctx, channel.Id)
if appErr != nil {
rctx.Logger().Warn("Failed to compute channel admin set for join request broadcast",
mlog.String("channel_id", channel.Id),
mlog.Err(appErr),
)
return
}
useOnlyChannelAdminsHook(message, admins)
}
a.Publish(message)
}
// marshalChannelJoinRequest returns the request as a JSON string for the WS
// payload. JSON encoding errors are logged and the payload is delivered as an
// empty string so the event still arrives (clients can tolerate a missing
// request body and refetch).
func marshalChannelJoinRequest(rctx request.CTX, req *model.ChannelJoinRequest) string {
if req == nil {
return ""
}
buf, err := json.Marshal(req)
if err != nil {
rctx.Logger().Warn("Failed to marshal ChannelJoinRequest for WS broadcast",
mlog.String("request_id", req.Id),
mlog.Err(err),
)
return ""
}
return string(buf)
}

View File

@@ -0,0 +1,379 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
// withDiscoverableChannelsFlag toggles the FeatureFlag for the duration of a
// test and restores it on cleanup. Feature flags are read-only by default in
// the test config store; flipping SetReadOnlyFF lets the UpdateConfig call
// land. We deliberately do NOT restore SetReadOnlyFF(true) afterward — the
// underlying store is per-test and disposed on cleanup.
func withDiscoverableChannelsFlag(t *testing.T, th *TestHelper, on bool) {
t.Helper()
th.ConfigStore.SetReadOnlyFF(false)
previous := th.App.Config().FeatureFlags.DiscoverableChannels
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.DiscoverableChannels = on })
t.Cleanup(func() {
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.DiscoverableChannels = previous })
})
}
// markDiscoverable flips the channel's discoverable flag in the store via
// PatchChannel so the model invariants run alongside the test scenario.
func markDiscoverable(t *testing.T, th *TestHelper, channel *model.Channel) *model.Channel {
t.Helper()
on := true
patched, err := th.App.PatchChannel(th.Context, channel, &model.ChannelPatch{Discoverable: &on}, th.BasicUser.Id)
require.Nil(t, err)
require.True(t, patched.Discoverable)
return patched
}
func TestRequestJoinChannel_RejectsNonDiscoverable(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := th.CreatePrivateChannel(t, th.BasicTeam)
other := th.CreateUser(t)
th.LinkUserToTeam(t, other, th.BasicTeam)
joined, req, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channel.Id, "please")
require.NotNil(t, appErr)
assert.Equal(t, http.StatusForbidden, appErr.StatusCode)
assert.Equal(t, "api.channel.discoverable_join_request.not_discoverable.app_error", appErr.Id)
assert.False(t, joined)
assert.Nil(t, req)
}
func TestRequestJoinChannel_RejectsExistingMember(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := th.CreatePrivateChannel(t, th.BasicTeam)
channel = markDiscoverable(t, th, channel)
// BasicUser is the channel creator → already a member.
_, _, appErr := th.App.RequestJoinChannel(th.Context, th.BasicUser.Id, channel.Id, "")
require.NotNil(t, appErr)
assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
assert.Equal(t, "api.channel.discoverable_join_request.already_member.app_error", appErr.Id)
}
func TestRequestJoinChannel_PendingHappyPath(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := th.CreatePrivateChannel(t, th.BasicTeam)
channel = markDiscoverable(t, th, channel)
other := th.CreateUser(t)
th.LinkUserToTeam(t, other, th.BasicTeam)
joined, req, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channel.Id, "let me in")
require.Nil(t, appErr)
assert.False(t, joined, "should not auto-join when no policy is enforced")
require.NotNil(t, req)
assert.Equal(t, model.ChannelJoinRequestStatusPending, req.Status)
assert.Equal(t, channel.Id, req.ChannelId)
assert.Equal(t, other.Id, req.UserId)
assert.Equal(t, "let me in", req.Message)
// Submitting again returns the existing pending row (idempotent on
// partial-unique conflict).
joined, req2, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channel.Id, "again")
require.Nil(t, appErr)
assert.False(t, joined)
require.NotNil(t, req2)
assert.Equal(t, req.Id, req2.Id)
}
func TestRequestJoinChannel_RejectsGuest(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := th.CreatePrivateChannel(t, th.BasicTeam)
channel = markDiscoverable(t, th, channel)
guest := th.CreateGuest(t)
th.LinkUserToTeam(t, guest, th.BasicTeam)
_, _, appErr := th.App.RequestJoinChannel(th.Context, guest.Id, channel.Id, "")
require.NotNil(t, appErr)
assert.Equal(t, http.StatusForbidden, appErr.StatusCode)
assert.Equal(t, "api.channel.discoverable_join_request.guest.app_error", appErr.Id)
}
func TestUpdateChannelJoinRequest_ApproveAddsMember(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := th.CreatePrivateChannel(t, th.BasicTeam)
channel = markDiscoverable(t, th, channel)
other := th.CreateUser(t)
th.LinkUserToTeam(t, other, th.BasicTeam)
_, req, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channel.Id, "")
require.Nil(t, appErr)
require.NotNil(t, req)
patch := &model.ChannelJoinRequestPatch{Status: model.ChannelJoinRequestStatusApproved}
updated, appErr := th.App.UpdateChannelJoinRequest(th.Context, req.Id, channel.Id, patch, th.BasicUser.Id)
require.Nil(t, appErr)
assert.Equal(t, model.ChannelJoinRequestStatusApproved, updated.Status)
assert.Equal(t, th.BasicUser.Id, updated.ReviewedBy)
assert.NotZero(t, updated.ReviewedAt)
assert.Empty(t, updated.Message, "message should be redacted from the response after review")
member, mErr := th.App.GetChannelMember(th.Context, channel.Id, other.Id)
require.Nil(t, mErr)
require.NotNil(t, member)
assert.Equal(t, other.Id, member.UserId)
}
func TestUpdateChannelJoinRequest_DenyKeepsReason(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := th.CreatePrivateChannel(t, th.BasicTeam)
channel = markDiscoverable(t, th, channel)
other := th.CreateUser(t)
th.LinkUserToTeam(t, other, th.BasicTeam)
_, req, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channel.Id, "please")
require.Nil(t, appErr)
require.NotNil(t, req)
reason := "team-internal channel"
patch := &model.ChannelJoinRequestPatch{
Status: model.ChannelJoinRequestStatusDenied,
DenialReason: &reason,
}
updated, appErr := th.App.UpdateChannelJoinRequest(th.Context, req.Id, channel.Id, patch, th.BasicUser.Id)
require.Nil(t, appErr)
assert.Equal(t, model.ChannelJoinRequestStatusDenied, updated.Status)
assert.Equal(t, reason, updated.DenialReason)
// Member must NOT have been added.
_, mErr := th.App.GetChannelMember(th.Context, channel.Id, other.Id)
require.NotNil(t, mErr)
assert.Equal(t, MissingChannelMemberError, mErr.Id)
}
func TestUpdateChannelJoinRequest_RejectsCrossChannel(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channelA := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
channelB := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
other := th.CreateUser(t)
th.LinkUserToTeam(t, other, th.BasicTeam)
_, req, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channelA.Id, "")
require.Nil(t, appErr)
require.NotNil(t, req)
patch := &model.ChannelJoinRequestPatch{Status: model.ChannelJoinRequestStatusApproved}
_, appErr = th.App.UpdateChannelJoinRequest(th.Context, req.Id, channelB.Id, patch, th.BasicUser.Id)
require.NotNil(t, appErr)
assert.Equal(t, http.StatusNotFound, appErr.StatusCode)
}
func TestWithdrawChannelJoinRequest_OwnerOnly(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
other := th.CreateUser(t)
th.LinkUserToTeam(t, other, th.BasicTeam)
_, req, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channel.Id, "")
require.Nil(t, appErr)
require.NotNil(t, req)
stranger := th.CreateUser(t)
_, appErr = th.App.WithdrawChannelJoinRequest(th.Context, req.Id, stranger.Id)
require.NotNil(t, appErr)
assert.Equal(t, http.StatusNotFound, appErr.StatusCode)
updated, appErr := th.App.WithdrawChannelJoinRequest(th.Context, req.Id, other.Id)
require.Nil(t, appErr)
assert.Equal(t, model.ChannelJoinRequestStatusWithdrawn, updated.Status)
// A second withdrawal is rejected with 409.
_, appErr = th.App.WithdrawChannelJoinRequest(th.Context, req.Id, other.Id)
require.NotNil(t, appErr)
assert.Equal(t, http.StatusConflict, appErr.StatusCode)
}
func TestGetMyChannelJoinRequests(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channelA := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
channelB := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
other := th.CreateUser(t)
th.LinkUserToTeam(t, other, th.BasicTeam)
_, _, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channelA.Id, "")
require.Nil(t, appErr)
_, _, appErr = th.App.RequestJoinChannel(th.Context, other.Id, channelB.Id, "")
require.Nil(t, appErr)
list, appErr := th.App.GetMyChannelJoinRequests(th.Context, other.Id, model.GetChannelJoinRequestsOpts{})
require.Nil(t, appErr)
require.NotNil(t, list)
assert.EqualValues(t, 2, list.TotalCount)
assert.Len(t, list.Requests, 2)
}
func TestCountPendingChannelJoinRequests(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
other := th.CreateUser(t)
th.LinkUserToTeam(t, other, th.BasicTeam)
_, _, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channel.Id, "")
require.Nil(t, appErr)
count, appErr := th.App.CountPendingChannelJoinRequests(th.Context, channel.Id)
require.Nil(t, appErr)
assert.EqualValues(t, 1, count)
}
func TestUpdateChannelPrivacy_CancelsPendingRequestsOnConvertToPublic(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
other := th.CreateUser(t)
th.LinkUserToTeam(t, other, th.BasicTeam)
_, req, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channel.Id, "")
require.Nil(t, appErr)
require.NotNil(t, req)
channel.Type = model.ChannelTypeOpen
converted, appErr := th.App.UpdateChannelPrivacy(th.Context, channel, th.BasicUser)
require.Nil(t, appErr)
// Discoverable must be reset on convert-to-public — the model invariant
// (Channel.IsValid) rejects (type=O, discoverable=true), so leaving it
// true would also break the next channel save.
assert.False(t, converted.Discoverable, "Discoverable must be reset to false after convert-to-public")
persisted, getErr := th.App.GetChannel(th.Context, channel.Id)
require.Nil(t, getErr)
assert.False(t, persisted.Discoverable, "Discoverable must be persisted as false after convert-to-public")
// The cancellation side-effect is dispatched on a goroutine; poll for
// the withdrawn state instead of sleeping.
require.Eventually(t, func() bool {
row, err := th.App.Srv().Store().ChannelJoinRequest().Get(req.Id)
if err != nil {
return false
}
return row.Status == model.ChannelJoinRequestStatusWithdrawn
}, 2*time.Second, 50*time.Millisecond)
}
func TestIsDiscoverableSelfAddBlocked(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
other := th.CreateUser(t)
assert.True(t, th.App.IsDiscoverableSelfAddBlocked(th.Context, channel, other.Id, other.Id), "self-add to discoverable + no-policy private must be blocked")
assert.False(t, th.App.IsDiscoverableSelfAddBlocked(th.Context, channel, th.BasicUser.Id, other.Id), "admin invite must not be blocked")
// Toggle off the flag → guard is inert.
withDiscoverableChannelsFlag(t, th, false)
assert.False(t, th.App.IsDiscoverableSelfAddBlocked(th.Context, channel, other.Id, other.Id))
}
func TestFilterDiscoverableChannelsByPolicy_FlagOff(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
// Flag off → filter is a no-op even when channels look discoverable.
channel := markDiscoverableInMemory(t, th.CreatePrivateChannel(t, th.BasicTeam))
channel.PolicyEnforced = true
out, appErr := th.App.FilterDiscoverableChannelsByPolicy(th.Context, []*model.Channel{channel}, th.BasicUser2.Id)
require.Nil(t, appErr)
require.Len(t, out, 1)
}
func TestFilterDiscoverableChannelsByPolicy_NoPolicyPasses(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := markDiscoverableInMemory(t, th.CreatePrivateChannel(t, th.BasicTeam))
out, appErr := th.App.FilterDiscoverableChannelsByPolicy(th.Context, []*model.Channel{channel}, th.BasicUser2.Id)
require.Nil(t, appErr)
require.Len(t, out, 1, "no-policy discoverable channels are visible without ABAC evaluation")
}
func TestFilterDiscoverableChannelsByPolicy_PolicyEnforcedFailSecure(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
// PolicyEnforced + Discoverable + no AccessControl service wired ⇒ hidden.
channel := markDiscoverableInMemory(t, th.CreatePrivateChannel(t, th.BasicTeam))
channel.PolicyEnforced = true
require.Nil(t, th.App.Srv().Channels().AccessControl, "test fixture must not have ABAC wired")
out, appErr := th.App.FilterDiscoverableChannelsByPolicy(th.Context, []*model.Channel{channel}, th.BasicUser2.Id)
require.Nil(t, appErr)
assert.Len(t, out, 0, "fail-secure must hide policy-enforced channels when ABAC is unavailable")
}
func TestFilterDiscoverableChannelsByPolicy_GuestHidden(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := markDiscoverableInMemory(t, th.CreatePrivateChannel(t, th.BasicTeam))
channel.PolicyEnforced = true
guest := th.CreateGuest(t)
out, appErr := th.App.FilterDiscoverableChannelsByPolicy(th.Context, []*model.Channel{channel}, guest.Id)
require.Nil(t, appErr)
assert.Empty(t, out, "guests must never see discoverable + policy-enforced channels")
}
// markDiscoverableInMemory is a no-DB helper for visibility filter tests that
// don't care about persistence — they only exercise the in-memory list filter.
func markDiscoverableInMemory(t *testing.T, channel *model.Channel) *model.Channel {
t.Helper()
channel.Discoverable = true
return channel
}

View File

@@ -0,0 +1,399 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"net/http"
"os"
"os/signal"
"runtime"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app/imaging"
"github.com/mattermost/mattermost/server/v8/config"
"github.com/mattermost/mattermost/server/v8/einterfaces"
"github.com/mattermost/mattermost/server/v8/platform/services/imageproxy"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
)
type configService interface {
Config() *model.Config
AddConfigListener(listener func(*model.Config, *model.Config)) string
RemoveConfigListener(id string)
UpdateConfig(f func(*model.Config))
SaveConfig(newCfg *model.Config, sendConfigChangeClusterMessage bool) (*model.Config, *model.Config, *model.AppError)
}
// Channels contains all channels related state.
type Channels struct {
srv *Server
cfgSvc configService
filestore filestore.FileBackend
exportFilestore filestore.FileBackend
postActionCookieSecret []byte
samlRelayStateSigningKey []byte
pluginCommandsLock sync.RWMutex
pluginCommands []*PluginCommand
pluginsLock sync.RWMutex
pluginsEnvironment *plugin.Environment
pluginConfigListenerID string
pluginLicenseListenerID string
pluginClusterLeaderListenerID string
// guardCache caches ChannelGuards rows by ChannelId -> []*store.ChannelGuard.
guardCache atomic.Pointer[sync.Map]
// guardCacheRetryInFlight collapses concurrent reload-failure retries to a single goroutine.
// See scheduleGuardCacheReloadRetry.
guardCacheRetryInFlight atomic.Bool
imageProxy *imageproxy.ImageProxy
agentsBridge AgentsBridge
// cached counts that are used during notice condition validation
cachedPostCount int64
cachedUserCount int64
cachedDBMSVersion string
// previously fetched notices
cachedNotices model.ProductNotices
managedCategoryGroupID string
managedCategoryFieldID string
AccountMigration einterfaces.AccountMigrationInterface
Compliance einterfaces.ComplianceInterface
DataRetention einterfaces.DataRetentionInterface
MessageExport einterfaces.MessageExportInterface
Saml einterfaces.SamlInterface
Notification einterfaces.NotificationInterface
Ldap einterfaces.LdapInterface
AccessControl einterfaces.AccessControlServiceInterface
Intune einterfaces.IntuneInterface
attributeViewRefreshMut sync.Mutex
attributeViewRefreshLast time.Time
attributeViewNeedsRefresh atomic.Bool
// These are used to prevent concurrent upload requests
// for a given upload session which could cause inconsistencies
// and data corruption.
uploadLockMapMut sync.Mutex
uploadLockMap map[string]bool
imgDecoder *imaging.Decoder
imgEncoder *imaging.Encoder
dndTaskMut sync.Mutex
dndTask *model.ScheduledTask
postReminderMut sync.Mutex
postReminderTask *model.ScheduledTask
interruptQuitChan chan struct{}
scheduledPostMut sync.Mutex
scheduledPostTask *model.ScheduledTask
}
func NewChannels(s *Server) (*Channels, error) {
ch := &Channels{
srv: s,
imageProxy: imageproxy.MakeImageProxy(s.platform, s.httpService, s.Log()),
uploadLockMap: map[string]bool{},
filestore: s.FileBackend(),
exportFilestore: s.ExportFileBackend(),
cfgSvc: s.Platform(),
interruptQuitChan: make(chan struct{}),
}
ch.guardCache.Store(&sync.Map{})
if s.agentsBridgeOverride != nil {
ch.agentsBridge = s.agentsBridgeOverride
} else {
ch.agentsBridge = newLiveAgentsBridge(ch)
}
// We are passing a partially filled Channels struct so that the enterprise
// methods can have access to app methods.
// Otherwise, passing server would mean it has to call s.Channels(),
// which would be nil at this point.
if complianceInterface != nil {
ch.Compliance = complianceInterface(New(ServerConnector(ch)))
}
if messageExportInterface != nil {
ch.MessageExport = messageExportInterface(New(ServerConnector(ch)))
}
if dataRetentionInterface != nil {
ch.DataRetention = dataRetentionInterface(New(ServerConnector(ch)))
}
if accountMigrationInterface != nil {
ch.AccountMigration = accountMigrationInterface(New(ServerConnector(ch)))
}
if ldapInterface != nil {
ch.Ldap = ldapInterface(New(ServerConnector(ch)))
}
if notificationInterface != nil {
ch.Notification = notificationInterface(New(ServerConnector(ch)))
}
if samlInterface != nil {
ch.Saml = samlInterface(New(ServerConnector(ch)))
if err := ch.Saml.ConfigureSP(request.EmptyContext(s.Log())); err != nil {
s.Log().Error("An error occurred while configuring SAML Service Provider", mlog.Err(err))
}
ch.AddConfigListener(func(_, _ *model.Config) {
if err := ch.Saml.ConfigureSP(request.EmptyContext(s.Log())); err != nil {
s.Log().Error("An error occurred while configuring SAML Service Provider", mlog.Err(err))
}
})
}
if intuneInterface != nil {
ch.Intune = intuneInterface(New(ServerConnector(ch)))
}
if pushProxyInterface != nil {
app := New(ServerConnector(ch))
s.PushProxy = pushProxyInterface(app)
// Add config listener to regenerate token when push proxy URL changes
app.AddConfigListener(func(oldCfg, newCfg *model.Config) {
// Only cluster leader should regenerate to avoid duplicate requests
if !app.IsLeader() {
return
}
oldURL := model.SafeDereference(oldCfg.EmailSettings.PushNotificationServer)
newURL := model.SafeDereference(newCfg.EmailSettings.PushNotificationServer)
// If push proxy URL changed
if oldURL != newURL {
if newURL != "" {
// URL changed to a new value, regenerate token
s.Log().Info("Push notification server URL changed, regenerating auth token",
mlog.String("old_url", oldURL),
mlog.String("new_url", newURL))
if err := s.PushProxy.GenerateAuthToken(); err != nil {
s.Log().Error("Failed to regenerate auth token after config change", mlog.Err(err))
}
} else if oldURL != "" {
// URL was cleared, delete the old token
s.Log().Info("Push notification server URL cleared, removing auth token")
if err := s.PushProxy.DeleteAuthToken(); err != nil {
s.Log().Error("Failed to delete auth token after URL cleared", mlog.Err(err))
}
}
}
})
}
if accessControlServiceInterface != nil {
app := New(ServerConnector(ch))
ch.AccessControl = accessControlServiceInterface(app)
appErr := ch.AccessControl.Init(request.EmptyContext(s.Log()))
if appErr != nil && appErr.StatusCode != http.StatusNotImplemented {
s.Log().Error("An error occurred while initializing Access Control", mlog.Err(appErr))
}
app.AddLicenseListener(func(newCfg, old *model.License) {
if ch.AccessControl != nil {
if appErr := ch.AccessControl.Init(request.EmptyContext(s.Log())); appErr != nil && appErr.StatusCode != http.StatusNotImplemented {
s.Log().Error("An error occurred while initializing Access Control", mlog.Err(appErr))
}
}
})
}
var imgErr error
decoderConcurrency := int(*ch.cfgSvc.Config().FileSettings.MaxImageDecoderConcurrency)
if decoderConcurrency == -1 {
decoderConcurrency = runtime.NumCPU()
}
ch.imgDecoder, imgErr = imaging.NewDecoder(imaging.DecoderOptions{
ConcurrencyLevel: decoderConcurrency,
MaxDecodedResolution: *ch.cfgSvc.Config().FileSettings.MaxImageResolution,
})
if imgErr != nil {
return nil, errors.Wrap(imgErr, "failed to create image decoder")
}
ch.imgEncoder, imgErr = imaging.NewEncoder(imaging.EncoderOptions{
ConcurrencyLevel: runtime.NumCPU(),
})
if imgErr != nil {
return nil, errors.Wrap(imgErr, "failed to create image encoder")
}
// Setup routes.
pluginsRoute := ch.srv.Router.PathPrefix("/plugins/{plugin_id:[A-Za-z0-9\\_\\-\\.]+}").Subrouter()
pluginsRoute.HandleFunc("", ch.ServePluginRequest)
pluginsRoute.HandleFunc("/public/{public_file:.*}", ch.ServePluginPublicRequest)
pluginsRoute.HandleFunc("/{anything:.*}", ch.ServePluginRequest)
if err := ch.reloadGuardCache(request.EmptyContext(s.Log()), s.Store()); err != nil {
s.Log().Warn(
"Failed to load channel guard cache at startup; retry scheduled",
mlog.Bool("clustered", s.platform.Cluster() != nil),
mlog.Err(err),
)
ch.scheduleGuardCacheReloadRetry()
}
return ch, nil
}
func (ch *Channels) SetAgentsBridge(bridge AgentsBridge) {
ch.agentsBridge = bridge
}
func (ch *Channels) Start() error {
// Start plugins
ctx := request.EmptyContext(ch.srv.Log())
ch.initPlugins(ctx, *ch.cfgSvc.Config().PluginSettings.Directory, *ch.cfgSvc.Config().PluginSettings.ClientDirectory)
interruptChan := make(chan os.Signal, 1)
signal.Notify(interruptChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
select {
case <-interruptChan:
if err := ch.Stop(); err != nil {
ch.srv.Log().Warn("Error stopping channels", mlog.Err(err))
}
os.Exit(1)
case <-ch.interruptQuitChan:
return
}
}()
ch.AddConfigListener(func(prevCfg, cfg *model.Config) {
// We compute the difference between configs
// to ensure we don't re-init plugins unnecessarily.
diffs, err := config.Diff(prevCfg, cfg)
if err != nil {
ch.srv.Log().Warn("Error in comparing configs", mlog.Err(err))
return
}
hasDiff := false
// TODO: This could be a method on ConfigDiffs itself
for _, diff := range diffs {
if strings.HasPrefix(diff.Path, "PluginSettings.") {
hasDiff = true
break
}
}
// Do only if some plugin related settings has changed.
if hasDiff {
if *cfg.PluginSettings.Enable {
ch.initPlugins(ctx, *cfg.PluginSettings.Directory, *ch.cfgSvc.Config().PluginSettings.ClientDirectory)
} else {
ch.ShutDownPlugins()
}
}
})
ch.AddConfigListener(ch.clearABACRenderCachesOnFlip)
// TODO: This should be moved to the platform service.
if err := ch.srv.platform.EnsureAsymmetricSigningKey(); err != nil {
return errors.Wrapf(err, "unable to ensure asymmetric signing key")
}
if err := ch.ensurePostActionCookieSecret(); err != nil {
return errors.Wrapf(err, "unable to ensure PostAction cookie secret")
}
if err := ch.ensureSamlRelayStateSigningKey(); err != nil {
return errors.Wrapf(err, "unable to ensure SAML RelayState signing key")
}
return nil
}
func (ch *Channels) Stop() error {
ch.ShutDownPlugins()
ch.dndTaskMut.Lock()
if ch.dndTask != nil {
ch.dndTask.Cancel()
}
ch.dndTaskMut.Unlock()
close(ch.interruptQuitChan)
return nil
}
func (ch *Channels) AddConfigListener(listener func(*model.Config, *model.Config)) string {
return ch.cfgSvc.AddConfigListener(listener)
}
func (ch *Channels) RemoveConfigListener(id string) {
ch.cfgSvc.RemoveConfigListener(id)
}
func (ch *Channels) RunMultiHook(hookRunnerFunc func(hooks plugin.Hooks, manifest *model.Manifest) bool, hookId int) {
if env := ch.GetPluginsEnvironment(); env != nil {
env.RunMultiPluginHook(hookRunnerFunc, hookId)
}
}
// RunMultiHookExcluding is like RunMultiHook but skips plugins whose IDs appear in excludePluginIDs.
// Fail-open semantics are preserved.
func (ch *Channels) RunMultiHookExcluding(excludePluginIDs []string, hookRunnerFunc func(plugin.Hooks, *model.Manifest) bool, hookId int) {
if env := ch.GetPluginsEnvironment(); env != nil {
env.RunMultiPluginHookExcluding(excludePluginIDs, hookRunnerFunc, hookId)
}
}
// RunMultiHookWithRPCErr dispatches a hook closure across active plugins, surfacing RPC transport
// errors. Returns nil in two cases that callers must distinguish themselves: (a) the plugin
// environment is unavailable (plugins disabled, or not yet initialized), so the closure was never
// invoked; (b) iteration completed and every closure invocation returned nil. Callers that need
// fail-closed semantics on case (a) must check `ch.GetPluginsEnvironment() != nil` at the call site
// before invoking — the right policy when plugins are disabled by config is caller-specific.
func (ch *Channels) RunMultiHookWithRPCErr(hookRunnerFunc func(hooks plugin.HooksWithRPCErr, manifest *model.Manifest) (bool, error), hookId int) error {
if env := ch.GetPluginsEnvironment(); env != nil {
return env.RunMultiPluginHookWithRPCErr(hookRunnerFunc, hookId)
}
return nil
}
func (ch *Channels) HooksForPlugin(id string) (plugin.Hooks, error) {
env := ch.GetPluginsEnvironment()
if env == nil {
return nil, errors.New("plugins are not initialized")
}
hooks, err := env.HooksForPlugin(id)
if err != nil {
return nil, err
}
return hooks, nil
}
// HooksForPluginWithRPCErr returns the full *WithRPCErr hook surface for the named plugin.
// Returns an error if the plugin environment is unavailable, the plugin is not found, or not active.
func (ch *Channels) HooksForPluginWithRPCErr(id string) (plugin.HooksWithRPCErr, error) {
env := ch.GetPluginsEnvironment()
if env == nil {
return nil, errors.New("plugin environment not available")
}
return env.HooksForPluginWithRPCErr(id)
}

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
)
func (a *App) AdjustInProductLimits(limits *model.ProductLimits, subscription *model.Subscription) *model.AppError {
if limits.Teams != nil && limits.Teams.Active != nil && *limits.Teams.Active > 0 {
err := a.AdjustTeamsFromProductLimits(limits.Teams)
if err != nil {
return err
}
}
return nil
}
// Create/ Update a subscription history event
// This function is run daily to record the number of activated users in the system for Cloud workspaces
func (a *App) SendSubscriptionHistoryEvent(userID string) (*model.SubscriptionHistory, error) {
license := a.Srv().License()
// No need to create a Subscription History Event if the license isn't cloud
if !license.IsCloud() {
return nil, nil
}
userCount, err := a.Srv().Store().User().Count(model.UserCountOptions{})
if err != nil {
return nil, err
}
return a.Cloud().CreateOrUpdateSubscriptionHistoryEvent(userID, int(userCount))
}
// GetPreviewModalData fetches modal content data from the configured S3 bucket
func (a *App) GetPreviewModalData() ([]model.PreviewModalContentData, *model.AppError) {
bucketURL := a.Config().CloudSettings.PreviewModalBucketURL
if bucketURL == nil || *bucketURL == "" {
return nil, model.NewAppError("GetPreviewModalData", "app.cloud.preview_modal_bucket_url_not_configured", nil, "", http.StatusNotFound)
}
// Construct the full URL to the modal_content.json file
fileURL := *bucketURL + "/modal_content.json"
// Make HTTP request to S3
resp, err := http.Get(fileURL)
if err != nil {
return nil, model.NewAppError("GetPreviewModalData", "app.cloud.preview_modal_data_fetch_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, model.NewAppError("GetPreviewModalData", "app.cloud.preview_modal_data_fetch_error", nil, "", resp.StatusCode)
}
// Parse the JSON response
var modalData []model.PreviewModalContentData
if err := json.NewDecoder(resp.Body).Decode(&modalData); err != nil {
return nil, model.NewAppError("GetPreviewModalData", "app.cloud.preview_modal_data_parse_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return modalData, nil
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
// Registers a given function to be called when the cluster leader may have changed. Returns a unique ID for the
// listener which can later be used to remove it. If clustering is not enabled in this build, the callback will never
// be called.
func (s *Server) AddClusterLeaderChangedListener(listener func()) string {
return s.platform.AddClusterLeaderChangedListener(listener)
}
// Removes a listener function by the unique ID returned when AddConfigListener was called
func (s *Server) RemoveClusterLeaderChangedListener(id string) {
s.platform.RemoveClusterLeaderChangedListener(id)
}
func (s *Server) InvokeClusterLeaderChangedListeners() {
s.platform.InvokeClusterLeaderChangedListeners()
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
func (s *Server) IsLeader() bool {
return s.platform.IsLeader()
}
func (a *App) IsLeader() bool {
return a.Srv().IsLeader()
}
func (a *App) GetClusterId() string {
return a.Srv().Platform().GetClusterId()
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (s *Server) clusterInstallPluginHandler(msg *model.ClusterMessage) {
var data model.PluginEventData
if jsonErr := json.Unmarshal(msg.Data, &data); jsonErr != nil {
s.Log().Warn("Failed to decode from JSON", mlog.Err(jsonErr))
}
s.Channels().installPluginFromClusterMessage(data.Id)
}
func (s *Server) clusterRemovePluginHandler(msg *model.ClusterMessage) {
var data model.PluginEventData
if jsonErr := json.Unmarshal(msg.Data, &data); jsonErr != nil {
s.Log().Warn("Failed to decode from JSON", mlog.Err(jsonErr))
}
s.Channels().removePluginFromClusterMessage(data.Id)
}
func (s *Server) clusterPluginEventHandler(msg *model.ClusterMessage) {
if msg.Props == nil {
s.Log().Warn("ClusterMessage.Props for plugin event should not be nil")
return
}
pluginID := msg.Props["PluginID"]
eventID := msg.Props["EventID"]
if pluginID == "" || eventID == "" {
s.Log().Warn("Invalid ClusterMessage.Props values for plugin event",
mlog.String("plugin_id", pluginID),
mlog.String("event_id", eventID),
)
return
}
hooks, err := s.Channels().HooksForPlugin(pluginID)
if err != nil {
s.Log().Warn("Getting hooks for plugin failed", mlog.String("plugin_id", pluginID), mlog.Err(err))
return
}
hooks.OnPluginClusterEvent(&plugin.Context{}, model.PluginClusterEvent{
Id: eventID,
Data: msg.Data,
})
}
// registerClusterHandlers registers the cluster message handlers that are handled by the server.
//
// The cluster event handlers are spread across this function and NewLocalCacheLayer.
// Be careful to not have duplicated handlers here and there.
func (s *Server) registerClusterHandlers() {
s.platform.RegisterClusterMessageHandler(model.ClusterEventInstallPlugin, s.clusterInstallPluginHandler)
s.platform.RegisterClusterMessageHandler(model.ClusterEventRemovePlugin, s.clusterRemovePluginHandler)
s.platform.RegisterClusterMessageHandler(model.ClusterEventPluginEvent, s.clusterPluginEventHandler)
s.platform.RegisterClusterMessageHandler(clusterEventInvalidateChannelGuardCache, s.Channels().clusterInvalidateGuardCacheHandler)
s.platform.RegisterClusterHandlers()
}

View File

@@ -0,0 +1,944 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"context"
"errors"
"io"
"maps"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"unicode"
"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"
)
const (
CmdCustomStatusTrigger = "status"
usernameSpecialChars = ".-_"
maxTriggerLen = 512
)
var atMentionRegexp = regexp.MustCompile(`\B@[[:alnum:]][[:alnum:]\.\-_:]*`)
type CommandProvider interface {
GetTrigger() string
GetCommand(a *App, T i18n.TranslateFunc) *model.Command
DoCommand(a *App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse
}
var commandProviders = make(map[string]CommandProvider)
func RegisterCommandProvider(newProvider CommandProvider) {
commandProviders[newProvider.GetTrigger()] = newProvider
}
func GetCommandProvider(name string) CommandProvider {
provider, ok := commandProviders[name]
if ok {
return provider
}
return nil
}
func (a *App) CreateCommandPost(rctx request.CTX, post *model.Post, teamID string, response *model.CommandResponse, skipSlackParsing bool) (*model.Post, *model.AppError) {
if skipSlackParsing {
post.Message = response.Text
} else {
post.Message = model.ParseSlackLinksToMarkdown(response.Text)
}
post.CreateAt = model.GetMillis()
if strings.HasPrefix(post.Type, model.PostSystemMessagePrefix) {
err := model.NewAppError("CreateCommandPost", "api.context.invalid_param.app_error", map[string]any{"Name": "post.type"}, "", http.StatusBadRequest)
return nil, err
}
if response.Attachments != nil {
model.ParseMessageAttachment(post, response.Attachments)
}
if response.ResponseType == model.CommandResponseTypeInChannel {
// Bot-style (non-builtin) command responses are flagged via from_webhook by
// HandleCommandResponsePost, which also sets override_username/override_icon_url
// from the slash command and response configuration. A command response's
// free-form Props (copied onto the post via post.SetProps in
// HandleCommandResponsePost) can additionally carry override_icon_emoji.
// Strip these props here and forward them through CreatePostFlags so
// SanitizeProps cannot drop them on save and the values land on the post
// only under verified integration authority (FromIncomingWebhook flag).
// webhook_display_name is not a slash command concept and is
// intentionally not forwarded.
fromWebhook := post.GetProp(model.PostPropsFromWebhook) == "true"
if fromWebhook {
post.DelProp(model.PostPropsFromWebhook)
}
overrideUsername, _ := post.GetProp(model.PostPropsOverrideUsername).(string)
if overrideUsername != "" {
post.DelProp(model.PostPropsOverrideUsername)
}
overrideIconURL, _ := post.GetProp(model.PostPropsOverrideIconURL).(string)
if overrideIconURL != "" {
post.DelProp(model.PostPropsOverrideIconURL)
}
overrideIconEmoji, _ := post.GetProp(model.PostPropsOverrideIconEmoji).(string)
if overrideIconEmoji != "" {
post.DelProp(model.PostPropsOverrideIconEmoji)
}
// HandleCommandResponsePost gates command.Username / response.Username and
// command.IconURL / response.IconURL on the EnablePostUsernameOverride /
// EnablePostIconOverride configs upstream, but a slash command response can
// also drop override_* directly into response.Props (carried onto post.Props
// via post.SetProps). Apply the same gates here so the response.Props pathway
// cannot bypass admin intent. Symmetric with CreateWebhookPost.
if !*a.Config().ServiceSettings.EnablePostUsernameOverride {
overrideUsername = ""
}
if !*a.Config().ServiceSettings.EnablePostIconOverride {
overrideIconURL = ""
overrideIconEmoji = ""
}
createdPost, _, appErr := a.CreatePostMissingChannelWithFlags(rctx, post, model.CreatePostFlags{
TriggerWebhooks: true,
SetOnline: true,
FromIncomingWebhook: fromWebhook,
OverrideUsername: overrideUsername,
OverrideIconURL: overrideIconURL,
OverrideIconEmoji: overrideIconEmoji,
})
if appErr != nil {
return nil, appErr
}
return createdPost, nil
}
if (response.ResponseType == "" || response.ResponseType == model.CommandResponseTypeEphemeral) && (response.Text != "" || response.Attachments != nil) {
a.SendEphemeralPost(rctx, post.UserId, post)
}
return post, nil
}
// previous ListCommands now ListAutocompleteCommands
func (a *App) ListAutocompleteCommands(teamID string, T i18n.TranslateFunc) ([]*model.Command, *model.AppError) {
commands := make([]*model.Command, 0, 32)
seen := make(map[string]bool)
// Disable custom status slash command if the feature or the setting is off
if !*a.Config().TeamSettings.EnableCustomUserStatuses {
seen[CmdCustomStatusTrigger] = true
}
for _, cmd := range a.CommandsForTeam(teamID) {
if cmd.AutoComplete && !seen[cmd.Trigger] {
seen[cmd.Trigger] = true
commands = append(commands, cmd)
}
}
if *a.Config().ServiceSettings.EnableCommands {
teamCmds, err := a.Srv().Store().Command().GetByTeam(teamID)
if err != nil {
return nil, model.NewAppError("ListAutocompleteCommands", "app.command.listautocompletecommands.internal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
for _, cmd := range teamCmds {
if cmd.AutoComplete && !seen[cmd.Trigger] {
cmd.Sanitize()
seen[cmd.Trigger] = true
commands = append(commands, cmd)
}
}
}
for _, value := range commandProviders {
if cmd := value.GetCommand(a, T); cmd != nil {
cpy := *cmd
if cpy.AutoComplete && !seen[cpy.Trigger] {
cpy.Sanitize()
seen[cpy.Trigger] = true
commands = append(commands, &cpy)
}
}
}
return commands, nil
}
func (a *App) ListTeamCommands(teamID string) ([]*model.Command, *model.AppError) {
return a.ListTeamCommandsByUser(teamID, "")
}
func (a *App) ListTeamCommandsByUser(teamID string, userID string) ([]*model.Command, *model.AppError) {
if !*a.Config().ServiceSettings.EnableCommands {
return nil, model.NewAppError("ListTeamCommands", "api.command.disabled.app_error", nil, "", http.StatusNotImplemented)
}
teamCmds, err := a.Srv().Store().Command().GetByTeam(teamID)
if err != nil {
return nil, model.NewAppError("ListTeamCommands", "app.command.listteamcommands.internal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Filter by user if userID is specified
if userID != "" {
filteredCmds := make([]*model.Command, 0)
for _, cmd := range teamCmds {
if cmd.CreatorId == userID {
filteredCmds = append(filteredCmds, cmd)
}
}
return filteredCmds, nil
}
return teamCmds, nil
}
func (a *App) ListAllCommands(teamID string, T i18n.TranslateFunc) ([]*model.Command, *model.AppError) {
return a.ListAllCommandsByUser(teamID, "", T)
}
func (a *App) ListAllCommandsByUser(teamID string, userID string, T i18n.TranslateFunc) ([]*model.Command, *model.AppError) {
commands := make([]*model.Command, 0, 32)
seen := make(map[string]bool)
for _, value := range commandProviders {
if cmd := value.GetCommand(a, T); cmd != nil {
cpy := *cmd
if cpy.AutoComplete && !seen[cpy.Trigger] {
cpy.Sanitize()
seen[cpy.Trigger] = true
commands = append(commands, &cpy)
}
}
}
for _, cmd := range a.CommandsForTeam(teamID) {
if !seen[cmd.Trigger] {
seen[cmd.Trigger] = true
commands = append(commands, cmd)
}
}
if *a.Config().ServiceSettings.EnableCommands {
teamCmds, err := a.Srv().Store().Command().GetByTeam(teamID)
if err != nil {
return nil, model.NewAppError("ListAllCommands", "app.command.listallcommands.internal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
for _, cmd := range teamCmds {
if !seen[cmd.Trigger] {
// Filter by user if userID is specified (before sanitizing)
if userID != "" && cmd.CreatorId != userID {
continue
}
cmd.Sanitize()
seen[cmd.Trigger] = true
commands = append(commands, cmd)
}
}
}
return commands, nil
}
func (a *App) ExecuteCommand(rctx request.CTX, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
trigger := ""
message := ""
index := strings.IndexFunc(args.Command, unicode.IsSpace)
if index != -1 {
trigger = args.Command[:index]
message = args.Command[index+1:]
} else {
trigger = args.Command
}
trigger = strings.ToLower(trigger)
if !strings.HasPrefix(trigger, "/") {
return nil, model.NewAppError("command", "api.command.execute_command.format.app_error", map[string]any{"Trigger": trigger}, "", http.StatusBadRequest)
}
trigger = strings.TrimPrefix(trigger, "/")
clientTriggerId, triggerId, appErr := model.GenerateTriggerId(args.UserId, args.ChannelId, a.AsymmetricSigningKey())
if appErr != nil {
rctx.Logger().Warn("error occurred in generating trigger Id for a user ", mlog.Err(appErr))
}
args.TriggerId = triggerId
// Plugins can override built in and custom commands
cmd, response, appErr := a.tryExecutePluginCommand(rctx, args)
if appErr != nil {
return nil, appErr
} else if cmd != nil && response != nil {
response.TriggerId = clientTriggerId
return a.HandleCommandResponse(rctx, cmd, args, response, true)
}
// Custom commands can override built ins
cmd, response, appErr = a.tryExecuteCustomCommand(rctx, args, trigger, message)
if appErr != nil {
return nil, appErr
} else if cmd != nil && response != nil {
response.TriggerId = clientTriggerId
return a.HandleCommandResponse(rctx, cmd, args, response, false)
}
cmd, response = a.tryExecuteBuiltInCommand(rctx, args, trigger, message)
if cmd != nil && response != nil {
return a.HandleCommandResponse(rctx, cmd, args, response, true)
}
if len(trigger) > maxTriggerLen {
trigger = trigger[:maxTriggerLen]
trigger += "..."
}
return nil, model.NewAppError("command", "api.command.execute_command.not_found.app_error", map[string]any{"Trigger": trigger}, "", http.StatusNotFound)
}
// MentionsToTeamMembers returns all the @ mentions found in message that
// belong to users in the specified team, linking them to their users
func (a *App) MentionsToTeamMembers(rctx request.CTX, message, teamID string) model.UserMentionMap {
type mentionMapItem struct {
Name string
Id string
}
possibleMentions := possibleAtMentions(message)
mentionChan := make(chan *mentionMapItem, len(possibleMentions))
var wg sync.WaitGroup
for _, mention := range possibleMentions {
wg.Add(1)
go func(mention string) {
defer wg.Done()
user, nErr := a.Srv().Store().User().GetByUsername(mention)
var nfErr *store.ErrNotFound
if nErr != nil && !errors.As(nErr, &nfErr) {
rctx.Logger().Warn("Failed to retrieve user @"+mention, mlog.Err(nErr))
return
}
// If it's a http.StatusNotFound error, check for usernames in substrings
// without trailing punctuation
if nErr != nil {
trimmed, ok := trimUsernameSpecialChar(mention)
for ; ok; trimmed, ok = trimUsernameSpecialChar(trimmed) {
userFromTrimmed, nErr := a.Srv().Store().User().GetByUsername(trimmed)
if nErr != nil && !errors.As(nErr, &nfErr) {
return
}
if nErr != nil {
continue
}
_, err := a.GetTeamMember(rctx, teamID, userFromTrimmed.Id)
if err != nil {
// The user is not in the team, so we should ignore it
return
}
mentionChan <- &mentionMapItem{trimmed, userFromTrimmed.Id}
return
}
return
}
_, err := a.GetTeamMember(rctx, teamID, user.Id)
if err != nil {
// The user is not in the team, so we should ignore it
return
}
mentionChan <- &mentionMapItem{mention, user.Id}
}(mention)
}
wg.Wait()
close(mentionChan)
atMentionMap := make(model.UserMentionMap)
for mention := range mentionChan {
atMentionMap[mention.Name] = mention.Id
}
return atMentionMap
}
// MentionsToPublicChannels returns all the mentions to public channels,
// linking them to their channels
func (a *App) MentionsToPublicChannels(rctx request.CTX, message, teamID string) model.ChannelMentionMap {
type mentionMapItem struct {
Name string
Id string
}
channelMentions := model.ChannelMentions(message)
mentionChan := make(chan *mentionMapItem, len(channelMentions))
var wg sync.WaitGroup
for _, channelName := range channelMentions {
wg.Add(1)
go func(channelName string) {
defer wg.Done()
channel, err := a.GetChannelByName(rctx, channelName, teamID, false)
if err != nil {
return
}
if !channel.IsOpen() {
return
}
mentionChan <- &mentionMapItem{channelName, channel.Id}
}(channelName)
}
wg.Wait()
close(mentionChan)
channelMentionMap := make(model.ChannelMentionMap)
for mention := range mentionChan {
channelMentionMap[mention.Name] = mention.Id
}
return channelMentionMap
}
// tryExecuteBuiltInCommand attempts to run a built in command based on the given arguments. If no such command can be
// found, returns nil for all arguments.
func (a *App) tryExecuteBuiltInCommand(rctx request.CTX, args *model.CommandArgs, trigger string, message string) (*model.Command, *model.CommandResponse) {
provider := GetCommandProvider(trigger)
if provider == nil {
return nil, nil
}
cmd := provider.GetCommand(a, args.T)
if cmd == nil {
return nil, nil
}
return cmd, provider.DoCommand(a, rctx, args, message)
}
// tryExecuteCustomCommand attempts to run a custom command based on the given arguments. If no such command can be
// found, returns nil for all arguments.
func (a *App) tryExecuteCustomCommand(rctx request.CTX, args *model.CommandArgs, trigger string, message string) (*model.Command, *model.CommandResponse, *model.AppError) {
// Handle custom commands
if !*a.Config().ServiceSettings.EnableCommands {
return nil, nil, model.NewAppError("ExecuteCommand", "api.command.disabled.app_error", nil, "", http.StatusNotImplemented)
}
chanChan := make(chan store.StoreResult[*model.Channel], 1)
go func() {
channel, err := a.Srv().Store().Channel().Get(args.ChannelId, true)
chanChan <- store.StoreResult[*model.Channel]{Data: channel, NErr: err}
close(chanChan)
}()
teamChan := make(chan store.StoreResult[*model.Team], 1)
go func() {
team, err := a.Srv().Store().Team().Get(args.TeamId)
teamChan <- store.StoreResult[*model.Team]{Data: team, NErr: err}
close(teamChan)
}()
userChan := make(chan store.StoreResult[*model.User], 1)
go func() {
user, err := a.Srv().Store().User().Get(rctx, args.UserId)
userChan <- store.StoreResult[*model.User]{Data: user, NErr: err}
close(userChan)
}()
teamCmds, err := a.Srv().Store().Command().GetByTeam(args.TeamId)
if err != nil {
return nil, nil, model.NewAppError("tryExecuteCustomCommand", "app.command.tryexecutecustomcommand.internal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
tr := <-teamChan
if tr.NErr != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(tr.NErr, &nfErr):
return nil, nil, model.NewAppError("tryExecuteCustomCommand", "app.team.get.find.app_error", nil, "", http.StatusNotFound).Wrap(tr.NErr)
default:
return nil, nil, model.NewAppError("tryExecuteCustomCommand", "app.team.get.finding.app_error", nil, "", http.StatusInternalServerError).Wrap(tr.NErr)
}
}
team := tr.Data
ur := <-userChan
if ur.NErr != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(ur.NErr, &nfErr):
return nil, nil, model.NewAppError("tryExecuteCustomCommand", MissingAccountError, nil, "", http.StatusNotFound).Wrap(ur.NErr)
default:
return nil, nil, model.NewAppError("tryExecuteCustomCommand", "app.user.get.app_error", nil, "", http.StatusInternalServerError).Wrap(ur.NErr)
}
}
user := ur.Data
cr := <-chanChan
if cr.NErr != nil {
errCtx := map[string]any{"channel_id": args.ChannelId}
var nfErr *store.ErrNotFound
switch {
case errors.As(cr.NErr, &nfErr):
return nil, nil, model.NewAppError("tryExecuteCustomCommand", "app.channel.get.existing.app_error", errCtx, "", http.StatusNotFound).Wrap(cr.NErr)
default:
return nil, nil, model.NewAppError("tryExecuteCustomCommand", "app.channel.get.find.app_error", errCtx, "", http.StatusInternalServerError).Wrap(cr.NErr)
}
}
channel := cr.Data
var cmd *model.Command
for _, teamCmd := range teamCmds {
if trigger == teamCmd.Trigger {
cmd = teamCmd
}
}
if cmd == nil {
return nil, nil, nil
}
rctx = rctx.WithLogFields(mlog.String("command_id", cmd.Id))
p := url.Values{}
p.Set("token", cmd.Token)
p.Set("team_id", cmd.TeamId)
p.Set("team_domain", team.Name)
p.Set("channel_id", args.ChannelId)
p.Set("channel_name", channel.Name)
p.Set("user_id", args.UserId)
p.Set("user_name", user.Username)
p.Set("command", "/"+trigger)
p.Set("text", message)
p.Set("trigger_id", args.TriggerId)
p.Set("root_id", args.RootId)
userMentionMap := a.MentionsToTeamMembers(rctx, message, team.Id)
maps.Copy(p, userMentionMap.ToURLValues())
channelMentionMap := a.MentionsToPublicChannels(rctx, message, team.Id)
maps.Copy(p, channelMentionMap.ToURLValues())
// Use configured SiteURL for response_url to prevent SSRF via Host header spoofing (MM-67142)
siteURL := *a.Config().ServiceSettings.SiteURL
if siteURL == "" {
return cmd, nil, model.NewAppError("tryExecuteCustomCommand", "api.command.execute_command.site_url_required.app_error", nil, "", http.StatusBadRequest)
}
if siteURL != args.SiteURL {
rctx.Logger().Warn(i18n.T("api.command.execute_command.provided_site_url_different.app_error"),
mlog.String("request_host", args.SiteURL),
mlog.String("configured_site_url", siteURL),
mlog.String("command", trigger))
}
hook, appErr := a.CreateCommandWebhook(cmd.Id, args)
if appErr != nil {
return cmd, nil, model.NewAppError("command", "api.command.execute_command.failed.app_error", map[string]any{"Trigger": trigger}, "", http.StatusInternalServerError).Wrap(appErr)
}
p.Set("response_url", siteURL+"/hooks/commands/"+hook.Id)
return a.DoCommandRequest(rctx, cmd, p)
}
func (a *App) DoCommandRequest(rctx request.CTX, cmd *model.Command, p url.Values) (*model.Command, *model.CommandResponse, *model.AppError) {
ctx, cancel := context.WithTimeout(a.Srv().Platform().GoContext(), time.Duration(*a.Config().ServiceSettings.OutgoingIntegrationRequestsTimeout)*time.Second)
defer cancel()
var accessToken *model.OutgoingOAuthConnectionToken
// Retrieve an access token from a connection if one exists to use for the webhook request
if a.Config().ServiceSettings.EnableOutgoingOAuthConnections != nil && *a.Config().ServiceSettings.EnableOutgoingOAuthConnections && a.OutgoingOAuthConnections() != nil {
connection, err := a.OutgoingOAuthConnections().GetConnectionForAudience(rctx, cmd.URL)
if err != nil {
a.Log().Error("Failed to find an outgoing oauth connection for the webhook", mlog.Err(err))
}
if connection != nil {
accessToken, err = a.OutgoingOAuthConnections().RetrieveTokenForConnection(rctx, connection)
if err != nil {
a.Log().Error("Failed to retrieve token for outgoing oauth connection", mlog.Err(err))
}
}
}
// Prepare the request
var req *http.Request
var err error
if cmd.Method == model.CommandMethodGet {
req, err = http.NewRequestWithContext(ctx, http.MethodGet, cmd.URL, nil)
} else {
req, err = http.NewRequestWithContext(ctx, http.MethodPost, cmd.URL, strings.NewReader(p.Encode()))
}
if err != nil {
return cmd, nil, model.NewAppError("command", "api.command.execute_command.failed.app_error", map[string]any{"Trigger": cmd.Trigger}, "", http.StatusInternalServerError).Wrap(err)
}
if cmd.Method == model.CommandMethodGet {
if req.URL.RawQuery != "" {
req.URL.RawQuery += "&"
}
req.URL.RawQuery += p.Encode()
}
req.Header.Set("Accept", "application/json")
if cmd.Token != "" {
req.Header.Set("Authorization", "Token "+cmd.Token)
}
if accessToken != nil {
req.Header.Set("Authorization", accessToken.AsHeaderValue())
}
if cmd.Method == model.CommandMethodPost {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
resp, err := a.Srv().outgoingWebhookClient.Do(req)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
rctx.Logger().Info("Outgoing Command request timed out. Consider increasing ServiceSettings.OutgoingIntegrationRequestsTimeout.")
}
return cmd, nil, model.NewAppError("command", "api.command.execute_command.failed.app_error", map[string]any{"Trigger": cmd.Trigger}, "", http.StatusInternalServerError).Wrap(err)
}
defer resp.Body.Close()
// Handle the response
body := io.LimitReader(resp.Body, MaxIntegrationResponseSize)
if resp.StatusCode != http.StatusOK {
// Ignore the error below because the resulting string will just be the empty string if bodyBytes is nil
bodyBytes, _ := io.ReadAll(body)
return cmd, nil, model.NewAppError("command", "api.command.execute_command.failed_resp.app_error", map[string]any{"Trigger": cmd.Trigger, "Status": resp.Status}, string(bodyBytes), http.StatusInternalServerError)
}
response, err := model.CommandResponseFromHTTPBody(resp.Header.Get("Content-Type"), body)
if err != nil {
return cmd, nil, model.NewAppError("command", "api.command.execute_command.failed.app_error", map[string]any{"Trigger": cmd.Trigger}, "", http.StatusInternalServerError).Wrap(err)
} else if response == nil {
return cmd, nil, model.NewAppError("command", "api.command.execute_command.failed_empty.app_error", map[string]any{"Trigger": cmd.Trigger}, "", http.StatusInternalServerError)
}
if appErr := response.IsValid(); appErr != nil {
rctx.Logger().Warn("Command response is not valid. Please update your integration to be compliant.", mlog.Err(appErr))
}
return cmd, response, nil
}
func (a *App) HandleCommandResponse(rctx request.CTX, command *model.Command, args *model.CommandArgs, response *model.CommandResponse, builtIn bool) (*model.CommandResponse, *model.AppError) {
trigger := ""
if args.Command != "" {
parts := strings.Split(args.Command, " ")
trigger = parts[0][1:]
trigger = strings.ToLower(trigger)
}
var lastError *model.AppError
_, err := a.HandleCommandResponsePost(rctx, command, args, response, builtIn)
if err != nil {
rctx.Logger().Debug("Error occurred in handling command response post", mlog.Err(err))
lastError = err
}
if response.ExtraResponses != nil {
for _, resp := range response.ExtraResponses {
_, err := a.HandleCommandResponsePost(rctx, command, args, resp, builtIn)
if err != nil {
rctx.Logger().Debug("Error occurred in handling command response post", mlog.Err(err))
lastError = err
}
}
}
if lastError != nil {
return response, model.NewAppError("command", "api.command.execute_command.create_post_failed.app_error", map[string]any{"Trigger": trigger}, "", http.StatusInternalServerError)
}
return response, nil
}
func (a *App) HandleCommandResponsePost(rctx request.CTX, command *model.Command, args *model.CommandArgs, response *model.CommandResponse, builtIn bool) (*model.Post, *model.AppError) {
post := &model.Post{}
post.ChannelId = args.ChannelId
post.RootId = args.RootId
post.UserId = args.UserId
post.Type = response.Type
post.SetProps(response.Props)
if response.ChannelId != "" {
_, err := a.GetChannelMember(rctx, response.ChannelId, args.UserId)
if err != nil {
err = model.NewAppError("HandleCommandResponsePost", "api.command.command_post.forbidden.app_error", nil, "", http.StatusForbidden).Wrap(err)
return nil, err
}
post.ChannelId = response.ChannelId
}
isBotPost := !builtIn
if *a.Config().ServiceSettings.EnablePostUsernameOverride {
if command.Username != "" {
post.AddProp(model.PostPropsOverrideUsername, command.Username)
isBotPost = true
} else if response.Username != "" {
post.AddProp(model.PostPropsOverrideUsername, response.Username)
isBotPost = true
}
}
if *a.Config().ServiceSettings.EnablePostIconOverride {
if command.IconURL != "" {
post.AddProp(model.PostPropsOverrideIconURL, command.IconURL)
isBotPost = true
} else if response.IconURL != "" {
post.AddProp(model.PostPropsOverrideIconURL, response.IconURL)
isBotPost = true
}
}
if isBotPost {
post.AddProp(model.PostPropsFromWebhook, "true")
}
// Process Slack text replacements if the response does not contain "skip_slack_parsing": true.
if !response.SkipSlackParsing {
response.Text = a.ProcessSlackText(rctx, response.Text)
response.Attachments = a.ProcessMessageAttachments(rctx, response.Attachments)
}
if _, err := a.CreateCommandPost(rctx, post, args.TeamId, response, response.SkipSlackParsing); err != nil {
return post, err
}
return post, nil
}
func (a *App) CreateCommand(cmd *model.Command) (*model.Command, *model.AppError) {
if !*a.Config().ServiceSettings.EnableCommands {
return nil, model.NewAppError("CreateCommand", "api.command.disabled.app_error", nil, "", http.StatusNotImplemented)
}
return a.createCommand(cmd)
}
func (a *App) createCommand(cmd *model.Command) (*model.Command, *model.AppError) {
cmd.Trigger = strings.ToLower(cmd.Trigger)
if appErr := a.validateCommandTriggerUniqueness(cmd.TeamId, cmd.Trigger, ""); appErr != nil {
return nil, appErr
}
command, nErr := a.Srv().Store().Command().Save(cmd)
if nErr != nil {
var appErr *model.AppError
switch {
case errors.As(nErr, &appErr):
return nil, appErr
default:
return nil, model.NewAppError("CreateCommand", "app.command.createcommand.internal_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
}
return command, nil
}
func (a *App) validateCommandTriggerUniqueness(teamID, trigger, excludeCommandID string) *model.AppError {
trigger = strings.ToLower(trigger)
for _, builtInProvider := range commandProviders {
builtInCommand := builtInProvider.GetCommand(a, i18n.T)
if builtInCommand != nil && trigger == strings.ToLower(builtInCommand.Trigger) {
return model.NewAppError("validateCommandTriggerUniqueness", "api.command.duplicate_trigger.app_error", nil, "", http.StatusBadRequest)
}
}
teamCmds, err := a.Srv().Store().Command().GetByTeam(teamID)
if err != nil {
return model.NewAppError("validateCommandTriggerUniqueness", "app.command.validatecommandtriggeruniqueness.internal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
for _, existingCommand := range teamCmds {
if existingCommand.Id != excludeCommandID && trigger == strings.ToLower(existingCommand.Trigger) {
return model.NewAppError("validateCommandTriggerUniqueness", "api.command.duplicate_trigger.app_error", nil, "", http.StatusBadRequest)
}
}
return nil
}
func (a *App) GetCommand(commandID string) (*model.Command, *model.AppError) {
if !*a.Config().ServiceSettings.EnableCommands {
return nil, model.NewAppError("GetCommand", "api.command.disabled.app_error", nil, "", http.StatusNotImplemented)
}
command, err := a.Srv().Store().Command().Get(commandID)
if err != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(err, &nfErr):
return nil, model.NewAppError("SqlCommandStore.Get", "store.sql_command.get.missing.app_error", map[string]any{"command_id": commandID}, "", http.StatusNotFound).Wrap(err)
default:
return nil, model.NewAppError("GetCommand", "app.command.getcommand.internal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
return command, nil
}
func (a *App) UpdateCommand(oldCmd, updatedCmd *model.Command) (*model.Command, *model.AppError) {
if !*a.Config().ServiceSettings.EnableCommands {
return nil, model.NewAppError("UpdateCommand", "api.command.disabled.app_error", nil, "", http.StatusNotImplemented)
}
updatedCmd.Trigger = strings.ToLower(updatedCmd.Trigger)
updatedCmd.Id = oldCmd.Id
updatedCmd.Token = oldCmd.Token
updatedCmd.CreateAt = oldCmd.CreateAt
updatedCmd.UpdateAt = model.GetMillis()
updatedCmd.DeleteAt = oldCmd.DeleteAt
updatedCmd.CreatorId = oldCmd.CreatorId
updatedCmd.PluginId = oldCmd.PluginId
updatedCmd.TeamId = oldCmd.TeamId
if appErr := a.validateCommandTriggerUniqueness(updatedCmd.TeamId, updatedCmd.Trigger, updatedCmd.Id); appErr != nil {
return nil, appErr
}
command, err := a.Srv().Store().Command().Update(updatedCmd)
if err != nil {
var nfErr *store.ErrNotFound
var appErr *model.AppError
switch {
case errors.As(err, &nfErr):
return nil, model.NewAppError("SqlCommandStore.Update", "store.sql_command.update.missing.app_error", map[string]any{"command_id": updatedCmd.Id}, "", http.StatusNotFound).Wrap(err)
case errors.As(err, &appErr):
return nil, appErr
default:
return nil, model.NewAppError("UpdateCommand", "app.command.updatecommand.internal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
return command, nil
}
func (a *App) MoveCommand(team *model.Team, command *model.Command) *model.AppError {
if appErr := a.validateCommandTriggerUniqueness(team.Id, command.Trigger, command.Id); appErr != nil {
return appErr
}
command.TeamId = team.Id
_, err := a.Srv().Store().Command().Update(command)
if err != nil {
var nfErr *store.ErrNotFound
var appErr *model.AppError
switch {
case errors.As(err, &nfErr):
return model.NewAppError("SqlCommandStore.Update", "store.sql_command.update.missing.app_error", map[string]any{"command_id": command.Id}, "", http.StatusNotFound).Wrap(err)
case errors.As(err, &appErr):
return appErr
default:
return model.NewAppError("MoveCommand", "app.command.movecommand.internal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
return nil
}
func (a *App) RegenCommandToken(cmd *model.Command) (*model.Command, *model.AppError) {
if !*a.Config().ServiceSettings.EnableCommands {
return nil, model.NewAppError("RegenCommandToken", "api.command.disabled.app_error", nil, "", http.StatusNotImplemented)
}
cmd.Token = model.NewId()
command, err := a.Srv().Store().Command().Update(cmd)
if err != nil {
var nfErr *store.ErrNotFound
var appErr *model.AppError
switch {
case errors.As(err, &nfErr):
return nil, model.NewAppError("SqlCommandStore.Update", "store.sql_command.update.missing.app_error", map[string]any{"command_id": cmd.Id}, "", http.StatusNotFound).Wrap(err)
case errors.As(err, &appErr):
return nil, appErr
default:
return nil, model.NewAppError("RegenCommandToken", "app.command.regencommandtoken.internal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
return command, nil
}
func (a *App) DeleteCommand(commandID string) *model.AppError {
if !*a.Config().ServiceSettings.EnableCommands {
return model.NewAppError("DeleteCommand", "api.command.disabled.app_error", nil, "", http.StatusNotImplemented)
}
err := a.Srv().Store().Command().Delete(commandID, model.GetMillis())
if err != nil {
return model.NewAppError("DeleteCommand", "app.command.deletecommand.internal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}
// possibleAtMentions returns all substrings in message that look like valid @
// mentions.
func possibleAtMentions(message string) []string {
var names []string
if !strings.Contains(message, "@") {
return names
}
alreadyMentioned := make(map[string]bool)
for _, match := range atMentionRegexp.FindAllString(message, -1) {
name := model.NormalizeUsername(match[1:])
if !alreadyMentioned[name] && model.IsValidUsernameAllowRemote(name) {
names = append(names, name)
alreadyMentioned[name] = true
}
}
return names
}
// trimUsernameSpecialChar tries to remove the last character from word if it
// is a special character for usernames (dot, dash or underscore). If not, it
// returns the same string.
func trimUsernameSpecialChar(word string) (string, bool) {
l := len(word)
if l > 0 && strings.LastIndexAny(word, usernameSpecialChars) == (l-1) {
return word[:l-1], true
}
return word, false
}

View File

@@ -0,0 +1,334 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"slices"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
// AutocompleteDynamicArgProvider dynamically provides auto-completion args for built-in commands.
type AutocompleteDynamicArgProvider interface {
GetAutoCompleteListItems(rctx request.CTX, a *App, commandArgs *model.CommandArgs, arg *model.AutocompleteArg, parsed, toBeParsed string) ([]model.AutocompleteListItem, error)
}
// GetSuggestions returns suggestions for user input.
func (a *App) GetSuggestions(rctx request.CTX, commandArgs *model.CommandArgs, commands []*model.Command, roleID string) []model.AutocompleteSuggestion {
slices.SortFunc(commands, func(a, b *model.Command) int {
return strings.Compare(strings.ToLower(a.Trigger), strings.ToLower(b.Trigger))
})
autocompleteData := []*model.AutocompleteData{}
for _, command := range commands {
if command.AutocompleteData == nil {
command.AutocompleteData = model.NewAutocompleteData(command.Trigger, command.AutoCompleteHint, command.AutoCompleteDesc)
}
autocompleteData = append(autocompleteData, command.AutocompleteData)
}
userInput := commandArgs.Command
suggestions := a.getSuggestions(rctx, commandArgs, autocompleteData, "", userInput, roleID)
for i, suggestion := range suggestions {
for _, command := range commands {
if strings.HasPrefix(suggestion.Complete, command.Trigger) {
suggestions[i].IconData = command.AutocompleteIconData
break
}
}
}
return suggestions
}
func (a *App) getSuggestions(rctx request.CTX, commandArgs *model.CommandArgs, commands []*model.AutocompleteData, inputParsed, inputToBeParsed, roleID string) []model.AutocompleteSuggestion {
suggestions := []model.AutocompleteSuggestion{}
index := strings.Index(inputToBeParsed, " ")
if index == -1 { // no space in input
for _, command := range commands {
if strings.HasPrefix(command.Trigger, strings.ToLower(inputToBeParsed)) && (command.RoleID == roleID || roleID == model.SystemAdminRoleId || roleID == "") {
s := model.AutocompleteSuggestion{
Complete: inputParsed + command.Trigger,
Suggestion: command.Trigger,
Description: command.HelpText,
Hint: command.Hint,
}
suggestions = append(suggestions, s)
}
}
return suggestions
}
for _, command := range commands {
if command.Trigger != strings.ToLower(inputToBeParsed[:index]) {
continue
}
if roleID != "" && roleID != model.SystemAdminRoleId && roleID != command.RoleID {
continue
}
toBeParsed := inputToBeParsed[index+1:]
parsed := inputParsed + inputToBeParsed[:index+1]
if len(command.Arguments) == 0 {
// Seek recursively in subcommands
subSuggestions := a.getSuggestions(rctx, commandArgs, command.SubCommands, parsed, toBeParsed, roleID)
suggestions = append(suggestions, subSuggestions...)
continue
}
found, _, _, suggestion := a.parseArguments(rctx, commandArgs, command.Arguments, parsed, toBeParsed)
if found {
suggestions = append(suggestions, suggestion...)
}
}
return suggestions
}
func (a *App) parseArguments(rctx request.CTX, commandArgs *model.CommandArgs, args []*model.AutocompleteArg, parsed, toBeParsed string) (found bool, alreadyParsed string, yetToBeParsed string, suggestions []model.AutocompleteSuggestion) {
if len(args) == 0 {
return false, parsed, toBeParsed, suggestions
}
if args[0].Required {
found, changedParsed, changedToBeParsed, suggestion := a.parseArgument(rctx, commandArgs, args[0], parsed, toBeParsed)
if found {
suggestions = append(suggestions, suggestion...)
return true, changedParsed, changedToBeParsed, suggestions
}
return a.parseArguments(rctx, commandArgs, args[1:], changedParsed, changedToBeParsed)
}
// Handling optional arguments. Optional argument can be inputted or not,
// so we have to pase both cases recursively and output combined suggestions.
foundWithOptional, changedParsedWithOptional, changedToBeParsedWithOptional, suggestionsWithOptional := a.parseArgument(rctx, commandArgs, args[0], parsed, toBeParsed)
if foundWithOptional {
suggestions = append(suggestions, suggestionsWithOptional...)
} else {
foundWithOptionalRest, changedParsedWithOptionalRest, changedToBeParsedWithOptionalRest, suggestionsWithOptionalRest := a.parseArguments(rctx, commandArgs, args[1:], changedParsedWithOptional, changedToBeParsedWithOptional)
if foundWithOptionalRest {
suggestions = append(suggestions, suggestionsWithOptionalRest...)
}
foundWithOptional = foundWithOptionalRest
changedParsedWithOptional = changedParsedWithOptionalRest
changedToBeParsedWithOptional = changedToBeParsedWithOptionalRest
}
foundWithoutOptional, changedParsedWithoutOptional, changedToBeParsedWithoutOptional, suggestionsWithoutOptional := a.parseArguments(rctx, commandArgs, args[1:], parsed, toBeParsed)
if foundWithoutOptional {
suggestions = append(suggestions, suggestionsWithoutOptional...)
}
// if suggestions were found we can return them
if foundWithOptional || foundWithoutOptional {
return true, parsed + toBeParsed, "", suggestions
}
// no suggestions found yet, check if optional argument was inputted
if changedParsedWithOptional != parsed && changedToBeParsedWithOptional != toBeParsed {
return false, changedParsedWithOptional, changedToBeParsedWithOptional, suggestions
}
// no suggestions and optional argument was not inputted
return foundWithoutOptional, changedParsedWithoutOptional, changedToBeParsedWithoutOptional, suggestions
}
func (a *App) parseArgument(rctx request.CTX, commandArgs *model.CommandArgs, arg *model.AutocompleteArg, parsed, toBeParsed string) (found bool, alreadyParsed string, yetToBeParsed string, suggestions []model.AutocompleteSuggestion) {
if arg.Name != "" { //Parse the --name first
found, changedParsed, changedToBeParsed, suggestion := parseNamedArgument(arg, parsed, toBeParsed)
if found {
suggestions = append(suggestions, suggestion)
return true, changedParsed, changedToBeParsed, suggestions
}
if changedToBeParsed == "" {
return true, changedParsed, changedToBeParsed, suggestions
}
if changedToBeParsed == " " {
changedToBeParsed = ""
}
parsed = changedParsed
toBeParsed = changedToBeParsed
}
if arg.Type == model.AutocompleteArgTypeText {
found, changedParsed, changedToBeParsed, suggestion := parseInputTextArgument(arg, parsed, toBeParsed)
if found {
suggestions = append(suggestions, suggestion)
return true, changedParsed, changedToBeParsed, suggestions
}
parsed = changedParsed
toBeParsed = changedToBeParsed
} else if arg.Type == model.AutocompleteArgTypeStaticList {
found, changedParsed, changedToBeParsed, staticListSuggestions := parseStaticListArgument(arg, parsed, toBeParsed)
if found {
suggestions = append(suggestions, staticListSuggestions...)
return true, changedParsed, changedToBeParsed, suggestions
}
parsed = changedParsed
toBeParsed = changedToBeParsed
} else if arg.Type == model.AutocompleteArgTypeDynamicList {
found, changedParsed, changedToBeParsed, dynamicListSuggestions := a.getDynamicListArgument(rctx, commandArgs, arg, parsed, toBeParsed)
if found {
suggestions = append(suggestions, dynamicListSuggestions...)
return true, changedParsed, changedToBeParsed, suggestions
}
parsed = changedParsed
toBeParsed = changedToBeParsed
}
return false, parsed, toBeParsed, suggestions
}
func parseNamedArgument(arg *model.AutocompleteArg, parsed, toBeParsed string) (found bool, alreadyParsed string, yetToBeParsed string, suggestion model.AutocompleteSuggestion) {
in := strings.TrimPrefix(toBeParsed, " ")
namedArg := "--" + arg.Name
if in == "" { //The user has not started typing the argument.
return true, parsed + toBeParsed, "", model.AutocompleteSuggestion{Complete: parsed + toBeParsed + namedArg + " ", Suggestion: namedArg, Hint: "", Description: arg.HelpText}
}
if strings.HasPrefix(strings.ToLower(namedArg), strings.ToLower(in)) {
return true, parsed + toBeParsed, "", model.AutocompleteSuggestion{Complete: parsed + toBeParsed + namedArg[len(in):] + " ", Suggestion: namedArg, Hint: "", Description: arg.HelpText}
}
if !strings.HasPrefix(strings.ToLower(in), strings.ToLower(namedArg)+" ") {
return false, parsed + toBeParsed, "", model.AutocompleteSuggestion{}
}
if strings.ToLower(in) == strings.ToLower(namedArg)+" " {
return false, parsed + namedArg + " ", " ", model.AutocompleteSuggestion{}
}
return false, parsed + namedArg + " ", in[len(namedArg)+1:], model.AutocompleteSuggestion{}
}
func parseInputTextArgument(arg *model.AutocompleteArg, parsed, toBeParsed string) (found bool, alreadyParsed string, yetToBeParsed string, suggestion model.AutocompleteSuggestion) {
in := strings.TrimPrefix(toBeParsed, " ")
a := arg.Data.(*model.AutocompleteTextArg)
if in == "" { //The user has not started typing the argument.
return true, parsed + toBeParsed, "", model.AutocompleteSuggestion{Complete: parsed + toBeParsed, Suggestion: "", Hint: a.Hint, Description: arg.HelpText}
}
if in[0] == '"' { //input with multiple words
indexOfSecondQuote := strings.Index(in[1:], `"`)
if indexOfSecondQuote == -1 { //typing of the multiple word argument is not finished
return true, parsed + toBeParsed, "", model.AutocompleteSuggestion{Complete: parsed + toBeParsed, Suggestion: "", Hint: a.Hint, Description: arg.HelpText}
}
// this argument is typed already
offset := 2
if len(in) > indexOfSecondQuote+2 && in[indexOfSecondQuote+2] == ' ' {
offset++
}
return false, parsed + in[:indexOfSecondQuote+offset], in[indexOfSecondQuote+offset:], model.AutocompleteSuggestion{}
}
// input with a single word
index := strings.Index(in, " ")
if index == -1 { // typing of the single word argument is not finished
return true, parsed + toBeParsed, "", model.AutocompleteSuggestion{Complete: parsed + toBeParsed, Suggestion: "", Hint: a.Hint, Description: arg.HelpText}
}
// single word argument already typed
return false, parsed + in[:index+1], in[index+1:], model.AutocompleteSuggestion{}
}
func parseStaticListArgument(arg *model.AutocompleteArg, parsed, toBeParsed string) (found bool, alreadyParsed string, yetToBeParsed string, suggestions []model.AutocompleteSuggestion) {
a := arg.Data.(*model.AutocompleteStaticListArg)
return parseListItems(a.PossibleArguments, parsed, toBeParsed)
}
func (a *App) getDynamicListArgument(rctx request.CTX, commandArgs *model.CommandArgs, arg *model.AutocompleteArg, parsed, toBeParsed string) (found bool, alreadyParsed string, yetToBeParsed string, suggestions []model.AutocompleteSuggestion) {
dynamicArg := arg.Data.(*model.AutocompleteDynamicListArg)
if strings.HasPrefix(dynamicArg.FetchURL, "builtin:") {
listItems, err := a.getBuiltinDynamicListArgument(rctx, commandArgs, arg, parsed, toBeParsed)
if err != nil {
rctx.Logger().Error("Can't fetch dynamic list arguments for", mlog.String("url", dynamicArg.FetchURL), mlog.Err(err))
return false, parsed, toBeParsed, []model.AutocompleteSuggestion{}
}
return parseListItems(listItems, parsed, toBeParsed)
}
params := url.Values{}
params.Add("user_input", parsed+toBeParsed)
params.Add("parsed", parsed)
// Encode the information normally provided to a plugin slash command handler into the request parameters
// Encode PluginContext:
pluginContext := pluginContext(rctx)
params.Add("request_id", pluginContext.RequestId)
params.Add("session_id", pluginContext.SessionId)
params.Add("ip_address", pluginContext.IPAddress)
params.Add("accept_language", pluginContext.AcceptLanguage)
params.Add("user_agent", pluginContext.UserAgent)
// Encode CommandArgs:
params.Add("channel_id", commandArgs.ChannelId)
params.Add("team_id", commandArgs.TeamId)
params.Add("root_id", commandArgs.RootId)
params.Add("user_id", commandArgs.UserId)
// Use configured SiteURL to prevent SSRF via Host header spoofing (MM-67142)
siteURL := *a.Config().ServiceSettings.SiteURL
if siteURL != "" {
params.Add("site_url", siteURL)
}
resp, err := a.doPluginRequest(rctx, "GET", dynamicArg.FetchURL, params, nil)
if err != nil {
rctx.Logger().Error("Can't fetch dynamic list arguments for", mlog.String("url", dynamicArg.FetchURL), mlog.Err(err))
return false, parsed, toBeParsed, []model.AutocompleteSuggestion{}
}
var listItems []model.AutocompleteListItem
if jsonErr := json.NewDecoder(resp.Body).Decode(&listItems); jsonErr != nil {
rctx.Logger().Warn("Failed to decode from JSON", mlog.Err(jsonErr))
}
return parseListItems(listItems, parsed, toBeParsed)
}
func parseListItems(items []model.AutocompleteListItem, parsed, toBeParsed string) (bool, string, string, []model.AutocompleteSuggestion) {
in := strings.TrimPrefix(toBeParsed, " ")
suggestions := []model.AutocompleteSuggestion{}
maxPrefix := ""
for _, arg := range items {
if strings.HasPrefix(strings.ToLower(in), strings.ToLower(arg.Item)+" ") && len(maxPrefix) < len(arg.Item)+1 {
maxPrefix = arg.Item + " "
}
}
if maxPrefix != "" { //typing of an argument finished
return false, parsed + in[:len(maxPrefix)], in[len(maxPrefix):], []model.AutocompleteSuggestion{}
}
// user has not finished typing static argument
for _, arg := range items {
if strings.HasPrefix(strings.ToLower(arg.Item), strings.ToLower(in)) {
suggestions = append(suggestions, model.AutocompleteSuggestion{Complete: parsed + arg.Item, Suggestion: arg.Item, Hint: arg.Hint, Description: arg.HelpText})
}
}
return true, parsed + toBeParsed, "", suggestions
}
func (a *App) getBuiltinDynamicListArgument(rctx request.CTX, commandArgs *model.CommandArgs, arg *model.AutocompleteArg, parsed, toBeParsed string) ([]model.AutocompleteListItem, error) {
dynamicArg := arg.Data.(*model.AutocompleteDynamicListArg)
arr := strings.Split(dynamicArg.FetchURL, ":")
if len(arr) < 2 {
return nil, errors.New("dynamic list URL missing built-in command name")
}
cmdName := arr[1]
provider := GetCommandProvider(cmdName)
if provider == nil {
return nil, fmt.Errorf("no command provider for %s", cmdName)
}
dp, ok := provider.(AutocompleteDynamicArgProvider)
if !ok {
return nil, fmt.Errorf("auto-completion not available for built-in command %s", cmdName)
}
return dp.GetAutoCompleteListItems(rctx, a, commandArgs, arg, parsed, toBeParsed)
}

View File

@@ -0,0 +1,682 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
)
func TestParseStaticListArgument(t *testing.T) {
mainHelper.Parallel(t)
items := []model.AutocompleteListItem{
{
Hint: "[hint]",
Item: "on",
HelpText: "help",
},
}
fixedArgs := &model.AutocompleteStaticListArg{PossibleArguments: items}
argument := &model.AutocompleteArg{
Name: "", // positional
HelpText: "some_help",
Type: model.AutocompleteArgTypeStaticList,
Data: fixedArgs,
}
found, _, _, suggestions := parseStaticListArgument(argument, "", "") // TODO understand this!
assert.True(t, found)
assert.Equal(t, []model.AutocompleteSuggestion{{Complete: "on", Suggestion: "on", Hint: "[hint]", Description: "help"}}, suggestions)
found, _, _, suggestions = parseStaticListArgument(argument, "", "o")
assert.True(t, found)
assert.Equal(t, []model.AutocompleteSuggestion{{Complete: "on", Suggestion: "on", Hint: "[hint]", Description: "help"}}, suggestions)
found, parsed, toBeParsed, _ := parseStaticListArgument(argument, "", "on ")
assert.False(t, found)
assert.Equal(t, "on ", parsed)
assert.Equal(t, "", toBeParsed)
found, parsed, toBeParsed, _ = parseStaticListArgument(argument, "", "on some")
assert.False(t, found)
assert.Equal(t, "on ", parsed)
assert.Equal(t, "some", toBeParsed)
fixedArgs.PossibleArguments = append(fixedArgs.PossibleArguments,
model.AutocompleteListItem{Hint: "[hint]", Item: "off", HelpText: "help"})
found, _, _, suggestions = parseStaticListArgument(argument, "", "o")
assert.True(t, found)
assert.Equal(t, []model.AutocompleteSuggestion{{Complete: "on", Suggestion: "on", Hint: "[hint]", Description: "help"}, {Complete: "off", Suggestion: "off", Hint: "[hint]", Description: "help"}}, suggestions)
found, _, _, suggestions = parseStaticListArgument(argument, "", "of")
assert.True(t, found)
assert.Equal(t, []model.AutocompleteSuggestion{{Complete: "off", Suggestion: "off", Hint: "[hint]", Description: "help"}}, suggestions)
found, _, _, suggestions = parseStaticListArgument(argument, "", "o some")
assert.True(t, found)
assert.Len(t, suggestions, 0)
found, parsed, toBeParsed, _ = parseStaticListArgument(argument, "", "off some")
assert.False(t, found)
assert.Equal(t, "off ", parsed)
assert.Equal(t, "some", toBeParsed)
fixedArgs.PossibleArguments = append(fixedArgs.PossibleArguments,
model.AutocompleteListItem{Hint: "[hint]", Item: "onon", HelpText: "help"})
found, _, _, suggestions = parseStaticListArgument(argument, "", "on")
assert.True(t, found)
assert.Equal(t, []model.AutocompleteSuggestion{{Complete: "on", Suggestion: "on", Hint: "[hint]", Description: "help"}, {Complete: "onon", Suggestion: "onon", Hint: "[hint]", Description: "help"}}, suggestions)
found, _, _, suggestions = parseStaticListArgument(argument, "bla ", "ono")
assert.True(t, found)
assert.Equal(t, []model.AutocompleteSuggestion{{Complete: "bla onon", Suggestion: "onon", Hint: "[hint]", Description: "help"}}, suggestions)
found, parsed, toBeParsed, _ = parseStaticListArgument(argument, "", "on some")
assert.False(t, found)
assert.Equal(t, "on ", parsed)
assert.Equal(t, "some", toBeParsed)
found, parsed, toBeParsed, _ = parseStaticListArgument(argument, "", "onon some")
assert.False(t, found)
assert.Equal(t, "onon ", parsed)
assert.Equal(t, "some", toBeParsed)
}
func TestParseInputTextArgument(t *testing.T) {
mainHelper.Parallel(t)
argument := &model.AutocompleteArg{
Name: "", // positional
HelpText: "some_help",
Type: model.AutocompleteArgTypeText,
Data: &model.AutocompleteTextArg{Hint: "hint", Pattern: "pat"},
}
found, _, _, suggestion := parseInputTextArgument(argument, "", "")
assert.True(t, found)
assert.Equal(t, model.AutocompleteSuggestion{Complete: "", Suggestion: "", Hint: "hint", Description: "some_help"}, suggestion)
found, _, _, suggestion = parseInputTextArgument(argument, "", " ")
assert.True(t, found)
assert.Equal(t, model.AutocompleteSuggestion{Complete: " ", Suggestion: "", Hint: "hint", Description: "some_help"}, suggestion)
found, _, _, suggestion = parseInputTextArgument(argument, "", "abc")
assert.True(t, found)
assert.Equal(t, model.AutocompleteSuggestion{Complete: "abc", Suggestion: "", Hint: "hint", Description: "some_help"}, suggestion)
found, _, _, suggestion = parseInputTextArgument(argument, "", "\"abc dfd df ")
assert.True(t, found)
assert.Equal(t, model.AutocompleteSuggestion{Complete: "\"abc dfd df ", Suggestion: "", Hint: "hint", Description: "some_help"}, suggestion)
found, parsed, toBeParsed, _ := parseInputTextArgument(argument, "", "abc efg ")
assert.False(t, found)
assert.Equal(t, "abc ", parsed)
assert.Equal(t, "efg ", toBeParsed)
found, parsed, toBeParsed, _ = parseInputTextArgument(argument, "", "abc ")
assert.False(t, found)
assert.Equal(t, "abc ", parsed)
assert.Equal(t, "", toBeParsed)
found, parsed, toBeParsed, _ = parseInputTextArgument(argument, "", "\"abc def\" abc")
assert.False(t, found)
assert.Equal(t, "\"abc def\" ", parsed)
assert.Equal(t, "abc", toBeParsed)
found, parsed, toBeParsed, _ = parseInputTextArgument(argument, "", "\"abc def\"")
assert.False(t, found)
assert.Equal(t, "\"abc def\"", parsed)
assert.Equal(t, "", toBeParsed)
}
func TestParseNamedArguments(t *testing.T) {
mainHelper.Parallel(t)
argument := &model.AutocompleteArg{
Name: "name", // named
HelpText: "some_help",
Type: model.AutocompleteArgTypeText,
Data: &model.AutocompleteTextArg{Hint: "hint", Pattern: "pat"},
}
found, _, _, suggestion := parseNamedArgument(argument, "", "")
assert.True(t, found)
assert.Equal(t, model.AutocompleteSuggestion{Complete: "--name ", Suggestion: "--name", Hint: "", Description: "some_help"}, suggestion)
found, _, _, suggestion = parseNamedArgument(argument, "", " ")
assert.True(t, found)
assert.Equal(t, model.AutocompleteSuggestion{Complete: " --name ", Suggestion: "--name", Hint: "", Description: "some_help"}, suggestion)
found, parsed, toBeParsed, _ := parseNamedArgument(argument, "", "abc")
assert.False(t, found)
assert.Equal(t, "abc", parsed)
assert.Equal(t, "", toBeParsed)
found, parsed, toBeParsed, suggestion = parseNamedArgument(argument, "", "-")
assert.True(t, found)
assert.Equal(t, "-", parsed)
assert.Equal(t, "", toBeParsed)
assert.Equal(t, model.AutocompleteSuggestion{Complete: "--name ", Suggestion: "--name", Hint: "", Description: "some_help"}, suggestion)
found, parsed, toBeParsed, suggestion = parseNamedArgument(argument, "", " -")
assert.True(t, found)
assert.Equal(t, " -", parsed)
assert.Equal(t, "", toBeParsed)
assert.Equal(t, model.AutocompleteSuggestion{Complete: " --name ", Suggestion: "--name", Hint: "", Description: "some_help"}, suggestion)
found, parsed, toBeParsed, suggestion = parseNamedArgument(argument, "", "--name")
assert.True(t, found)
assert.Equal(t, "--name", parsed)
assert.Equal(t, "", toBeParsed)
assert.Equal(t, model.AutocompleteSuggestion{Complete: "--name ", Suggestion: "--name", Hint: "", Description: "some_help"}, suggestion)
found, parsed, toBeParsed, _ = parseNamedArgument(argument, "", "--name bla")
assert.False(t, found)
assert.Equal(t, "--name ", parsed)
assert.Equal(t, "bla", toBeParsed)
found, parsed, toBeParsed, _ = parseNamedArgument(argument, "", "--name bla gla")
assert.False(t, found)
assert.Equal(t, "--name ", parsed)
assert.Equal(t, "bla gla", toBeParsed)
found, parsed, toBeParsed, _ = parseNamedArgument(argument, "", "--name \"bla gla\"")
assert.False(t, found)
assert.Equal(t, "--name ", parsed)
assert.Equal(t, "\"bla gla\"", toBeParsed)
found, parsed, toBeParsed, _ = parseNamedArgument(argument, "", "--name \"bla gla\" ")
assert.False(t, found)
assert.Equal(t, "--name ", parsed)
assert.Equal(t, "\"bla gla\" ", toBeParsed)
found, parsed, toBeParsed, _ = parseNamedArgument(argument, "", "bla")
assert.False(t, found)
assert.Equal(t, "bla", parsed)
assert.Equal(t, "", toBeParsed)
}
func TestSuggestions(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
jira := createJiraAutocompleteData()
emptyCmdArgs := &model.CommandArgs{}
suggestions := th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{jira}, "", "ji", model.SystemAdminRoleId)
assert.Len(t, suggestions, 1)
assert.Equal(t, jira.Trigger, suggestions[0].Complete)
assert.Equal(t, jira.Trigger, suggestions[0].Suggestion)
assert.Equal(t, "[command]", suggestions[0].Hint)
assert.Equal(t, jira.HelpText, suggestions[0].Description)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{jira}, "", "jira crea", model.SystemAdminRoleId)
assert.Len(t, suggestions, 1)
assert.Equal(t, "jira create", suggestions[0].Complete)
assert.Equal(t, "create", suggestions[0].Suggestion)
assert.Equal(t, "[issue text]", suggestions[0].Hint)
assert.Equal(t, "Create a new Issue", suggestions[0].Description)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{jira}, "", "jira c", model.SystemAdminRoleId)
assert.Len(t, suggestions, 2)
assert.Equal(t, "jira create", suggestions[1].Complete)
assert.Equal(t, "create", suggestions[1].Suggestion)
assert.Equal(t, "[issue text]", suggestions[1].Hint)
assert.Equal(t, "Create a new Issue", suggestions[1].Description)
assert.Equal(t, "jira connect", suggestions[0].Complete)
assert.Equal(t, "connect", suggestions[0].Suggestion)
assert.Equal(t, "[url]", suggestions[0].Hint)
assert.Equal(t, "Connect your Mattermost account to your Jira account", suggestions[0].Description)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{jira}, "", "jira create ", model.SystemAdminRoleId)
assert.Len(t, suggestions, 1)
assert.Equal(t, "jira create ", suggestions[0].Complete)
assert.Equal(t, "", suggestions[0].Suggestion)
assert.Equal(t, "[text]", suggestions[0].Hint)
assert.Equal(t, "This text is optional, will be inserted into the description field", suggestions[0].Description)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{jira}, "", "jira create some", model.SystemAdminRoleId)
assert.Len(t, suggestions, 1)
assert.Equal(t, "jira create some", suggestions[0].Complete)
assert.Equal(t, "", suggestions[0].Suggestion)
assert.Equal(t, "[text]", suggestions[0].Hint)
assert.Equal(t, "This text is optional, will be inserted into the description field", suggestions[0].Description)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{jira}, "", "jira create some text ", model.SystemAdminRoleId)
assert.Len(t, suggestions, 0)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{jira}, "", "invalid command", model.SystemAdminRoleId)
assert.Len(t, suggestions, 0)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{jira}, "", "jira settings notifications o", model.SystemAdminRoleId)
assert.Len(t, suggestions, 2)
assert.Equal(t, "jira settings notifications On", suggestions[0].Complete)
assert.Equal(t, "On", suggestions[0].Suggestion)
assert.Equal(t, "Turn notifications on", suggestions[0].Hint)
assert.Equal(t, "", suggestions[0].Description)
assert.Equal(t, "jira settings notifications Off", suggestions[1].Complete)
assert.Equal(t, "Off", suggestions[1].Suggestion)
assert.Equal(t, "Turn notifications off", suggestions[1].Hint)
assert.Equal(t, "", suggestions[1].Description)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{jira}, "", "jira ", model.SystemAdminRoleId)
assert.Len(t, suggestions, 11)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{jira}, "", "jira ", model.SystemUserRoleId)
assert.Len(t, suggestions, 9)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{jira}, "", "jira create \"some issue text", model.SystemAdminRoleId)
assert.Len(t, suggestions, 1)
assert.Equal(t, "jira create \"some issue text", suggestions[0].Complete)
assert.Equal(t, "", suggestions[0].Suggestion)
assert.Equal(t, "[text]", suggestions[0].Hint)
assert.Equal(t, "This text is optional, will be inserted into the description field", suggestions[0].Description)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{jira}, "", "jira timezone ", model.SystemAdminRoleId)
assert.Len(t, suggestions, 1)
assert.Equal(t, "jira timezone --zone ", suggestions[0].Complete)
assert.Equal(t, "--zone", suggestions[0].Suggestion)
assert.Equal(t, "", suggestions[0].Hint)
assert.Equal(t, "Set timezone", suggestions[0].Description)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{jira}, "", "jira timezone --", model.SystemAdminRoleId)
assert.Len(t, suggestions, 1)
assert.Equal(t, "jira timezone --zone ", suggestions[0].Complete)
assert.Equal(t, "--zone", suggestions[0].Suggestion)
assert.Equal(t, "", suggestions[0].Hint)
assert.Equal(t, "Set timezone", suggestions[0].Description)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{jira}, "", "jira timezone --zone ", model.SystemAdminRoleId)
assert.Len(t, suggestions, 1)
assert.Equal(t, "jira timezone --zone ", suggestions[0].Complete)
assert.Equal(t, "", suggestions[0].Suggestion)
assert.Equal(t, "[UTC+07:00]", suggestions[0].Hint)
assert.Equal(t, "Set timezone", suggestions[0].Description)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{jira}, "", "jira timezone --zone bla", model.SystemAdminRoleId)
assert.Len(t, suggestions, 1)
assert.Equal(t, "jira timezone --zone bla", suggestions[0].Complete)
assert.Equal(t, "", suggestions[0].Suggestion)
assert.Equal(t, "[UTC+07:00]", suggestions[0].Hint)
assert.Equal(t, "Set timezone", suggestions[0].Description)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{jira}, "", "jira timezone bla", model.SystemAdminRoleId)
assert.Len(t, suggestions, 0)
commandA := &model.Command{
Trigger: "alice",
AutocompleteData: model.NewAutocompleteData("alice", "", ""),
}
commandB := &model.Command{
Trigger: "bob",
AutocompleteData: model.NewAutocompleteData("bob", "", ""),
}
commandC := &model.Command{
Trigger: "charles",
AutocompleteData: model.NewAutocompleteData("charles", "", ""),
}
suggestions = th.App.GetSuggestions(th.Context, emptyCmdArgs, []*model.Command{commandB, commandC, commandA}, model.SystemAdminRoleId)
assert.Len(t, suggestions, 3)
assert.Equal(t, "alice", suggestions[0].Complete)
assert.Equal(t, "bob", suggestions[1].Complete)
assert.Equal(t, "charles", suggestions[2].Complete)
}
func TestCommandWithOptionalArgs(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
command := createCommandWithOptionalArgs()
emptyCmdArgs := &model.CommandArgs{}
suggestions := th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{command}, "", "comm", model.SystemAdminRoleId)
assert.Len(t, suggestions, 1)
assert.Equal(t, command.Trigger, suggestions[0].Complete)
assert.Equal(t, command.Trigger, suggestions[0].Suggestion)
assert.Equal(t, "", suggestions[0].Hint)
assert.Equal(t, command.HelpText, suggestions[0].Description)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{command}, "", "command ", model.SystemAdminRoleId)
assert.Len(t, suggestions, 4)
assert.Equal(t, "command subcommand1", suggestions[0].Complete)
assert.Equal(t, "subcommand1", suggestions[0].Suggestion)
assert.Equal(t, "", suggestions[0].Hint)
assert.Equal(t, "", suggestions[0].Description)
assert.Equal(t, "command subcommand2", suggestions[1].Complete)
assert.Equal(t, "subcommand2", suggestions[1].Suggestion)
assert.Equal(t, "", suggestions[1].Hint)
assert.Equal(t, "", suggestions[1].Description)
assert.Equal(t, "command subcommand3", suggestions[2].Complete)
assert.Equal(t, "subcommand3", suggestions[2].Suggestion)
assert.Equal(t, "", suggestions[2].Hint)
assert.Equal(t, "", suggestions[2].Description)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{command}, "", "command subcommand1 ", model.SystemAdminRoleId)
assert.Len(t, suggestions, 2)
assert.Equal(t, "command subcommand1 item1", suggestions[0].Complete)
assert.Equal(t, "item1", suggestions[0].Suggestion)
assert.Equal(t, "", suggestions[0].Hint)
assert.Equal(t, "", suggestions[0].Description)
assert.Equal(t, "command subcommand1 item2", suggestions[1].Complete)
assert.Equal(t, "item2", suggestions[1].Suggestion)
assert.Equal(t, "", suggestions[1].Hint)
assert.Equal(t, "", suggestions[1].Description)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{command}, "", "command subcommand1 item1 ", model.SystemAdminRoleId)
assert.Len(t, suggestions, 1)
assert.Equal(t, "command subcommand1 item1 --name2 ", suggestions[0].Complete)
assert.Equal(t, "--name2", suggestions[0].Suggestion)
assert.Equal(t, "", suggestions[0].Hint)
assert.Equal(t, "arg2", suggestions[0].Description)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{command}, "", "command subcommand1 item1 --name2 bla", model.SystemAdminRoleId)
assert.Len(t, suggestions, 1)
assert.Equal(t, "command subcommand1 item1 --name2 bla", suggestions[0].Complete)
assert.Equal(t, "", suggestions[0].Suggestion)
assert.Equal(t, "", suggestions[0].Hint)
assert.Equal(t, "arg2", suggestions[0].Description)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{command}, "", "command subcommand2 ", model.SystemAdminRoleId)
assert.Len(t, suggestions, 2)
assert.Equal(t, "command subcommand2 --name1 ", suggestions[0].Complete)
assert.Equal(t, "--name1", suggestions[0].Suggestion)
assert.Equal(t, "", suggestions[0].Hint)
assert.Equal(t, "arg1", suggestions[0].Description)
assert.Equal(t, "command subcommand2 ", suggestions[1].Complete)
assert.Equal(t, "", suggestions[1].Suggestion)
assert.Equal(t, "", suggestions[1].Hint)
assert.Equal(t, "arg2", suggestions[1].Description)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{command}, "", "command subcommand2 -", model.SystemAdminRoleId)
assert.Len(t, suggestions, 2)
assert.Equal(t, "command subcommand2 --name1 ", suggestions[0].Complete)
assert.Equal(t, "--name1", suggestions[0].Suggestion)
assert.Equal(t, "", suggestions[0].Hint)
assert.Equal(t, "arg1", suggestions[0].Description)
assert.Equal(t, "command subcommand2 -", suggestions[1].Complete)
assert.Equal(t, "", suggestions[1].Suggestion)
assert.Equal(t, "", suggestions[1].Hint)
assert.Equal(t, "arg2", suggestions[1].Description)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{command}, "", "command subcommand2 --name1 ", model.SystemAdminRoleId)
assert.Len(t, suggestions, 3)
assert.Equal(t, "command subcommand2 --name1 item1", suggestions[0].Complete)
assert.Equal(t, "item1", suggestions[0].Suggestion)
assert.Equal(t, "", suggestions[0].Hint)
assert.Equal(t, "", suggestions[0].Description)
assert.Equal(t, "command subcommand2 --name1 item2", suggestions[1].Complete)
assert.Equal(t, "item2", suggestions[1].Suggestion)
assert.Equal(t, "", suggestions[1].Hint)
assert.Equal(t, "", suggestions[1].Description)
assert.Equal(t, "command subcommand2 --name1 ", suggestions[2].Complete)
assert.Equal(t, "", suggestions[2].Suggestion)
assert.Equal(t, "", suggestions[2].Hint)
assert.Equal(t, "arg3", suggestions[2].Description)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{command}, "", "command subcommand2 --name1 item", model.SystemAdminRoleId)
assert.Len(t, suggestions, 3)
assert.Equal(t, "command subcommand2 --name1 item1", suggestions[0].Complete)
assert.Equal(t, "item1", suggestions[0].Suggestion)
assert.Equal(t, "", suggestions[0].Hint)
assert.Equal(t, "", suggestions[0].Description)
assert.Equal(t, "command subcommand2 --name1 item2", suggestions[1].Complete)
assert.Equal(t, "item2", suggestions[1].Suggestion)
assert.Equal(t, "", suggestions[1].Hint)
assert.Equal(t, "", suggestions[1].Description)
assert.Equal(t, "command subcommand2 --name1 item", suggestions[2].Complete)
assert.Equal(t, "", suggestions[2].Suggestion)
assert.Equal(t, "", suggestions[2].Hint)
assert.Equal(t, "arg3", suggestions[2].Description)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{command}, "", "command subcommand2 --name1 item1 ", model.SystemAdminRoleId)
assert.Len(t, suggestions, 1)
assert.Equal(t, "command subcommand2 --name1 item1 ", suggestions[0].Complete)
assert.Equal(t, "", suggestions[0].Suggestion)
assert.Equal(t, "", suggestions[0].Hint)
assert.Equal(t, "arg2", suggestions[0].Description)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{command}, "", "command subcommand2 --name1 item1 bla ", model.SystemAdminRoleId)
assert.Len(t, suggestions, 1)
assert.Equal(t, "command subcommand2 --name1 item1 bla ", suggestions[0].Complete)
assert.Equal(t, "", suggestions[0].Suggestion)
assert.Equal(t, "", suggestions[0].Hint)
assert.Equal(t, "arg3", suggestions[0].Description)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{command}, "", "command subcommand2 --name1 item1 bla bla ", model.SystemAdminRoleId)
assert.Len(t, suggestions, 0)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{command}, "", "command subcommand3 ", model.SystemAdminRoleId)
assert.Len(t, suggestions, 3)
assert.Equal(t, "command subcommand3 --name1 ", suggestions[0].Complete)
assert.Equal(t, "--name1", suggestions[0].Suggestion)
assert.Equal(t, "", suggestions[0].Hint)
assert.Equal(t, "arg1", suggestions[0].Description)
assert.Equal(t, "command subcommand3 --name2 ", suggestions[1].Complete)
assert.Equal(t, "--name2", suggestions[1].Suggestion)
assert.Equal(t, "", suggestions[1].Hint)
assert.Equal(t, "arg2", suggestions[1].Description)
assert.Equal(t, "command subcommand3 --name3 ", suggestions[2].Complete)
assert.Equal(t, "--name3", suggestions[2].Suggestion)
assert.Equal(t, "", suggestions[2].Hint)
assert.Equal(t, "arg3", suggestions[2].Description)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{command}, "", "command subcommand3 --name", model.SystemAdminRoleId)
assert.Len(t, suggestions, 3)
assert.Equal(t, "command subcommand3 --name1 ", suggestions[0].Complete)
assert.Equal(t, "--name1", suggestions[0].Suggestion)
assert.Equal(t, "", suggestions[0].Hint)
assert.Equal(t, "arg1", suggestions[0].Description)
assert.Equal(t, "command subcommand3 --name2 ", suggestions[1].Complete)
assert.Equal(t, "--name2", suggestions[1].Suggestion)
assert.Equal(t, "", suggestions[1].Hint)
assert.Equal(t, "arg2", suggestions[1].Description)
assert.Equal(t, "command subcommand3 --name3 ", suggestions[2].Complete)
assert.Equal(t, "--name3", suggestions[2].Suggestion)
assert.Equal(t, "", suggestions[2].Hint)
assert.Equal(t, "arg3", suggestions[2].Description)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{command}, "", "command subcommand3 --name1 ", model.SystemAdminRoleId)
assert.Len(t, suggestions, 2)
assert.Equal(t, "command subcommand3 --name1 item1", suggestions[0].Complete)
assert.Equal(t, "item1", suggestions[0].Suggestion)
assert.Equal(t, "", suggestions[0].Hint)
assert.Equal(t, "", suggestions[0].Description)
assert.Equal(t, "command subcommand3 --name1 item2", suggestions[1].Complete)
assert.Equal(t, "item2", suggestions[1].Suggestion)
assert.Equal(t, "", suggestions[1].Hint)
assert.Equal(t, "", suggestions[1].Description)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{command}, "", "command subcommand4 ", model.SystemAdminRoleId)
assert.Len(t, suggestions, 2)
assert.Equal(t, "command subcommand4 item1", suggestions[0].Complete)
assert.Equal(t, "item1", suggestions[0].Suggestion)
assert.Equal(t, "(optional)", suggestions[0].Hint)
assert.Equal(t, "help3", suggestions[0].Description)
assert.Equal(t, "command subcommand4 ", suggestions[1].Complete)
assert.Equal(t, "", suggestions[1].Suggestion)
assert.Equal(t, "message", suggestions[1].Hint)
assert.Equal(t, "help4", suggestions[1].Description)
suggestions = th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{command}, "", "command subcommand4 item1 ", model.SystemAdminRoleId)
assert.Len(t, suggestions, 1)
assert.Equal(t, "command subcommand4 item1 ", suggestions[0].Complete)
assert.Equal(t, "", suggestions[0].Suggestion)
assert.Equal(t, "message", suggestions[0].Hint)
assert.Equal(t, "help4", suggestions[0].Description)
}
func createCommandWithOptionalArgs() *model.AutocompleteData {
command := model.NewAutocompleteData("command", "", "")
subCommand1 := model.NewAutocompleteData("subcommand1", "", "")
subCommand1.AddStaticListArgument("arg1", true, []model.AutocompleteListItem{{Item: "item1"}, {Item: "item2"}})
subCommand1.AddNamedTextArgument("name2", "arg2", "", "", false)
command.AddCommand(subCommand1)
subCommand2 := model.NewAutocompleteData("subcommand2", "", "")
subCommand2.AddNamedStaticListArgument("name1", "arg1", false, []model.AutocompleteListItem{{Item: "item1"}, {Item: "item2"}})
subCommand2.AddTextArgument("arg2", "", "")
subCommand2.AddTextArgument("arg3", "", "")
command.AddCommand(subCommand2)
subCommand3 := model.NewAutocompleteData("subcommand3", "", "")
subCommand3.AddNamedStaticListArgument("name1", "arg1", false, []model.AutocompleteListItem{{Item: "item1"}, {Item: "item2"}})
subCommand3.AddNamedTextArgument("name2", "arg2", "", "", false)
subCommand3.AddNamedTextArgument("name3", "arg3", "", "", false)
command.AddCommand(subCommand3)
subcommand4 := model.NewAutocompleteData("subcommand4", "", "help1")
subcommand4.AddStaticListArgument("help2", false, []model.AutocompleteListItem{{
HelpText: "help3",
Hint: "(optional)",
Item: "item1",
}})
subcommand4.AddTextArgument("help4", "message", "")
command.AddCommand(subcommand4)
return command
}
// createJiraAutocompleteData will create autocomplete data for jira plugin. For testing purposes only.
func createJiraAutocompleteData() *model.AutocompleteData {
jira := model.NewAutocompleteData("jira", "[command]", "Available commands: connect, assign, disconnect, create, transition, view, subscribe, settings, install cloud/server, uninstall cloud/server, help")
connect := model.NewAutocompleteData("connect", "[url]", "Connect your Mattermost account to your Jira account")
jira.AddCommand(connect)
disconnect := model.NewAutocompleteData("disconnect", "", "Disconnect your Mattermost account from your Jira account")
jira.AddCommand(disconnect)
assign := model.NewAutocompleteData("assign", "[issue]", "Change the assignee of a Jira issue")
assign.AddDynamicListArgument("List of issues is downloading from your Jira account", "/url/issue-key", true)
assign.AddDynamicListArgument("List of assignees is downloading from your Jira account", "/url/assignee", true)
jira.AddCommand(assign)
create := model.NewAutocompleteData("create", "[issue text]", "Create a new Issue")
create.AddTextArgument("This text is optional, will be inserted into the description field", "[text]", "")
jira.AddCommand(create)
transition := model.NewAutocompleteData("transition", "[issue]", "Change the state of a Jira issue")
assign.AddDynamicListArgument("List of issues is downloading from your Jira account", "/url/issue-key", true)
assign.AddDynamicListArgument("List of states is downloading from your Jira account", "/url/states", true)
jira.AddCommand(transition)
subscribe := model.NewAutocompleteData("subscribe", "", "Configure the Jira notifications sent to this channel")
jira.AddCommand(subscribe)
view := model.NewAutocompleteData("view", "[issue]", "View the details of a specific Jira issue")
assign.AddDynamicListArgument("List of issues is downloading from your Jira account", "/url/issue-key", true)
jira.AddCommand(view)
settings := model.NewAutocompleteData("settings", "", "Update your user settings")
notifications := model.NewAutocompleteData("notifications", "[on/off]", "Turn notifications on or off")
items := []model.AutocompleteListItem{
{
Hint: "Turn notifications on",
Item: "On",
},
{
Hint: "Turn notifications off",
Item: "Off",
},
}
notifications.AddStaticListArgument("Turn notifications on or off", true, items)
settings.AddCommand(notifications)
jira.AddCommand(settings)
timezone := model.NewAutocompleteData("timezone", "", "Update your timezone")
timezone.AddNamedTextArgument("zone", "Set timezone", "[UTC+07:00]", "", true)
jira.AddCommand(timezone)
install := model.NewAutocompleteData("install", "", "Connect Mattermost to a Jira instance")
install.RoleID = model.SystemAdminRoleId
cloud := model.NewAutocompleteData("cloud", "", "Connect to a Jira Cloud instance")
urlPattern := "https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\\+.~#?&//=]*)"
cloud.AddTextArgument("input URL of the Jira Cloud instance", "[URL]", urlPattern)
install.AddCommand(cloud)
server := model.NewAutocompleteData("server", "", "Connect to a Jira Server or Data Center instance")
server.AddTextArgument("input URL of the Jira Server or Data Center instance", "[URL]", urlPattern)
install.AddCommand(server)
jira.AddCommand(install)
uninstall := model.NewAutocompleteData("uninstall", "", "Disconnect Mattermost from a Jira instance")
uninstall.RoleID = model.SystemAdminRoleId
cloud = model.NewAutocompleteData("cloud", "", "Disconnect from a Jira Cloud instance")
cloud.AddTextArgument("input URL of the Jira Cloud instance", "[URL]", urlPattern)
uninstall.AddCommand(cloud)
server = model.NewAutocompleteData("server", "", "Disconnect from a Jira Server or Data Center instance")
server.AddTextArgument("input URL of the Jira Server or Data Center instance", "[URL]", urlPattern)
uninstall.AddCommand(server)
jira.AddCommand(uninstall)
return jira
}
func TestDynamicListArgsForBuiltin(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
provider := &testCommandProvider{}
RegisterCommandProvider(provider)
command := provider.GetCommand(th.App, nil)
emptyCmdArgs := &model.CommandArgs{}
t.Run("GetAutoCompleteListItems", func(t *testing.T) {
suggestions := th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{command.AutocompleteData}, "", "bogus --dynaArg ", model.SystemAdminRoleId)
assert.Len(t, suggestions, 3)
assert.Equal(t, "this is hint 1", suggestions[0].Hint)
assert.Equal(t, "this is hint 2", suggestions[1].Hint)
assert.Equal(t, "this is hint 3", suggestions[2].Hint)
})
t.Run("GetAutoCompleteListItems bad arg", func(t *testing.T) {
suggestions := th.App.getSuggestions(th.Context, emptyCmdArgs, []*model.AutocompleteData{command.AutocompleteData}, "", "bogus --badArg ", model.SystemAdminRoleId)
assert.Empty(t, suggestions)
})
}
type testCommandProvider struct{}
func (p *testCommandProvider) GetTrigger() string {
return "bogus"
}
func (p *testCommandProvider) GetCommand(a *App, T i18n.TranslateFunc) *model.Command {
top := model.NewAutocompleteData(p.GetTrigger(), "[command]", "Just a test.")
top.AddNamedDynamicListArgument("dynaArg", "A dynamic list", "builtin:bogus", true)
return &model.Command{
Trigger: p.GetTrigger(),
AutoComplete: true,
AutoCompleteDesc: "Test description",
AutoCompleteHint: "Test hint.",
DisplayName: "test display name",
AutocompleteData: top,
}
}
func (p *testCommandProvider) DoCommand(a *App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
return &model.CommandResponse{
Text: "I do nothing!",
ResponseType: model.CommandResponseTypeEphemeral,
}
}
func (p *testCommandProvider) GetAutoCompleteListItems(rctx request.CTX, a *App, commandArgs *model.CommandArgs, arg *model.AutocompleteArg, parsed, toBeParsed string) ([]model.AutocompleteListItem, error) {
if arg.Name == "dynaArg" {
return []model.AutocompleteListItem{
{Item: "item1", Hint: "this is hint 1", HelpText: "This is help text 1."},
{Item: "item2", Hint: "this is hint 2", HelpText: "This is help text 2."},
{Item: "item3", Hint: "this is hint 3", HelpText: "This is help text 3."},
}, nil
}
return nil, fmt.Errorf("%s not a dynamic argument", arg.Name)
}
// ensure testCommandProvider implements AutocompleteDynamicArgProvider
var _ AutocompleteDynamicArgProvider = (*testCommandProvider)(nil)

View File

@@ -0,0 +1,86 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestPossibleAtMentions(t *testing.T) {
mainHelper.Parallel(t)
fixture := []struct {
message string
expected []string
}{
{
"",
[]string{},
},
{
"@user",
[]string{"user"},
},
{
"@user-with_special.chars @multiple.-_chars",
[]string{"user-with_special.chars", "multiple.-_chars"},
},
{
"@repeated @user @repeated",
[]string{"repeated", "user"},
},
{
"@user1 @user2 @user3",
[]string{"user1", "user2", "user3"},
},
{
"@李",
[]string{},
},
{
"@withfinaldot. @withfinaldash- @withfinalunderscore_",
[]string{
"withfinaldot.",
"withfinaldash-",
"withfinalunderscore_",
},
},
}
for _, data := range fixture {
actual := possibleAtMentions(data.message)
require.ElementsMatch(t, actual, data.expected)
}
}
func TestTrimUsernameSpecialChar(t *testing.T) {
mainHelper.Parallel(t)
fixture := []struct {
word string
expectedString string
expectedBool bool
}{
{"user...", "user..", true},
{"user..", "user.", true},
{"user.", "user", true},
{"user--", "user-", true},
{"user-", "user", true},
{"user_.-", "user_.", true},
{"user_.", "user_", true},
{"user_", "user", true},
{"user", "user", false},
{"user.with-inner_chars", "user.with.inner.chars", false},
}
for _, data := range fixture {
actualString, actualBool := trimUsernameSpecialChar(data.word)
require.Equal(t, actualBool, data.expectedBool)
if actualBool {
require.Equal(t, actualString, data.expectedString)
} else {
require.Equal(t, actualString, data.word)
}
}
}

View File

@@ -0,0 +1,86 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"errors"
"net/http"
"os"
"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"
)
func (a *App) GetComplianceReports(page, perPage int) (model.Compliances, *model.AppError) {
if license := a.Srv().License(); !*a.Config().ComplianceSettings.Enable || license == nil || !*license.Features.Compliance {
return nil, model.NewAppError("GetComplianceReports", "ent.compliance.licence_disable.app_error", nil, "", http.StatusNotImplemented)
}
compliances, err := a.Srv().Store().Compliance().GetAll(page*perPage, perPage)
if err != nil {
return nil, model.NewAppError("GetComplianceReports", "app.compliance.get.finding.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return compliances, nil
}
func (a *App) SaveComplianceReport(rctx request.CTX, job *model.Compliance) (*model.Compliance, *model.AppError) {
if license := a.Srv().License(); !*a.Config().ComplianceSettings.Enable || license == nil || !*license.Features.Compliance || a.Compliance() == nil {
return nil, model.NewAppError("saveComplianceReport", "ent.compliance.licence_disable.app_error", nil, "", http.StatusNotImplemented)
}
job.Type = model.ComplianceTypeAdhoc
rctx = rctx.WithLogFields(job.LoggerFields()...)
job, err := a.Srv().Store().Compliance().Save(job)
if err != nil {
var appErr *model.AppError
switch {
case errors.As(err, &appErr):
return nil, appErr
default:
return nil, model.NewAppError("SaveComplianceReport", "app.compliance.save.saving.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
jCopy := job.DeepCopy()
a.Srv().Go(func() {
err := a.Compliance().RunComplianceJob(rctx, jCopy)
if err != nil {
rctx.Logger().Warn("Error running compliance job", mlog.Err(err))
}
})
return job, nil
}
func (a *App) GetComplianceReport(reportId string) (*model.Compliance, *model.AppError) {
if license := a.Srv().License(); !*a.Config().ComplianceSettings.Enable || license == nil || !*license.Features.Compliance || a.Compliance() == nil {
return nil, model.NewAppError("downloadComplianceReport", "ent.compliance.licence_disable.app_error", nil, "", http.StatusNotImplemented)
}
compliance, err := a.Srv().Store().Compliance().Get(reportId)
if err != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(err, &nfErr):
return nil, model.NewAppError("GetComplianceReport", "app.compliance.get.finding.app_error", nil, "", http.StatusNotFound).Wrap(err)
default:
return nil, model.NewAppError("GetComplianceReport", "app.compliance.get.finding.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
return compliance, nil
}
func (a *App) GetComplianceFile(job *model.Compliance) ([]byte, *model.AppError) {
f, err := os.ReadFile(*a.Config().ComplianceSettings.Directory + "compliance/" + job.JobName() + ".zip")
if err != nil {
return nil, model.NewAppError("readFile", "api.file.read_file.reading_local.app_error", nil, "", http.StatusNotImplemented).Wrap(err)
}
return f, nil
}

View File

@@ -0,0 +1,351 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"crypto/ecdsa"
"crypto/rand"
"encoding/json"
"net/url"
"reflect"
"strconv"
"time"
"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/utils"
"github.com/mattermost/mattermost/server/v8/platform/shared/mail"
)
const (
ErrorTermsOfServiceNoRowsFound = "app.terms_of_service.get.no_rows.app_error"
)
func (s *Server) Config() *model.Config {
return s.platform.Config()
}
func (a *App) Config() *model.Config {
return a.ch.cfgSvc.Config()
}
func (a *App) EnvironmentConfig(filter func(reflect.StructField) bool) map[string]any {
return a.Srv().platform.GetEnvironmentOverridesWithFilter(filter)
}
func (a *App) UpdateConfig(f func(*model.Config)) {
a.Srv().platform.UpdateConfig(f)
}
func (a *App) IsConfigReadOnly() bool {
return a.Srv().platform.IsConfigReadOnly()
}
func (a *App) ReloadConfig() error {
return a.Srv().platform.ReloadConfig()
}
func (a *App) ClientConfig() map[string]string {
return a.ch.srv.platform.ClientConfig()
}
func (a *App) ClientConfigHash() string {
return a.ch.ClientConfigHash()
}
func (a *App) LimitedClientConfig() map[string]string {
return a.ch.srv.platform.LimitedClientConfig()
}
func (a *App) AddConfigListener(listener func(*model.Config, *model.Config)) string {
return a.Srv().platform.AddConfigListener(listener)
}
func (a *App) AddLicenseListener(listener func(oldLicense, newLicense *model.License)) string {
return a.Srv().platform.AddLicenseListener(listener)
}
// Removes a listener function by the unique ID returned when AddConfigListener was called
func (a *App) RemoveConfigListener(id string) {
a.Srv().platform.RemoveConfigListener(id)
}
// ensurePostActionCookieSecret ensures that the key for encrypting PostActionCookie exists
// and future calls to PostActionCookieSecret will always return a valid key, same on all
// servers in the cluster
func (ch *Channels) ensurePostActionCookieSecret() error {
if ch.postActionCookieSecret != nil {
return nil
}
var secret *model.SystemPostActionCookieSecret
value, err := ch.srv.Store().System().GetByName(model.SystemPostActionCookieSecretKey)
if err == nil {
if err := json.Unmarshal([]byte(value.Value), &secret); err != nil {
return err
}
}
// If we don't already have a key, try to generate one.
if secret == nil {
newSecret := &model.SystemPostActionCookieSecret{
Secret: make([]byte, 32),
}
_, err := rand.Reader.Read(newSecret.Secret)
if err != nil {
return err
}
system := &model.System{
Name: model.SystemPostActionCookieSecretKey,
}
v, err := json.Marshal(newSecret)
if err != nil {
return err
}
system.Value = string(v)
// If we were able to save the key, use it, otherwise log the error.
if err = ch.srv.Store().System().Save(system); err != nil {
mlog.Warn("Failed to save PostActionCookieSecret", mlog.Err(err))
} else {
secret = newSecret
}
}
// If we weren't able to save a new key above, another server must have beat us to it. Get the
// key from the database, and if that fails, error out.
if secret == nil {
value, err := ch.srv.Store().System().GetByName(model.SystemPostActionCookieSecretKey)
if err != nil {
return err
}
if err := json.Unmarshal([]byte(value.Value), &secret); err != nil {
return err
}
}
ch.postActionCookieSecret = secret.Secret
return nil
}
// ensureSamlRelayStateSigningKey ensures that the key for signing SAML RelayState exists
// and future calls to SamlRelayStateSigningKey will always return a valid key, same on all
// servers in the cluster
func (ch *Channels) ensureSamlRelayStateSigningKey() error {
if ch.samlRelayStateSigningKey != nil {
return nil
}
var secret *model.SystemSamlRelayStateSigningKey
value, err := ch.srv.Store().System().GetByName(model.SystemSamlRelayStateSigningKeyKey)
if err == nil {
if err := json.Unmarshal([]byte(value.Value), &secret); err != nil {
return err
}
}
// If we don't already have a key, try to generate one.
if secret == nil {
newSecret := &model.SystemSamlRelayStateSigningKey{
Secret: make([]byte, 32),
}
_, err := rand.Reader.Read(newSecret.Secret)
if err != nil {
return err
}
system := &model.System{
Name: model.SystemSamlRelayStateSigningKeyKey,
}
v, err := json.Marshal(newSecret)
if err != nil {
return err
}
system.Value = string(v)
// If we were able to save the key, use it, otherwise log the error.
if err = ch.srv.Store().System().Save(system); err != nil {
mlog.Warn("Failed to save SamlRelayStateSigningKey", mlog.Err(err))
} else {
secret = newSecret
}
}
// If we weren't able to save a new key above, another server must have beat us to it. Get the
// key from the database, and if that fails, error out.
if secret == nil {
value, err := ch.srv.Store().System().GetByName(model.SystemSamlRelayStateSigningKeyKey)
if err != nil {
return err
}
if err := json.Unmarshal([]byte(value.Value), &secret); err != nil {
return err
}
}
ch.samlRelayStateSigningKey = secret.Secret
return nil
}
func (s *Server) ensureInstallationDate() error {
_, appErr := s.platform.GetSystemInstallDate()
if appErr == nil {
return nil
}
installDate, nErr := s.Store().User().InferSystemInstallDate()
var installationDate int64
if nErr == nil && installDate > 0 {
installationDate = installDate
} else {
installationDate = utils.MillisFromTime(time.Now())
}
if err := s.Store().System().SaveOrUpdate(&model.System{
Name: model.SystemInstallationDateKey,
Value: strconv.FormatInt(installationDate, 10),
}); err != nil {
return err
}
return nil
}
func (s *Server) ensureFirstServerRunTimestamp() error {
_, appErr := s.getFirstServerRunTimestamp()
if appErr == nil {
return nil
}
if err := s.Store().System().SaveOrUpdate(&model.System{
Name: model.SystemFirstServerRunTimestampKey,
Value: strconv.FormatInt(utils.MillisFromTime(time.Now()), 10),
}); err != nil {
return err
}
return nil
}
// AsymmetricSigningKey will return a private key that can be used for asymmetric signing.
func (ch *Channels) AsymmetricSigningKey() *ecdsa.PrivateKey {
return ch.srv.platform.AsymmetricSigningKey()
}
func (a *App) AsymmetricSigningKey() *ecdsa.PrivateKey {
return a.ch.AsymmetricSigningKey()
}
func (ch *Channels) PostActionCookieSecret() []byte {
return ch.postActionCookieSecret
}
func (a *App) PostActionCookieSecret() []byte {
return a.ch.PostActionCookieSecret()
}
func (ch *Channels) SamlRelayStateSigningKey() []byte {
return ch.samlRelayStateSigningKey
}
func (a *App) SamlRelayStateSigningKey() []byte {
return a.ch.SamlRelayStateSigningKey()
}
func (a *App) GetCookieDomain() string {
if *a.Config().ServiceSettings.AllowCookiesForSubdomains {
if siteURL, err := url.Parse(*a.Config().ServiceSettings.SiteURL); err == nil {
return siteURL.Hostname()
}
}
return ""
}
func (a *App) GetSiteURL() string {
return *a.Config().ServiceSettings.SiteURL
}
// GetConfigFile proxies access to the given configuration file to the underlying config store.
func (a *App) GetConfigFile(name string) ([]byte, error) {
data, err := a.Srv().platform.GetConfigFile(name)
if err != nil {
return nil, errors.Wrapf(err, "failed to get config file %s", name)
}
return data, nil
}
// GetSanitizedConfig gets the configuration for a system admin without any secrets.
func (a *App) GetSanitizedConfig() *model.Config {
cfg := a.Config().Clone()
a.SanitizedConfig(cfg)
return cfg
}
// SanitizedConfig sanitizes a given configuration for a system admin without any secrets.
func (a *App) SanitizedConfig(cfg *model.Config) {
manifests, err := a.getPluginManifests()
if err != nil {
// GetPluginManifests might error, e.g. when plugins are disabled.
// Sanitize all plugin settings in this case.
cfg.Sanitize(nil, nil)
return
}
cfg.Sanitize(manifests, nil)
}
// GetEnvironmentConfig returns a map of configuration keys whose values have been overridden by an environment variable.
// If filter is not nil and returns false for a struct field, that field will be omitted.
func (a *App) GetEnvironmentConfig(filter func(reflect.StructField) bool) map[string]any {
return a.EnvironmentConfig(filter)
}
// SaveConfig replaces the active configuration, optionally notifying cluster peers.
func (a *App) SaveConfig(newCfg *model.Config, sendConfigChangeClusterMessage bool) (*model.Config, *model.Config, *model.AppError) {
return a.Srv().platform.SaveConfig(newCfg, sendConfigChangeClusterMessage)
}
func (a *App) HandleMessageExportConfig(cfg *model.Config, appCfg *model.Config) {
// If the Message Export feature has been toggled in the System Console, rewrite the ExportFromTimestamp field to an
// appropriate value. The rewriting occurs here to ensure it doesn't affect values written to the config file
// directly and not through the System Console UI.
if *cfg.MessageExportSettings.EnableExport != *appCfg.MessageExportSettings.EnableExport {
if *cfg.MessageExportSettings.EnableExport && model.SafeDereference(cfg.MessageExportSettings.ExportFromTimestamp) == int64(0) {
// When the feature is toggled on, use the current timestamp as the start time for future exports.
cfg.MessageExportSettings.ExportFromTimestamp = new(model.GetMillis())
} else if !*cfg.MessageExportSettings.EnableExport {
// When the feature is disabled, reset the timestamp so that the timestamp will be set if
// the feature is re-enabled from the System Console in future.
cfg.MessageExportSettings.ExportFromTimestamp = new(int64(0))
}
}
}
func (s *Server) MailServiceConfig() *mail.SMTPConfig {
emailSettings := s.platform.Config().EmailSettings
hostname := utils.GetHostnameFromSiteURL(*s.platform.Config().ServiceSettings.SiteURL)
cfg := mail.SMTPConfig{
Hostname: hostname,
ConnectionSecurity: *emailSettings.ConnectionSecurity,
SkipServerCertificateVerification: *emailSettings.SkipServerCertificateVerification,
ServerName: *emailSettings.SMTPServer,
Server: *emailSettings.SMTPServer,
Port: *emailSettings.SMTPPort,
ServerTimeout: *emailSettings.SMTPServerTimeout,
Username: *emailSettings.SMTPUsername,
Password: *emailSettings.SMTPPassword,
EnableSMTPAuth: *emailSettings.EnableSMTPAuth,
SendEmailNotifications: *emailSettings.SendEmailNotifications,
FeedbackName: *emailSettings.FeedbackName,
FeedbackEmail: *emailSettings.FeedbackEmail,
ReplyToAddress: *emailSettings.ReplyToAddress,
}
return &cfg
}

View File

@@ -0,0 +1,142 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"strconv"
"testing"
"time"
"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"
"github.com/mattermost/mattermost/server/v8/channels/utils"
)
func TestAsymmetricSigningKey(t *testing.T) {
mainHelper.Parallel(t)
th := SetupWithStoreMock(t)
assert.NotNil(t, th.App.AsymmetricSigningKey())
assert.NotEmpty(t, th.App.ClientConfig()["AsymmetricSigningPublicKey"])
}
func TestPostActionCookieSecret(t *testing.T) {
mainHelper.Parallel(t)
th := SetupWithStoreMock(t)
assert.Equal(t, 32, len(th.App.PostActionCookieSecret()))
}
func TestClientConfigWithComputed(t *testing.T) {
mainHelper.Parallel(t)
th := SetupWithStoreMock(t)
mockStore := th.App.Srv().Store().(*mocks.Store)
mockUserStore := mocks.UserStore{}
mockUserStore.On("Count", mock.Anything).Return(int64(10), nil)
mockPostStore := mocks.PostStore{}
mockPostStore.On("GetMaxPostSize").Return(model.PostMessageMaxBytesV2, nil)
mockSystemStore := mocks.SystemStore{}
mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil)
mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil)
mockStore.On("User").Return(&mockUserStore)
mockStore.On("Post").Return(&mockPostStore)
mockStore.On("System").Return(&mockSystemStore)
mockStore.On("GetDBSchemaVersion").Return(1, nil)
config := th.App.Srv().Platform().ClientConfigWithComputed()
_, ok := config["NoAccounts"]
assert.True(t, ok, "expected NoAccounts in returned config")
_, ok = config["MaxPostSize"]
assert.True(t, ok, "expected MaxPostSize in returned config")
v, ok := config["SchemaVersion"]
assert.True(t, ok, "expected SchemaVersion in returned config")
assert.Equal(t, "1", v)
}
func TestEnsureInstallationDate(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
tt := []struct {
Name string
PrevInstallationDate *int64
UsersCreationDates []int64
ExpectedInstallationDate *int64
}{
{
Name: "New installation: no users, no installation date",
PrevInstallationDate: nil,
UsersCreationDates: nil,
ExpectedInstallationDate: new(utils.MillisFromTime(time.Now())),
},
{
Name: "Old installation: users, no installation date",
PrevInstallationDate: nil,
UsersCreationDates: []int64{10000000000, 30000000000, 20000000000},
ExpectedInstallationDate: new(int64(10000000000)),
},
{
Name: "New installation, second run: no users, installation date",
PrevInstallationDate: new(int64(80000000000)),
UsersCreationDates: []int64{10000000000, 30000000000, 20000000000},
ExpectedInstallationDate: new(int64(80000000000)),
},
{
Name: "Old installation already updated: users, installation date",
PrevInstallationDate: new(int64(90000000000)),
UsersCreationDates: []int64{10000000000, 30000000000, 20000000000},
ExpectedInstallationDate: new(int64(90000000000)),
},
}
for _, tc := range tt {
t.Run(tc.Name, func(t *testing.T) {
sqlStore := th.GetSqlStore()
_, err := sqlStore.GetMaster().Exec("DELETE FROM Users")
assert.NoError(t, err)
for _, createAt := range tc.UsersCreationDates {
user := th.CreateUser(t)
user.CreateAt = createAt
_, err = sqlStore.GetMaster().Exec("UPDATE Users SET CreateAt = ? WHERE Id = ?", createAt, user.Id)
assert.NoError(t, err)
}
if tc.PrevInstallationDate == nil {
_, err = th.App.Srv().Store().System().PermanentDeleteByName(model.SystemInstallationDateKey)
assert.NoError(t, err)
} else {
err = th.App.Srv().Store().System().SaveOrUpdate(&model.System{
Name: model.SystemInstallationDateKey,
Value: strconv.FormatInt(*tc.PrevInstallationDate, 10),
})
assert.NoError(t, err)
}
err = th.App.Srv().ensureInstallationDate()
if tc.ExpectedInstallationDate == nil {
assert.Error(t, err)
} else {
assert.NoError(t, err)
var data *model.System
data, err = th.App.Srv().Store().System().GetByName(model.SystemInstallationDateKey)
assert.NoError(t, err)
var value int64
value, err = strconv.ParseInt(data.Value, 10, 64)
require.NoError(t, err)
assert.True(t, *tc.ExpectedInstallationDate <= value && *tc.ExpectedInstallationDate+1000 >= value)
}
_, err = sqlStore.GetMaster().Exec("DELETE FROM Users")
assert.NoError(t, err)
})
}
}

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
const MissingChannelMemberError = "app.channel.get_member.missing.app_error"
const MissingAccountError = "app.user.missing_account.const"
const MissingAuthAccountError = "app.user.get_by_auth.missing_account.app_error"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,258 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"cmp"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"net/http"
"slices"
"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"
)
const exposureProfileBatchSize = 1000
var exposurePreambleSanitizer = strings.NewReplacer("\r\n", " ", "\n", " ", "\r", " ", ",", " ", ";", " ", "\t", " ")
func (a *App) ComputePostExposure(rctx request.CTX, postID string) (*model.PostExposureReport, *model.AppError) {
post, appErr := a.GetSinglePost(rctx, postID, true)
if appErr != nil {
return nil, appErr
}
if post.OriginalId != "" {
return nil, model.NewAppError("ComputePostExposure", "app.data_spillage.exposure.edit_history_post.app_error", nil, "", http.StatusBadRequest)
}
channel, appErr := a.GetChannel(rctx, post.ChannelId)
if appErr != nil {
return nil, appErr
}
if channel.Type == model.ChannelTypeDirect || channel.Type == model.ChannelTypeGroup {
return nil, model.NewAppError("ComputePostExposure", "app.data_spillage.exposure.unsupported_channel_type.app_error", nil, "", http.StatusBadRequest)
}
windowEnd, appErr := a.getPostFlagTime(rctx, post.Id)
if appErr != nil {
return nil, appErr
}
report := &model.PostExposureReport{
Version: model.PostExposureReportVersion,
PostID: post.Id,
ChannelID: channel.Id,
ChannelName: channel.DisplayName,
ChannelType: channel.Type,
TeamID: channel.TeamId,
WindowStart: post.CreateAt,
WindowEnd: windowEnd,
GeneratedAt: model.GetMillis(),
Entries: []*model.PostExposureReportEntry{},
}
// Data source 1: who was in the channel while the post was live.
histories, err := a.Srv().Store().ChannelMemberHistory().GetUsersInChannelDuring(post.CreateAt, windowEnd, []string{channel.Id})
if err != nil {
return nil, model.NewAppError("ComputePostExposure", "app.data_spillage.exposure.get_channel_members.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// GetUsersInChannelDuring returns one row per membership interval, so a user who left
// and rejoined appears more than once. The report is a list of users, so collapse them.
memberUserIDs := make([]string, 0, len(histories))
seen := make(map[string]bool, len(histories))
for _, h := range histories {
if h.IsBot || seen[h.UserId] {
continue
}
seen[h.UserId] = true
memberUserIDs = append(memberUserIDs, h.UserId)
}
if len(memberUserIDs) == 0 {
return report, nil
}
// Data source 2: channel read state.
lastViewedByUser, appErr := a.getChannelLastViewedAt(rctx, channel.Id)
if appErr != nil {
return nil, appErr
}
users, appErr := a.getUsersProfiles(rctx, memberUserIDs)
if appErr != nil {
return nil, appErr
}
for _, userID := range memberUserIDs {
user, ok := users[userID]
if !ok {
rctx.Logger().Warn("Skipping exposure report entry for a user that no longer exists", mlog.String("user_id", userID), mlog.String("post_id", post.Id))
continue
}
entry := &model.PostExposureReportEntry{
UserID: user.Id,
Username: user.Username,
UserEmail: user.Email,
IsGuest: user.IsGuest(),
IsRemote: user.IsRemote(),
IsDeactivated: user.DeleteAt != 0,
WasChannelMember: true,
}
if lastViewedAt, isMember := lastViewedByUser[userID]; isMember {
entry.LastViewedAt = model.NewPointer(lastViewedAt)
}
if !entry.IsDeactivated {
lastActivityAt, appErr := a.getUserLastActivityAt(rctx, userID)
if appErr != nil {
return nil, appErr
}
entry.LastActivityAt = lastActivityAt
}
report.Entries = append(report.Entries, entry)
}
slices.SortFunc(report.Entries, func(x, y *model.PostExposureReportEntry) int {
return cmp.Or(cmp.Compare(x.Username, y.Username), cmp.Compare(x.UserID, y.UserID))
})
return report, nil
}
func (a *App) getPostFlagTime(rctx request.CTX, postID string) (int64, *model.AppError) {
value, appErr := a.GetPostContentFlaggingPropertyValue(rctx, postID, contentFlaggingPropertyNameReportingTime)
if appErr != nil {
if appErr.StatusCode == http.StatusNotFound {
return 0, model.NewAppError("getPostFlagTime", "app.data_spillage.exposure.missing_reporting_time.app_error", nil, "", http.StatusInternalServerError)
}
return 0, appErr
}
var reportingTime int64
if err := json.Unmarshal(value.Value, &reportingTime); err != nil {
return 0, model.NewAppError("getPostFlagTime", "app.data_spillage.exposure.missing_reporting_time.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if reportingTime <= 0 {
return 0, model.NewAppError("getPostFlagTime", "app.data_spillage.exposure.missing_reporting_time.app_error", nil, "", http.StatusInternalServerError)
}
return reportingTime, nil
}
func (a *App) getChannelLastViewedAt(rctx request.CTX, channelID string) (map[string]int64, *model.AppError) {
lastViewedByUser := map[string]int64{}
afterUserID := ""
for {
members, err := a.Srv().Store().Channel().GetMembersWithLastViewedAtSince(rctx, channelID, 0, afterUserID, model.ChannelMemberLastViewedMaxPerPage)
if err != nil {
return nil, model.NewAppError("getChannelLastViewedAt", "app.data_spillage.exposure.get_possible_viewers.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if len(members) == 0 {
break
}
for _, m := range members {
lastViewedByUser[m.UserId] = m.LastViewedAt
}
afterUserID = members[len(members)-1].UserId
if len(members) < model.ChannelMemberLastViewedMaxPerPage {
break
}
}
return lastViewedByUser, nil
}
func (a *App) getUsersProfiles(rctx request.CTX, userIDs []string) (map[string]*model.User, *model.AppError) {
profiles := make(map[string]*model.User, len(userIDs))
for batch := range slices.Chunk(userIDs, exposureProfileBatchSize) {
users, appErr := a.GetUsers(rctx, batch)
if appErr != nil {
return nil, appErr
}
for _, u := range users {
profiles[u.Id] = u
}
}
return profiles, nil
}
func (a *App) getUserLastActivityAt(rctx request.CTX, userID string) (*int64, *model.AppError) {
sessions, appErr := a.GetSessions(rctx, userID)
if appErr != nil {
return nil, model.NewAppError("getUserLastActivityAt", "app.data_spillage.exposure.get_last_activity.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
if len(sessions) == 0 {
return nil, nil
}
var lastActivityAt int64
for _, session := range sessions {
if session.LastActivityAt > lastActivityAt {
lastActivityAt = session.LastActivityAt
}
}
return model.NewPointer(lastActivityAt), nil
}
func WritePostExposureCSV(w io.Writer, report *model.PostExposureReport, T i18n.TranslateFunc) error {
if err := writePostExposurePreamble(w, report, T); err != nil {
return err
}
cw := csv.NewWriter(w)
if err := cw.Write(model.PostExposureReportCSVHeader(T)); err != nil {
return err
}
for _, entry := range report.Entries {
if err := cw.Write(entry.ToCSVRow(T)); err != nil {
return err
}
}
cw.Flush()
return cw.Error()
}
// writePostExposurePreamble writes the report metadata as CSV comment lines above the header
// row, so the artifact is self-describing without a sidecar file. Readers skip these with
// the standard comment option (Go's csv.Reader Comment: '#', pandas comment='#').
func writePostExposurePreamble(w io.Writer, report *model.PostExposureReport, T i18n.TranslateFunc) error {
lines := [][2]string{
{T("app.data_spillage.exposure.meta.report_version"), report.Version},
{T("app.data_spillage.exposure.meta.post_id"), report.PostID},
{T("app.data_spillage.exposure.meta.channel"), fmt.Sprintf("%s (%s)", report.ChannelName, report.ChannelID)},
{T("app.data_spillage.exposure.meta.window_start"), model.FormatExposureTime(report.WindowStart)},
{T("app.data_spillage.exposure.meta.window_end"), model.FormatExposureTime(report.WindowEnd)},
{T("app.data_spillage.exposure.meta.generated_at"), model.FormatExposureTime(report.GeneratedAt)},
{T("app.data_spillage.exposure.meta.total_users"), fmt.Sprintf("%d", len(report.Entries))},
}
for _, line := range lines {
if _, err := fmt.Fprintf(w, "# %s: %s\n", line[0], exposurePreambleSanitizer.Replace(line[1])); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,596 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"bytes"
"encoding/csv"
"net/http"
"slices"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
)
// flagPostInChannel creates a post in the given channel and flags it, returning the post.
// Unlike setupFlaggedPost it does not sleep: the content flagging property values, including
// reporting_time, are written synchronously by FlagPost.
func flagPostInChannel(t *testing.T, th *TestHelper, channel *model.Channel) *model.Post {
t.Helper()
post := th.CreatePost(t, channel)
appErr := th.App.FlagPost(th.Context, post, channel.TeamId, th.BasicUser2.Id, model.FlagContentRequest{
Reason: "spam",
Comment: "This is spam content",
})
require.Nil(t, appErr)
return post
}
// setLastViewedAt writes an exact LastViewedAt for a channel member. The app-level paths all
// clamp with GREATEST or derive the value from Channels.LastPostAt, so none of them can set
// an arbitrary value.
func setLastViewedAt(t *testing.T, th *TestHelper, channelID, userID string, lastViewedAt int64) {
t.Helper()
_, err := th.SQLStore.GetMaster().Exec(
`UPDATE ChannelMembers SET LastViewedAt = ? WHERE ChannelId = ? AND UserId = ?`,
lastViewedAt, channelID, userID)
require.NoError(t, err)
}
func seedSessionWithLastActivityAt(t *testing.T, th *TestHelper, userID string, lastActivityAt int64) {
t.Helper()
session, err := th.App.Srv().Store().Session().Save(th.Context, &model.Session{UserId: userID})
require.NoError(t, err)
require.NoError(t, th.App.Srv().Store().Session().UpdateLastActivityAt(session.Id, lastActivityAt))
}
// seedOldChannelMemberHistory makes GetUsersInChannelDuring use ChannelMemberHistory rather
// than its current-membership fallback.
//
// The fallback triggers when MIN(JoinTime) across the whole ChannelMemberHistory table is
// later than the window start, and it returns only current members with synthesised join and
// leave times. On a production server the table stretches back far enough that this never
// happens, but a test database starts empty and TestHelper.CreatePost backdates posts by ten
// seconds, so every history row is newer than the post. Seeding one very old, already-closed
// row for a synthetic channel pins the accurate code path.
func seedOldChannelMemberHistory(t *testing.T, th *TestHelper) {
t.Helper()
_, err := th.SQLStore.GetMaster().Exec(
`INSERT INTO ChannelMemberHistory (ChannelId, UserId, JoinTime, LeaveTime) VALUES (?, ?, ?, ?)`,
model.NewId(), model.NewId(), int64(1), int64(2))
require.NoError(t, err)
}
// setRemoteID marks a user as originating from a remote server. RemoteId is not a
// user-editable field, so UpdateUser silently drops it.
func setRemoteID(t *testing.T, th *TestHelper, userID, remoteID string) {
t.Helper()
_, err := th.SQLStore.GetMaster().Exec(`UPDATE Users SET RemoteId = ? WHERE Id = ?`, remoteID, userID)
require.NoError(t, err)
th.App.InvalidateCacheForUser(userID)
}
func entryFor(report *model.PostExposureReport, userID string) *model.PostExposureReportEntry {
for _, e := range report.Entries {
if e.UserID == userID {
return e
}
}
return nil
}
func entryUserIDs(report *model.PostExposureReport) []string {
out := make([]string, 0, len(report.Entries))
for _, e := range report.Entries {
out = append(out, e.UserID)
}
return out
}
func TestComputePostExposure(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
require.Nil(t, setBaseConfig(th))
seedOldChannelMemberHistory(t, th)
t.Run("reports a member who viewed the channel after the post", func(t *testing.T) {
channel := th.CreateChannel(t, th.BasicTeam)
viewer := th.CreateUser(t)
th.LinkUserToTeam(t, viewer, th.BasicTeam)
th.AddUserToChannel(t, viewer, channel)
post := flagPostInChannel(t, th, channel)
setLastViewedAt(t, th, channel.Id, viewer.Id, post.CreateAt+1000)
report, appErr := th.App.ComputePostExposure(th.Context, post.Id)
require.Nil(t, appErr)
entry := entryFor(report, viewer.Id)
require.NotNil(t, entry)
require.True(t, entry.WasChannelMember)
require.NotNil(t, entry.LastViewedAt)
require.Equal(t, post.CreateAt+1000, *entry.LastViewedAt)
require.Equal(t, viewer.Username, entry.Username)
require.Equal(t, viewer.Email, entry.UserEmail)
})
t.Run("reports a member whose last channel view predates the post", func(t *testing.T) {
channel := th.CreateChannel(t, th.BasicTeam)
nonViewer := th.CreateUser(t)
th.LinkUserToTeam(t, nonViewer, th.BasicTeam)
th.AddUserToChannel(t, nonViewer, channel)
post := flagPostInChannel(t, th, channel)
setLastViewedAt(t, th, channel.Id, nonViewer.Id, post.CreateAt-1)
report, appErr := th.App.ComputePostExposure(th.Context, post.Id)
require.Nil(t, appErr)
entry := entryFor(report, nonViewer.Id)
require.NotNil(t, entry)
require.True(t, entry.WasChannelMember)
require.NotNil(t, entry.LastViewedAt)
require.Equal(t, post.CreateAt-1, *entry.LastViewedAt)
})
t.Run("leaves LastViewedAt unset for a member who has since left the channel", func(t *testing.T) {
channel := th.CreateChannel(t, th.BasicTeam)
leaver := th.CreateUser(t)
th.LinkUserToTeam(t, leaver, th.BasicTeam)
th.AddUserToChannel(t, leaver, channel)
post := flagPostInChannel(t, th, channel)
require.Nil(t, th.RemoveUserFromChannel(t, leaver, channel))
report, appErr := th.App.ComputePostExposure(th.Context, post.Id)
require.Nil(t, appErr)
entry := entryFor(report, leaver.Id)
require.NotNil(t, entry, "a user who left after the post must still be reported as a member")
require.True(t, entry.WasChannelMember)
require.Nil(t, entry.LastViewedAt, "no read state survives for a former member")
})
t.Run("includes a deactivated user, flagged as such", func(t *testing.T) {
channel := th.CreateChannel(t, th.BasicTeam)
user := th.CreateUser(t)
th.LinkUserToTeam(t, user, th.BasicTeam)
th.AddUserToChannel(t, user, channel)
post := flagPostInChannel(t, th, channel)
_, appErr := th.App.UpdateActive(th.Context, user, false)
require.Nil(t, appErr)
report, appErr := th.App.ComputePostExposure(th.Context, post.Id)
require.Nil(t, appErr)
entry := entryFor(report, user.Id)
require.NotNil(t, entry, "deactivating a user does not undo their exposure")
require.True(t, entry.IsDeactivated)
})
t.Run("flags a guest user", func(t *testing.T) {
channel := th.CreateChannel(t, th.BasicTeam)
guest := th.CreateGuest(t)
th.LinkUserToTeam(t, guest, th.BasicTeam)
th.AddUserToChannel(t, guest, channel)
post := flagPostInChannel(t, th, channel)
report, appErr := th.App.ComputePostExposure(th.Context, post.Id)
require.Nil(t, appErr)
entry := entryFor(report, guest.Id)
require.NotNil(t, entry)
require.True(t, entry.IsGuest)
require.False(t, entry.IsRemote)
})
t.Run("flags a remote user", func(t *testing.T) {
channel := th.CreateChannel(t, th.BasicTeam)
remote := th.CreateUser(t)
th.LinkUserToTeam(t, remote, th.BasicTeam)
th.AddUserToChannel(t, remote, channel)
setRemoteID(t, th, remote.Id, model.NewId())
post := flagPostInChannel(t, th, channel)
report, appErr := th.App.ComputePostExposure(th.Context, post.Id)
require.Nil(t, appErr)
entry := entryFor(report, remote.Id)
require.NotNil(t, entry)
require.True(t, entry.IsRemote, "content on a remote server is beyond the reach of hiding the post")
})
t.Run("still reports members of an archived channel", func(t *testing.T) {
channel := th.CreateChannel(t, th.BasicTeam)
user := th.CreateUser(t)
th.LinkUserToTeam(t, user, th.BasicTeam)
th.AddUserToChannel(t, user, channel)
post := flagPostInChannel(t, th, channel)
setLastViewedAt(t, th, channel.Id, user.Id, post.CreateAt+1)
require.Nil(t, th.App.DeleteChannel(th.Context, channel, th.BasicUser.Id))
report, appErr := th.App.ComputePostExposure(th.Context, post.Id)
require.Nil(t, appErr)
entry := entryFor(report, user.Id)
require.NotNil(t, entry, "archiving is soft; membership and read state survive it")
require.NotNil(t, entry.LastViewedAt)
require.Equal(t, post.CreateAt+1, *entry.LastViewedAt)
})
t.Run("reports the latest activity across all of a user's sessions", func(t *testing.T) {
channel := th.CreateChannel(t, th.BasicTeam)
user := th.CreateUser(t)
th.LinkUserToTeam(t, user, th.BasicTeam)
th.AddUserToChannel(t, user, channel)
post := flagPostInChannel(t, th, channel)
seedSessionWithLastActivityAt(t, th, user.Id, post.CreateAt-5000)
seedSessionWithLastActivityAt(t, th, user.Id, post.CreateAt+7000)
report, appErr := th.App.ComputePostExposure(th.Context, post.Id)
require.Nil(t, appErr)
entry := entryFor(report, user.Id)
require.NotNil(t, entry)
require.NotNil(t, entry.LastActivityAt)
require.Equal(t, post.CreateAt+7000, *entry.LastActivityAt)
})
t.Run("leaves LastActivityAt unset for a member with no sessions", func(t *testing.T) {
channel := th.CreateChannel(t, th.BasicTeam)
user := th.CreateUser(t)
th.LinkUserToTeam(t, user, th.BasicTeam)
th.AddUserToChannel(t, user, channel)
post := flagPostInChannel(t, th, channel)
report, appErr := th.App.ComputePostExposure(th.Context, post.Id)
require.Nil(t, appErr)
entry := entryFor(report, user.Id)
require.NotNil(t, entry)
require.Nil(t, entry.LastActivityAt)
})
t.Run("excludes bots", func(t *testing.T) {
channel := th.CreateChannel(t, th.BasicTeam)
bot := th.CreateBot(t)
botUser, appErr := th.App.GetUser(th.Context, bot.UserId)
require.Nil(t, appErr)
th.LinkUserToTeam(t, botUser, th.BasicTeam)
th.AddUserToChannel(t, botUser, channel)
human := th.CreateUser(t)
th.LinkUserToTeam(t, human, th.BasicTeam)
th.AddUserToChannel(t, human, channel)
_, err := th.App.GetChannelMember(th.Context, channel.Id, bot.UserId)
require.Nil(t, err, "the bot must really be a channel member for this to test anything")
post := flagPostInChannel(t, th, channel)
report, appErr := th.App.ComputePostExposure(th.Context, post.Id)
require.Nil(t, appErr)
require.NotNil(t, entryFor(report, human.Id), "the channel's human members must still be reported")
require.Nil(t, entryFor(report, bot.UserId), "a bot cannot be a recipient of a data spillage")
})
t.Run("populates the report window and metadata", func(t *testing.T) {
channel := th.CreateChannel(t, th.BasicTeam)
post := flagPostInChannel(t, th, channel)
report, appErr := th.App.ComputePostExposure(th.Context, post.Id)
require.Nil(t, appErr)
require.Equal(t, model.PostExposureReportVersion, report.Version)
require.Equal(t, post.Id, report.PostID)
require.Equal(t, channel.Id, report.ChannelID)
require.Equal(t, channel.DisplayName, report.ChannelName)
require.Equal(t, th.BasicTeam.Id, report.TeamID)
require.Equal(t, post.CreateAt, report.WindowStart)
// The window must end at the flag time, not at "now".
values := searchPropertyValue(t, th, post.Id, contentFlaggingPropertyNameReportingTime)
require.Len(t, values, 1)
require.Equal(t, string(values[0].Value), strconv.FormatInt(report.WindowEnd, 10))
require.NotZero(t, report.GeneratedAt)
})
t.Run("orders entries deterministically by username", func(t *testing.T) {
channel := th.CreateChannel(t, th.BasicTeam)
for range 4 {
u := th.CreateUser(t)
th.LinkUserToTeam(t, u, th.BasicTeam)
th.AddUserToChannel(t, u, channel)
}
post := flagPostInChannel(t, th, channel)
first, appErr := th.App.ComputePostExposure(th.Context, post.Id)
require.Nil(t, appErr)
second, appErr := th.App.ComputePostExposure(th.Context, post.Id)
require.Nil(t, appErr)
require.Equal(t, entryUserIDs(first), entryUserIDs(second))
usernames := make([]string, 0, len(first.Entries))
for _, e := range first.Entries {
usernames = append(usernames, e.Username)
}
require.IsIncreasing(t, usernames)
})
t.Run("returns an error when the post is not flagged", func(t *testing.T) {
channel := th.CreateChannel(t, th.BasicTeam)
post := th.CreatePost(t, channel)
report, appErr := th.App.ComputePostExposure(th.Context, post.Id)
require.NotNil(t, appErr)
require.Nil(t, report)
})
t.Run("returns an error when reporting_time is missing", func(t *testing.T) {
channel := th.CreateChannel(t, th.BasicTeam)
post := flagPostInChannel(t, th, channel)
values := searchPropertyValue(t, th, post.Id, contentFlaggingPropertyNameReportingTime)
require.Len(t, values, 1)
require.Nil(t, th.App.DeletePropertyValue(th.Context, values[0].GroupID, values[0].ID))
report, appErr := th.App.ComputePostExposure(th.Context, post.Id)
require.NotNil(t, appErr)
require.Nil(t, report)
require.Equal(t, "app.data_spillage.exposure.missing_reporting_time.app_error", appErr.Id)
})
t.Run("returns an error for a post that does not exist", func(t *testing.T) {
report, appErr := th.App.ComputePostExposure(th.Context, model.NewId())
require.NotNil(t, appErr)
require.Nil(t, report)
})
t.Run("rejects direct and group message channels", func(t *testing.T) {
other := th.CreateUser(t)
dm := th.CreateDmChannel(t, other)
dmPost := th.CreatePost(t, dm)
appErr := th.App.FlagPost(th.Context, dmPost, "", th.BasicUser2.Id, model.FlagContentRequest{Reason: "spam", Comment: "c"})
require.Nil(t, appErr)
report, appErr := th.App.ComputePostExposure(th.Context, dmPost.Id)
require.NotNil(t, appErr)
require.Nil(t, report)
require.Equal(t, "app.data_spillage.exposure.unsupported_channel_type.app_error", appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
gm := th.CreateGroupChannel(t, th.BasicUser2, other)
gmPost := th.CreatePost(t, gm)
appErr = th.App.FlagPost(th.Context, gmPost, "", th.BasicUser2.Id, model.FlagContentRequest{Reason: "spam", Comment: "c"})
require.Nil(t, appErr)
report, appErr = th.App.ComputePostExposure(th.Context, gmPost.Id)
require.NotNil(t, appErr)
require.Nil(t, report)
require.Equal(t, "app.data_spillage.exposure.unsupported_channel_type.app_error", appErr.Id)
})
t.Run("rejects an edit history revision", func(t *testing.T) {
channel := th.CreateChannel(t, th.BasicTeam)
post := flagPostInChannel(t, th, channel)
patched, _, appErr := th.App.PatchPost(th.Context, post.Id, &model.PostPatch{Message: model.NewPointer("edited")}, &model.UpdatePostOptions{})
require.Nil(t, appErr)
require.NotNil(t, patched)
history, appErr := th.App.GetEditHistoryForPost(th.Context, post.Id)
require.Nil(t, appErr)
require.NotEmpty(t, history)
report, appErr := th.App.ComputePostExposure(th.Context, history[0].Id)
require.NotNil(t, appErr)
require.Nil(t, report)
require.Equal(t, "app.data_spillage.exposure.edit_history_post.app_error", appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
})
}
func TestWritePostExposureCSV(t *testing.T) {
mainHelper.Parallel(t)
T := i18n.GetUserTranslations("en")
baseReport := func() *model.PostExposureReport {
return &model.PostExposureReport{
Version: model.PostExposureReportVersion,
PostID: "post1",
ChannelID: "channel1",
ChannelName: "Town Square",
ChannelType: model.ChannelTypeOpen,
TeamID: "team1",
WindowStart: 1700000000000,
WindowEnd: 1700000600000,
GeneratedAt: 1700001000000,
Entries: []*model.PostExposureReportEntry{},
}
}
// parseCSV strips the comment preamble and returns the remaining records.
parseCSV := func(t *testing.T, b []byte) [][]string {
t.Helper()
r := csv.NewReader(bytes.NewReader(b))
r.Comment = '#'
records, err := r.ReadAll()
require.NoError(t, err)
return records
}
headerIndex := func(t *testing.T, key string) int {
t.Helper()
idx := slices.Index(model.PostExposureReportCSVHeader(T), T(key))
require.GreaterOrEqual(t, idx, 0, "no column is headed by %s", key)
return idx
}
t.Run("writes a preamble and a header for an empty report", func(t *testing.T) {
var buf bytes.Buffer
require.NoError(t, WritePostExposureCSV(&buf, baseReport(), T))
out := buf.String()
require.Contains(t, out, "# Post ID: post1")
require.Contains(t, out, "# Channel: Town Square (channel1)")
require.Contains(t, out, "# Post created at: 2023-11-14T22:13:20Z")
require.Contains(t, out, "# Flagged at: 2023-11-14T22:23:20Z")
require.Contains(t, out, "# Total users: 0")
records := parseCSV(t, buf.Bytes())
require.Len(t, records, 1, "an empty report is still a valid CSV with a header")
require.Equal(t, model.PostExposureReportCSVHeader(T), records[0])
})
t.Run("renders every column", func(t *testing.T) {
report := baseReport()
report.Entries = append(report.Entries, &model.PostExposureReportEntry{
UserID: "user1",
Username: "alice",
UserEmail: "alice@example.com",
IsGuest: true,
IsRemote: false,
IsDeactivated: true,
WasChannelMember: true,
LastViewedAt: model.NewPointer(int64(1700000300000)),
LastActivityAt: model.NewPointer(int64(1700000900000)),
})
var buf bytes.Buffer
require.NoError(t, WritePostExposureCSV(&buf, report, T))
records := parseCSV(t, buf.Bytes())
require.Len(t, records, 2)
require.Equal(t, []string{
"user1", "alice", "alice@example.com",
"Yes", "No", "Yes", "Yes",
"2023-11-14T22:18:20Z", "2023-11-14T22:28:20Z",
}, records[1])
require.Len(t, records[1], len(model.PostExposureReportCSVHeader(T)))
})
t.Run("renders missing read state as markers rather than the epoch", func(t *testing.T) {
report := baseReport()
report.Entries = append(report.Entries,
&model.PostExposureReportEntry{UserID: "u1", Username: "aa", LastViewedAt: nil},
&model.PostExposureReportEntry{UserID: "u2", Username: "bb", LastViewedAt: model.NewPointer(int64(0))},
)
var buf bytes.Buffer
require.NoError(t, WritePostExposureCSV(&buf, report, T))
records := parseCSV(t, buf.Bytes())
require.Len(t, records, 3)
lastViewed := headerIndex(t, "app.data_spillage.exposure.column.last_viewed_at")
lastActivity := headerIndex(t, "app.data_spillage.exposure.column.last_activity_at")
require.Equal(t, "Unknown", records[1][lastViewed])
require.Equal(t, "No sessions found", records[1][lastActivity])
require.Equal(t, "N/A", records[2][lastViewed])
require.NotContains(t, buf.String(), "1970-01-01")
})
t.Run("escapes separators and quotes in user data", func(t *testing.T) {
report := baseReport()
report.Entries = append(report.Entries, &model.PostExposureReportEntry{
UserID: "u1",
Username: `we,ird "name"`,
UserEmail: "line\nbreak@example.com",
})
var buf bytes.Buffer
require.NoError(t, WritePostExposureCSV(&buf, report, T))
records := parseCSV(t, buf.Bytes())
require.Len(t, records, 2)
require.Equal(t, `we,ird "name"`, records[1][1])
require.Equal(t, "line\nbreak@example.com", records[1][2])
})
t.Run("collapses line breaks in preamble values", func(t *testing.T) {
// A channel display name may contain line breaks: Channel.IsValid only bounds its
// length and SanitizeUnicode leaves \n and \r alone. Written raw, the remainder of
// the value would land on a line without a leading "#", which a comment-aware
// reader parses as a data record and rejects for having the wrong field count.
report := baseReport()
report.ChannelName = "Town\nSquare\r\nAnnex\rWing"
var buf bytes.Buffer
require.NoError(t, WritePostExposureCSV(&buf, report, T))
out := buf.String()
require.Contains(t, out, "# Channel: Town Square Annex Wing (channel1)")
records := parseCSV(t, buf.Bytes())
require.Len(t, records, 1, "the preamble must stay fully commented out")
require.Equal(t, model.PostExposureReportCSVHeader(T), records[0])
})
t.Run("keeps a preamble value from opening a second cell", func(t *testing.T) {
// A spreadsheet honours no comment convention: it splits every line on the field
// separator and reads each cell on its own. A separator in the value would open a
// second cell that the leading "#" no longer guards, and a cell starting with "=",
// "+", "-" or "@" is a formula.
report := baseReport()
report.ChannelName = "a,=1+1;=2+2\t=3+3"
var buf bytes.Buffer
require.NoError(t, WritePostExposureCSV(&buf, report, T))
out := buf.String()
require.Contains(t, out, "# Channel: a =1+1 =2+2 =3+3 (channel1)")
for line := range strings.SplitSeq(out, "\n") {
if !strings.HasPrefix(line, "#") {
continue
}
require.False(t, strings.ContainsAny(line, ",;\t"), "preamble line %q is more than one cell", line)
}
records := parseCSV(t, buf.Bytes())
require.Len(t, records, 1, "the preamble must stay fully commented out")
require.Equal(t, model.PostExposureReportCSVHeader(T), records[0])
})
t.Run("is byte-for-byte deterministic", func(t *testing.T) {
report := baseReport()
report.Entries = append(report.Entries,
&model.PostExposureReportEntry{UserID: "u1", Username: "aa", WasChannelMember: true},
&model.PostExposureReportEntry{UserID: "u2", Username: "bb", WasChannelMember: true},
)
var first, second bytes.Buffer
require.NoError(t, WritePostExposureCSV(&first, report, T))
require.NoError(t, WritePostExposureCSV(&second, report, T))
require.Equal(t, first.Bytes(), second.Bytes())
})
}

View File

@@ -0,0 +1,542 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path"
"strings"
"github.com/goccy/go-yaml"
"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"
)
const (
flaggedPostReportPostDir = "post"
flaggedPostReportEditHistoryDir = "edit_history"
flaggedPostReportAttachmentsDir = "attachments"
flaggedPostReportPostYAMLFile = "post.yaml"
flaggedPostReportContentReviewFile = "content_review.yaml"
flaggedPostReportMetadataFile = "report_metadata.yaml"
flaggedPostReportExposureFile = "exposure_report.csv"
flaggedPostReportTempPattern = "mm-flag-report-*.zip"
flaggedPostReportAttachmentsOmittedFile = "ATTACHMENTS_OMITTED.txt"
)
// GenerateFlaggedPostReport builds a ZIP archive of a flagged post's data into a
// temporary file and returns the file path. The caller is responsible for
// removing the file when the response has been served.
func (a *App) GenerateFlaggedPostReport(rctx request.CTX, postID, generatedByUserID, comment, action string, includeAttachments bool) (string, int, *model.AppError) {
tmp, err := os.CreateTemp("", flaggedPostReportTempPattern)
if err != nil {
return "", 0, model.NewAppError("GenerateFlaggedPostReport", "app.data_spillage.report.tempfile.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
tmpPath := tmp.Name()
cleanup := func() {
_ = tmp.Close()
_ = os.Remove(tmpPath)
}
zw := zip.NewWriter(tmp)
omittedAttachments, appErr := a.writeFlaggedPostReport(rctx, zw, postID, generatedByUserID, comment, action, includeAttachments)
if appErr != nil {
_ = zw.Close()
cleanup()
return "", 0, appErr
}
if err := zw.Close(); err != nil {
cleanup()
return "", 0, model.NewAppError("GenerateFlaggedPostReport", "app.data_spillage.report.zip_close.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if err := tmp.Sync(); err != nil {
cleanup()
return "", 0, model.NewAppError("GenerateFlaggedPostReport", "app.data_spillage.report.sync.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpPath)
return "", 0, model.NewAppError("GenerateFlaggedPostReport", "app.data_spillage.report.close.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return tmpPath, omittedAttachments, nil
}
func (a *App) writeFlaggedPostReport(rctx request.CTX, zw *zip.Writer, postID, generatedByUserID, comment, action string, includeAttachments bool) (int, *model.AppError) {
rc, appErr := a.loadFlaggedPostReportContext(rctx, postID)
if appErr != nil {
return 0, appErr
}
// Track FileInfo.Id seen anywhere in the archive so each unique attachment is handled
// exactly once across the base post and all edit history entries. When attachments are
// excluded none of them are written, so its size is the omitted count.
seenFiles := map[string]bool{}
if appErr := a.writeBasePostSection(rctx, zw, rc, seenFiles, includeAttachments); appErr != nil {
return 0, appErr
}
if appErr := a.writeEditHistorySection(rctx, zw, rc, seenFiles, includeAttachments); appErr != nil {
return 0, appErr
}
if appErr := a.writeContentReviewEntry(rctx, zw, rc.Post, generatedByUserID, comment, action); appErr != nil {
return 0, appErr
}
if appErr := a.writeExposureReportEntry(rctx, zw, rc.Post.Id); appErr != nil {
return 0, appErr
}
if appErr := a.writeReportMetadataEntry(rctx, zw, generatedByUserID); appErr != nil {
return 0, appErr
}
if includeAttachments || len(seenFiles) == 0 {
return 0, nil
}
if appErr := writeAttachmentsOmittedEntry(zw, rctx.GetT()); appErr != nil {
return 0, appErr
}
return len(seenFiles), nil
}
func writeAttachmentsOmittedEntry(zw *zip.Writer, T i18n.TranslateFunc) *model.AppError {
w, err := zw.Create(flaggedPostReportAttachmentsOmittedFile)
if err != nil {
return model.NewAppError("GenerateFlaggedPostReport", "app.data_spillage.report.zip_create.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if _, err := io.WriteString(w, T("app.data_spillage.report.attachments_omitted.file_text")); err != nil {
return model.NewAppError("GenerateFlaggedPostReport", "app.data_spillage.report.write_attachments_omitted.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}
func (a *App) writeExposureReportEntry(rctx request.CTX, zw *zip.Writer, postID string) *model.AppError {
report, appErr := a.ComputePostExposure(rctx, postID)
if appErr != nil {
return appErr
}
w, err := zw.Create(flaggedPostReportExposureFile)
if err != nil {
return model.NewAppError("GenerateFlaggedPostReport", "app.data_spillage.report.zip_create.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if csvWriteErr := WritePostExposureCSV(w, report, rctx.GetT()); csvWriteErr != nil {
return model.NewAppError("GenerateFlaggedPostReport", "app.data_spillage.report.write_exposure_csv.app_error", nil, "", http.StatusInternalServerError).Wrap(csvWriteErr)
}
return nil
}
func (a *App) loadFlaggedPostReportContext(rctx request.CTX, postID string) (*model.FlaggedPostReportContext, *model.AppError) {
post, appErr := a.GetSinglePost(rctx, postID, true)
if appErr != nil {
return nil, appErr
}
channel, appErr := a.GetChannel(rctx, post.ChannelId)
if appErr != nil {
return nil, appErr
}
var team *model.Team
if channel.TeamId != "" {
team, appErr = a.GetTeam(channel.TeamId)
if appErr != nil {
return nil, appErr
}
}
author, appErr := a.GetUser(rctx, post.UserId)
if appErr != nil {
return nil, appErr
}
// GetEditHistoryForPost returns a 404 AppError when the post has no edit
// history rows. That is the normal case for an unedited post, so treat it
// as an empty history rather than failing the whole report.
editHistory, appErr := a.getEditHistoryForPostUnrestricted(rctx, postID)
if appErr != nil && appErr.StatusCode != http.StatusNotFound {
return nil, appErr
}
return &model.FlaggedPostReportContext{
Post: post,
Channel: channel,
Team: team,
Author: author,
EditHistory: editHistory,
}, nil
}
func (a *App) writeBasePostSection(rctx request.CTX, zw *zip.Writer, rc *model.FlaggedPostReportContext, seen map[string]bool, includeAttachments bool) *model.AppError {
editOrder := make([]string, 0, len(rc.EditHistory))
for _, e := range rc.EditHistory {
editOrder = append(editOrder, e.Id)
}
if !includeAttachments && rc.Post.Metadata != nil {
rc.Post.Metadata.Files = nil
}
yamlPayload := buildPostYAML(rc.Post, rc.Channel, rc.Team, rc.Author, editOrder)
postYAMLPath := path.Join(flaggedPostReportPostDir, flaggedPostReportPostYAMLFile)
if err := writeYAMLEntry(zw, postYAMLPath, yamlPayload); err != nil {
return model.NewAppError("GenerateFlaggedPostReport", "app.data_spillage.report.write_post_yaml.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
baseFiles, _, appErr := a.GetFileInfosForPost(rctx, rc.Post, false, true)
if appErr != nil {
return appErr
}
attachmentsDir := path.Join(flaggedPostReportPostDir, flaggedPostReportAttachmentsDir)
return a.writeAttachments(rctx, zw, attachmentsDir, baseFiles, seen, includeAttachments)
}
func (a *App) writeEditHistorySection(rctx request.CTX, zw *zip.Writer, rc *model.FlaggedPostReportContext, seen map[string]bool, includeAttachments bool) *model.AppError {
for _, edit := range rc.EditHistory {
// FileInfos for an edit-history Post are populated on Post.Metadata.Files
// by populateEditHistoryFileMetadata. See app.GetEditHistoryForPost.
var editFiles []*model.FileInfo
if edit.Metadata != nil {
editFiles = edit.Metadata.Files
if !includeAttachments {
edit.Metadata.Files = nil
}
}
yamlPayload := buildPostYAML(edit, rc.Channel, rc.Team, rc.Author, nil)
entryPath := path.Join(flaggedPostReportEditHistoryDir, edit.Id, flaggedPostReportPostYAMLFile)
if err := writeYAMLEntry(zw, entryPath, yamlPayload); err != nil {
return model.NewAppError("GenerateFlaggedPostReport", "app.data_spillage.report.write_edit_yaml.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
dir := path.Join(flaggedPostReportEditHistoryDir, edit.Id, flaggedPostReportAttachmentsDir)
if appErr := a.writeAttachments(rctx, zw, dir, editFiles, seen, includeAttachments); appErr != nil {
return appErr
}
}
return nil
}
func (a *App) writeContentReviewEntry(rctx request.CTX, zw *zip.Writer, post *model.Post, generatedByUserID, comment, action string) *model.AppError {
payload, appErr := a.buildContentReviewYAML(rctx, post, generatedByUserID, comment, action)
if appErr != nil {
return appErr
}
if err := writeYAMLEntry(zw, flaggedPostReportContentReviewFile, payload); err != nil {
return model.NewAppError("GenerateFlaggedPostReport", "app.data_spillage.report.write_review_yaml.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}
func (a *App) writeReportMetadataEntry(rctx request.CTX, zw *zip.Writer, generatedByUserID string) *model.AppError {
generator, appErr := a.GetUser(rctx, generatedByUserID)
if appErr != nil {
return appErr
}
payload := model.FlaggedPostReportMetadata{
GeneratedByUserID: generator.Id,
GeneratedByUsername: generator.Username,
Timestamp: model.GetMillis(),
ReportVersion: model.FlaggedPostReportVersion,
}
if err := writeYAMLEntry(zw, flaggedPostReportMetadataFile, payload); err != nil {
return model.NewAppError("GenerateFlaggedPostReport", "app.data_spillage.report.write_metadata_yaml.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}
func buildPostYAML(post *model.Post, channel *model.Channel, team *model.Team, author *model.User, editHistoryOrder []string) model.FlaggedPostReportPost {
out := model.FlaggedPostReportPost{
Post: post,
ChannelDisplayName: channel.DisplayName,
EditHistoryOrder: editHistoryOrder,
}
if author != nil {
out.AuthorName = author.Username
out.AuthorEmail = author.Email
}
if team != nil {
out.TeamID = team.Id
out.TeamDisplayName = team.DisplayName
}
if post.RootId == "" {
replyCount := post.ReplyCount
out.ReplyCountPtr = &replyCount
}
return out
}
func (a *App) buildContentReviewYAML(rctx request.CTX, post *model.Post, generatedByUserID, actorComment, pendingAction string) (model.FlaggedPostReportContentReview, *model.AppError) {
out := model.FlaggedPostReportContentReview{}
values, appErr := a.GetPostContentFlaggingPropertyValues(rctx, post.Id)
if appErr != nil {
return out, appErr
}
groupID, gErr := a.ContentFlaggingGroupId()
if gErr != nil {
return out, gErr
}
mappedFields, appErr := a.GetContentFlaggingMappedFields(rctx, groupID)
if appErr != nil {
return out, appErr
}
// Index field ID -> field name so we can resolve property values by name.
fieldIDToName := make(map[string]string, len(mappedFields))
for name, f := range mappedFields {
fieldIDToName[f.ID] = name
}
byName := make(map[string]json.RawMessage, len(values))
for _, v := range values {
name, ok := fieldIDToName[v.FieldID]
if !ok {
continue
}
byName[name] = v.Value
}
out.ReporterUserID = decodePropertyString(rctx, byName, contentFlaggingPropertyNameReportingUserID)
out.ReporterReason = decodePropertyString(rctx, byName, contentFlaggingPropertyNameReportingReason)
out.ReporterComment = decodePropertyString(rctx, byName, contentFlaggingPropertyNameReportingComment)
out.ReportTimestamp = decodePropertyInt64(rctx, byName, contentFlaggingPropertyNameReportingTime)
contentFlaggingManaged, appErr := a.GetPostContentFlaggingPropertyValue(rctx, post.Id, contentFlaggingPropertyManageByContentFlagging)
if appErr != nil && appErr.StatusCode != http.StatusNotFound {
return out, appErr
}
postHiddenByContentFlagging := contentFlaggingManaged != nil && string(contentFlaggingManaged.Value) == "true"
out.Hidden = postHiddenByContentFlagging
if reporterID := out.ReporterUserID; reporterID != "" {
if u, uErr := a.GetUser(rctx, reporterID); uErr == nil {
out.ReporterUsername = u.Username
} else {
rctx.Logger().Warn("Failed to fetch reporter user for flagged post report", mlog.String("user_id", reporterID), mlog.Err(uErr))
}
}
reviewerID := decodePropertyString(rctx, byName, contentFlaggingPropertyNameReviewerUserID)
out.ReviewerUserID = reviewerID
// Use saved comment if available, else use incoming comment
out.ReviewerComment = decodePropertyString(rctx, byName, contentFlaggingPropertyNameActorComment)
if out.ReviewerComment == "" && actorComment != "" {
out.ReviewerComment = actorComment
}
out.ActionTime = decodePropertyInt64(rctx, byName, contentFlaggingPropertyNameActionTime)
// Use saved actor if available, else use calling user. The check for pending action is used
// as the client passes a pending action when generating report just before performing an action.
// All other flows do not pass an action.
actorUserId := decodePropertyString(rctx, byName, contentFlaggingPropertyNameActorUserID)
if actorUserId == "" && pendingAction != "" {
actorUserId = generatedByUserID
}
if actorUserId != "" {
if u, uErr := a.GetUser(rctx, actorUserId); uErr == nil {
out.ActorUsername = u.Username
out.ActorUserId = u.Id
} else {
rctx.Logger().Warn("Failed to fetch report generator user for flagged post report", mlog.String("actor_user_id", actorUserId), mlog.Err(uErr))
}
}
switch decodePropertyString(rctx, byName, ContentFlaggingPropertyNameStatus) {
case model.ContentFlaggingStatusRetained:
out.ActorDecision = model.ContentFlaggingActionKeep
case model.ContentFlaggingStatusRemoved:
out.ActorDecision = model.ContentFlaggingActionRemove
default:
if pendingAction == model.ContentFlaggingActionKeep || pendingAction == model.ContentFlaggingActionRemove {
out.ActorDecision = pendingAction
}
}
if reviewerID != "" {
if u, uErr := a.GetUser(rctx, reviewerID); uErr == nil {
out.ReviewerUsername = u.Username
} else {
rctx.Logger().Warn("Failed to fetch reviewer user for flagged post report", mlog.String("user_id", reviewerID), mlog.Err(uErr))
}
}
return out, nil
}
func (a *App) writeAttachments(rctx request.CTX, zw *zip.Writer, dirPrefix string, files []*model.FileInfo, seen map[string]bool, includeAttachments bool) *model.AppError {
for _, fi := range files {
if fi == nil || seen[fi.Id] {
continue
}
// Marked even when excluded, because the caller counts omissions from seen.
seen[fi.Id] = true
if !includeAttachments {
continue
}
reader, appErr := a.FileReader(fi.Path)
if appErr != nil {
return appErr
}
entryName := path.Join(dirPrefix, attachmentEntryName(fi))
w, err := zw.Create(entryName)
if err != nil {
_ = reader.Close()
return model.NewAppError("GenerateFlaggedPostReport", "app.data_spillage.report.zip_create.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if _, err := io.Copy(w, reader); err != nil {
_ = reader.Close()
return model.NewAppError("GenerateFlaggedPostReport", "app.data_spillage.report.zip_copy.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
_ = reader.Close()
}
return nil
}
// attachmentEntryName returns a zip-safe entry name for a FileInfo. We prefix the
// FileInfo.Id to guarantee uniqueness in case two attachments share the same Name,
// and strip path separators from the user-supplied Name to prevent path traversal.
func attachmentEntryName(fi *model.FileInfo) string {
name := strings.ReplaceAll(fi.Name, "/", "_")
name = strings.ReplaceAll(name, "\\", "_")
name = strings.TrimSpace(name)
if name == "" {
name = "attachment"
if fi.Extension != "" {
name += "." + fi.Extension
}
}
return fi.Id + "_" + name
}
func writeYAMLEntry(zw *zip.Writer, name string, payload any) error {
w, err := zw.Create(name)
if err != nil {
return err
}
b, err := yaml.Marshal(payload)
if err != nil {
return err
}
_, err = w.Write(b)
return err
}
// decodePropertyString returns the value for fieldName decoded as a JSON string.
// Property values are stored as json.RawMessage (e.g. `"hello \"world\""`); a
// naive Trim of quotes leaves backslash-escapes in place, so we round-trip
// through json.Unmarshal to get the cleartext value.
func decodePropertyString(rctx request.CTX, byName map[string]json.RawMessage, fieldName string) string {
raw, ok := byName[fieldName]
if !ok || len(raw) == 0 {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err != nil {
rctx.Logger().Warn("Failed to decode content flagging property string value", mlog.String("field", fieldName), mlog.Err(err))
return ""
}
return s
}
// decodePropertyInt64 returns the value for fieldName decoded as a JSON number.
// Some content flagging timestamps are stored as raw JSON numbers (e.g. `12345`).
func decodePropertyInt64(rctx request.CTX, byName map[string]json.RawMessage, fieldName string) int64 {
raw, ok := byName[fieldName]
if !ok || len(raw) == 0 {
return 0
}
var n int64
if err := json.Unmarshal(raw, &n); err != nil {
rctx.Logger().Warn("Failed to decode content flagging property int value", mlog.String("field", fieldName), mlog.Err(err))
return 0
}
return n
}
// NotifyReviewersOfFlaggedPostReportGeneration posts a notification reply on each
// reviewer's content review thread to record that a report was generated.
// Best-effort: errors are logged, never returned.
func (a *App) NotifyReviewersOfFlaggedPostReportGeneration(rctx request.CTX, flaggedPostID, generatedByUserID string) {
a.notifyReviewersOfReportGeneration(rctx, flaggedPostID, generatedByUserID, "@%s generated a report for the quarantined message.")
}
// NotifyReviewersOfPostExposureReportGeneration is the exposure report counterpart of
// NotifyReviewersOfFlaggedPostReportGeneration.
func (a *App) NotifyReviewersOfPostExposureReportGeneration(rctx request.CTX, flaggedPostID, generatedByUserID string) {
a.notifyReviewersOfReportGeneration(rctx, flaggedPostID, generatedByUserID, "@%s downloaded an exposure report for the quarantined message.")
}
func (a *App) notifyReviewersOfReportGeneration(rctx request.CTX, flaggedPostID, generatedByUserID, messageFormat string) {
groupID, err := a.ContentFlaggingGroupId()
if err != nil {
rctx.Logger().Warn("Failed to get content flagging group id for report generation notification", mlog.Err(err))
return
}
generator, appErr := a.GetUser(rctx, generatedByUserID)
if appErr != nil {
rctx.Logger().Warn("Failed to fetch generating user for report generation notification", mlog.Err(appErr))
return
}
message := fmt.Sprintf(messageFormat, generator.Username)
if _, appErr := a.postReviewerMessage(rctx, message, groupID, flaggedPostID, nil, "", ""); appErr != nil {
rctx.Logger().Warn("Failed to post report generation notification to reviewers", mlog.String("flagged_post_id", flaggedPostID), mlog.Err(appErr))
}
}
func (a *App) NotifyRequestingReviewerOfSkippedAttachments(rctx request.CTX, flaggedPostID, requestingUserID string) {
groupID, err := a.ContentFlaggingGroupId()
if err != nil {
rctx.Logger().Warn("Failed to get content flagging group id for skipped attachments notification", mlog.Err(err))
return
}
var locale string
if reviewer, appErr := a.GetUser(rctx, requestingUserID); appErr != nil {
rctx.Logger().Warn("Failed to fetch requesting reviewer, using default locale", mlog.String("user_id", requestingUserID), mlog.Err(appErr))
} else {
locale = reviewer.Locale
}
T := i18n.GetUserTranslations(locale)
message := T("app.data_spillage.report.attachments_omitted.notification")
posts, appErr := a.postReviewerMessage(rctx, message, groupID, flaggedPostID, nil, "", requestingUserID)
if appErr != nil {
rctx.Logger().Warn("Failed to notify the requesting reviewer of skipped attachments", mlog.String("flagged_post_id", flaggedPostID), mlog.String("user_id", requestingUserID), mlog.Err(appErr))
return
}
// Reviewer eligibility is resolved dynamically, so a legitimate reviewer can pass the
// API's reviewer check yet have no thread for a post flagged before they became one.
if len(posts) == 0 {
rctx.Logger().Warn("No content review thread for the requesting reviewer; skipped attachments notification not delivered", mlog.String("flagged_post_id", flaggedPostID), mlog.String("user_id", requestingUserID))
}
}

View File

@@ -0,0 +1,774 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"archive/zip"
"bytes"
"encoding/csv"
"encoding/json"
"io"
"os"
"strings"
"testing"
"github.com/goccy/go-yaml"
"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/public/shared/request"
)
// readReportZip opens the ZIP archive at path and returns a map of entry name
// to contents. The temp file produced by GenerateFlaggedPostReport is also
// removed from disk after reading so individual tests don't have to clean up.
func readReportZip(t *testing.T, path string) map[string][]byte {
t.Helper()
defer func() {
_ = os.Remove(path)
}()
zr, err := zip.OpenReader(path)
require.NoError(t, err)
defer zr.Close()
out := map[string][]byte{}
for _, f := range zr.File {
rc, err := f.Open()
require.NoError(t, err)
b, err := io.ReadAll(rc)
require.NoError(t, err)
_ = rc.Close()
out[f.Name] = b
}
return out
}
func TestGenerateFlaggedPostReport(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
t.Run("returns error when post does not exist", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, model.NewId(), th.BasicUser.Id, "", "", true)
require.NotNil(t, appErr)
require.Empty(t, path)
})
t.Run("returns error when generator user does not exist", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := setupFlaggedPost(t, th)
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, model.NewId(), "", "", true)
require.NotNil(t, appErr)
require.Empty(t, path)
})
t.Run("produces a zip with the expected entries for a basic flagged post", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := setupFlaggedPost(t, th)
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", true)
require.Nil(t, appErr)
require.NotEmpty(t, path)
_, statErr := os.Stat(path)
require.NoError(t, statErr, "report temp file should exist on disk until caller removes it")
entries := readReportZip(t, path)
require.Contains(t, entries, "post/post.yaml")
require.Contains(t, entries, "content_review.yaml")
require.Contains(t, entries, "report_metadata.yaml")
require.Contains(t, entries, "exposure_report.csv")
})
t.Run("exposure_report.csv is a parseable exposure report", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
seedOldChannelMemberHistory(t, th)
post := setupFlaggedPost(t, th)
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", true)
require.Nil(t, appErr)
entries := readReportZip(t, path)
require.Contains(t, entries, "exposure_report.csv")
body := string(entries["exposure_report.csv"])
require.Contains(t, body, "# Post ID: "+post.Id)
r := csv.NewReader(bytes.NewReader(entries["exposure_report.csv"]))
r.Comment = '#'
records, err := r.ReadAll()
require.NoError(t, err)
require.NotEmpty(t, records)
require.Equal(t, model.PostExposureReportCSVHeader(i18n.GetUserTranslations("en")), records[0])
var found bool
for _, record := range records[1:] {
if record[0] == th.BasicUser.Id {
found = true
}
}
require.True(t, found)
})
t.Run("post.yaml contains channel, team, and author details", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := setupFlaggedPost(t, th)
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", true)
require.Nil(t, appErr)
entries := readReportZip(t, path)
var payload map[string]any
require.NoError(t, yaml.Unmarshal(entries["post/post.yaml"], &payload))
require.Equal(t, post.Id, payload["id"])
require.Equal(t, post.UserId, payload["author_id"])
require.Equal(t, th.BasicUser.Username, payload["author_name"])
require.Equal(t, th.BasicChannel.DisplayName, payload["channel_display_name"])
require.Equal(t, th.BasicTeam.Id, payload["team_id"])
require.Equal(t, th.BasicTeam.DisplayName, payload["team_display_name"])
})
t.Run("report_metadata.yaml records the generating user and a non-zero timestamp", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := setupFlaggedPost(t, th)
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", true)
require.Nil(t, appErr)
entries := readReportZip(t, path)
var meta model.FlaggedPostReportMetadata
require.NoError(t, yaml.Unmarshal(entries["report_metadata.yaml"], &meta))
require.Equal(t, th.BasicUser.Id, meta.GeneratedByUserID)
require.Equal(t, th.BasicUser.Username, meta.GeneratedByUsername)
require.Equal(t, model.FlaggedPostReportVersion, meta.ReportVersion)
require.Greater(t, meta.Timestamp, int64(0))
})
t.Run("content_review.yaml records reporter details", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := setupFlaggedPost(t, th)
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", true)
require.Nil(t, appErr)
entries := readReportZip(t, path)
var review model.FlaggedPostReportContentReview
require.NoError(t, yaml.Unmarshal(entries["content_review.yaml"], &review))
// setupFlaggedPost flags as BasicUser2 with reason "spam".
require.Equal(t, th.BasicUser2.Id, review.ReporterUserID)
require.Equal(t, th.BasicUser2.Username, review.ReporterUsername)
require.Equal(t, "spam", review.ReporterReason)
require.Equal(t, "This is spam content", review.ReporterComment)
require.Greater(t, review.ReportTimestamp, int64(0))
require.Empty(t, review.ActorDecision)
require.Empty(t, review.ActorUserId)
require.Empty(t, review.ActorUsername)
})
t.Run("content_review.yaml records remove decision and committed actor after permanent delete", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := setupFlaggedPost(t, th)
appErr = th.App.PermanentDeleteFlaggedPost(th.Context, &model.FlagContentActionRequest{Comment: "violates policy"}, th.SystemAdminUser.Id, post)
require.Nil(t, appErr)
// Report is generated by BasicUser, but the committed actor on the post is SystemAdminUser.
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", true)
require.Nil(t, appErr)
entries := readReportZip(t, path)
var review model.FlaggedPostReportContentReview
require.NoError(t, yaml.Unmarshal(entries["content_review.yaml"], &review))
require.Equal(t, "remove", review.ActorDecision)
require.Equal(t, th.SystemAdminUser.Id, review.ActorUserId)
require.Equal(t, th.SystemAdminUser.Username, review.ActorUsername)
})
t.Run("content_review.yaml records keep decision and committed actor after keep action", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := setupFlaggedPost(t, th)
appErr = th.App.KeepFlaggedPost(th.Context, &model.FlagContentActionRequest{Comment: "looks fine"}, th.SystemAdminUser.Id, post)
require.Nil(t, appErr)
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", true)
require.Nil(t, appErr)
entries := readReportZip(t, path)
var review model.FlaggedPostReportContentReview
require.NoError(t, yaml.Unmarshal(entries["content_review.yaml"], &review))
require.Equal(t, "keep", review.ActorDecision)
require.Equal(t, th.SystemAdminUser.Id, review.ActorUserId)
require.Equal(t, th.SystemAdminUser.Username, review.ActorUsername)
})
t.Run("content_review.yaml prefers committed actor over pending action's generator", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := setupFlaggedPost(t, th)
// Commit a keep action so the actor user id property is set to SystemAdminUser.
appErr = th.App.KeepFlaggedPost(th.Context, &model.FlagContentActionRequest{Comment: "looks fine"}, th.SystemAdminUser.Id, post)
require.Nil(t, appErr)
// Even with a pending action supplied (and a different generator), the
// committed actor on the post should win over the pending-action fallback.
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", model.ContentFlaggingActionRemove, true)
require.Nil(t, appErr)
entries := readReportZip(t, path)
var review model.FlaggedPostReportContentReview
require.NoError(t, yaml.Unmarshal(entries["content_review.yaml"], &review))
require.Equal(t, th.SystemAdminUser.Id, review.ActorUserId)
require.Equal(t, th.SystemAdminUser.Username, review.ActorUsername)
})
t.Run("content_review.yaml uses pending action when status is not yet committed", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := setupFlaggedPost(t, th)
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", model.ContentFlaggingActionRemove, true)
require.Nil(t, appErr)
entries := readReportZip(t, path)
var review model.FlaggedPostReportContentReview
require.NoError(t, yaml.Unmarshal(entries["content_review.yaml"], &review))
require.Equal(t, "remove", review.ActorDecision)
require.Equal(t, th.BasicUser.Id, review.ActorUserId)
require.Equal(t, th.BasicUser.Username, review.ActorUsername)
})
t.Run("content_review.yaml ignores invalid pending action", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := setupFlaggedPost(t, th)
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "bogus", true)
require.Nil(t, appErr)
entries := readReportZip(t, path)
var review model.FlaggedPostReportContentReview
require.NoError(t, yaml.Unmarshal(entries["content_review.yaml"], &review))
require.Empty(t, review.ActorDecision)
// Actor details are still populated whenever a pending action is supplied,
// even when the value isn't a recognised decision.
require.Equal(t, th.BasicUser.Id, review.ActorUserId)
require.Equal(t, th.BasicUser.Username, review.ActorUsername)
})
t.Run("includes file attachments for the base post", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t, th.BasicChannel)
attachmentBody := []byte("hello attachment body")
fileInfo := &model.FileInfo{
Id: model.NewId(),
PostId: post.Id,
CreatorId: post.UserId,
Path: "test/" + model.NewId() + "/attachment.txt",
Name: "attachment.txt",
Size: int64(len(attachmentBody)),
}
_, appErr = th.App.WriteFile(bytes.NewReader(attachmentBody), fileInfo.Path)
require.Nil(t, appErr)
t.Cleanup(func() { _ = th.App.RemoveFile(fileInfo.Path) })
_, err := th.App.Srv().Store().FileInfo().Save(th.Context, fileInfo)
require.NoError(t, err)
// Persist FileIds directly via the store. UpdatePost would route through
// AttachToPost, which is a no-op here because the FileInfo row already has
// PostId set, leaving post.FileIds empty in the DB.
post.FileIds = []string{fileInfo.Id}
_, err = th.App.Srv().Store().Post().Overwrite(th.Context, post)
require.NoError(t, err)
flagData := model.FlagContentRequest{
Reason: "spam",
Comment: "This is spam content",
}
appErr = th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
require.Nil(t, appErr)
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", true)
require.Nil(t, appErr)
entries := readReportZip(t, path)
entryName := "post/attachments/" + fileInfo.Id + "_" + fileInfo.Name
require.Contains(t, entries, entryName)
require.Equal(t, attachmentBody, entries[entryName])
require.Contains(t, entries, "post/post.yaml")
require.Contains(t, entries, "content_review.yaml")
require.Contains(t, entries, "report_metadata.yaml")
})
t.Run("includes edit history entries when the post has been edited", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t, th.BasicChannel)
edited := post.Clone()
edited.Message = "Edited message"
edited.EditAt = model.GetMillis()
_, _, appErr = th.App.UpdatePost(th.Context, edited, &model.UpdatePostOptions{})
require.Nil(t, appErr)
editHistory, appErr := th.App.GetEditHistoryForPost(th.Context, post.Id)
require.Nil(t, appErr)
require.NotEmpty(t, editHistory)
editID := editHistory[0].Id
flagData := model.FlagContentRequest{
Reason: "spam",
Comment: "This is spam content",
}
appErr = th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
require.Nil(t, appErr)
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", true)
require.Nil(t, appErr)
entries := readReportZip(t, path)
require.Contains(t, entries, "edit_history/"+editID+"/post.yaml")
// The base post.yaml should also list the edit history order.
var basePayload map[string]any
require.NoError(t, yaml.Unmarshal(entries["post/post.yaml"], &basePayload))
order, ok := basePayload["edit_history_order"].([]any)
require.True(t, ok, "edit_history_order should be present on the base post payload")
require.Contains(t, order, editID)
})
}
// attachFileToPost persists FileIds with Overwrite rather than UpdatePost, which would
// route through AttachToPost and no-op because the FileInfo row already carries PostId.
func attachFileToPost(t *testing.T, th *TestHelper, post *model.Post, name string, body []byte) *model.FileInfo {
t.Helper()
fileInfo := &model.FileInfo{
Id: model.NewId(),
PostId: post.Id,
CreatorId: post.UserId,
Path: "test/" + model.NewId() + "/" + name,
Name: name,
Size: int64(len(body)),
}
_, appErr := th.App.WriteFile(bytes.NewReader(body), fileInfo.Path)
require.Nil(t, appErr)
t.Cleanup(func() { _ = th.App.RemoveFile(fileInfo.Path) })
_, err := th.App.Srv().Store().FileInfo().Save(th.Context, fileInfo)
require.NoError(t, err)
post.FileIds = append(post.FileIds, fileInfo.Id)
_, err = th.App.Srv().Store().Post().Overwrite(th.Context, post)
require.NoError(t, err)
return fileInfo
}
func attachmentEntries(entries map[string][]byte) []string {
out := []string{}
for name := range entries {
if strings.Contains(name, "/"+flaggedPostReportAttachmentsDir+"/") {
out = append(out, name)
}
}
return out
}
func flagPostForReport(t *testing.T, th *TestHelper, post *model.Post) {
t.Helper()
appErr := th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, model.FlagContentRequest{
Reason: "spam",
Comment: "This is spam content",
})
require.Nil(t, appErr)
}
func TestGenerateFlaggedPostReportAttachmentPolicy(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
t.Run("includes attachments and writes no marker when the caller allows the download", func(t *testing.T) {
require.Nil(t, setBaseConfig(th))
post := th.CreatePost(t, th.BasicChannel)
fileInfo := attachFileToPost(t, th, post, "allowed.txt", []byte("allowed attachment body"))
flagPostForReport(t, th, post)
path, omitted, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", true)
require.Nil(t, appErr)
require.Equal(t, 0, omitted)
entries := readReportZip(t, path)
entry := "post/attachments/" + fileInfo.Id + "_" + fileInfo.Name
require.Contains(t, entries, entry)
require.Equal(t, []byte("allowed attachment body"), entries[entry])
require.NotContains(t, entries, flaggedPostReportAttachmentsOmittedFile)
})
t.Run("omits attachments and writes the marker when the caller denies the download", func(t *testing.T) {
require.Nil(t, setBaseConfig(th))
post := th.CreatePost(t, th.BasicChannel)
fileInfo := attachFileToPost(t, th, post, "denied.txt", []byte("denied attachment body"))
flagPostForReport(t, th, post)
path, omitted, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", false)
require.Nil(t, appErr)
require.Equal(t, 1, omitted)
entries := readReportZip(t, path)
require.NotContains(t, entries, "post/attachments/"+fileInfo.Id+"_"+fileInfo.Name)
require.Empty(t, attachmentEntries(entries))
require.Contains(t, entries, "post/post.yaml")
require.Contains(t, entries, "content_review.yaml")
require.Contains(t, entries, "report_metadata.yaml")
require.Contains(t, entries, "exposure_report.csv")
require.Contains(t, entries, flaggedPostReportAttachmentsOmittedFile)
require.Equal(t,
i18n.GetUserTranslations("en")("app.data_spillage.report.attachments_omitted.file_text"),
string(entries[flaggedPostReportAttachmentsOmittedFile]),
)
})
t.Run("strips attachment metadata from the edit history payload when denied", func(t *testing.T) {
require.Nil(t, setBaseConfig(th))
post := th.CreatePost(t, th.BasicChannel)
fileInfo := attachFileToPost(t, th, post, "edited.txt", []byte("edited attachment body"))
edited := post.Clone()
edited.Message = "Edited message"
edited.EditAt = model.GetMillis()
_, _, appErr := th.App.UpdatePost(th.Context, edited, &model.UpdatePostOptions{})
require.Nil(t, appErr)
editHistory, appErr := th.App.GetEditHistoryForPost(th.Context, post.Id)
require.Nil(t, appErr)
require.NotEmpty(t, editHistory)
editID := editHistory[0].Id
require.NotEmpty(t, editHistory[0].Metadata.Files, "precondition: the edit history entry should carry file metadata")
flagPostForReport(t, th, post)
allowedPath, omitted, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", true)
require.Nil(t, appErr)
require.Equal(t, 0, omitted)
allowed := readReportZip(t, allowedPath)
require.Contains(t, string(allowed["edit_history/"+editID+"/post.yaml"]), fileInfo.Id,
"precondition: the edit history payload leaks file metadata when the download is allowed")
deniedPath, omitted, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", false)
require.Nil(t, appErr)
require.Equal(t, 1, omitted, "an attachment shared by the base post and an edit history entry counts once")
denied := readReportZip(t, deniedPath)
require.Contains(t, denied, "edit_history/"+editID+"/post.yaml")
require.NotContains(t, string(denied["edit_history/"+editID+"/post.yaml"]), fileInfo.Id)
require.NotContains(t, string(denied["edit_history/"+editID+"/post.yaml"]), fileInfo.Name)
require.NotContains(t, string(denied["post/post.yaml"]), fileInfo.Id)
require.Empty(t, attachmentEntries(denied))
})
t.Run("writes no marker when a denied post has no attachments to omit", func(t *testing.T) {
require.Nil(t, setBaseConfig(th))
post := th.CreatePost(t, th.BasicChannel)
flagPostForReport(t, th, post)
path, omitted, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", false)
require.Nil(t, appErr)
require.Equal(t, 0, omitted)
entries := readReportZip(t, path)
require.NotContains(t, entries, flaggedPostReportAttachmentsOmittedFile)
require.Contains(t, entries, "post/post.yaml")
})
}
// TestBuildContentReviewYAMLActorCommentFallback exercises the replica-lag
// fallback in buildContentReviewYAML: when the property-value read (which is
// served from a read replica) returns no actor_comment value, the function
// must fall back to the in-hand actorComment supplied by the caller so the
// generated report still reflects the comment the user just submitted.
func TestBuildContentReviewYAMLActorCommentFallback(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
t.Run("falls back to in-hand comment when no actor_comment is persisted", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := setupFlaggedPost(t, th)
// setupFlaggedPost only writes the reporting_* properties — no
// actor_comment row exists yet, so the replica read returns "" for
// that field and the fallback branch must take effect.
review, appErr := th.App.buildContentReviewYAML(th.Context, post, th.BasicUser.Id, "fallback note", "")
require.Nil(t, appErr)
require.Equal(t, "fallback note", review.ReviewerComment)
})
t.Run("reviewer comment stays empty when both sources are empty", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := setupFlaggedPost(t, th)
review, appErr := th.App.buildContentReviewYAML(th.Context, post, th.BasicUser.Id, "", "")
require.Nil(t, appErr)
require.Empty(t, review.ReviewerComment)
})
}
func TestBuildPostYAML(t *testing.T) {
channel := &model.Channel{Id: "channel-id", DisplayName: "Channel Name"}
team := &model.Team{Id: "team-id", DisplayName: "Team Name"}
author := &model.User{Id: "author-id", Username: "alice", Email: "alice@example.com"}
t.Run("populates author, channel, and team fields", func(t *testing.T) {
post := &model.Post{Id: "post-id", UserId: author.Id, ChannelId: channel.Id, Message: "hi"}
got := buildPostYAML(post, channel, team, author, nil)
require.Equal(t, "alice", got.AuthorName)
require.Equal(t, "alice@example.com", got.AuthorEmail)
require.Equal(t, "Channel Name", got.ChannelDisplayName)
require.Equal(t, "team-id", got.TeamID)
require.Equal(t, "Team Name", got.TeamDisplayName)
})
t.Run("omits team fields for DM/GM posts", func(t *testing.T) {
post := &model.Post{Id: "post-id", UserId: author.Id, ChannelId: channel.Id}
got := buildPostYAML(post, channel, nil, author, nil)
require.Empty(t, got.TeamID)
require.Empty(t, got.TeamDisplayName)
})
t.Run("omits author fields when author is nil", func(t *testing.T) {
post := &model.Post{Id: "post-id", ChannelId: channel.Id}
got := buildPostYAML(post, channel, team, nil, nil)
require.Empty(t, got.AuthorName)
require.Empty(t, got.AuthorEmail)
})
t.Run("populates reply count only for root posts", func(t *testing.T) {
root := &model.Post{Id: "root", ChannelId: channel.Id, ReplyCount: 3}
got := buildPostYAML(root, channel, team, author, nil)
require.NotNil(t, got.ReplyCountPtr)
require.Equal(t, int64(3), *got.ReplyCountPtr)
reply := &model.Post{Id: "reply", ChannelId: channel.Id, RootId: "root", ReplyCount: 0}
got = buildPostYAML(reply, channel, team, author, nil)
require.Nil(t, got.ReplyCountPtr)
})
t.Run("preserves edit history order verbatim", func(t *testing.T) {
post := &model.Post{Id: "post-id", ChannelId: channel.Id}
order := []string{"edit-3", "edit-2", "edit-1"}
got := buildPostYAML(post, channel, team, author, order)
require.Equal(t, order, got.EditHistoryOrder)
})
}
func TestAttachmentEntryName(t *testing.T) {
t.Run("prefixes the file id and keeps the original name", func(t *testing.T) {
fi := &model.FileInfo{Id: "abc123", Name: "report.pdf"}
require.Equal(t, "abc123_report.pdf", attachmentEntryName(fi))
})
t.Run("strips forward and back slashes to prevent path traversal", func(t *testing.T) {
fi := &model.FileInfo{Id: "abc123", Name: "../../etc/passwd"}
require.Equal(t, "abc123_.._.._etc_passwd", attachmentEntryName(fi))
fi = &model.FileInfo{Id: "abc123", Name: `..\..\windows\system32`}
require.Equal(t, "abc123_.._.._windows_system32", attachmentEntryName(fi))
})
t.Run("falls back to a synthesised name when the original is empty", func(t *testing.T) {
fi := &model.FileInfo{Id: "abc123", Name: "", Extension: "pdf"}
require.Equal(t, "abc123_attachment.pdf", attachmentEntryName(fi))
fi = &model.FileInfo{Id: "abc123", Name: " ", Extension: ""}
require.Equal(t, "abc123_attachment", attachmentEntryName(fi))
})
}
func TestDecodePropertyString(t *testing.T) {
rctx := request.EmptyContext(mlog.CreateConsoleTestLogger(t))
t.Run("returns empty string when key is missing", func(t *testing.T) {
got := decodePropertyString(rctx, map[string]json.RawMessage{}, "missing")
require.Empty(t, got)
})
t.Run("returns empty string for empty raw value", func(t *testing.T) {
got := decodePropertyString(rctx, map[string]json.RawMessage{"k": []byte{}}, "k")
require.Empty(t, got)
})
t.Run("decodes a JSON-encoded string", func(t *testing.T) {
got := decodePropertyString(rctx, map[string]json.RawMessage{"k": json.RawMessage(`"hello \"world\""`)}, "k")
require.Equal(t, `hello "world"`, got)
})
t.Run("returns empty string when the raw value is not valid JSON", func(t *testing.T) {
got := decodePropertyString(rctx, map[string]json.RawMessage{"k": json.RawMessage("not-json")}, "k")
require.Empty(t, got)
})
}
func TestDecodePropertyInt64(t *testing.T) {
rctx := request.EmptyContext(mlog.CreateConsoleTestLogger(t))
t.Run("returns zero when key is missing", func(t *testing.T) {
got := decodePropertyInt64(rctx, map[string]json.RawMessage{}, "missing")
require.Equal(t, int64(0), got)
})
t.Run("returns zero for empty raw value", func(t *testing.T) {
got := decodePropertyInt64(rctx, map[string]json.RawMessage{"k": []byte{}}, "k")
require.Equal(t, int64(0), got)
})
t.Run("decodes a JSON-encoded number", func(t *testing.T) {
got := decodePropertyInt64(rctx, map[string]json.RawMessage{"k": json.RawMessage("12345")}, "k")
require.Equal(t, int64(12345), got)
})
t.Run("returns zero when the raw value is not a JSON number", func(t *testing.T) {
got := decodePropertyInt64(rctx, map[string]json.RawMessage{"k": json.RawMessage(`"12345"`)}, "k")
require.Equal(t, int64(0), got)
})
}
func TestNotifyReviewersOfFlaggedPostReportGeneration(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
t.Run("does not panic when called for a non-flagged post", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
// Use a random post id to exercise the best-effort error path. The function
// should swallow any errors and return without raising.
require.NotPanics(t, func() {
th.App.NotifyReviewersOfFlaggedPostReportGeneration(th.Context, model.NewId(), th.BasicUser.Id)
})
})
t.Run("does not panic when generator user does not exist", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := setupFlaggedPost(t, th)
require.NotPanics(t, func() {
th.App.NotifyReviewersOfFlaggedPostReportGeneration(th.Context, post.Id, model.NewId())
})
})
}
func TestNotifyReviewersOfPostExposureReportGeneration(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
reviewerMessages := func(t *testing.T, postID string) []string {
t.Helper()
groupID, appErr := th.App.ContentFlaggingGroupId()
require.Nil(t, appErr)
mappedFields, appErr := th.App.GetContentFlaggingMappedFields(th.Context, groupID)
require.Nil(t, appErr)
rootPostIDs, appErr := th.App.getReviewerPostsForFlaggedPost(th.Context, groupID, postID, mappedFields[contentFlaggingPropertyNameFlaggedPostId].ID)
require.Nil(t, appErr)
require.NotEmpty(t, rootPostIDs)
var messages []string
for _, rootPostID := range rootPostIDs {
thread, appErr := th.App.GetPostThread(th.Context, rootPostID, model.GetPostsOptions{}, "")
require.Nil(t, appErr)
for _, reply := range thread.Posts {
messages = append(messages, reply.Message)
}
}
return messages
}
t.Run("posts an exposure-specific message distinct from the flagged post report message", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := setupFlaggedPost(t, th)
th.App.NotifyReviewersOfPostExposureReportGeneration(th.Context, post.Id, th.BasicUser.Id)
messages := reviewerMessages(t, post.Id)
require.Contains(t, messages, "@"+th.BasicUser.Username+" downloaded an exposure report for the quarantined message.")
require.NotContains(t, messages, "@"+th.BasicUser.Username+" generated a report for the quarantined message.")
})
t.Run("leaves the flagged post report wording unchanged", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := setupFlaggedPost(t, th)
th.App.NotifyReviewersOfFlaggedPostReportGeneration(th.Context, post.Id, th.BasicUser.Id)
messages := reviewerMessages(t, post.Id)
require.Contains(t, messages, "@"+th.BasicUser.Username+" generated a report for the quarantined message.")
})
t.Run("does not panic for a non-flagged post", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
require.NotPanics(t, func() {
th.App.NotifyReviewersOfPostExposureReportGeneration(th.Context, model.NewId(), th.BasicUser.Id)
})
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store/sqlstore"
)
// RequestContextWithMaster adds the context value that master DB should be selected for this request.
func RequestContextWithMaster(rctx request.CTX) request.CTX {
return sqlstore.RequestContextWithMaster(rctx)
}
// RequestContextWithCallerID adds the caller ID to a request.CTX for access control purposes.
func RequestContextWithCallerID(rctx request.CTX, callerID string) request.CTX {
ctx := model.WithCallerID(rctx.Context(), callerID)
return rctx.WithContext(ctx)
}
// CallerIDFromRequestContext extracts the caller ID from a request.CTX.
// Returns the caller ID and true if found, or empty string and false if not.
func CallerIDFromRequestContext(rctx request.CTX) (string, bool) {
if rctx == nil {
return "", false
}
return model.CallerIDFromContext(rctx.Context())
}
// requestContextWithCallerIDAndOptions adds the caller ID and the caller's
// per-call property request options to a request.CTX.
func requestContextWithCallerIDAndOptions(rctx request.CTX, callerID string, options model.PropertyRequestOptions) request.CTX {
ctx := model.WithCallerID(rctx.Context(), callerID)
ctx = model.WithPropertyRequestOptions(ctx, options)
return rctx.WithContext(ctx)
}
func pluginContext(rctx request.CTX) *plugin.Context {
context := &plugin.Context{
RequestId: rctx.RequestId(),
SessionId: rctx.Session().Id,
IPAddress: rctx.IPAddress(),
AcceptLanguage: rctx.AcceptLanguage(),
UserAgent: rctx.UserAgent(),
ConnectionId: rctx.ConnectionId(),
}
return context
}

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
)
func TestPluginContext(t *testing.T) {
t.Run("creates plugin context with all fields from request context", func(t *testing.T) {
rctx := request.TestContext(t)
session := &model.Session{
Id: "session-id-123",
UserId: "user-id-456",
}
rctx = rctx.WithSession(session).(*request.Context)
rctx = rctx.WithRequestId("request-id-789").(*request.Context)
rctx = rctx.WithIPAddress("192.168.1.1").(*request.Context)
rctx = rctx.WithAcceptLanguage("en-US").(*request.Context)
rctx = rctx.WithUserAgent("TestAgent/1.0").(*request.Context)
rctx = rctx.WithConnectionId("connection-id-abc").(*request.Context)
ctx := pluginContext(rctx)
assert.Equal(t, "request-id-789", ctx.RequestId)
assert.Equal(t, "session-id-123", ctx.SessionId)
assert.Equal(t, "192.168.1.1", ctx.IPAddress)
assert.Equal(t, "en-US", ctx.AcceptLanguage)
assert.Equal(t, "TestAgent/1.0", ctx.UserAgent)
assert.Equal(t, "connection-id-abc", ctx.ConnectionId)
})
t.Run("creates plugin context with empty connection id when not set", func(t *testing.T) {
rctx := request.TestContext(t)
ctx := pluginContext(rctx)
assert.Empty(t, ctx.ConnectionId)
})
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app/properties"
)
// auditCPAValueChange emits one audit record for a successful CPA value change.
// It is the ValueAuditSink registered on PropertyValueAuditHook for the CPA
// group. Logged at the content audit level (data change).
func (a *App) auditCPAValueChange(rctx request.CTX, e properties.ValueAuditEvent) {
callerID, _ := CallerIDFromRequestContext(rctx)
scope := model.PropertyRequestOptionsFromContext(rctx.Context()).ActingAsScope
rec := a.MakeAuditRecord(rctx, model.AuditEventCPAValueChange, model.AuditStatusSuccess)
rec.AddMeta("caller_id", callerID)
rec.AddMeta("acting_as_scope", scope)
rec.AddMeta("group", model.AccessControlPropertyGroupName)
rec.AddMeta("action", e.Action)
rec.AddMeta("target_type", e.TargetType)
rec.AddMeta("target_id", e.TargetID)
if e.FieldID != "" {
rec.AddMeta("field_id", e.FieldID)
}
if e.ValueID != "" {
rec.AddMeta("value_id", e.ValueID)
}
switch e.Action {
case properties.ValueAuditActionDeleteForTarget, properties.ValueAuditActionDeleteForField:
// Bulk deletes have no per-value payload.
default:
if e.Prev != nil && len(e.Prev.Value) > 0 {
rec.AddMeta("prior_value", string(e.Prev.Value))
}
if e.Current != nil && len(e.Current.Value) > 0 {
rec.AddMeta("new_value", string(e.Current.Value))
}
}
a.LogAuditRecWithLevel(rctx, rec, LevelContent, nil)
}

View File

@@ -0,0 +1,151 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app/properties"
)
// The CPA value sink writes through AddMeta rather than event parameters, so
// auditParam does not apply here.
func auditMeta(t *testing.T, rec map[string]any, key string) any {
t.Helper()
meta, ok := rec[model.AuditKeyMeta].(map[string]any)
require.True(t, ok, "audit record has no meta")
return meta[key]
}
func auditMetaHasKey(t *testing.T, rec map[string]any, key string) bool {
t.Helper()
meta, ok := rec[model.AuditKeyMeta].(map[string]any)
require.True(t, ok, "audit record has no meta")
_, present := meta[key]
return present
}
// Pins the audit trail governance relies on to answer "who marked this channel,
// and as what". Asserts new_value is captured verbatim: masking hides some values
// from some administrators, so raw capture in the audit stream is a decision, and
// narrowing it must break this test rather than pass unnoticed.
func TestCPAValueChangeAuditForChannelValues(t *testing.T) {
th := Setup(t).InitBasic(t)
// LicenseCheckHook gates access_control writes on an Enterprise license.
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
capture := startPluginAuditCapture(t, th)
rctx := request.TestContext(t)
cpaGroup, groupErr := th.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
require.Nil(t, groupErr)
newChannelField := func(t *testing.T) *model.PropertyField {
t.Helper()
field, fieldErr := th.App.CreatePropertyField(rctx, &model.PropertyField{
GroupID: cpaGroup.ID,
Name: "chan_attr_" + model.NewId(),
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeChannel,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}, false, "")
require.Nil(t, fieldErr)
return field
}
writeValue := func(t *testing.T, field *model.PropertyField, raw string) *model.PropertyValue {
t.Helper()
values, upsertErr := th.App.UpsertPropertyValues(rctx, []*model.PropertyValue{{
TargetID: th.BasicChannel.Id,
TargetType: model.PropertyFieldObjectTypeChannel,
GroupID: cpaGroup.ID,
FieldID: field.ID,
Value: json.RawMessage(raw),
}}, model.PropertyFieldObjectTypeChannel, th.BasicChannel.Id, "")
require.Nil(t, upsertErr)
require.Len(t, values, 1)
return values[0]
}
nextRecord := func(t *testing.T, fn func()) map[string]any {
t.Helper()
before := len(capture.recordsFor(model.AuditEventCPAValueChange))
fn()
records := capture.recordsFor(model.AuditEventCPAValueChange)
require.Len(t, records, before+1, "expected exactly one new %s record", model.AuditEventCPAValueChange)
return records[len(records)-1]
}
t.Run("upsert records the channel target, the field, and the new value verbatim", func(t *testing.T) {
field := newChannelField(t)
rec := nextRecord(t, func() { writeValue(t, field, `"AURORA"`) })
require.Equal(t, model.AuditStatusSuccess, rec[model.AuditKeyStatus])
require.Equal(t, model.AccessControlPropertyGroupName, auditMeta(t, rec, "group"))
require.Equal(t, properties.ValueAuditActionUpsert, auditMeta(t, rec, "action"))
require.Equal(t, model.PropertyFieldObjectTypeChannel, auditMeta(t, rec, "target_type"))
require.Equal(t, th.BasicChannel.Id, auditMeta(t, rec, "target_id"))
require.Equal(t, field.ID, auditMeta(t, rec, "field_id"))
require.Equal(t, `"AURORA"`, auditMeta(t, rec, "new_value"))
})
t.Run("editing a value records the new value but not the previous one", func(t *testing.T) {
field := newChannelField(t)
writeValue(t, field, `"AURORA"`)
rec := nextRecord(t, func() { writeValue(t, field, `"NOFORN"`) })
require.Equal(t, properties.ValueAuditActionUpsert, auditMeta(t, rec, "action"))
require.Equal(t, `"NOFORN"`, auditMeta(t, rec, "new_value"))
// The sink supports prior_value but the upsert path never sets
// ValueAuditEvent.Prev, so an edit has no before/after pair. Asserted so
// the gap is visible rather than assumed absent.
require.False(t, auditMetaHasKey(t, rec, "prior_value"))
})
t.Run("deleting a single value records the prior value and the value id", func(t *testing.T) {
field := newChannelField(t)
value := writeValue(t, field, `"ELEVATED"`)
rec := nextRecord(t, func() {
require.Nil(t, th.App.DeletePropertyValue(rctx, cpaGroup.ID, value.ID))
})
require.Equal(t, properties.ValueAuditActionDelete, auditMeta(t, rec, "action"))
require.Equal(t, th.BasicChannel.Id, auditMeta(t, rec, "target_id"))
require.Equal(t, field.ID, auditMeta(t, rec, "field_id"))
require.Equal(t, value.ID, auditMeta(t, rec, "value_id"))
require.Equal(t, `"ELEVATED"`, auditMeta(t, rec, "prior_value"))
})
t.Run("bulk deletes record the scope but carry no value payload", func(t *testing.T) {
targetField := newChannelField(t)
writeValue(t, targetField, `"UNCLASSIFIED"`)
rec := nextRecord(t, func() {
require.Nil(t, th.App.DeletePropertyValuesForTarget(rctx, cpaGroup.ID, model.PropertyFieldObjectTypeChannel, th.BasicChannel.Id))
})
require.Equal(t, properties.ValueAuditActionDeleteForTarget, auditMeta(t, rec, "action"))
require.Equal(t, th.BasicChannel.Id, auditMeta(t, rec, "target_id"))
require.False(t, auditMetaHasKey(t, rec, "prior_value"))
require.False(t, auditMetaHasKey(t, rec, "new_value"))
fieldScoped := newChannelField(t)
writeValue(t, fieldScoped, `"SECRET"`)
rec = nextRecord(t, func() {
require.Nil(t, th.App.DeletePropertyValuesForField(rctx, cpaGroup.ID, fieldScoped.ID))
})
require.Equal(t, properties.ValueAuditActionDeleteForField, auditMeta(t, rec, "action"))
require.Equal(t, fieldScoped.ID, auditMeta(t, rec, "field_id"))
require.False(t, auditMetaHasKey(t, rec, "prior_value"))
require.False(t, auditMetaHasKey(t, rec, "new_value"))
})
}

View File

@@ -0,0 +1,290 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"fmt"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/stretchr/testify/require"
)
func TestGetCPAValue(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
cpaGroup, groupErr := th.App.GetPropertyGroup(request.TestContext(t), model.AccessControlPropertyGroupName)
require.Nil(t, groupErr)
cpaID := cpaGroup.ID
rctx := th.emptyContextWithCallerID(anonymousCallerId)
field := &model.PropertyField{
GroupID: cpaID,
Name: "f_" + model.NewId(),
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
createdField, err := th.App.CreatePropertyField(rctx, field, false, "")
require.Nil(t, err)
fieldID := createdField.ID
t.Run("should fail if the value doesn't exist", func(t *testing.T) {
pv, appErr := th.App.GetPropertyValue(rctx, cpaID, model.NewId())
require.NotNil(t, appErr)
require.Nil(t, pv)
})
t.Run("should fail if the group id is invalid", func(t *testing.T) {
propertyValue := &model.PropertyValue{
TargetID: model.NewId(),
TargetType: model.PropertyValueTargetTypeUser,
GroupID: model.NewId(),
FieldID: fieldID,
Value: json.RawMessage(`"Value"`),
}
created, err := th.App.CreatePropertyValue(rctx, propertyValue)
require.Nil(t, err)
require.NotNil(t, created)
pv, appErr := th.App.GetPropertyValue(rctx, cpaID, created.ID)
require.NotNil(t, appErr)
require.Nil(t, pv)
})
t.Run("should succeed if id exists", func(t *testing.T) {
propertyValue := &model.PropertyValue{
TargetID: model.NewId(),
TargetType: model.PropertyValueTargetTypeUser,
GroupID: cpaID,
FieldID: fieldID,
Value: json.RawMessage(`"Value"`),
}
propertyValue, err := th.App.CreatePropertyValue(rctx, propertyValue)
require.Nil(t, err)
pv, appErr := th.App.GetPropertyValue(rctx, cpaID, propertyValue.ID)
require.Nil(t, appErr)
require.NotNil(t, pv)
})
t.Run("should handle array values correctly", func(t *testing.T) {
optionIDs := []string{model.NewId(), model.NewId(), model.NewId()}
arrayField := &model.PropertyField{
GroupID: cpaID,
Name: "f_" + model.NewId(),
Type: model.PropertyFieldTypeMultiselect,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": optionIDs[0], "name": "option1"},
map[string]any{"id": optionIDs[1], "name": "option2"},
map[string]any{"id": optionIDs[2], "name": "option3"},
},
},
}
createdField, err := th.App.CreatePropertyField(rctx, arrayField, false, "")
require.Nil(t, err)
propertyValue := &model.PropertyValue{
TargetID: model.NewId(),
TargetType: model.PropertyValueTargetTypeUser,
GroupID: cpaID,
FieldID: createdField.ID,
Value: json.RawMessage(fmt.Sprintf(`["%s", "%s", "%s"]`, optionIDs[0], optionIDs[1], optionIDs[2])),
}
propertyValue, err = th.App.CreatePropertyValue(rctx, propertyValue)
require.Nil(t, err)
pv, appErr := th.App.GetPropertyValue(rctx, cpaID, propertyValue.ID)
require.Nil(t, appErr)
require.NotNil(t, pv)
var arrayValues []string
require.NoError(t, json.Unmarshal(pv.Value, &arrayValues))
require.Equal(t, optionIDs, arrayValues)
})
}
func TestDeleteCPAValues(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
cpaGroup, groupErr := th.App.GetPropertyGroup(request.TestContext(t), model.AccessControlPropertyGroupName)
require.Nil(t, groupErr)
cpaID := cpaGroup.ID
rctx := th.emptyContextWithCallerID(anonymousCallerId)
userID := model.NewId()
otherUserID := model.NewId()
listValues := func(targetID string) []*model.PropertyValue {
t.Helper()
values, appErr := th.App.SearchPropertyValues(rctx, cpaID, model.PropertyValueSearchOpts{
TargetIDs: []string{targetID},
TargetType: model.PropertyValueTargetTypeUser,
// Single-target search: at most one value per (target, field), so the field cap bounds the page.
PerPage: model.AccessControlGroupFieldLimit + 5,
})
require.Nil(t, appErr)
return values
}
// Create multiple fields and a value per field for userID.
var createdFields []*model.PropertyField
for i := 1; i <= 3; i++ {
field := &model.PropertyField{
GroupID: cpaID,
Name: fmt.Sprintf("field_%d", i),
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
createdField, err := th.App.CreatePropertyField(rctx, field, false, "")
require.Nil(t, err)
createdFields = append(createdFields, createdField)
value := &model.PropertyValue{
TargetID: userID,
TargetType: model.PropertyValueTargetTypeUser,
GroupID: cpaID,
FieldID: createdField.ID,
Value: json.RawMessage(fmt.Sprintf(`"Value %d"`, i)),
}
_, err = th.App.CreatePropertyValue(rctx, value)
require.Nil(t, err)
}
require.Len(t, listValues(userID), 3)
t.Run("should delete all values for a user", func(t *testing.T) {
appErr := th.App.DeletePropertyValuesForTarget(rctx, cpaID, model.PropertyFieldObjectTypeUser, userID)
require.Nil(t, appErr)
require.Empty(t, listValues(userID))
})
t.Run("should handle deleting values for a user with no values", func(t *testing.T) {
appErr := th.App.DeletePropertyValuesForTarget(rctx, cpaID, model.PropertyFieldObjectTypeUser, otherUserID)
require.Nil(t, appErr)
})
t.Run("should not affect values for other users", func(t *testing.T) {
// Create values for otherUserID.
for _, field := range createdFields {
value := &model.PropertyValue{
TargetID: otherUserID,
TargetType: model.PropertyValueTargetTypeUser,
GroupID: cpaID,
FieldID: field.ID,
Value: json.RawMessage(`"Other user value"`),
}
_, err := th.App.CreatePropertyValue(rctx, value)
require.Nil(t, err)
}
appErr := th.App.DeletePropertyValuesForTarget(rctx, cpaID, model.PropertyFieldObjectTypeUser, userID)
require.Nil(t, appErr)
require.Len(t, listValues(otherUserID), 3)
})
}
// TestCPAValueSyncLock exercises AccessControlHook.checkSyncLock end-to-end
// at the app layer: a write for a field with ldap= or saml= set only
// succeeds when the caller ID matches the field's sync source. Covering this
// at the app layer also asserts that the startup wiring in server.go
// (access_control group registration, AccessControlHook install, and
// CallerIDExtractor reading from request.CTX) is intact — something the
// properties-package tests cannot verify because they install the hook
// themselves.
func TestCPAValueSyncLock(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
cpaGroup, groupErr := th.App.GetPropertyGroup(request.TestContext(t), model.AccessControlPropertyGroupName)
require.Nil(t, groupErr)
cpaID := cpaGroup.ID
adminRctx := th.emptyContextWithCallerID(th.SystemAdminUser.Id)
createField := func(name string, attrs model.CPAAttrs) *model.PropertyField {
t.Helper()
cpa := &model.CPAField{
PropertyField: model.PropertyField{
GroupID: cpaID,
Name: name,
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
},
Attrs: attrs,
}
// Sanitization/validation runs inside CreatePropertyField via the
// AccessControlAttributeValidationHook.
created, appErr := th.App.CreatePropertyField(adminRctx, cpa.ToPropertyField(), false, "")
require.Nil(t, appErr)
return created
}
ldapField := createField("ldap_attr_"+model.NewId(), model.CPAAttrs{LDAP: "mail"})
samlField := createField("saml_attr_"+model.NewId(), model.CPAAttrs{SAML: "email"})
plainField := createField("plain_attr_"+model.NewId(), model.CPAAttrs{})
userID := model.NewId()
upsertAs := func(callerID string, field *model.PropertyField) *model.AppError {
t.Helper()
rctx := th.emptyContextWithCallerID(callerID)
_, appErr := th.App.UpsertPropertyValues(rctx, []*model.PropertyValue{{
GroupID: cpaID,
TargetType: model.PropertyValueTargetTypeUser,
TargetID: userID,
FieldID: field.ID,
Value: json.RawMessage(`"value"`),
}}, model.PropertyFieldObjectTypeUser, userID, "")
return appErr
}
requireSyncLock := func(appErr *model.AppError) {
t.Helper()
require.NotNil(t, appErr)
require.Equal(t, "app.property.sync_lock.app_error", appErr.Id)
}
t.Run("anonymous caller is blocked on an LDAP-synced field", func(t *testing.T) {
requireSyncLock(upsertAs(anonymousCallerId, ldapField))
})
t.Run("anonymous caller is blocked on a SAML-synced field", func(t *testing.T) {
requireSyncLock(upsertAs(anonymousCallerId, samlField))
})
t.Run("anonymous caller is allowed on a non-synced field", func(t *testing.T) {
require.Nil(t, upsertAs(anonymousCallerId, plainField))
})
t.Run("LDAP sync caller is allowed on an LDAP-synced field", func(t *testing.T) {
require.Nil(t, upsertAs(model.CallerIDLDAPSync, ldapField))
})
t.Run("LDAP sync caller is blocked on a SAML-synced field", func(t *testing.T) {
requireSyncLock(upsertAs(model.CallerIDLDAPSync, samlField))
})
t.Run("SAML sync caller is allowed on a SAML-synced field", func(t *testing.T) {
require.Nil(t, upsertAs(model.CallerIDSAMLSync, samlField))
})
t.Run("SAML sync caller is blocked on an LDAP-synced field", func(t *testing.T) {
requireSyncLock(upsertAs(model.CallerIDSAMLSync, ldapField))
})
}

View File

@@ -0,0 +1,120 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"net/http"
"github.com/mattermost/mattermost/server/public/model"
)
func (a *App) GetGlobalRetentionPolicy() (*model.GlobalRetentionPolicy, *model.AppError) {
if a.DataRetention() == nil {
return nil, newLicenseError("GetGlobalRetentionPolicy")
}
return a.DataRetention().GetGlobalPolicy()
}
func (a *App) GetRetentionPolicies(offset, limit int) (*model.RetentionPolicyWithTeamAndChannelCountsList, *model.AppError) {
if a.DataRetention() == nil {
return nil, newLicenseError("GetRetentionPolicies")
}
return a.DataRetention().GetPolicies(offset, limit)
}
func (a *App) GetRetentionPoliciesCount() (int64, *model.AppError) {
if a.DataRetention() == nil {
return 0, newLicenseError("GetRetentionPoliciesCount")
}
return a.DataRetention().GetPoliciesCount()
}
func (a *App) GetRetentionPolicy(policyID string) (*model.RetentionPolicyWithTeamAndChannelCounts, *model.AppError) {
if a.DataRetention() == nil {
return nil, newLicenseError("GetRetentionPolicy")
}
return a.DataRetention().GetPolicy(policyID)
}
func (a *App) CreateRetentionPolicy(policy *model.RetentionPolicyWithTeamAndChannelIDs) (*model.RetentionPolicyWithTeamAndChannelCounts, *model.AppError) {
if a.DataRetention() == nil {
return nil, newLicenseError("CreateRetentionPolicy")
}
return a.DataRetention().CreatePolicy(policy)
}
func (a *App) PatchRetentionPolicy(patch *model.RetentionPolicyWithTeamAndChannelIDs) (*model.RetentionPolicyWithTeamAndChannelCounts, *model.AppError) {
if a.DataRetention() == nil {
return nil, newLicenseError("PatchRetentionPolicy")
}
return a.DataRetention().PatchPolicy(patch)
}
func (a *App) DeleteRetentionPolicy(policyID string) *model.AppError {
if a.DataRetention() == nil {
return newLicenseError("DeleteRetentionPolicy")
}
return a.DataRetention().DeletePolicy(policyID)
}
func (a *App) GetTeamsForRetentionPolicy(policyID string, offset, limit int) (*model.TeamsWithCount, *model.AppError) {
if a.DataRetention() == nil {
return nil, newLicenseError("GetTeamsForRetentionPolicy")
}
return a.DataRetention().GetTeamsForPolicy(policyID, offset, limit)
}
func (a *App) AddTeamsToRetentionPolicy(policyID string, teamIDs []string) *model.AppError {
if a.DataRetention() == nil {
return newLicenseError("AddTeamsToRetentionPolicy")
}
return a.DataRetention().AddTeamsToPolicy(policyID, teamIDs)
}
func (a *App) RemoveTeamsFromRetentionPolicy(policyID string, teamIDs []string) *model.AppError {
if a.DataRetention() == nil {
return newLicenseError("RemoveTeamsFromRetentionPolicy")
}
return a.DataRetention().RemoveTeamsFromPolicy(policyID, teamIDs)
}
func (a *App) GetChannelsForRetentionPolicy(policyID string, offset, limit int) (*model.ChannelsWithCount, *model.AppError) {
if a.DataRetention() == nil {
return nil, newLicenseError("GetChannelsForRetentionPolicy")
}
return a.DataRetention().GetChannelsForPolicy(policyID, offset, limit)
}
func (a *App) AddChannelsToRetentionPolicy(policyID string, channelIDs []string) *model.AppError {
if a.DataRetention() == nil {
return newLicenseError("AddChannelsToRetentionPolicies")
}
return a.DataRetention().AddChannelsToPolicy(policyID, channelIDs)
}
func (a *App) RemoveChannelsFromRetentionPolicy(policyID string, channelIDs []string) *model.AppError {
if a.DataRetention() == nil {
return newLicenseError("RemoveChannelsFromRetentionPolicy")
}
return a.DataRetention().RemoveChannelsFromPolicy(policyID, channelIDs)
}
func (a *App) GetTeamPoliciesForUser(userID string, offset, limit int) (*model.RetentionPolicyForTeamList, *model.AppError) {
if a.DataRetention() == nil {
return nil, newLicenseError("GetTeamPoliciesForUser")
}
return a.DataRetention().GetTeamPoliciesForUser(userID, offset, limit)
}
func (a *App) GetChannelPoliciesForUser(userID string, offset, limit int) (*model.RetentionPolicyForChannelList, *model.AppError) {
if a.DataRetention() == nil {
return nil, newLicenseError("GetChannelPoliciesForUser")
}
return a.DataRetention().GetChannelPoliciesForUser(userID, offset, limit)
}
func newLicenseError(methodName string) *model.AppError {
return model.NewAppError("App."+methodName, "ent.data_retention.generic.license.error",
nil, "", http.StatusNotImplemented)
}

View File

@@ -0,0 +1,240 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
func (a *App) deliveryTrackingEnabled() bool {
cfg := a.Config()
return cfg.FeatureFlags != nil && cfg.FeatureFlags.PostDeliveryTracking &&
model.SafeDereference(cfg.DeliveryTrackingSettings.Enable)
}
func shouldTrackPost(post *model.Post) bool {
return post != nil && post.Id != "" && !post.IsSystemMessage() && post.Type != model.PostTypeBurnOnRead && post.Type != model.PostTypeEphemeral
}
func (a *App) shouldTrackChannel(rctx request.CTX, channelID string) bool {
if channelID == "" {
return false
}
deliveryTracking := a.Srv().Store().DeliveryTracking()
// An explicit allow-list is validated DM/GM-free when it is saved, so membership alone
// settles eligibility and no channel has to be resolved.
if !model.SafeDereference(a.Config().DeliveryTrackingSettings.EnableForAllChannels) {
tracked, err := deliveryTracking.IsChannelTracked(rctx, channelID)
if err != nil {
rctx.Logger().Warn("Failed to read tracked channel for post delivery tracking", mlog.String("channel_id", channelID), mlog.Err(err))
return false
}
return tracked
}
trackable, err := deliveryTracking.IsChannelTrackable(rctx, channelID)
if err != nil {
rctx.Logger().Warn("Failed to read channel eligibility for post delivery tracking", mlog.String("channel_id", channelID), mlog.Err(err))
return false
}
return trackable
}
// emitDeliveryRecord writes one postDelivered record. actorUserID is the recipient, and is empty
// for plugin and outgoing webhook deliveries, which have no human recipient.
func (a *App) emitDeliveryRecord(actorUserID string, meta map[string]any) {
if a.Srv() == nil || a.Srv().Audit == nil {
return
}
a.Srv().Audit.LogRecord(mlog.LvlAuditDelivery, model.AuditRecord{
EventName: model.AuditEventPostDelivered,
Status: model.AuditStatusSuccess,
Actor: model.AuditEventActor{UserId: actorUserID},
Meta: meta,
})
}
func deliveryMeta(post *model.Post, mechanism string) map[string]any {
return map[string]any{
model.PostDeliveryKeyPostID: post.Id,
model.PostDeliveryKeyChannelID: post.ChannelId,
model.PostDeliveryKeyMechanism: mechanism,
}
}
func (a *App) emitPluginDeliveryRecord(pluginID string, post *model.Post) {
meta := deliveryMeta(post, model.DeliveryMechanismPlugin)
meta[model.PostDeliveryKeyPluginID] = pluginID
a.emitDeliveryRecord("", meta)
}
func (a *App) recordPostDelivery(rctx request.CTX, userID string, post *model.Post, mechanism string) {
if userID == "" || !shouldTrackPost(post) || post.UserId == userID || !a.shouldTrackChannel(rctx, post.ChannelId) {
return
}
a.emitDeliveryRecord(userID, deliveryMeta(post, mechanism))
}
func (a *App) recordPostDeliveryToPlugin(rctx request.CTX, pluginID string, post *model.Post) {
if !shouldTrackPost(post) || !a.shouldTrackChannel(rctx, post.ChannelId) {
return
}
a.emitPluginDeliveryRecord(pluginID, post)
}
// RecordPostDelivery, RecordPostsDelivery and RecordPostListDelivery record that a post's content
// reached userID. Request handlers must call these only after the response body was written
// successfully, so that a failed write doesn't claim a delivery that never happened.
func (a *App) RecordPostDelivery(rctx request.CTX, userID string, post *model.Post, mechanism string) {
if !a.deliveryTrackingEnabled() {
return
}
a.recordPostDelivery(rctx, userID, post, mechanism)
}
func (a *App) RecordPostsDelivery(rctx request.CTX, userID string, posts []*model.Post, mechanism string) {
if !a.deliveryTrackingEnabled() || userID == "" {
return
}
for _, post := range posts {
a.recordPostDelivery(rctx, userID, post, mechanism)
}
}
func (a *App) RecordPostListDelivery(rctx request.CTX, userID string, list *model.PostList, mechanism string) {
if !a.deliveryTrackingEnabled() || userID == "" || list == nil {
return
}
for _, post := range list.Posts {
a.recordPostDelivery(rctx, userID, post, mechanism)
}
}
// RecordPermalinkPreviewDelivery records that a previewed post's content reached a user inside
// containingPost. The previewed post's channel governs: the delivery is recorded even when the
// containing post lives in a channel that is not tracked, otherwise a tracked post could be read
// by permalinking it into any other channel.
func (a *App) RecordPermalinkPreviewDelivery(rctx request.CTX, userID string, previewedPost, containingPost *model.Post) {
if !a.deliveryTrackingEnabled() || userID == "" || !shouldTrackPost(previewedPost) ||
previewedPost.UserId == userID || !a.shouldTrackChannel(rctx, previewedPost.ChannelId) {
return
}
meta := deliveryMeta(previewedPost, model.DeliveryMechanismPermalinkPreview)
if containingPost != nil {
meta[model.PostDeliveryKeyViaPostID] = containingPost.Id
meta[model.PostDeliveryKeyViaChannelID] = containingPost.ChannelId
}
a.emitDeliveryRecord(userID, meta)
}
// RecordBroadcastDelivery records a websocket delivery of the marked post to one connection's
// user. The marker is attached only to events whose post and channel already passed the tracking
// checks, so nothing is resolved here — this runs on the hub's goroutine.
func (a *App) RecordBroadcastDelivery(marker *model.PostDeliveryMarker, userID string) {
if marker == nil || marker.PostId == "" || userID == "" || marker.UserId == userID ||
!a.deliveryTrackingEnabled() {
return
}
a.emitDeliveryRecord(userID, map[string]any{
model.PostDeliveryKeyPostID: marker.PostId,
model.PostDeliveryKeyChannelID: marker.ChannelId,
model.PostDeliveryKeyMechanism: model.DeliveryMechanismPostBroadcast,
})
}
func (a *App) markPostDeliveryForBroadcast(rctx request.CTX, message *model.WebSocketEvent, post *model.Post) {
if !a.deliveryTrackingEnabled() || !shouldTrackPost(post) || !a.shouldTrackChannel(rctx, post.ChannelId) {
return
}
message.GetBroadcast().RecordPostDelivery = &model.PostDeliveryMarker{
PostId: post.Id,
ChannelId: post.ChannelId,
UserId: post.UserId,
}
}
// RecordPushDelivery records that a post's content reached a user inside a push notification
// payload. Only full-content message pushes carry the post body: generic pushes deliver no
// content, and an id-loaded push carries only ids — that delivery is recorded by the ack handler
// the device calls to fetch the message.
func (a *App) RecordPushDelivery(rctx request.CTX, userID string, msg *model.PushNotification) {
if !a.deliveryTrackingEnabled() || msg == nil || msg.Type != model.PushTypeMessage ||
msg.PostId == "" || *a.Config().EmailSettings.PushNotificationContents != model.FullNotification {
return
}
a.recordPostDelivery(rctx, userID, &model.Post{
Id: msg.PostId,
ChannelId: msg.ChannelId,
UserId: msg.SenderId,
Type: msg.PostType,
}, model.DeliveryMechanismPush)
}
func (a *App) RecordPostDeliveryToPlugin(rctx request.CTX, pluginID string, post *model.Post) {
if !a.deliveryTrackingEnabled() || pluginID == "" {
return
}
a.recordPostDeliveryToPlugin(rctx, pluginID, post)
}
func (a *App) RecordPostDeliveryToPlugins(rctx request.CTX, pluginIDs []string, post *model.Post) {
if !a.deliveryTrackingEnabled() || len(pluginIDs) == 0 || !shouldTrackPost(post) ||
!a.shouldTrackChannel(rctx, post.ChannelId) {
return
}
for _, pluginID := range pluginIDs {
if pluginID == "" {
continue
}
a.emitPluginDeliveryRecord(pluginID, post)
}
}
func (a *App) RecordPostsDeliveryToPlugin(rctx request.CTX, pluginID string, posts []*model.Post) {
if !a.deliveryTrackingEnabled() || pluginID == "" {
return
}
for _, post := range posts {
a.recordPostDeliveryToPlugin(rctx, pluginID, post)
}
}
func (a *App) RecordPostListDeliveryToPlugin(rctx request.CTX, pluginID string, list *model.PostList) {
if !a.deliveryTrackingEnabled() || pluginID == "" || list == nil {
return
}
for _, post := range list.Posts {
a.recordPostDeliveryToPlugin(rctx, pluginID, post)
}
}
func (a *App) RecordPostDeliveryToWebhook(rctx request.CTX, webhookID string, post *model.Post) {
if !a.deliveryTrackingEnabled() || webhookID == "" || !shouldTrackPost(post) ||
!a.shouldTrackChannel(rctx, post.ChannelId) {
return
}
meta := deliveryMeta(post, model.DeliveryMechanismOutgoingWebhook)
meta[model.PostDeliveryKeyWebhookID] = webhookID
a.emitDeliveryRecord("", meta)
}

View File

@@ -0,0 +1,124 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"bytes"
"net/http"
"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/public/utils"
"github.com/mattermost/mattermost/server/v8/config"
)
func (a *App) GetDeliveryTrackingConfig(rctx request.CTX) (*model.DeliveryTrackingConfig, *model.AppError) {
channelIDs, err := a.Srv().Store().DeliveryTracking().GetTrackedChannelIDs(rctx)
if err != nil {
return nil, model.NewAppError("GetDeliveryTrackingConfig", "app.delivery_tracking.get_config.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
settings := a.Config().DeliveryTrackingSettings
return &model.DeliveryTrackingConfig{
DeliveryTrackingSettings: model.DeliveryTrackingSettings{
Enable: settings.Enable,
EnableForAllChannels: settings.EnableForAllChannels,
},
ChannelIds: channelIDs,
}, nil
}
func (a *App) SaveDeliveryTrackingConfig(rctx request.CTX, cfg *model.DeliveryTrackingConfig) *model.AppError {
if cfg.ChannelIds != nil {
if appErr := a.validateTrackedChannelIDs(cfg.ChannelIds); appErr != nil {
return appErr
}
// Persist the channel list before the toggles so a store failure leaves config
// untouched, matching SaveContentFlaggingConfig.
if err := a.Srv().Store().DeliveryTracking().SaveTrackedChannelIDs(rctx, cfg.ChannelIds); err != nil {
return model.NewAppError("SaveDeliveryTrackingConfig", "app.delivery_tracking.save_config.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
a.UpdateConfig(func(c *model.Config) {
c.DeliveryTrackingSettings.Enable = cfg.Enable
c.DeliveryTrackingSettings.EnableForAllChannels = cfg.EnableForAllChannels
})
return nil
}
func (a *App) validateTrackedChannelIDs(channelIDs []string) *model.AppError {
uniqueIDs := utils.Dedup(channelIDs)
if len(uniqueIDs) == 0 {
return nil
}
channels, err := a.Srv().Store().Channel().GetChannelsByIds(uniqueIDs, true)
if err != nil {
return model.NewAppError("SaveDeliveryTrackingConfig", "app.delivery_tracking.save_config.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if len(channels) != len(uniqueIDs) {
return model.NewAppError("SaveDeliveryTrackingConfig", "app.delivery_tracking.invalid_channel.app_error", nil, "", http.StatusBadRequest)
}
for _, channel := range channels {
if channel.IsGroupOrDirect() {
return model.NewAppError("SaveDeliveryTrackingConfig", "app.delivery_tracking.dm_gm_not_allowed.app_error", nil, "", http.StatusBadRequest)
}
}
return nil
}
func (s *Server) warnIfDeliveryAuditTargetMissing(cfg *model.Config) {
if cfg.FeatureFlags == nil || !cfg.FeatureFlags.PostDeliveryTracking {
return
}
if cfg.DeliveryTrackingSettings.Enable == nil || !*cfg.DeliveryTrackingSettings.Enable {
return
}
license := s.License()
allowAdvancedLogging := license != nil && license.Features != nil &&
license.Features.AdvancedLogging != nil && *license.Features.AdvancedLogging
if config.IsAuditLevelActive(cfg.ExperimentalAuditSettings, allowAdvancedLogging, mlog.LvlAuditDelivery) {
return
}
mlog.Warn(
"DeliveryTrackingSettings.Enable is enabled but no configured audit log target consumes the audit-delivery level; post delivery audit records will be discarded. Add a target for the level via ExperimentalAuditSettings.AdvancedLoggingJSON.",
mlog.String("level", mlog.LvlAuditDelivery.Name),
)
}
// deliveryAuditWarnInputsChanged reports whether anything the delivery audit target warning
// depends on has changed. Config listeners fire on every save, so without this the warning
// would re-log on every unrelated System Console change while the condition holds.
func deliveryAuditWarnInputsChanged(oldCfg, newCfg *model.Config) bool {
oldFlag := oldCfg.FeatureFlags != nil && oldCfg.FeatureFlags.PostDeliveryTracking
newFlag := newCfg.FeatureFlags != nil && newCfg.FeatureFlags.PostDeliveryTracking
if oldFlag != newFlag {
return true
}
if model.SafeDereference(oldCfg.DeliveryTrackingSettings.Enable) != model.SafeDereference(newCfg.DeliveryTrackingSettings.Enable) {
return true
}
if model.SafeDereference(oldCfg.ExperimentalAuditSettings.FileEnabled) != model.SafeDereference(newCfg.ExperimentalAuditSettings.FileEnabled) {
return true
}
return !bytes.Equal(
oldCfg.ExperimentalAuditSettings.GetAdvancedLoggingConfig(),
newCfg.ExperimentalAuditSettings.GetAdvancedLoggingConfig(),
)
}

View File

@@ -0,0 +1,191 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestGetDeliveryTrackingConfig(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
t.Run("returns config toggles and stored channel ids", func(t *testing.T) {
channel := th.CreateChannel(t, th.BasicTeam)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.DeliveryTrackingSettings.Enable = new(true)
cfg.DeliveryTrackingSettings.EnableForAllChannels = new(false)
})
require.NoError(t, th.App.Srv().Store().DeliveryTracking().SaveTrackedChannelIDs(th.Context, []string{channel.Id}))
config, appErr := th.App.GetDeliveryTrackingConfig(th.Context)
require.Nil(t, appErr)
assert.True(t, *config.Enable)
assert.False(t, *config.EnableForAllChannels)
assert.Equal(t, []string{channel.Id}, config.ChannelIds)
})
t.Run("returns an empty slice when nothing is tracked", func(t *testing.T) {
require.NoError(t, th.App.Srv().Store().DeliveryTracking().SaveTrackedChannelIDs(th.Context, []string{}))
config, appErr := th.App.GetDeliveryTrackingConfig(th.Context)
require.Nil(t, appErr)
require.NotNil(t, config.ChannelIds)
assert.Empty(t, config.ChannelIds)
})
}
func TestSaveDeliveryTrackingConfig(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
t.Run("persists toggles and channel ids", func(t *testing.T) {
publicChannel := th.CreateChannel(t, th.BasicTeam)
privateChannel := th.CreatePrivateChannel(t, th.BasicTeam)
appErr := th.App.SaveDeliveryTrackingConfig(th.Context, &model.DeliveryTrackingConfig{
DeliveryTrackingSettings: model.DeliveryTrackingSettings{Enable: new(true), EnableForAllChannels: new(false)},
ChannelIds: []string{publicChannel.Id, privateChannel.Id},
})
require.Nil(t, appErr)
assert.True(t, *th.App.Config().DeliveryTrackingSettings.Enable)
assert.False(t, *th.App.Config().DeliveryTrackingSettings.EnableForAllChannels)
stored, err := th.App.Srv().Store().DeliveryTracking().GetTrackedChannelIDs(th.Context)
require.NoError(t, err)
assert.ElementsMatch(t, []string{publicChannel.Id, privateChannel.Id}, stored)
})
t.Run("nil ChannelIds leaves the stored list untouched", func(t *testing.T) {
channel := th.CreateChannel(t, th.BasicTeam)
require.NoError(t, th.App.Srv().Store().DeliveryTracking().SaveTrackedChannelIDs(th.Context, []string{channel.Id}))
// Turning the feature off must not discard the admin's channel selection.
appErr := th.App.SaveDeliveryTrackingConfig(th.Context, &model.DeliveryTrackingConfig{
DeliveryTrackingSettings: model.DeliveryTrackingSettings{Enable: new(false), EnableForAllChannels: new(true)},
})
require.Nil(t, appErr)
stored, err := th.App.Srv().Store().DeliveryTracking().GetTrackedChannelIDs(th.Context)
require.NoError(t, err)
assert.Equal(t, []string{channel.Id}, stored)
assert.False(t, *th.App.Config().DeliveryTrackingSettings.Enable)
})
t.Run("empty ChannelIds clears the stored list", func(t *testing.T) {
channel := th.CreateChannel(t, th.BasicTeam)
require.NoError(t, th.App.Srv().Store().DeliveryTracking().SaveTrackedChannelIDs(th.Context, []string{channel.Id}))
appErr := th.App.SaveDeliveryTrackingConfig(th.Context, &model.DeliveryTrackingConfig{
DeliveryTrackingSettings: model.DeliveryTrackingSettings{Enable: new(true), EnableForAllChannels: new(true)},
ChannelIds: []string{},
})
require.Nil(t, appErr)
stored, err := th.App.Srv().Store().DeliveryTracking().GetTrackedChannelIDs(th.Context)
require.NoError(t, err)
assert.Empty(t, stored)
})
t.Run("archived channels are accepted", func(t *testing.T) {
channel := th.CreateChannel(t, th.BasicTeam)
require.Nil(t, th.App.DeleteChannel(th.Context, channel, th.BasicUser.Id))
// Rows for archived channels are kept, so re-saving an unchanged list containing
// one must not lock the admin out of saving.
appErr := th.App.SaveDeliveryTrackingConfig(th.Context, &model.DeliveryTrackingConfig{
DeliveryTrackingSettings: model.DeliveryTrackingSettings{Enable: new(true), EnableForAllChannels: new(false)},
ChannelIds: []string{channel.Id},
})
require.Nil(t, appErr)
stored, err := th.App.Srv().Store().DeliveryTracking().GetTrackedChannelIDs(th.Context)
require.NoError(t, err)
assert.Equal(t, []string{channel.Id}, stored)
})
t.Run("rejects direct message channels", func(t *testing.T) {
user := th.CreateUser(t)
dmChannel := th.CreateDmChannel(t, user)
appErr := th.App.SaveDeliveryTrackingConfig(th.Context, &model.DeliveryTrackingConfig{
DeliveryTrackingSettings: model.DeliveryTrackingSettings{Enable: new(true), EnableForAllChannels: new(false)},
ChannelIds: []string{dmChannel.Id},
})
require.NotNil(t, appErr)
assert.Equal(t, "app.delivery_tracking.dm_gm_not_allowed.app_error", appErr.Id)
assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
})
t.Run("rejects group message channels", func(t *testing.T) {
user1 := th.CreateUser(t)
user2 := th.CreateUser(t)
gmChannel := th.CreateGroupChannel(t, user1, user2)
appErr := th.App.SaveDeliveryTrackingConfig(th.Context, &model.DeliveryTrackingConfig{
DeliveryTrackingSettings: model.DeliveryTrackingSettings{Enable: new(true), EnableForAllChannels: new(false)},
ChannelIds: []string{gmChannel.Id},
})
require.NotNil(t, appErr)
assert.Equal(t, "app.delivery_tracking.dm_gm_not_allowed.app_error", appErr.Id)
})
t.Run("rejects nonexistent channels", func(t *testing.T) {
appErr := th.App.SaveDeliveryTrackingConfig(th.Context, &model.DeliveryTrackingConfig{
DeliveryTrackingSettings: model.DeliveryTrackingSettings{Enable: new(true), EnableForAllChannels: new(false)},
ChannelIds: []string{model.NewId()},
})
require.NotNil(t, appErr)
assert.Equal(t, "app.delivery_tracking.invalid_channel.app_error", appErr.Id)
assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
})
t.Run("rejection writes nothing", func(t *testing.T) {
existing := th.CreateChannel(t, th.BasicTeam)
require.NoError(t, th.App.Srv().Store().DeliveryTracking().SaveTrackedChannelIDs(th.Context, []string{existing.Id}))
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.DeliveryTrackingSettings.Enable = new(false)
})
valid := th.CreateChannel(t, th.BasicTeam)
appErr := th.App.SaveDeliveryTrackingConfig(th.Context, &model.DeliveryTrackingConfig{
DeliveryTrackingSettings: model.DeliveryTrackingSettings{Enable: new(true), EnableForAllChannels: new(false)},
ChannelIds: []string{valid.Id, model.NewId()},
})
require.NotNil(t, appErr)
stored, err := th.App.Srv().Store().DeliveryTracking().GetTrackedChannelIDs(th.Context)
require.NoError(t, err)
assert.Equal(t, []string{existing.Id}, stored, "channel list should be unchanged")
assert.False(t, *th.App.Config().DeliveryTrackingSettings.Enable, "config should be unchanged")
})
t.Run("duplicate ids are accepted and stored once", func(t *testing.T) {
channel := th.CreateChannel(t, th.BasicTeam)
appErr := th.App.SaveDeliveryTrackingConfig(th.Context, &model.DeliveryTrackingConfig{
DeliveryTrackingSettings: model.DeliveryTrackingSettings{Enable: new(true), EnableForAllChannels: new(false)},
ChannelIds: []string{channel.Id, channel.Id},
})
require.Nil(t, appErr)
stored, err := th.App.Srv().Store().DeliveryTracking().GetTrackedChannelIDs(th.Context)
require.NoError(t, err)
assert.Equal(t, []string{channel.Id}, stored)
})
}

View File

@@ -0,0 +1,806 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
// deliveryAuditCapture routes the test server's audit logger to a temp file bound to the
// audit-delivery level, so tests can assert on the records the emission helpers produce.
type deliveryAuditCapture struct {
t *testing.T
th *TestHelper
path string
}
func startDeliveryAuditCapture(t *testing.T, th *TestHelper) *deliveryAuditCapture {
t.Helper()
path := filepath.Join(t.TempDir(), "delivery-audit.log")
cfg := mlog.LoggerConfiguration{
"_testDelivery": mlog.TargetCfg{
Type: "file",
Format: "json",
Levels: []mlog.Level{mlog.LvlAuditDelivery},
Options: json.RawMessage(fmt.Sprintf(`{"filename":%q}`, path)),
},
}
require.NoError(t, th.App.Srv().Audit.Configure(cfg))
return &deliveryAuditCapture{t: t, th: th, path: path}
}
// records returns every postDelivered record logged so far.
func (c *deliveryAuditCapture) records() []map[string]any {
c.t.Helper()
require.NoError(c.t, c.th.App.Srv().Audit.Flush())
data, err := os.ReadFile(c.path)
if errors.Is(err, os.ErrNotExist) {
// The file target creates the log lazily on first write.
return nil
}
require.NoError(c.t, err)
var out []map[string]any
for line := range bytesLines(data) {
var rec map[string]any
require.NoError(c.t, json.Unmarshal(line, &rec))
if rec[model.AuditKeyEventName] == model.AuditEventPostDelivered {
out = append(out, rec)
}
}
return out
}
func (c *deliveryAuditCapture) requireNone() {
c.t.Helper()
require.Empty(c.t, c.records())
}
// postIDs returns the post ID of every record emitted so far. Callers that drive a PostList must
// compare it as a set: RecordPostListDelivery walks the Posts map, so emission order is
// unspecified.
func (c *deliveryAuditCapture) postIDs() []string {
c.t.Helper()
records := c.records()
out := make([]string, 0, len(records))
for _, rec := range records {
postID, _ := auditMeta(c.t, rec, model.PostDeliveryKeyPostID).(string)
out = append(out, postID)
}
return out
}
// requireOne asserts exactly one record was emitted and returns its actor user ID and meta.
func (c *deliveryAuditCapture) requireOne() (string, map[string]any) {
c.t.Helper()
records := c.records()
require.Len(c.t, records, 1)
return auditActorUserID(c.t, records[0]), auditMetaMap(c.t, records[0])
}
func auditActorUserID(t *testing.T, rec map[string]any) string {
t.Helper()
actor, ok := rec[model.AuditKeyActor].(map[string]any)
require.True(t, ok, "audit record has no actor")
userID, _ := actor["user_id"].(string)
return userID
}
// auditMetaMap returns a record's whole meta map, for assertions that read several keys off the
// same record. auditMeta reads a single key.
func auditMetaMap(t *testing.T, rec map[string]any) map[string]any {
t.Helper()
meta, ok := rec[model.AuditKeyMeta].(map[string]any)
require.True(t, ok, "audit record has no meta")
return meta
}
// setupDeliveryTracking returns a test helper with post delivery audit logging on for all
// eligible channels. The feature flag has to be set when the config store is built: writes to
// FeatureFlags through UpdateConfig are discarded by the store's read-only feature flag
// handling.
func setupDeliveryTracking(t *testing.T) *TestHelper {
t.Helper()
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.PostDeliveryTracking = true
}).InitBasic(t)
enableDeliveryTracking(th)
return th
}
// enableDeliveryTracking turns the admin toggle on for all eligible channels.
func enableDeliveryTracking(th *TestHelper) {
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.DeliveryTrackingSettings.Enable = model.NewPointer(true)
cfg.DeliveryTrackingSettings.EnableForAllChannels = model.NewPointer(true)
})
}
// trackOnlyChannels narrows tracking to channelIDs for the duration of the test.
func trackOnlyChannels(t *testing.T, th *TestHelper, channelIDs ...string) {
t.Helper()
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.DeliveryTrackingSettings.EnableForAllChannels = model.NewPointer(false)
})
require.NoError(t, th.App.Srv().Store().DeliveryTracking().SaveTrackedChannelIDs(th.Context, channelIDs))
t.Cleanup(func() {
require.NoError(t, th.App.Srv().Store().DeliveryTracking().SaveTrackedChannelIDs(th.Context, nil))
enableDeliveryTracking(th)
})
}
func TestDeliveryTrackingEnabled(t *testing.T) {
t.Run("off by default", func(t *testing.T) {
th := Setup(t)
require.False(t, th.App.deliveryTrackingEnabled())
})
t.Run("requires the feature flag", func(t *testing.T) {
th := Setup(t)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.DeliveryTrackingSettings.Enable = model.NewPointer(true)
})
require.False(t, th.App.Config().FeatureFlags.PostDeliveryTracking)
require.False(t, th.App.deliveryTrackingEnabled())
})
t.Run("requires the admin toggle", func(t *testing.T) {
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.PostDeliveryTracking = true
})
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.DeliveryTrackingSettings.Enable = model.NewPointer(false)
})
require.False(t, th.App.deliveryTrackingEnabled())
})
t.Run("on with both", func(t *testing.T) {
th := setupDeliveryTracking(t)
require.True(t, th.App.deliveryTrackingEnabled())
})
}
func TestShouldTrackPost(t *testing.T) {
testCases := []struct {
name string
post *model.Post
expected bool
}{
{"nil post", nil, false},
{"no id", &model.Post{ChannelId: model.NewId()}, false},
{"regular post", &model.Post{Id: model.NewId()}, true},
{"system message", &model.Post{Id: model.NewId(), Type: model.PostTypeJoinChannel}, false},
{"burn on read", &model.Post{Id: model.NewId(), Type: model.PostTypeBurnOnRead}, false},
{"ephemeral", &model.Post{Id: model.NewId(), Type: model.PostTypeEphemeral}, false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.expected, shouldTrackPost(tc.post))
})
}
}
func TestRecordPostDelivery(t *testing.T) {
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.PostDeliveryTracking = true
}).InitBasic(t)
t.Run("emits nothing when disabled", func(t *testing.T) {
capture := startDeliveryAuditCapture(t, th)
th.App.RecordPostDelivery(th.Context, th.BasicUser2.Id, th.BasicPost, model.DeliveryMechanismProduct)
capture.requireNone()
})
enableDeliveryTracking(th)
t.Run("records the recipient as actor with the post payload", func(t *testing.T) {
capture := startDeliveryAuditCapture(t, th)
th.App.RecordPostDelivery(th.Context, th.BasicUser2.Id, th.BasicPost, model.DeliveryMechanismProduct)
actorUserID, meta := capture.requireOne()
require.Equal(t, th.BasicUser2.Id, actorUserID)
require.Equal(t, th.BasicPost.Id, meta[model.PostDeliveryKeyPostID])
require.Equal(t, th.BasicPost.ChannelId, meta[model.PostDeliveryKeyChannelID])
require.Equal(t, model.DeliveryMechanismProduct, meta[model.PostDeliveryKeyMechanism])
require.NotContains(t, meta, model.PostDeliveryKeyPluginID)
require.NotContains(t, meta, model.PostDeliveryKeyViaPostID)
})
t.Run("skips the post author", func(t *testing.T) {
capture := startDeliveryAuditCapture(t, th)
th.App.RecordPostDelivery(th.Context, th.BasicPost.UserId, th.BasicPost, model.DeliveryMechanismProduct)
capture.requireNone()
})
t.Run("skips an empty recipient", func(t *testing.T) {
capture := startDeliveryAuditCapture(t, th)
th.App.RecordPostDelivery(th.Context, "", th.BasicPost, model.DeliveryMechanismProduct)
capture.requireNone()
})
t.Run("skips system messages", func(t *testing.T) {
capture := startDeliveryAuditCapture(t, th)
systemPost := th.CreatePost(t, th.BasicChannel)
systemPost.Type = model.PostTypeJoinChannel
th.App.RecordPostDelivery(th.Context, th.BasicUser2.Id, systemPost, model.DeliveryMechanismProduct)
capture.requireNone()
})
t.Run("skips burn on read posts", func(t *testing.T) {
capture := startDeliveryAuditCapture(t, th)
borPost := th.CreatePost(t, th.BasicChannel)
borPost.Type = model.PostTypeBurnOnRead
th.App.RecordPostDelivery(th.Context, th.BasicUser2.Id, borPost, model.DeliveryMechanismProduct)
capture.requireNone()
})
t.Run("skips DMs", func(t *testing.T) {
capture := startDeliveryAuditCapture(t, th)
dm, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, th.BasicUser2.Id)
require.Nil(t, appErr)
dmPost := th.CreatePost(t, dm)
th.App.RecordPostDelivery(th.Context, th.BasicUser2.Id, dmPost, model.DeliveryMechanismProduct)
capture.requireNone()
})
t.Run("honours the tracked channel list", func(t *testing.T) {
otherChannel := th.CreateChannel(t, th.BasicTeam)
otherPost := th.CreatePost(t, otherChannel)
trackOnlyChannels(t, th, th.BasicChannel.Id)
capture := startDeliveryAuditCapture(t, th)
th.App.RecordPostDelivery(th.Context, th.BasicUser2.Id, otherPost, model.DeliveryMechanismProduct)
capture.requireNone()
th.App.RecordPostDelivery(th.Context, th.BasicUser2.Id, th.BasicPost, model.DeliveryMechanismProduct)
_, meta := capture.requireOne()
require.Equal(t, th.BasicPost.Id, meta[model.PostDeliveryKeyPostID])
})
}
func TestRecordPostsDelivery(t *testing.T) {
th := setupDeliveryTracking(t)
t.Run("one record per post", func(t *testing.T) {
capture := startDeliveryAuditCapture(t, th)
posts := []*model.Post{
th.CreatePost(t, th.BasicChannel),
th.CreatePost(t, th.BasicChannel),
th.CreatePost(t, th.BasicChannel),
}
th.App.RecordPostsDelivery(th.Context, th.BasicUser2.Id, posts, model.DeliveryMechanismProduct)
records := capture.records()
require.Len(t, records, len(posts))
for i, rec := range records {
require.Equal(t, th.BasicUser2.Id, auditActorUserID(t, rec))
require.Equal(t, posts[i].Id, auditMeta(t, rec, model.PostDeliveryKeyPostID))
}
})
t.Run("skips nils, system messages and the recipient's own post", func(t *testing.T) {
capture := startDeliveryAuditCapture(t, th)
// BasicUser2 is the recipient, so their own post must not produce a record.
authoredByRecipient := th.CreatePost(t, th.BasicChannel, func(post *model.Post) {
post.UserId = th.BasicUser2.Id
})
systemPost := th.CreatePost(t, th.BasicChannel)
systemPost.Type = model.PostTypeJoinChannel
tracked := th.CreatePost(t, th.BasicChannel)
th.App.RecordPostsDelivery(th.Context, th.BasicUser2.Id,
[]*model.Post{nil, authoredByRecipient, systemPost, tracked}, model.DeliveryMechanismProduct)
records := capture.records()
require.Len(t, records, 1)
require.Equal(t, tracked.Id, auditMeta(t, records[0], model.PostDeliveryKeyPostID))
})
t.Run("empty input emits nothing", func(t *testing.T) {
capture := startDeliveryAuditCapture(t, th)
th.App.RecordPostsDelivery(th.Context, th.BasicUser2.Id, nil, model.DeliveryMechanismProduct)
capture.requireNone()
})
}
func TestRecordPostListDelivery(t *testing.T) {
th := setupDeliveryTracking(t)
t.Run("records every post in the list", func(t *testing.T) {
capture := startDeliveryAuditCapture(t, th)
first := th.CreatePost(t, th.BasicChannel)
second := th.CreatePost(t, th.BasicChannel)
list := model.NewPostList()
list.AddPost(first)
list.AddOrder(first.Id)
list.AddPost(second)
list.AddOrder(second.Id)
th.App.RecordPostListDelivery(th.Context, th.BasicUser2.Id, list, model.DeliveryMechanismProduct)
require.ElementsMatch(t, []string{first.Id, second.Id}, capture.postIDs())
})
t.Run("records posts present in Posts but absent from Order", func(t *testing.T) {
// SqlPostStore.GetPosts and getPostsAround add out-of-window thread context with AddPost
// and no AddOrder. PostList marshals Posts, so that content reaches the client and has to
// be recorded — walking Order alone would miss it.
capture := startDeliveryAuditCapture(t, th)
ordered := th.CreatePost(t, th.BasicChannel)
unordered := th.CreatePost(t, th.BasicChannel)
list := model.NewPostList()
list.AddPost(ordered)
list.AddOrder(ordered.Id)
list.AddPost(unordered) // deliberately no AddOrder
th.App.RecordPostListDelivery(th.Context, th.BasicUser2.Id, list, model.DeliveryMechanismProduct)
require.ElementsMatch(t, []string{ordered.Id, unordered.Id}, capture.postIDs())
})
t.Run("nil and empty lists emit nothing", func(t *testing.T) {
capture := startDeliveryAuditCapture(t, th)
th.App.RecordPostListDelivery(th.Context, th.BasicUser2.Id, nil, model.DeliveryMechanismProduct)
th.App.RecordPostListDelivery(th.Context, th.BasicUser2.Id, model.NewPostList(), model.DeliveryMechanismProduct)
capture.requireNone()
})
}
func TestRecordPermalinkPreviewDelivery(t *testing.T) {
th := setupDeliveryTracking(t)
t.Run("carries the containing post as via_", func(t *testing.T) {
capture := startDeliveryAuditCapture(t, th)
containingChannel := th.CreateChannel(t, th.BasicTeam)
containing := th.CreatePost(t, containingChannel)
th.App.RecordPermalinkPreviewDelivery(th.Context, th.BasicUser2.Id, th.BasicPost, containing)
actorUserID, meta := capture.requireOne()
require.Equal(t, th.BasicUser2.Id, actorUserID)
require.Equal(t, th.BasicPost.Id, meta[model.PostDeliveryKeyPostID])
require.Equal(t, th.BasicPost.ChannelId, meta[model.PostDeliveryKeyChannelID])
require.Equal(t, model.DeliveryMechanismPermalinkPreview, meta[model.PostDeliveryKeyMechanism])
require.Equal(t, containing.Id, meta[model.PostDeliveryKeyViaPostID])
require.Equal(t, containingChannel.Id, meta[model.PostDeliveryKeyViaChannelID])
})
t.Run("previewed channel governs, not the containing one", func(t *testing.T) {
untracked := th.CreateChannel(t, th.BasicTeam)
containing := th.CreatePost(t, untracked)
trackOnlyChannels(t, th, th.BasicChannel.Id)
capture := startDeliveryAuditCapture(t, th)
th.App.RecordPermalinkPreviewDelivery(th.Context, th.BasicUser2.Id, th.BasicPost, containing)
_, meta := capture.requireOne()
require.Equal(t, th.BasicPost.Id, meta[model.PostDeliveryKeyPostID])
require.Equal(t, untracked.Id, meta[model.PostDeliveryKeyViaChannelID])
})
t.Run("skips a previewed post in an untracked channel", func(t *testing.T) {
untracked := th.CreateChannel(t, th.BasicTeam)
previewed := th.CreatePost(t, untracked)
trackOnlyChannels(t, th, th.BasicChannel.Id)
capture := startDeliveryAuditCapture(t, th)
th.App.RecordPermalinkPreviewDelivery(th.Context, th.BasicUser2.Id, previewed, th.BasicPost)
capture.requireNone()
})
}
// TestSanitizePostMetadataForUserRecordsPreviewDelivery drives the real permalink preview
// path, rather than the record helper in isolation.
func TestSanitizePostMetadataForUserRecordsPreviewDelivery(t *testing.T) {
th := setupDeliveryTracking(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.SiteURL = "http://mymattermost.com"
*cfg.ServiceSettings.EnablePermalinkPreviews = true
})
th.Context.Session().UserId = th.BasicUser.Id
previewed, _, appErr := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "previewed message",
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
previewed.Metadata.Embeds = nil
containingChannel := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, th.BasicUser2, containingChannel)
link := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, previewed.Id)
containing, _, appErr := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: containingChannel.Id,
Message: link,
}, containingChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
containing.Metadata.Embeds = nil
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, containing, &model.PreparePostForClientOpts{})
require.NotNil(t, clientPost.GetPreviewPost(), "the permalink preview must be embedded for this test to be meaningful")
capture := startDeliveryAuditCapture(t, th)
_, _, appErr = th.App.SanitizePostMetadataForUser(th.Context, clientPost, th.BasicUser2.Id)
require.Nil(t, appErr)
records := capture.records()
require.Len(t, records, 1)
meta := auditMetaMap(t, records[0])
require.Equal(t, th.BasicUser2.Id, auditActorUserID(t, records[0]))
require.Equal(t, previewed.Id, meta[model.PostDeliveryKeyPostID])
require.Equal(t, th.BasicChannel.Id, meta[model.PostDeliveryKeyChannelID])
require.Equal(t, model.DeliveryMechanismPermalinkPreview, meta[model.PostDeliveryKeyMechanism])
require.Equal(t, containing.Id, meta[model.PostDeliveryKeyViaPostID])
require.Equal(t, containingChannel.Id, meta[model.PostDeliveryKeyViaChannelID])
}
// TestCreatePostWithPermalinkRecordsPreviewForAuthor pins that posting a permalink records the
// poster as having seen the previewed post: CreatePost sanitizes the new post for its own author,
// which is where the preview embed is resolved for them.
func TestCreatePostWithPermalinkRecordsPreviewForAuthor(t *testing.T) {
th := setupDeliveryTracking(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.SiteURL = "http://mymattermost.com"
*cfg.ServiceSettings.EnablePermalinkPreviews = true
})
// Authored by BasicUser2 so the poster is not the previewed post's author.
previewed, _, appErr := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser2.Id,
ChannelId: th.BasicChannel.Id,
Message: "previewed message",
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
th.Context.Session().UserId = th.BasicUser.Id
link := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, previewed.Id)
capture := startDeliveryAuditCapture(t, th)
containing, _, appErr := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: link,
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
require.NotNil(t, containing.GetPreviewPost(), "the permalink preview must be embedded for this test to be meaningful")
var previewRecord map[string]any
for _, rec := range capture.records() {
meta := auditMetaMap(t, rec)
if meta[model.PostDeliveryKeyMechanism] != model.DeliveryMechanismPermalinkPreview {
continue
}
require.Equal(t, th.BasicUser.Id, auditActorUserID(t, rec))
previewRecord = meta
break
}
require.NotNil(t, previewRecord, "expected the poster to be recorded for the previewed post")
require.Equal(t, previewed.Id, previewRecord[model.PostDeliveryKeyPostID])
require.Equal(t, th.BasicChannel.Id, previewRecord[model.PostDeliveryKeyChannelID])
require.Equal(t, containing.Id, previewRecord[model.PostDeliveryKeyViaPostID])
require.Equal(t, th.BasicChannel.Id, previewRecord[model.PostDeliveryKeyViaChannelID])
}
// TestShouldTrackChannelIsAllocationFree guards the hot path: once the channel eligibility memo is
// warm, the per-delivery check must not allocate.
func TestShouldTrackChannelIsAllocationFree(t *testing.T) {
th := setupDeliveryTracking(t)
t.Run("all channels", func(t *testing.T) {
require.True(t, th.App.shouldTrackChannel(th.Context, th.BasicChannel.Id))
allocs := testing.AllocsPerRun(200, func() {
th.App.shouldTrackChannel(th.Context, th.BasicChannel.Id)
})
require.Zero(t, allocs)
})
t.Run("explicit channel list", func(t *testing.T) {
trackOnlyChannels(t, th, th.BasicChannel.Id)
require.True(t, th.App.shouldTrackChannel(th.Context, th.BasicChannel.Id))
allocs := testing.AllocsPerRun(200, func() {
th.App.shouldTrackChannel(th.Context, th.BasicChannel.Id)
})
require.Zero(t, allocs)
})
}
func TestRecordBroadcastDelivery(t *testing.T) {
th := setupDeliveryTracking(t)
marker := &model.PostDeliveryMarker{
PostId: th.BasicPost.Id,
ChannelId: th.BasicPost.ChannelId,
UserId: th.BasicPost.UserId,
}
t.Run("records without resolving the channel", func(t *testing.T) {
capture := startDeliveryAuditCapture(t, th)
th.App.RecordBroadcastDelivery(marker, th.BasicUser2.Id)
actorUserID, meta := capture.requireOne()
require.Equal(t, th.BasicUser2.Id, actorUserID)
require.Equal(t, marker.PostId, meta[model.PostDeliveryKeyPostID])
require.Equal(t, marker.ChannelId, meta[model.PostDeliveryKeyChannelID])
require.Equal(t, model.DeliveryMechanismPostBroadcast, meta[model.PostDeliveryKeyMechanism])
})
t.Run("skips the author's own echo", func(t *testing.T) {
capture := startDeliveryAuditCapture(t, th)
th.App.RecordBroadcastDelivery(marker, marker.UserId)
capture.requireNone()
})
t.Run("skips a nil marker and an empty user", func(t *testing.T) {
capture := startDeliveryAuditCapture(t, th)
th.App.RecordBroadcastDelivery(nil, th.BasicUser2.Id)
th.App.RecordBroadcastDelivery(marker, "")
capture.requireNone()
})
}
func TestMarkPostDeliveryForBroadcast(t *testing.T) {
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.PostDeliveryTracking = true
}).InitBasic(t)
newEvent := func() *model.WebSocketEvent {
return model.NewWebSocketEvent(model.WebsocketEventPosted, "", th.BasicChannel.Id, "", nil, "")
}
t.Run("no marker when disabled", func(t *testing.T) {
event := newEvent()
th.App.markPostDeliveryForBroadcast(th.Context, event, th.BasicPost)
require.Nil(t, event.GetBroadcast().RecordPostDelivery)
})
enableDeliveryTracking(th)
t.Run("marker carries the post, channel and author", func(t *testing.T) {
event := newEvent()
th.App.markPostDeliveryForBroadcast(th.Context, event, th.BasicPost)
marker := event.GetBroadcast().RecordPostDelivery
require.NotNil(t, marker)
require.Equal(t, th.BasicPost.Id, marker.PostId)
require.Equal(t, th.BasicPost.ChannelId, marker.ChannelId)
require.Equal(t, th.BasicPost.UserId, marker.UserId)
})
t.Run("no marker for a system message", func(t *testing.T) {
systemPost := th.CreatePost(t, th.BasicChannel)
systemPost.Type = model.PostTypeJoinChannel
event := newEvent()
th.App.markPostDeliveryForBroadcast(th.Context, event, systemPost)
require.Nil(t, event.GetBroadcast().RecordPostDelivery)
})
}
func TestRecordPostDeliveryToIntegrations(t *testing.T) {
th := setupDeliveryTracking(t)
t.Run("plugin delivery has no actor and carries plugin_id", func(t *testing.T) {
capture := startDeliveryAuditCapture(t, th)
th.App.RecordPostDeliveryToPlugin(th.Context, "com.example.plugin", th.BasicPost)
actorUserID, meta := capture.requireOne()
require.Empty(t, actorUserID)
require.Equal(t, "com.example.plugin", meta[model.PostDeliveryKeyPluginID])
require.Equal(t, model.DeliveryMechanismPlugin, meta[model.PostDeliveryKeyMechanism])
})
t.Run("plugin delivery is recorded even for the post author's own plugin post", func(t *testing.T) {
capture := startDeliveryAuditCapture(t, th)
th.App.RecordPostDeliveryToPlugins(th.Context, []string{"plugin.a", "plugin.b", ""}, th.BasicPost)
records := capture.records()
require.Len(t, records, 2)
require.Equal(t, "plugin.a", auditMeta(t, records[0], model.PostDeliveryKeyPluginID))
require.Equal(t, "plugin.b", auditMeta(t, records[1], model.PostDeliveryKeyPluginID))
})
t.Run("one record per post handed to a plugin", func(t *testing.T) {
capture := startDeliveryAuditCapture(t, th)
posts := []*model.Post{th.CreatePost(t, th.BasicChannel), th.CreatePost(t, th.BasicChannel)}
th.App.RecordPostsDeliveryToPlugin(th.Context, "com.example.plugin", posts)
require.Len(t, capture.records(), 2)
})
t.Run("plugin list delivery records posts absent from Order", func(t *testing.T) {
// Mirrors the RecordPostListDelivery case: the PluginAPI read wrappers hand over whole
// PostLists, whose Posts map carries out-of-window thread context with no Order entry.
capture := startDeliveryAuditCapture(t, th)
ordered := th.CreatePost(t, th.BasicChannel)
unordered := th.CreatePost(t, th.BasicChannel)
list := model.NewPostList()
list.AddPost(ordered)
list.AddOrder(ordered.Id)
list.AddPost(unordered) // deliberately no AddOrder
th.App.RecordPostListDeliveryToPlugin(th.Context, "com.example.plugin", list)
require.ElementsMatch(t, []string{ordered.Id, unordered.Id}, capture.postIDs())
})
t.Run("webhook delivery has no actor and carries webhook_id", func(t *testing.T) {
capture := startDeliveryAuditCapture(t, th)
th.App.RecordPostDeliveryToWebhook(th.Context, "hook-id", th.BasicPost)
actorUserID, meta := capture.requireOne()
require.Empty(t, actorUserID)
require.Equal(t, "hook-id", meta[model.PostDeliveryKeyWebhookID])
require.Equal(t, model.DeliveryMechanismOutgoingWebhook, meta[model.PostDeliveryKeyMechanism])
})
t.Run("empty integration ids emit nothing", func(t *testing.T) {
capture := startDeliveryAuditCapture(t, th)
th.App.RecordPostDeliveryToPlugin(th.Context, "", th.BasicPost)
th.App.RecordPostDeliveryToWebhook(th.Context, "", th.BasicPost)
th.App.RecordPostDeliveryToPlugins(th.Context, nil, th.BasicPost)
capture.requireNone()
})
}
// TestTriggerWebhookRecordsDeliveryOncePerHook drives the real outgoing webhook path: a hook
// with two callback URLs is one logical integration, so it must produce one record.
func TestTriggerWebhookRecordsDeliveryOncePerHook(t *testing.T) {
th := setupDeliveryTracking(t)
received := make(chan struct{}, 2)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
received <- struct{}{}
})
ts1 := httptest.NewServer(handler)
defer ts1.Close()
ts2 := httptest.NewServer(handler)
defer ts2.Close()
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1"
*cfg.ServiceSettings.EnableOutgoingWebhooks = true
})
channel := th.CreateChannel(t, th.BasicTeam)
post := th.CreatePost(t, channel)
hook, appErr := th.App.CreateOutgoingWebhook(&model.OutgoingWebhook{
ChannelId: channel.Id,
TeamId: channel.TeamId,
CallbackURLs: []string{ts1.URL, ts2.URL},
CreatorId: th.BasicUser.Id,
TriggerWords: []string{"Abracadabra"},
ContentType: "application/json",
})
require.Nil(t, appErr)
capture := startDeliveryAuditCapture(t, th)
th.App.TriggerWebhook(th.Context, &model.OutgoingWebhookPayload{
Token: hook.Token,
TeamId: hook.TeamId,
ChannelId: channel.Id,
PostId: post.Id,
Text: post.Message,
TriggerWord: "Abracadabra",
}, hook, post, channel)
// TriggerWebhook fans out to both URLs concurrently and waits for them.
for range 2 {
select {
case <-received:
case <-time.After(10 * time.Second):
require.Fail(t, "timed out waiting for the webhook callbacks")
}
}
actorUserID, meta := capture.requireOne()
require.Empty(t, actorUserID)
require.Equal(t, hook.Id, meta[model.PostDeliveryKeyWebhookID])
require.Equal(t, post.Id, meta[model.PostDeliveryKeyPostID])
require.Equal(t, channel.Id, meta[model.PostDeliveryKeyChannelID])
require.Equal(t, model.DeliveryMechanismOutgoingWebhook, meta[model.PostDeliveryKeyMechanism])
}
func TestRecordPushDelivery(t *testing.T) {
th := setupDeliveryTracking(t)
newMsg := func() *model.PushNotification {
return &model.PushNotification{
Type: model.PushTypeMessage,
PostId: th.BasicPost.Id,
ChannelId: th.BasicPost.ChannelId,
SenderId: th.BasicPost.UserId,
PostType: th.BasicPost.Type,
}
}
t.Run("records a full content message push", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.EmailSettings.PushNotificationContents = model.NewPointer(model.FullNotification)
})
capture := startDeliveryAuditCapture(t, th)
th.App.RecordPushDelivery(th.Context, th.BasicUser2.Id, newMsg())
actorUserID, meta := capture.requireOne()
require.Equal(t, th.BasicUser2.Id, actorUserID)
require.Equal(t, model.DeliveryMechanismPush, meta[model.PostDeliveryKeyMechanism])
})
t.Run("skips a generic push, which carries no content", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.EmailSettings.PushNotificationContents = model.NewPointer(model.GenericNotification)
})
capture := startDeliveryAuditCapture(t, th)
th.App.RecordPushDelivery(th.Context, th.BasicUser2.Id, newMsg())
capture.requireNone()
})
t.Run("skips non-message push types", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.EmailSettings.PushNotificationContents = model.NewPointer(model.FullNotification)
})
msg := newMsg()
msg.Type = model.PushTypeClear
capture := startDeliveryAuditCapture(t, th)
th.App.RecordPushDelivery(th.Context, th.BasicUser2.Id, msg)
capture.requireNone()
})
t.Run("skips the sender", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.EmailSettings.PushNotificationContents = model.NewPointer(model.FullNotification)
})
capture := startDeliveryAuditCapture(t, th)
th.App.RecordPushDelivery(th.Context, th.BasicPost.UserId, newMsg())
capture.requireNone()
})
}

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
func (a *App) GenerateAndSaveDesktopToken(createAt int64, user *model.User) (*string, *model.AppError) {
token := model.NewRandomString(64)
err := a.Srv().Store().DesktopTokens().Insert(token, createAt, user.Id)
if err != nil {
// Delete any other related tokens if there's an error
if deleteErr := a.Srv().Store().DesktopTokens().DeleteByUserId(user.Id); deleteErr != nil {
a.Log().Error("Unable to delete desktop token", mlog.Err(deleteErr))
}
return nil, model.NewAppError("GenerateAndSaveDesktopToken", "app.desktop_token.generateServerToken.invalid_or_expired", nil, "", http.StatusBadRequest).Wrap(err)
}
return &token, nil
}
func (a *App) ValidateDesktopToken(rctx request.CTX, token string, expiryTime int64) (*model.User, *model.AppError) {
// Check if token is valid
userId, err := a.Srv().Store().DesktopTokens().GetUserId(token, expiryTime)
if err != nil {
// Delete the token if it is expired or invalid
if deleteErr := a.Srv().Store().DesktopTokens().Delete(token); deleteErr != nil {
a.Log().Error("Unable to delete desktop token", mlog.Err(deleteErr))
}
return nil, model.NewAppError("ValidateDesktopToken", "app.desktop_token.validate.invalid", nil, "", http.StatusUnauthorized).Wrap(err)
}
// Get the user profile
user, userErr := a.GetUser(rctx, *userId)
if userErr != nil {
// Delete the token if the user is invalid somehow
if deleteErr := a.Srv().Store().DesktopTokens().Delete(token); deleteErr != nil {
a.Log().Error("Unable to delete desktop token", mlog.Err(deleteErr))
}
return nil, model.NewAppError("ValidateDesktopToken", "app.desktop_token.validate.no_user", nil, "", http.StatusInternalServerError).Wrap(userErr)
}
// Clean up other tokens if they exist
if deleteErr := a.Srv().Store().DesktopTokens().DeleteByUserId(*userId); deleteErr != nil {
a.Log().Error("Unable to delete desktop token", mlog.Err(deleteErr))
}
return user, nil
}

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
TTL = time.Minute * 3
ExpiredLength = time.Minute * 10
)
func TestGenerateAndSaveDesktopToken(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
t.Run("generate token", func(t *testing.T) {
token, err := th.App.GenerateAndSaveDesktopToken(time.Now().Unix(), th.BasicUser)
assert.Nil(t, err)
assert.NotNil(t, token)
})
}
func TestValidateDesktopToken(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
authenticatedServerToken, err := th.App.GenerateAndSaveDesktopToken(time.Now().Unix(), th.BasicUser)
require.Nil(t, err)
require.NotNil(t, authenticatedServerToken)
expiredServerToken, err := th.App.GenerateAndSaveDesktopToken(time.Now().Add(-ExpiredLength).Unix(), th.BasicUser2)
require.Nil(t, err)
require.NotNil(t, expiredServerToken)
badUser := model.User{Id: "some_garbage_user_id"}
badUserServerToken, err := th.App.GenerateAndSaveDesktopToken(time.Now().Unix(), &badUser)
require.Nil(t, err)
require.NotNil(t, badUserServerToken)
t.Run("validate token", func(t *testing.T) {
user, err := th.App.ValidateDesktopToken(th.Context, *authenticatedServerToken, time.Now().Add(-TTL).Unix())
assert.Nil(t, err)
assert.NotNil(t, user)
assert.Equal(t, th.BasicUser.Id, user.Id)
})
t.Run("validate token - expired", func(t *testing.T) {
user, err := th.App.ValidateDesktopToken(th.Context, *expiredServerToken, time.Now().Add(-TTL).Unix())
assert.NotNil(t, err)
assert.Nil(t, user)
assert.Equal(t, "app.desktop_token.validate.invalid", err.Id)
})
t.Run("validate token - not authenticated", func(t *testing.T) {
user, err := th.App.ValidateDesktopToken(th.Context, "not_real_token", time.Now().Add(-TTL).Unix())
assert.NotNil(t, err)
assert.Nil(t, user)
assert.Equal(t, "app.desktop_token.validate.invalid", err.Id)
})
t.Run("validate token - bad user id", func(t *testing.T) {
user, err := th.App.ValidateDesktopToken(th.Context, *badUserServerToken, time.Now().Add(-TTL).Unix())
assert.NotNil(t, err)
assert.Nil(t, user)
assert.Equal(t, "app.desktop_token.validate.no_user", err.Id)
})
}

View File

@@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/*
Package app provides the business logic layer for Mattermost Channels.
This package serves as the core business logic layer that sits between the API layer (api4)
and the data access layer (store). It contains the primary application logic for all
Mattermost functionality including user management, channel operations, post handling,
notifications, authentication, authorization, and integrations.
# Architecture
The app package follows a layered architecture pattern:
┌─────────────────────────────────────────────────────────────────┐
│ API Layer (api4) │
├─────────────────────────────────────────────────────────────────┤
│ Business Logic (app) │
├─────────────────────────────────────────────────────────────────┤
│ Data Access (store) │
└─────────────────────────────────────────────────────────────────┘
# Core Components
## App Structure
The App struct is the main entry point for business logic operations. It is a pure
functional component that does not hold state, constructed per request and provides
access to business logic methods through its association with the Channels struct.
## Server
The Server struct manages the HTTP server, routing, middleware, and service lifecycle.
It coordinates between platform services, manages enterprise features, handles
clustering, and provides the runtime environment for the application.
## Channels
The Channels struct contains all channels-related state and enterprise interface
implementations. It manages plugins, file storage, image processing, and coordinates
various enterprise features like compliance, LDAP, and SAML.
## Platform Service
The platform service handles non-entity related functionalities required by the
application including database access, configuration management, caching, licensing,
metrics, and search engines.
# Design Patterns
## Request Context Pattern
All business logic methods accept a request.CTX parameter for request-scoped
logging, tracing, and cancellation.
## Interface Segregation
Enterprise features are accessed through interfaces in the einterfaces package,
allowing for modular enterprise functionality.
## Dependency Injection
Services and dependencies are injected through the Server and Channels structs,
enabling testability and modularity.
## Event-Driven Architecture
The application uses WebSocket events and plugin hooks for real-time updates
and extensibility.
# Key Responsibilities
- Business logic: Core application rules and workflows
- Data orchestration: Coordinate between multiple stores and services
- External integrations: Third-party service calls and API interactions
- Cache management: Handle cache invalidation and updates
- Event handling: Trigger notifications, webhooks, and background jobs
# Error Handling
The package uses model.AppError for consistent error handling across the
application. Errors include structured information for logging, user
messages, and HTTP status codes.
This package is central to the Mattermost server architecture and provides
the foundation for all collaboration features in the platform.
*/
package app

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"io"
"net/http"
"net/url"
"time"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/utils"
)
const (
// HTTPRequestTimeout defines a high timeout for downloading large files
// from an external URL to avoid slow connections from failing to install.
HTTPRequestTimeout = 1 * time.Hour
)
func (a *App) DownloadFromURL(downloadURL string) ([]byte, error) {
return a.Srv().downloadFromURL(downloadURL)
}
func (s *Server) downloadFromURL(downloadURL string) ([]byte, error) {
if !model.IsValidHTTPURL(downloadURL) {
return nil, errors.Errorf("invalid url %s", downloadURL)
}
u, err := url.ParseRequestURI(downloadURL)
if err != nil {
return nil, errors.Errorf("failed to parse url %s", downloadURL)
}
if !*s.platform.Config().PluginSettings.AllowInsecureDownloadURL && u.Scheme != "https" {
return nil, errors.Errorf("insecure url not allowed %s", downloadURL)
}
client := s.HTTPService().MakeClient(true)
client.Timeout = HTTPRequestTimeout
var resp *http.Response
err = utils.ProgressiveRetry(func() error {
resp, err = client.Get(downloadURL)
if err != nil {
return errors.Wrapf(err, "failed to fetch from %s", downloadURL)
}
if !(resp.StatusCode >= 200 && resp.StatusCode < 300) {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
return errors.Errorf("failed to fetch from %s", downloadURL)
}
return nil
})
if err != nil {
return nil, errors.Wrap(err, "download failed after multiple retries.")
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
func TestDownloadFromURL(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
app := th.App
app.Config().PluginSettings.AllowInsecureDownloadURL = new(true)
// To keep track of how many times an endpoint is retried. This needs to be reset
// for each test run.
retries := 0
mux := http.NewServeMux()
mux.HandleFunc("/succeeds-after-retry", func(w http.ResponseWriter, r *http.Request) {
if retries < 2 {
http.Error(w, "Request Timed out", http.StatusGatewayTimeout)
retries++
return
}
_, _ = w.Write([]byte("Your request is successful."))
})
mux.HandleFunc("/fails-forever", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "This would fail forever", http.StatusInternalServerError)
})
testServer := httptest.NewServer(mux)
tests := []struct {
name string
downloadURL string
wantErr bool
}{
{
name: "Should succeed after two retries",
downloadURL: fmt.Sprintf("%s/succeeds-after-retry", testServer.URL),
wantErr: false,
},
{
name: "Should not retry forever",
downloadURL: fmt.Sprintf("%s/fails-forever", testServer.URL),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
retries = 0 // reset the retires
_, err := th.App.DownloadFromURL(tt.downloadURL)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}

View File

@@ -0,0 +1,192 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"errors"
"net/http"
"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"
)
func (a *App) GetDraft(userID, channelID, rootID string) (*model.Draft, *model.AppError) {
if !*a.Config().ServiceSettings.AllowSyncedDrafts {
return nil, model.NewAppError("GetDraft", "app.draft.feature_disabled", nil, "", http.StatusNotImplemented)
}
draft, err := a.Srv().Store().Draft().Get(userID, channelID, rootID, false)
if err != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(err, &nfErr):
return nil, model.NewAppError("GetDraft", "app.draft.get.app_error", nil, "", http.StatusNotFound).Wrap(err)
default:
return nil, model.NewAppError("GetDraft", "app.draft.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
return draft, nil
}
func (a *App) UpsertDraft(rctx request.CTX, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) {
if !*a.Config().ServiceSettings.AllowSyncedDrafts {
return nil, model.NewAppError("CreateDraft", "app.draft.feature_disabled", nil, "", http.StatusNotImplemented)
}
// Check that channel exists and has not been deleted
channel, errCh := a.Srv().Store().Channel().Get(draft.ChannelId, true)
if errCh != nil {
err := model.NewAppError("CreateDraft", "api.context.invalid_param.app_error", map[string]any{"Name": "draft.channel_id"}, "", http.StatusBadRequest).Wrap(errCh)
return nil, err
}
if channel.DeleteAt != 0 {
err := model.NewAppError("CreateDraft", "api.draft.create_draft.can_not_draft_to_deleted.error", nil, "", http.StatusBadRequest)
return nil, err
}
restrictDM, err := a.CheckIfChannelIsRestrictedDM(rctx, channel)
if err != nil {
return nil, err
}
if restrictDM {
err := model.NewAppError("CreateDraft", "api.draft.create_draft.can_not_draft_to_restricted_dm.error", nil, "", http.StatusBadRequest)
return nil, err
}
_, nErr := a.Srv().Store().User().Get(rctx, draft.UserId)
if nErr != nil {
return nil, model.NewAppError("CreateDraft", "app.user.get.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
// If the draft is empty, just delete it
if draft.Message == "" {
deleteErr := a.Srv().Store().Draft().Delete(draft.UserId, draft.ChannelId, draft.RootId)
if deleteErr != nil {
return nil, model.NewAppError("CreateDraft", "app.draft.save.app_error", nil, "", http.StatusInternalServerError).Wrap(deleteErr)
}
rctx.Logger().Debug("Draft deleted via empty-message upsert", mlog.String("user_id", draft.UserId), mlog.String("channel_id", draft.ChannelId), mlog.String("root_id", draft.RootId))
return nil, nil
}
var guardErr *model.AppError
draft, guardErr = a.runGuardedDraftWillBeUpserted(rctx, draft)
if guardErr != nil {
return nil, guardErr
}
dt, nErr := a.Srv().Store().Draft().Upsert(draft)
if nErr != nil {
return nil, model.NewAppError("CreateDraft", "app.draft.save.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
dt = a.prepareDraftWithFileInfos(rctx, draft.UserId, dt)
message := model.NewWebSocketEvent(model.WebsocketEventDraftCreated, "", dt.ChannelId, dt.UserId, nil, connectionID)
draftJSON, jsonErr := json.Marshal(dt)
if jsonErr != nil {
rctx.Logger().Warn("Failed to encode draft to JSON", mlog.Err(jsonErr))
}
message.Add("draft", string(draftJSON))
a.Publish(message)
return dt, nil
}
func (a *App) GetDraftsForUser(rctx request.CTX, userID, teamID string) ([]*model.Draft, *model.AppError) {
if !*a.Config().ServiceSettings.AllowSyncedDrafts {
return nil, model.NewAppError("GetDraftsForUser", "app.draft.feature_disabled", nil, "", http.StatusNotImplemented)
}
drafts, err := a.Srv().Store().Draft().GetDraftsForUser(userID, teamID)
if err != nil {
return nil, model.NewAppError("GetDraftsForUser", "app.draft.get_drafts.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
for _, draft := range drafts {
a.prepareDraftWithFileInfos(rctx, userID, draft)
}
return drafts, nil
}
func (a *App) prepareDraftWithFileInfos(rctx request.CTX, userID string, draft *model.Draft) *model.Draft {
if fileInfos, err := a.getFileInfosForDraft(rctx, draft); err != nil {
rctx.Logger().Error("Failed to get files for a user's drafts", mlog.String("user_id", userID), mlog.Err(err))
} else {
draft.Metadata = &model.PostMetadata{}
draft.Metadata.Files = fileInfos
}
return draft
}
func (a *App) getFileInfosForDraft(rctx request.CTX, draft *model.Draft) ([]*model.FileInfo, *model.AppError) {
if len(draft.FileIds) == 0 {
return nil, nil
}
allFileInfos, err := a.Srv().Store().FileInfo().GetByIds(draft.FileIds, false, true, false)
if err != nil {
return nil, model.NewAppError("GetFileInfosForDraft", "app.draft.get_for_draft.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
fileInfos := []*model.FileInfo{}
for _, fileInfo := range allFileInfos {
if fileInfo.PostId == "" && fileInfo.CreatorId == draft.UserId {
fileInfos = append(fileInfos, fileInfo)
} else {
rctx.Logger().Debug("Invalid file id in draft", mlog.String("file_id", fileInfo.Id), mlog.String("user_id", draft.UserId))
}
}
if len(fileInfos) == 0 {
return nil, nil
}
// A draft is unsent content the author still has to be able to see and send, so the
// metadata stays. The rendered thumbnail is the file's actual content, so it is
// withheld — and never generated — when the policy denies downloading it.
if !a.hasFileAttachmentAccess(rctx, draft.UserId, draft.ChannelId) {
rctx.Logger().Debug("Withholding draft file thumbnails due to ABAC permission policy",
mlog.String("user_id", draft.UserId),
mlog.String("channel_id", draft.ChannelId),
)
for _, fileInfo := range fileInfos {
fileInfo.MiniPreview = nil
}
return fileInfos, nil
}
a.generateMiniPreviewForInfos(rctx, fileInfos)
return fileInfos, nil
}
func (a *App) DeleteDraft(rctx request.CTX, draft *model.Draft, connectionID string) *model.AppError {
if !*a.Config().ServiceSettings.AllowSyncedDrafts {
return model.NewAppError("DeleteDraft", "app.draft.feature_disabled", nil, "", http.StatusNotImplemented)
}
if err := a.Srv().Store().Draft().Delete(draft.UserId, draft.ChannelId, draft.RootId); err != nil {
return model.NewAppError("DeleteDraft", "app.draft.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
draftJSON, jsonErr := json.Marshal(draft)
if jsonErr != nil {
rctx.Logger().Warn("Failed to encode draft to JSON")
}
message := model.NewWebSocketEvent(model.WebsocketEventDraftDeleted, "", draft.ChannelId, draft.UserId, nil, connectionID)
message.Add("draft", string(draftJSON))
a.Publish(message)
return nil
}

View File

@@ -0,0 +1,424 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
)
func TestGetDraft(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.Server.platform.SetConfigReadOnlyFF(false)
defer th.Server.platform.SetConfigReadOnlyFF(true)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true })
user := th.BasicUser
channel := th.BasicChannel
draft := &model.Draft{
CreateAt: 00001,
UpdateAt: 00001,
UserId: user.Id,
ChannelId: channel.Id,
Message: "draft",
}
_, upsertDraftErr := th.App.UpsertDraft(th.Context, draft, "")
assert.Nil(t, upsertDraftErr)
t.Run("get draft", func(t *testing.T) {
draftResp, err := th.App.GetDraft(user.Id, channel.Id, "")
assert.Nil(t, err)
assert.Equal(t, draft.Message, draftResp.Message)
assert.Equal(t, draft.ChannelId, draftResp.ChannelId)
})
t.Run("get draft feature flag", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false })
defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true })
_, err := th.App.GetDraft(user.Id, channel.Id, "")
assert.NotNil(t, err)
})
}
func TestUpsertDraft(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.Server.platform.SetConfigReadOnlyFF(false)
defer th.Server.platform.SetConfigReadOnlyFF(true)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true })
user := th.BasicUser
channel := th.BasicChannel
draft := &model.Draft{
UserId: user.Id,
ChannelId: channel.Id,
Message: "draft",
}
t.Run("upsert draft", func(t *testing.T) {
_, err := th.App.UpsertDraft(th.Context, draft, "")
assert.Nil(t, err)
drafts, err := th.App.GetDraftsForUser(th.Context, user.Id, th.BasicTeam.Id)
assert.Nil(t, err)
assert.Len(t, drafts, 1)
draft1 := drafts[0]
assert.Equal(t, "draft", draft1.Message)
assert.Equal(t, channel.Id, draft1.ChannelId)
assert.Greater(t, draft1.CreateAt, int64(0))
draft = &model.Draft{
UserId: user.Id,
ChannelId: channel.Id,
Message: "updated draft",
}
_, err = th.App.UpsertDraft(th.Context, draft, "")
assert.Nil(t, err)
drafts, err = th.App.GetDraftsForUser(th.Context, user.Id, th.BasicTeam.Id)
assert.Nil(t, err)
assert.Len(t, drafts, 1)
draft2 := drafts[0]
assert.Equal(t, "updated draft", draft2.Message)
assert.Equal(t, channel.Id, draft2.ChannelId)
assert.Equal(t, draft1.CreateAt, draft2.CreateAt)
})
t.Run("upsert draft feature flag", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false })
defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true })
_, err := th.App.UpsertDraft(th.Context, draft, "")
assert.NotNil(t, err)
})
}
func TestCreateDraft(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.Server.platform.SetConfigReadOnlyFF(false)
defer th.Server.platform.SetConfigReadOnlyFF(true)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true })
user := th.BasicUser
channel := th.BasicChannel
channel2 := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, user, channel2)
draft1 := &model.Draft{
CreateAt: 00001,
UpdateAt: 00001,
UserId: user.Id,
ChannelId: channel.Id,
Message: "draft",
}
draft2 := &model.Draft{
CreateAt: 00001,
UpdateAt: 00001,
UserId: user.Id,
ChannelId: channel2.Id,
Message: "draft2",
}
t.Run("create draft", func(t *testing.T) {
draftResp, err := th.App.UpsertDraft(th.Context, draft1, "")
assert.Nil(t, err)
assert.Equal(t, draft1.Message, draftResp.Message)
assert.Equal(t, draft1.ChannelId, draftResp.ChannelId)
})
t.Run("create draft with files", func(t *testing.T) {
// upload file
sent, readFileErr := testutils.ReadTestFile("test.png")
require.NoError(t, readFileErr)
fileResp, uploadFileErr := th.App.UploadFile(th.Context, sent, channel.Id, "test.png")
assert.Nil(t, uploadFileErr)
draftWithFiles := draft2
draftWithFiles.FileIds = []string{fileResp.Id}
draftResp, err := th.App.UpsertDraft(th.Context, draftWithFiles, "")
assert.Nil(t, err)
assert.Equal(t, draftWithFiles.Message, draftResp.Message)
assert.Equal(t, draftWithFiles.ChannelId, draftResp.ChannelId)
assert.ElementsMatch(t, draftWithFiles.FileIds, draftResp.FileIds)
})
}
func TestUpdateDraft(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.Server.platform.SetConfigReadOnlyFF(false)
defer th.Server.platform.SetConfigReadOnlyFF(true)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true })
user := th.BasicUser
channel := th.BasicChannel
draft1 := &model.Draft{
UserId: user.Id,
ChannelId: channel.Id,
Message: "draft1",
}
_, createDraftErr := th.App.UpsertDraft(th.Context, draft1, "")
assert.Nil(t, createDraftErr)
t.Run("update draft with files", func(t *testing.T) {
// upload file
sent, readFileErr := testutils.ReadTestFile("test.png")
require.NoError(t, readFileErr)
fileResp, uploadFileErr := th.App.UploadFile(th.Context, sent, channel.Id, "test.png")
assert.Nil(t, uploadFileErr)
draftWithFiles := draft1
draftWithFiles.FileIds = []string{fileResp.Id}
_, err := th.App.UpsertDraft(th.Context, draft1, "")
assert.Nil(t, err)
drafts, err := th.App.GetDraftsForUser(th.Context, user.Id, th.BasicTeam.Id)
assert.Nil(t, err)
draftResp := drafts[0]
assert.Equal(t, draftWithFiles.Message, draftResp.Message)
assert.Equal(t, draftWithFiles.ChannelId, draftResp.ChannelId)
assert.ElementsMatch(t, draftWithFiles.FileIds, draftResp.FileIds)
})
t.Run("cannot upsert draft in restricted DM", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.TeamSettings.RestrictDirectMessage = model.DirectMessageTeam
})
// Create a DM channel between two users who don't share a team
dmChannel := th.CreateDmChannel(t, th.BasicUser2)
// Ensure the two users do not share a team
teams, err := th.App.GetTeamsForUser(th.BasicUser.Id)
require.Nil(t, err)
for _, team := range teams {
teamErr := th.App.RemoveUserFromTeam(th.Context, team.Id, th.BasicUser.Id, th.SystemAdminUser.Id)
require.Nil(t, teamErr)
}
teams, err = th.App.GetTeamsForUser(th.BasicUser2.Id)
require.Nil(t, err)
for _, team := range teams {
teamErr := th.App.RemoveUserFromTeam(th.Context, team.Id, th.BasicUser2.Id, th.SystemAdminUser.Id)
require.Nil(t, teamErr)
}
// Create separate teams for each user
team1 := th.CreateTeam(t)
team2 := th.CreateTeam(t)
th.LinkUserToTeam(t, th.BasicUser, team1)
th.LinkUserToTeam(t, th.BasicUser2, team2)
draft := &model.Draft{
CreateAt: 00001,
UpdateAt: 00001,
UserId: th.BasicUser.Id,
ChannelId: dmChannel.Id,
Message: "draft message",
}
_, err = th.App.UpsertDraft(th.Context, draft, "")
require.NotNil(t, err)
require.Equal(t, "api.draft.create_draft.can_not_draft_to_restricted_dm.error", err.Id)
require.Equal(t, http.StatusBadRequest, err.StatusCode)
// Reset config
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.TeamSettings.RestrictDirectMessage = model.DirectMessageAny
})
})
}
func TestGetDraftsForUser(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.Server.platform.SetConfigReadOnlyFF(false)
defer th.Server.platform.SetConfigReadOnlyFF(true)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true })
user := th.BasicUser
channel := th.BasicChannel
channel2 := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, user, channel2)
draft1 := &model.Draft{
CreateAt: 00001,
UserId: user.Id,
ChannelId: channel.Id,
Message: "draft1",
}
draft2 := &model.Draft{
CreateAt: 00005,
UserId: user.Id,
ChannelId: channel2.Id,
Message: "draft2",
}
_, createDraftErr1 := th.App.UpsertDraft(th.Context, draft1, "")
assert.Nil(t, createDraftErr1)
// Wait a bit so the second draft gets a newer UpdateAt
time.Sleep(100 * time.Millisecond)
_, createDraftErr2 := th.App.UpsertDraft(th.Context, draft2, "")
assert.Nil(t, createDraftErr2)
t.Run("get drafts", func(t *testing.T) {
draftResp, err := th.App.GetDraftsForUser(th.Context, user.Id, th.BasicTeam.Id)
assert.Nil(t, err)
assert.Equal(t, draft2.Message, draftResp[0].Message)
assert.Equal(t, draft2.ChannelId, draftResp[0].ChannelId)
assert.Equal(t, draft1.Message, draftResp[1].Message)
assert.Equal(t, draft1.ChannelId, draftResp[1].ChannelId)
})
t.Run("get drafts with files", func(t *testing.T) {
// upload file
sent, readFileErr := testutils.ReadTestFile("test.png")
require.NoError(t, readFileErr)
fileResp, updateDraftErr := th.App.UploadFileForUserAndTeam(th.Context, sent, channel.Id, "test.png", user.Id, "")
assert.Nil(t, updateDraftErr)
draftWithFiles := draft1
draftWithFiles.FileIds = []string{fileResp.Id}
draftResp, updateDraftErr := th.App.UpsertDraft(th.Context, draft1, "")
assert.Nil(t, updateDraftErr)
assert.Equal(t, draftWithFiles.Message, draftResp.Message)
assert.Equal(t, draftWithFiles.ChannelId, draftResp.ChannelId)
assert.ElementsMatch(t, draftWithFiles.FileIds, draftResp.FileIds)
draftsWithFilesResp, err := th.App.GetDraftsForUser(th.Context, user.Id, th.BasicTeam.Id)
assert.Nil(t, err)
assert.Equal(t, draftWithFiles.Message, draftsWithFilesResp[0].Message)
assert.Equal(t, draftWithFiles.ChannelId, draftsWithFilesResp[0].ChannelId)
assert.ElementsMatch(t, draftWithFiles.FileIds, draftsWithFilesResp[0].FileIds)
assert.Len(t, draftsWithFilesResp[0].Metadata.Files, 1)
assert.Equal(t, fileResp.Name, draftsWithFilesResp[0].Metadata.Files[0].Name)
assert.Len(t, draftsWithFilesResp, 2)
})
t.Run("get draft with invalid files", func(t *testing.T) {
// upload file
sent, readFileErr := testutils.ReadTestFile("test.png")
require.NoError(t, readFileErr)
fileResp1, updateDraftErr := th.App.UploadFileForUserAndTeam(th.Context, sent, channel.Id, "test1.png", user.Id, "")
assert.Nil(t, updateDraftErr)
fileResp2, updateDraftErr := th.App.UploadFileForUserAndTeam(th.Context, sent, channel.Id, "test2.png", th.BasicUser2.Id, "")
assert.Nil(t, updateDraftErr)
draftWithFiles := draft1
draftWithFiles.FileIds = []string{fileResp1.Id, fileResp2.Id}
draftResp, updateDraftErr := th.App.UpsertDraft(th.Context, draft1, "")
assert.Nil(t, updateDraftErr)
assert.Equal(t, draftWithFiles.Message, draftResp.Message)
assert.Equal(t, draftWithFiles.ChannelId, draftResp.ChannelId)
assert.ElementsMatch(t, draftWithFiles.FileIds, draftResp.FileIds)
assert.Len(t, draftWithFiles.Metadata.Files, 1)
assert.Equal(t, fileResp1.Name, draftWithFiles.Metadata.Files[0].Name)
draftsWithFilesResp, err := th.App.GetDraftsForUser(th.Context, user.Id, th.BasicTeam.Id)
assert.Nil(t, err)
assert.Equal(t, draftWithFiles.Message, draftsWithFilesResp[0].Message)
assert.Equal(t, draftWithFiles.ChannelId, draftsWithFilesResp[0].ChannelId)
assert.ElementsMatch(t, draftWithFiles.FileIds, draftsWithFilesResp[0].FileIds)
assert.Len(t, draftsWithFilesResp[0].Metadata.Files, 1)
assert.Equal(t, fileResp1.Name, draftsWithFilesResp[0].Metadata.Files[0].Name)
})
t.Run("get drafts feature flag", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false })
defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true })
_, err := th.App.GetDraftsForUser(th.Context, user.Id, th.BasicTeam.Id)
assert.NotNil(t, err)
})
}
func TestDeleteDraft(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.Server.platform.SetConfigReadOnlyFF(false)
defer th.Server.platform.SetConfigReadOnlyFF(true)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true })
user := th.BasicUser
channel := th.BasicChannel
draft1 := &model.Draft{
CreateAt: 00001,
UpdateAt: 00001,
UserId: user.Id,
ChannelId: channel.Id,
Message: "draft1",
}
_, createDraftErr := th.App.UpsertDraft(th.Context, draft1, "")
assert.Nil(t, createDraftErr)
t.Run("delete draft", func(t *testing.T) {
err := th.App.DeleteDraft(th.Context, draft1, "")
assert.Nil(t, err)
})
t.Run("delete drafts feature flag", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false })
defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true })
err := th.App.DeleteDraft(th.Context, draft1, "")
assert.NotNil(t, err)
})
}

View File

@@ -0,0 +1,285 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"fmt"
"maps"
"net/http"
"sync"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
)
type e2eAgentsBridge struct {
mut sync.Mutex
status *model.AIBridgeTestHelperStatus
agents *[]model.BridgeAgentInfo
services *[]model.BridgeServiceInfo
agentCompletions map[string][]model.AIBridgeTestHelperCompletion
recordRequests bool
recordedRequests []model.AIBridgeTestHelperRecordedRequest
}
func newE2EAgentsBridge(config *model.AIBridgeTestHelperConfig) (*e2eAgentsBridge, error) {
b := &e2eAgentsBridge{
agentCompletions: make(map[string][]model.AIBridgeTestHelperCompletion),
recordedRequests: []model.AIBridgeTestHelperRecordedRequest{},
}
if err := b.setConfig(config); err != nil {
return nil, err
}
return b, nil
}
func (b *e2eAgentsBridge) setConfig(config *model.AIBridgeTestHelperConfig) error {
if config == nil {
return fmt.Errorf("config cannot be nil")
}
normalizedCompletions := make(map[string][]model.AIBridgeTestHelperCompletion, len(config.AgentCompletions))
for operation, completions := range config.AgentCompletions {
if operation == "" {
return fmt.Errorf("agent completion operation key cannot be empty")
}
normalizedCompletions[operation] = make([]model.AIBridgeTestHelperCompletion, 0, len(completions))
for idx, completion := range completions {
hasCompletion := completion.Completion != ""
hasError := completion.Error != ""
if hasCompletion == hasError {
return fmt.Errorf("agent completion %q at index %d must set exactly one of completion or error", operation, idx)
}
if hasError && completion.StatusCode == 0 {
completion.StatusCode = http.StatusInternalServerError
}
normalizedCompletions[operation] = append(normalizedCompletions[operation], completion)
}
}
for idx, agent := range config.Agents {
if agent.ID == "" {
return fmt.Errorf("agent at index %d is missing id", idx)
}
if agent.Username == "" {
return fmt.Errorf("agent %q is missing username", agent.ID)
}
}
for idx, service := range config.Services {
if service.ID == "" {
return fmt.Errorf("service at index %d is missing id", idx)
}
if service.Name == "" {
return fmt.Errorf("service %q is missing name", service.ID)
}
}
b.mut.Lock()
defer b.mut.Unlock()
b.status = cloneAIBridgeStatus(config.Status)
b.agents = cloneBridgeAgentsPtr(config.Agents)
b.services = cloneBridgeServicesPtr(config.Services)
b.agentCompletions = normalizedCompletions
b.recordRequests = config.RecordRequests != nil && *config.RecordRequests
b.recordedRequests = []model.AIBridgeTestHelperRecordedRequest{}
return nil
}
func (b *e2eAgentsBridge) GetState() *model.AIBridgeTestHelperState {
b.mut.Lock()
defer b.mut.Unlock()
return &model.AIBridgeTestHelperState{
Status: cloneAIBridgeStatus(b.status),
Agents: cloneBridgeAgentsValue(b.agents),
Services: cloneBridgeServicesValue(b.services),
AgentCompletions: cloneAIBridgeCompletions(b.agentCompletions),
RecordRequests: b.recordRequests,
RecordedRequests: cloneRecordedRequests(b.recordedRequests),
}
}
func (b *e2eAgentsBridge) Status(_ request.CTX) (bool, string) {
b.mut.Lock()
defer b.mut.Unlock()
if b.status == nil {
return true, ""
}
return b.status.Available, b.status.Reason
}
func (b *e2eAgentsBridge) GetAgents(_, _ string) ([]model.BridgeAgentInfo, error) {
b.mut.Lock()
defer b.mut.Unlock()
if b.agents == nil {
return []model.BridgeAgentInfo{}, nil
}
return append([]model.BridgeAgentInfo(nil), (*b.agents)...), nil
}
func (b *e2eAgentsBridge) GetServices(_, _ string) ([]model.BridgeServiceInfo, error) {
b.mut.Lock()
defer b.mut.Unlock()
if b.services == nil {
return []model.BridgeServiceInfo{}, nil
}
return append([]model.BridgeServiceInfo(nil), (*b.services)...), nil
}
func (b *e2eAgentsBridge) AgentCompletion(sessionUserID, agentID string, req BridgeCompletionRequest) (string, error) {
b.mut.Lock()
defer b.mut.Unlock()
b.recordRequestLocked(sessionUserID, agentID, "", req)
return b.getCompletionLocked(string(req.Operation))
}
func (b *e2eAgentsBridge) ServiceCompletion(sessionUserID, serviceID string, req BridgeCompletionRequest) (string, error) {
b.mut.Lock()
defer b.mut.Unlock()
b.recordRequestLocked(sessionUserID, "", serviceID, req)
return b.getCompletionLocked(string(req.Operation))
}
func (b *e2eAgentsBridge) getCompletionLocked(operation string) (string, error) {
completions, ok := b.agentCompletions[operation]
if !ok || len(completions) == 0 {
return "", nil
}
completion := completions[0]
b.agentCompletions[operation] = completions[1:]
if completion.Error != "" {
if completion.StatusCode > 0 {
return "", fmt.Errorf("request failed with status %d: %s", completion.StatusCode, completion.Error)
}
return "", fmt.Errorf("%s", completion.Error)
}
return completion.Completion, nil
}
func (b *e2eAgentsBridge) recordRequestLocked(sessionUserID, agentID, serviceID string, req BridgeCompletionRequest) {
if !b.recordRequests {
return
}
recorded := model.AIBridgeTestHelperRecordedRequest{
Operation: string(req.Operation),
ClientOperation: req.ClientOperation,
OperationSubType: req.OperationSubType,
SessionUserID: sessionUserID,
UserID: req.UserID,
ChannelID: req.ChannelID,
AgentID: agentID,
ServiceID: serviceID,
Messages: toE2ERecordedMessages(req.Messages),
JSONOutputFormat: cloneJSONOutputFormat(req.JSONOutputFormat),
}
b.recordedRequests = append(b.recordedRequests, recorded)
}
func toE2ERecordedMessages(messages []BridgeMessage) []model.AIBridgeTestHelperMessage {
recorded := make([]model.AIBridgeTestHelperMessage, 0, len(messages))
for _, message := range messages {
recorded = append(recorded, model.AIBridgeTestHelperMessage{
Role: message.Role,
Message: message.Message,
FileIDs: append([]string(nil), message.FileIDs...),
})
}
return recorded
}
func cloneAIBridgeStatus(status *model.AIBridgeTestHelperStatus) *model.AIBridgeTestHelperStatus {
if status == nil {
return nil
}
cloned := *status
return &cloned
}
func cloneBridgeAgentsPtr(agents []model.BridgeAgentInfo) *[]model.BridgeAgentInfo {
if agents == nil {
return nil
}
cloned := append([]model.BridgeAgentInfo(nil), agents...)
return &cloned
}
func cloneBridgeServicesPtr(services []model.BridgeServiceInfo) *[]model.BridgeServiceInfo {
if services == nil {
return nil
}
cloned := append([]model.BridgeServiceInfo(nil), services...)
return &cloned
}
func cloneBridgeAgentsValue(agents *[]model.BridgeAgentInfo) []model.BridgeAgentInfo {
if agents == nil {
return nil
}
return append([]model.BridgeAgentInfo(nil), (*agents)...)
}
func cloneBridgeServicesValue(services *[]model.BridgeServiceInfo) []model.BridgeServiceInfo {
if services == nil {
return nil
}
return append([]model.BridgeServiceInfo(nil), (*services)...)
}
func cloneAIBridgeCompletions(agentCompletions map[string][]model.AIBridgeTestHelperCompletion) map[string][]model.AIBridgeTestHelperCompletion {
if agentCompletions == nil {
return nil
}
cloned := make(map[string][]model.AIBridgeTestHelperCompletion, len(agentCompletions))
for key, value := range agentCompletions {
cloned[key] = append([]model.AIBridgeTestHelperCompletion(nil), value...)
}
return cloned
}
func cloneRecordedRequests(recordedRequests []model.AIBridgeTestHelperRecordedRequest) []model.AIBridgeTestHelperRecordedRequest {
if recordedRequests == nil {
return nil
}
cloned := make([]model.AIBridgeTestHelperRecordedRequest, 0, len(recordedRequests))
for _, req := range recordedRequests {
clonedReq := req
clonedReq.Messages = append([]model.AIBridgeTestHelperMessage(nil), req.Messages...)
if req.JSONOutputFormat != nil {
clonedReq.JSONOutputFormat = make(map[string]any, len(req.JSONOutputFormat))
maps.Copy(clonedReq.JSONOutputFormat, req.JSONOutputFormat)
}
cloned = append(cloned, clonedReq)
}
return cloned
}
var _ AgentsBridge = (*e2eAgentsBridge)(nil)

View File

@@ -0,0 +1,11 @@
with-expecter: false
packages:
github.com/mattermost/mattermost/server/v8/channels/app/email:
config:
dir: "channels/app/email/mocks"
filename: "{{.InterfaceName}}.go"
mockname: "{{.InterfaceName}}"
outpkg: mocks
note: "Regenerate this file using `make email-mocks`."
interfaces:
ServiceInterface:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,388 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"bytes"
"fmt"
"html/template"
"io"
"net/http"
"strconv"
"strings"
"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/utils"
)
const (
EmailBatchingTaskName = "Email Batching"
)
type postData struct {
SenderName string
ChannelName string
Message template.HTML
MessageURL string
SenderPhoto string
PostPhoto string
Time string
ShowChannelIcon bool
OtherChannelMembersCount int
MessageAttachments []*EmailMessageAttachment
}
func (es *Service) InitEmailBatching() {
if *es.config().EmailSettings.EnableEmailBatching {
if es.EmailBatching == nil {
es.EmailBatching = NewEmailBatchingJob(es, *es.config().EmailSettings.EmailBatchingBufferSize)
}
// note that we don't support changing EmailBatchingBufferSize without restarting the server
es.EmailBatching.Start()
}
}
func (es *Service) AddNotificationEmailToBatch(user *model.User, post *model.Post, team *model.Team) *model.AppError {
if !*es.config().EmailSettings.EnableEmailBatching {
return model.NewAppError("AddNotificationEmailToBatch", "api.email_batching.add_notification_email_to_batch.disabled.app_error", nil, "", http.StatusNotImplemented)
}
if !es.EmailBatching.Add(user, post, team) {
mlog.Error("Email batching job's receiving buffer was full. Please increase the EmailBatchingBufferSize. Falling back to sending immediate mail.")
return model.NewAppError("AddNotificationEmailToBatch", "api.email_batching.add_notification_email_to_batch.channel_full.app_error", nil, "", http.StatusInternalServerError)
}
return nil
}
type batchedNotification struct {
userID string
post *model.Post
teamName string
}
type EmailBatchingJob struct {
config func() *model.Config
service *Service
newNotifications chan *batchedNotification
pendingNotifications map[string][]*batchedNotification
task *model.ScheduledTask
taskMutex sync.Mutex
}
func NewEmailBatchingJob(es *Service, bufferSize int) *EmailBatchingJob {
return &EmailBatchingJob{
config: es.config,
service: es,
newNotifications: make(chan *batchedNotification, bufferSize),
pendingNotifications: make(map[string][]*batchedNotification),
}
}
func (job *EmailBatchingJob) Start() {
mlog.Debug("Email batching job starting. Checking for pending emails periodically.", mlog.Int("interval_in_seconds", *job.config().EmailSettings.EmailBatchingInterval))
newTask := model.CreateRecurringTask(EmailBatchingTaskName, job.CheckPendingEmails, time.Duration(*job.config().EmailSettings.EmailBatchingInterval)*time.Second)
job.taskMutex.Lock()
oldTask := job.task
job.task = newTask
job.taskMutex.Unlock()
if oldTask != nil {
oldTask.Cancel()
}
}
// Stop will cancel the task properly, flushing out any pending notifications.
// Although this still won't send those notifications which are yet to be sent
// due to a user's PreferenceNameEmailInterval.
func (job *EmailBatchingJob) Stop() {
job.taskMutex.Lock()
if task := job.task; task != nil {
task.Cancel()
}
job.taskMutex.Unlock()
}
func (job *EmailBatchingJob) Add(user *model.User, post *model.Post, team *model.Team) bool {
notification := &batchedNotification{
userID: user.Id,
post: post,
teamName: team.Name,
}
select {
case job.newNotifications <- notification:
return true
default:
// return false if we couldn't queue the email notification so that we can send an immediate email
return false
}
}
func (job *EmailBatchingJob) CheckPendingEmails() {
job.handleNewNotifications()
// it's a bit weird to pass the send email function through here, but it makes it so that we can test
// without actually sending emails
job.checkPendingNotifications(time.Now(), job.service.sendBatchedEmailNotification)
mlog.Debug("Email batching job ran. Notifications might be still pending.", mlog.Int("number_of_users", len(job.pendingNotifications)))
}
func (job *EmailBatchingJob) handleNewNotifications() {
receiving := true
// read in new notifications to send
for receiving {
select {
case notification := <-job.newNotifications:
userID := notification.userID
if _, ok := job.pendingNotifications[userID]; !ok {
job.pendingNotifications[userID] = []*batchedNotification{notification}
} else {
job.pendingNotifications[userID] = append(job.pendingNotifications[userID], notification)
}
default:
receiving = false
}
}
}
func (job *EmailBatchingJob) checkPendingNotifications(now time.Time, handler func(string, []*batchedNotification)) {
for userID, notifications := range job.pendingNotifications {
// Defensive code.
if len(notifications) == 0 {
mlog.Warn("Unexpected result. Got 0 pending notifications for batched email.", mlog.String("user_id", userID))
continue
}
// get how long we need to wait to send notifications to the user
var interval int64
preference, err := job.service.store.Preference().Get(userID, model.PreferenceCategoryNotifications, model.PreferenceNameEmailInterval)
if err != nil {
// use the default batching interval if an error occurs while fetching user preferences
interval, _ = strconv.ParseInt(model.PreferenceEmailIntervalBatchingSeconds, 10, 64)
} else {
if value, err := strconv.ParseInt(preference.Value, 10, 64); err != nil {
// // use the default batching interval if an error occurs while deserializing user preferences
interval, _ = strconv.ParseInt(model.PreferenceEmailIntervalBatchingSeconds, 10, 64)
} else {
interval = value
}
}
batchStartTime := notifications[0].post.CreateAt
// Ignore if it isn't time yet to send.
if now.Sub(time.UnixMilli(batchStartTime)) <= time.Duration(interval)*time.Second {
continue
}
// If the user has viewed any channels in this team since the notification was queued, delete
// all queued notifications
inspectedTeamNames := make(map[string]string)
for _, notification := range notifications {
// at most, we'll do one check for each team that notifications were sent for
if inspectedTeamNames[notification.teamName] != "" {
continue
}
team, nErr := job.service.store.Team().GetByName(notifications[0].teamName)
if nErr != nil {
mlog.Error("Unable to find Team id for notification", mlog.Err(nErr))
continue
}
if team != nil {
inspectedTeamNames[notification.teamName] = team.Id
}
channelMembers, err := job.service.store.Channel().GetMembersForUser(inspectedTeamNames[notification.teamName], userID)
if err != nil {
mlog.Error("Unable to find ChannelMembers for user", mlog.Err(err))
continue
}
deleted := false
for _, channelMember := range channelMembers {
if channelMember.LastViewedAt >= batchStartTime {
mlog.Debug("Deleted notifications for user", mlog.String("user_id", userID))
delete(job.pendingNotifications, userID)
deleted = true
break
}
}
if deleted {
break
}
}
// The notifications might have been cleared from the above step.
// We need to check again.
if len(job.pendingNotifications[userID]) == 0 {
continue
}
handler(userID, job.pendingNotifications[userID])
delete(job.pendingNotifications, userID)
}
}
/**
* If the name is longer than i characters, replace remaining characters with ...
*/
func truncateUserNames(name string, i int) string {
runes := []rune(name)
if len(runes) > i {
newString := string(runes[:i])
return newString + "..."
}
return name
}
func (es *Service) sendBatchedEmailNotification(userID string, notifications []*batchedNotification) {
user, err := es.userService.GetUser(request.EmptyContext(es.logger), userID)
if err != nil {
mlog.Warn("Unable to find recipient for batched email notification")
return
}
translateFunc := i18n.GetUserTranslations(user.Locale)
displayNameFormat := *es.config().TeamSettings.TeammateNameDisplay
siteURL := *es.config().ServiceSettings.SiteURL
postsData := make([]*postData, 0 /* len */, len(notifications) /* cap */)
embeddedFiles := make(map[string]io.Reader)
emailNotificationContentsType := model.EmailNotificationContentsFull
if license := es.license(); license != nil && *license.Features.EmailNotificationContents {
emailNotificationContentsType = *es.config().EmailSettings.EmailNotificationContentsType
}
// check if user has CRT set to ON
appCRT := *es.config().ServiceSettings.CollapsedThreads
threadsEnabled := appCRT == model.CollapsedThreadsAlwaysOn
if !threadsEnabled && appCRT != model.CollapsedThreadsDisabled {
threadsEnabled = appCRT == model.CollapsedThreadsDefaultOn
// check if a participant has overridden collapsed threads settings
if preference, errCrt := es.store.Preference().Get(userID, model.PreferenceCategoryDisplaySettings, model.PreferenceNameCollapsedThreadsEnabled); errCrt == nil {
threadsEnabled = preference.Value == "on"
}
}
var useMilitaryTime bool
if data, err := es.store.Preference().Get(user.Id, model.PreferenceCategoryDisplaySettings, model.PreferenceNameUseMilitaryTime); err != nil {
useMilitaryTime = false
} else {
useMilitaryTime = data.Value == "true"
}
if emailNotificationContentsType == model.EmailNotificationContentsFull {
for i, notification := range notifications {
sender, errSender := es.userService.GetUser(request.EmptyContext(es.logger), notification.post.UserId)
if errSender != nil {
mlog.Warn("Unable to find sender of post for batched email notification")
}
channel, errCh := es.store.Channel().Get(notification.post.ChannelId, true)
if errCh != nil {
mlog.Warn("Unable to find channel of post for batched email notification")
}
senderProfileImage, _, errProfileImage := es.userService.GetProfileImage(sender)
if errProfileImage != nil {
mlog.Warn("Unable to get the sender user profile image.", mlog.String("user_id", sender.Id), mlog.Err(errProfileImage))
}
senderPhoto := fmt.Sprintf("user-avatar-%d.png", i)
if senderProfileImage != nil {
embeddedFiles[senderPhoto] = bytes.NewReader(senderProfileImage)
}
formattedTime := utils.GetFormattedPostTime(user, notification.post, useMilitaryTime, translateFunc)
t := translateFunc("api.email_batching.send_batched_email_notification.time", formattedTime)
MessageURL := siteURL + "/" + notification.teamName + "/pl/" + notification.post.Id
channelDisplayName := channel.DisplayName
showChannelIcon := true
otherChannelMembersCount := 0
if threadsEnabled && notification.post.RootId != "" {
props := map[string]any{"channelName": channelDisplayName}
channelDisplayName = translateFunc("api.push_notification.title.collapsed_threads", props)
if channel.Type == model.ChannelTypeDirect {
channelDisplayName = translateFunc("api.push_notification.title.collapsed_threads_dm")
}
}
if channel.Type == model.ChannelTypeGroup {
otherChannelMembersCount = len(strings.Split(channelDisplayName, ",")) - 1
showChannelIcon = false
channelDisplayName = truncateUserNames(channel.DisplayName, 11)
}
postMessage := es.GetMessageForNotification(notification.post, notification.teamName, siteURL, translateFunc)
postsData = append(postsData, &postData{
SenderPhoto: senderPhoto,
SenderName: truncateUserNames(sender.GetDisplayName(displayNameFormat), 22),
Time: t,
ChannelName: channelDisplayName,
Message: template.HTML(postMessage),
MessageURL: MessageURL,
ShowChannelIcon: showChannelIcon,
OtherChannelMembersCount: otherChannelMembersCount,
MessageAttachments: ProcessMessageAttachments(notification.post, siteURL),
})
}
}
formattedTime := utils.GetFormattedPostTime(user, notifications[0].post, useMilitaryTime, translateFunc)
subject := translateFunc("api.email_batching.send_batched_email_notification.subject", len(notifications), map[string]any{
"SiteName": es.config().TeamSettings.SiteName,
"Year": formattedTime.Year,
"Month": formattedTime.Month,
"Day": formattedTime.Day,
})
data := es.NewEmailTemplateData(user.Locale)
data.Props["SiteURL"] = siteURL
data.Props["Title"] = translateFunc("api.email_batching.send_batched_email_notification.title", len(notifications)-1)
data.Props["SubTitle"] = translateFunc("api.email_batching.send_batched_email_notification.subTitle")
data.Props["Button"] = translateFunc("api.email_batching.send_batched_email_notification.button")
data.Props["ButtonURL"] = siteURL
data.Props["Posts"] = postsData
data.Props["MessageButton"] = translateFunc("api.email_batching.send_batched_email_notification.messageButton")
data.Props["NotificationFooterTitle"] = translateFunc("app.notification.footer.title")
data.Props["NotificationFooterInfoLogin"] = translateFunc("app.notification.footer.infoLogin")
data.Props["NotificationFooterInfo"] = translateFunc("app.notification.footer.info")
renderedPage, renderErr := es.templatesContainer.RenderToString("messages_notification", data)
if renderErr != nil {
mlog.Error("Unable to render email", mlog.Err(renderErr))
}
if nErr := es.SendMailWithEmbeddedFiles(user.Email, subject, renderedPage, embeddedFiles, "", "", "", "BatchedEmailNotification"); nErr != nil {
mlog.Warn("Unable to send batched email notification", mlog.String("email", user.Email), mlog.Err(nErr))
return
}
// Only track post deliveries if email notifications are configured to send the post contents in the email
if es.postDeliveryRecorder != nil && emailNotificationContentsType == model.EmailNotificationContentsFull {
for _, notification := range notifications {
es.postDeliveryRecorder(userID, notification.post)
}
}
}

View File

@@ -0,0 +1,285 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestHandleNewNotifications(t *testing.T) {
mainHelper.Parallel(t)
th := SetupWithStoreMock(t)
id1 := model.NewId()
id2 := model.NewId()
id3 := model.NewId()
// test queueing of received posts by user
job := NewEmailBatchingJob(th.service, 128)
job.handleNewNotifications()
require.Empty(t, job.pendingNotifications, "shouldn't have added any pending notifications")
job.Add(&model.User{Id: id1}, &model.Post{UserId: id1, Message: "test"}, &model.Team{Name: "team"})
require.Empty(t, job.pendingNotifications, "shouldn't have added any pending notifications")
job.handleNewNotifications()
require.Len(t, job.pendingNotifications, 1, "should have received posts for 1 user")
require.Len(t, job.pendingNotifications[id1], 1, "should have received 1 post for user")
job.Add(&model.User{Id: id1}, &model.Post{UserId: id1, Message: "test"}, &model.Team{Name: "team"})
job.handleNewNotifications()
require.Len(t, job.pendingNotifications, 1, "should have received posts for 1 user")
require.Len(t, job.pendingNotifications[id1], 2, "should have received 2 posts for user1")
job.Add(&model.User{Id: id2}, &model.Post{UserId: id1, Message: "test"}, &model.Team{Name: "team"})
job.handleNewNotifications()
require.Len(t, job.pendingNotifications, 2, "should have received posts for 2 users")
require.Len(t, job.pendingNotifications[id1], 2, "should have received 2 posts for user1")
require.Len(t, job.pendingNotifications[id2], 1, "should have received 1 post for user2")
job.Add(&model.User{Id: id2}, &model.Post{UserId: id2, Message: "test"}, &model.Team{Name: "team"})
job.Add(&model.User{Id: id1}, &model.Post{UserId: id3, Message: "test"}, &model.Team{Name: "team"})
job.Add(&model.User{Id: id3}, &model.Post{UserId: id3, Message: "test"}, &model.Team{Name: "team"})
job.Add(&model.User{Id: id2}, &model.Post{UserId: id2, Message: "test"}, &model.Team{Name: "team"})
job.handleNewNotifications()
require.Len(t, job.pendingNotifications, 3, "should have received posts for 3 users")
require.Len(t, job.pendingNotifications[id1], 3, "should have received 3 posts for user1")
require.Len(t, job.pendingNotifications[id2], 3, "should have received 3 posts for user2")
require.Len(t, job.pendingNotifications[id3], 1, "should have received 1 post for user3")
// test ordering of received posts
job = NewEmailBatchingJob(th.service, 128)
job.Add(&model.User{Id: id1}, &model.Post{UserId: id1, Message: "test1"}, &model.Team{Name: "team"})
job.Add(&model.User{Id: id1}, &model.Post{UserId: id1, Message: "test2"}, &model.Team{Name: "team"})
job.Add(&model.User{Id: id2}, &model.Post{UserId: id1, Message: "test3"}, &model.Team{Name: "team"})
job.Add(&model.User{Id: id1}, &model.Post{UserId: id1, Message: "test4"}, &model.Team{Name: "team"})
job.Add(&model.User{Id: id2}, &model.Post{UserId: id1, Message: "test5"}, &model.Team{Name: "team"})
job.handleNewNotifications()
assert.Equal(t, job.pendingNotifications[id1][0].post.Message, "test1", "incorrect order of received posts for user1")
assert.Equal(t, job.pendingNotifications[id1][1].post.Message, "test2", "incorrect order of received posts for user1")
assert.Equal(t, job.pendingNotifications[id1][2].post.Message, "test4", "incorrect order of received posts for user1")
assert.Equal(t, job.pendingNotifications[id2][0].post.Message, "test3", "incorrect order of received posts for user2")
assert.Equal(t, job.pendingNotifications[id2][1].post.Message, "test5", "incorrect order of received posts for user2")
}
func TestCheckPendingNotifications(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
job := NewEmailBatchingJob(th.service, 128)
job.pendingNotifications[th.BasicUser.Id] = []*batchedNotification{
{
post: &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
CreateAt: 10000000,
},
teamName: th.BasicTeam.Name,
},
}
channelMember, err := th.store.Channel().GetMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id)
require.NoError(t, err)
channelMember.LastViewedAt = 9999999
_, err = th.store.Channel().UpdateMember(th.Context, channelMember)
require.NoError(t, err)
nErr := th.store.Preference().Save(model.Preferences{{
UserId: th.BasicUser.Id,
Category: model.PreferenceCategoryNotifications,
Name: model.PreferenceNameEmailInterval,
Value: "60",
}})
require.NoError(t, nErr)
// test that notifications aren't sent before interval
job.checkPendingNotifications(time.Unix(10001, 0), func(string, []*batchedNotification) {})
require.NotNil(t, job.pendingNotifications[th.BasicUser.Id])
require.Len(t, job.pendingNotifications[th.BasicUser.Id], 1, "shouldn't have sent queued post")
// test that notifications are cleared if the user has acted
channelMember, err = th.store.Channel().GetMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id)
require.NoError(t, err)
channelMember.LastViewedAt = 10001000
_, err = th.store.Channel().UpdateMember(th.Context, channelMember)
require.NoError(t, err)
// We reset the interval to something shorter
nErr = th.store.Preference().Save(model.Preferences{{
UserId: th.BasicUser.Id,
Category: model.PreferenceCategoryNotifications,
Name: model.PreferenceNameEmailInterval,
Value: "10",
}})
require.NoError(t, nErr)
var wasCalled atomic.Int32
job.checkPendingNotifications(time.Unix(10050, 0), func(string, []*batchedNotification) {
wasCalled.Store(int32(1))
})
// A hack to check whether the handler was called.
// It's not straightforward to just wait for it using a channel because the test should
// NOT call the handler, and it will be called only if the test fails.
time.Sleep(1 * time.Second)
// We do a check outside the email handler, because otherwise, failing from
// inside the handler doesn't let the .Go() function exit cleanly, and it gets
// stuck during server shutdown, trying to wait for the goroutine to exit
require.Equal(t, int32(0), wasCalled.Load(), "email handler should not have been called")
require.Nil(t, job.pendingNotifications[th.BasicUser.Id])
require.Empty(t, job.pendingNotifications[th.BasicUser.Id], "should've remove queued post since user acted")
// test that notifications are sent if enough time passes since the first message
job.pendingNotifications[th.BasicUser.Id] = []*batchedNotification{
{
post: &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
CreateAt: 10060000,
Message: "post1",
},
teamName: th.BasicTeam.Name,
},
{
post: &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
CreateAt: 10090000,
Message: "post2",
},
teamName: th.BasicTeam.Name,
},
}
received := make(chan *model.Post, 2)
job.checkPendingNotifications(time.Unix(10130, 0), func(s string, notifications []*batchedNotification) {
for _, notification := range notifications {
received <- notification.post
}
})
require.Nil(t, job.pendingNotifications[th.BasicUser.Id], "shouldn't have sent queued post")
select {
case post := <-received:
require.Equal(t, post.Message, "post1", "should've received post1 first")
case <-time.After(5 * time.Second):
require.Fail(t, "timed out waiting for first post notification")
}
select {
case post := <-received:
require.Equal(t, post.Message, "post2", "should've received post2 second")
case <-time.After(5 * time.Second):
require.Fail(t, "timed out waiting for second post notification")
}
}
/**
* Ensures that email batch interval defaults to 15 minutes for users that haven't explicitly set this preference
*/
func TestCheckPendingNotificationsDefaultInterval(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
job := NewEmailBatchingJob(th.service, 128)
// bypasses recent user activity check
require.NotNil(t, th.store)
require.NotNil(t, th.store.Channel())
require.NotNil(t, th.BasicUser)
require.NotNil(t, th.BasicChannel)
channelMember, err := th.store.Channel().GetMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id)
require.NoError(t, err)
channelMember.LastViewedAt = 9999000
_, err = th.store.Channel().UpdateMember(th.Context, channelMember)
require.NoError(t, err)
job.pendingNotifications[th.BasicUser.Id] = []*batchedNotification{
{
post: &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
CreateAt: 10000000,
},
teamName: th.BasicTeam.Name,
},
}
// notifications should not be sent 1s after post was created, because default batch interval is 15mins
job.checkPendingNotifications(time.Unix(10001, 0), func(string, []*batchedNotification) {})
require.NotNil(t, job.pendingNotifications[th.BasicUser.Id])
require.Len(t, job.pendingNotifications[th.BasicUser.Id], 1, "shouldn't have sent queued post")
// notifications should be sent 901s after post was created, because default batch interval is 15mins
job.checkPendingNotifications(time.Unix(10901, 0), func(string, []*batchedNotification) {})
require.Nil(t, job.pendingNotifications[th.BasicUser.Id])
require.Empty(t, job.pendingNotifications[th.BasicUser.Id], "should have sent queued post")
}
/**
* Ensures that email batch interval defaults to 15 minutes if user preference is invalid
*/
func TestCheckPendingNotificationsCantParseInterval(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
job := NewEmailBatchingJob(th.service, 128)
require.NotNil(t, th.store)
require.NotNil(t, th.store.Channel())
require.NotNil(t, th.BasicChannel)
require.NotNil(t, th.BasicUser)
// bypasses recent user activity check
channelMember, err := th.store.Channel().GetMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id)
require.NoError(t, err)
channelMember.LastViewedAt = 9999000
_, err = th.store.Channel().UpdateMember(th.Context, channelMember)
require.NoError(t, err)
// preference value is not an integer, so we'll fall back to the default 15min value
nErr := th.store.Preference().Save(model.Preferences{{
UserId: th.BasicUser.Id,
Category: model.PreferenceCategoryNotifications,
Name: model.PreferenceNameEmailInterval,
Value: "notAnIntegerValue",
}})
require.NoError(t, nErr)
job.pendingNotifications[th.BasicUser.Id] = []*batchedNotification{
{
post: &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
CreateAt: 10000000,
},
teamName: th.BasicTeam.Name,
},
}
// notifications should not be sent 1s after post was created, because default batch interval is 15mins
job.checkPendingNotifications(time.Unix(10001, 0), func(string, []*batchedNotification) {})
require.NotNil(t, job.pendingNotifications[th.BasicUser.Id])
require.Len(t, job.pendingNotifications[th.BasicUser.Id], 1, "shouldn't have sent queued post")
// notifications should be sent 901s after post was created, because default batch interval is 15mins
job.checkPendingNotifications(time.Unix(10901, 0), func(string, []*batchedNotification) {})
require.Nil(t, job.pendingNotifications[th.BasicUser.Id], "should have sent queued post")
}

View File

@@ -0,0 +1,486 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"html"
"net/url"
"regexp"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/platform/shared/mail"
)
func TestCondenseSiteURL(t *testing.T) {
mainHelper.Parallel(t)
require.Equal(t, "", condenseSiteURL(""))
require.Equal(t, "mattermost.com", condenseSiteURL("mattermost.com"))
require.Equal(t, "mattermost.com", condenseSiteURL("mattermost.com/"))
require.Equal(t, "chat.mattermost.com", condenseSiteURL("chat.mattermost.com"))
require.Equal(t, "chat.mattermost.com", condenseSiteURL("chat.mattermost.com/"))
require.Equal(t, "mattermost.com/subpath", condenseSiteURL("mattermost.com/subpath"))
require.Equal(t, "mattermost.com/subpath", condenseSiteURL("mattermost.com/subpath/"))
require.Equal(t, "chat.mattermost.com/subpath", condenseSiteURL("chat.mattermost.com/subpath"))
require.Equal(t, "chat.mattermost.com/subpath", condenseSiteURL("chat.mattermost.com/subpath/"))
require.Equal(t, "mattermost.com:8080", condenseSiteURL("http://mattermost.com:8080"))
require.Equal(t, "mattermost.com:8080", condenseSiteURL("http://mattermost.com:8080/"))
require.Equal(t, "chat.mattermost.com:8080", condenseSiteURL("http://chat.mattermost.com:8080"))
require.Equal(t, "chat.mattermost.com:8080", condenseSiteURL("http://chat.mattermost.com:8080/"))
require.Equal(t, "mattermost.com:8080/subpath", condenseSiteURL("http://mattermost.com:8080/subpath"))
require.Equal(t, "mattermost.com:8080/subpath", condenseSiteURL("http://mattermost.com:8080/subpath/"))
require.Equal(t, "chat.mattermost.com:8080/subpath", condenseSiteURL("http://chat.mattermost.com:8080/subpath"))
require.Equal(t, "chat.mattermost.com:8080/subpath", condenseSiteURL("http://chat.mattermost.com:8080/subpath/"))
}
func TestGetLicenseSkuName(t *testing.T) {
tests := []struct {
name string
license *model.License
expectedSku string
expectedPrefixed string
}{
{
name: "nil license",
license: nil,
expectedSku: "Mattermost",
expectedPrefixed: "Mattermost",
},
{
name: "empty sku name",
license: &model.License{SkuName: ""},
expectedSku: "Mattermost",
expectedPrefixed: "Mattermost",
},
{
name: "Professional",
license: &model.License{SkuName: "Professional"},
expectedSku: "Professional",
expectedPrefixed: "Mattermost Professional",
},
{
name: "Enterprise",
license: &model.License{SkuName: "Enterprise"},
expectedSku: "Enterprise",
expectedPrefixed: "Mattermost Enterprise",
},
{
name: "Enterprise Advanced",
license: &model.License{SkuName: "Enterprise Advanced"},
expectedSku: "Enterprise Advanced",
expectedPrefixed: "Mattermost Enterprise Advanced",
},
{
name: "Entry",
license: &model.License{SkuName: "Entry"},
expectedSku: "Entry",
expectedPrefixed: "Mattermost Entry",
},
{
name: "Mattermost Entry (prefixed by license server)",
license: &model.License{SkuName: "Mattermost Entry"},
expectedSku: "Entry",
expectedPrefixed: "Mattermost Entry",
},
{
name: "E10",
license: &model.License{SkuName: "E10"},
expectedSku: "E10",
expectedPrefixed: "Mattermost E10",
},
{
name: "E20",
license: &model.License{SkuName: "E20"},
expectedSku: "E20",
expectedPrefixed: "Mattermost E20",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
es := &Service{
license: func() *model.License { return tt.license },
}
require.Equal(t, tt.expectedSku, es.getLicenseSkuName())
require.Equal(t, tt.expectedPrefixed, es.getPrefixedLicenseSkuName())
})
}
}
func TestSendInviteEmails(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.ConfigureInbucketMail(t)
emailTo := strings.ToLower(model.NewId()) + "@example.com"
err := mail.DeleteMailBox(emailTo)
require.NoError(t, err, "Failed to delete mailbox")
newInviteData := func() InviteEmailData {
return InviteEmailData{
Team: th.BasicTeam,
SenderName: "test-user",
SenderUserID: th.BasicUser.Id,
Invites: []string{emailTo},
SiteURL: "http://testserver",
}
}
retrieveEmail := func(t *testing.T) mail.JSONMessageInbucket {
t.Helper()
var resultsMailbox mail.JSONMessageHeaderInbucket
err2 := mail.RetryInbucket(5, func() error {
var err error
resultsMailbox, err = mail.GetMailBox(emailTo)
return err
})
if err2 != nil {
t.Skipf("No email was received, maybe due load on the server: %v", err2)
}
require.Len(t, resultsMailbox, 1)
require.Contains(t, resultsMailbox[0].To[0], emailTo, "Wrong To: recipient")
resultsEmail, err := mail.GetMessageFromMailbox(emailTo, resultsMailbox[0].ID)
require.NoError(t, err, "Could not get message from mailbox")
return resultsEmail
}
verifyMailbox := func(t *testing.T) {
t.Helper()
email := retrieveEmail(t)
require.Contains(t, email.Body.HTML, "http://testserver", "Wrong received message %s", email.Body.Text)
require.Contains(t, email.Body.HTML, "test-user", "Wrong received message %s", email.Body.Text)
require.Contains(t, email.Body.Text, "http://testserver", "Wrong received message %s", email.Body.Text)
require.Contains(t, email.Body.Text, "test-user", "Wrong received message %s", email.Body.Text)
}
th.UpdateConfig(t, func(cfg *model.Config) {
*cfg.ServiceSettings.EnableEmailInvitations = true
*cfg.EmailSettings.SendEmailNotifications = false
})
t.Run("SendInviteEmails", func(t *testing.T) {
err := mail.DeleteMailBox(emailTo)
require.NoError(t, err, "Failed to delete mailbox")
err = th.service.SendInviteEmails(th.Context, newInviteData())
require.NoError(t, err)
verifyMailbox(t)
})
t.Run("SendInviteEmails can return error when SMTP connection fails", func(t *testing.T) {
originalPort := *th.service.config().EmailSettings.SMTPPort
originalTimeout := *th.service.config().EmailSettings.SMTPServerTimeout
th.UpdateConfig(t, func(cfg *model.Config) {
*cfg.EmailSettings.SMTPPort = "5432"
*cfg.EmailSettings.SMTPServerTimeout = 4
})
defer th.UpdateConfig(t, func(cfg *model.Config) {
*cfg.EmailSettings.SMTPPort = originalPort
*cfg.EmailSettings.SMTPServerTimeout = originalTimeout
})
inviteData := newInviteData()
inviteData.ErrorWhenNotSent = true
err := th.service.SendInviteEmails(th.Context, inviteData)
require.Error(t, err)
err = th.service.SendInviteEmails(th.Context, newInviteData())
require.NoError(t, err)
})
t.Run("SendGuestInviteEmails", func(t *testing.T) {
err := mail.DeleteMailBox(emailTo)
require.NoError(t, err, "Failed to delete mailbox")
err = th.service.SendGuestInviteEmails(
th.Context,
th.BasicTeam,
[]*model.Channel{th.BasicChannel},
"test-user",
th.BasicUser.Id,
nil,
[]string{emailTo},
"http://testserver",
"hello world",
false,
false,
false,
false,
)
require.NoError(t, err)
verifyMailbox(t)
})
t.Run("SendGuestInviteEmail can return error when SMTP connection fails", func(t *testing.T) {
originalTimeout := *th.service.config().EmailSettings.SMTPServerTimeout
originalPort := *th.service.config().EmailSettings.SMTPPort
th.UpdateConfig(t, func(cfg *model.Config) {
*cfg.EmailSettings.SMTPPort = "5432"
*cfg.EmailSettings.SMTPServerTimeout = 4
})
defer th.UpdateConfig(t, func(cfg *model.Config) {
*cfg.EmailSettings.SMTPPort = originalPort
*cfg.EmailSettings.SMTPServerTimeout = originalTimeout
})
err := th.service.SendGuestInviteEmails(
th.Context,
th.BasicTeam,
[]*model.Channel{th.BasicChannel},
"test-user",
th.BasicUser.Id,
nil,
[]string{emailTo},
"http://testserver",
"hello world",
false,
false,
false,
false,
)
require.NoError(t, err)
err = th.service.SendGuestInviteEmails(
th.Context,
th.BasicTeam,
[]*model.Channel{th.BasicChannel},
"test-user",
th.BasicUser.Id,
nil,
[]string{emailTo},
"http://testserver",
"hello world",
true,
false,
false,
false,
)
require.Error(t, err)
})
t.Run("SendGuestInviteEmails should sanitize HTML input", func(t *testing.T) {
err := mail.DeleteMailBox(emailTo)
require.NoError(t, err, "Failed to delete mailbox")
message := `<a href="http://testserver">sanitized message</a>`
err = th.service.SendGuestInviteEmails(
th.Context,
th.BasicTeam,
[]*model.Channel{th.BasicChannel},
"test-user",
th.BasicUser.Id,
nil,
[]string{emailTo},
"http://testserver",
message,
false,
false,
false,
false,
)
require.NoError(t, err)
email := retrieveEmail(t)
require.NotContains(t, email.Body.HTML, message)
require.Contains(t, email.Body.HTML, "sanitized message")
require.Contains(t, email.Body.Text, "sanitized message")
})
t.Run("SendInviteEmails should contain button URL with 'started by role' param for system user", func(t *testing.T) {
err := mail.DeleteMailBox(emailTo)
require.NoError(t, err, "Failed to delete mailbox")
err = th.service.SendInviteEmails(th.Context, newInviteData())
require.NoError(t, err)
email := retrieveEmail(t)
require.Contains(t, email.Body.HTML, "&amp;sbr=su")
})
t.Run("SendInviteEmails should contain button URL with 'started by role' param for system admin", func(t *testing.T) {
err := mail.DeleteMailBox(emailTo)
require.NoError(t, err, "Failed to delete mailbox")
inviteData := newInviteData()
inviteData.IsSystemAdmin = true
err = th.service.SendInviteEmails(th.Context, inviteData)
require.NoError(t, err)
email := retrieveEmail(t)
require.Contains(t, email.Body.HTML, "&amp;sbr=sa")
})
t.Run("SendInviteEmails should contain button URL with 'started by role' param for first system admin", func(t *testing.T) {
err := mail.DeleteMailBox(emailTo)
require.NoError(t, err, "Failed to delete mailbox")
inviteData := newInviteData()
inviteData.IsSystemAdmin = true
inviteData.IsFirstAdmin = true
err = th.service.SendInviteEmails(th.Context, inviteData)
require.NoError(t, err)
email := retrieveEmail(t)
require.Contains(t, email.Body.HTML, "&amp;sbr=fa")
})
t.Run("SendInviteEmails with profiles should put profile fields into token extra and link data", func(t *testing.T) {
err := mail.DeleteMailBox(emailTo)
require.NoError(t, err, "Failed to delete mailbox")
profiles := map[string]*model.MemberInviteProfile{
emailTo: {
Email: emailTo,
Username: "dave.roberts",
FirstName: "Dave",
LastName: "Roberts",
},
}
inviteData := newInviteData()
inviteData.Profiles = profiles
err = th.service.SendInviteEmails(th.Context, inviteData)
require.NoError(t, err)
email := retrieveEmail(t)
token := findTokenFromEmail(t, th, email.Body.HTML)
tokenData := model.MapFromJSON(strings.NewReader(token.Extra))
require.Equal(t, emailTo, tokenData["email"])
require.Equal(t, "dave.roberts", tokenData["username"])
require.Equal(t, "Dave", tokenData["first_name"])
require.Equal(t, "Roberts", tokenData["last_name"])
linkData := findLinkDataFromEmail(t, email.Body.HTML)
require.Equal(t, "dave.roberts", linkData["username"])
require.Equal(t, "Dave", linkData["first_name"])
require.Equal(t, "Roberts", linkData["last_name"])
})
}
// findSignupQueryFromEmail extracts the signup_user_complete query parameters from an invite email body.
func findSignupQueryFromEmail(t *testing.T, emailHTML string) url.Values {
t.Helper()
re := regexp.MustCompile(`signup_user_complete/\?([^"]*)`)
matches := re.FindStringSubmatch(html.UnescapeString(emailHTML))
require.Len(t, matches, 2, "invite email should contain a signup link")
queryString, err := url.ParseQuery(matches[1])
require.NoError(t, err)
return queryString
}
// findTokenFromEmail loads the invitation token referenced by an invite email body.
func findTokenFromEmail(t *testing.T, th *TestHelper, emailHTML string) *model.Token {
t.Helper()
queryString := findSignupQueryFromEmail(t, emailHTML)
token, err := th.service.store.Token().GetByToken(queryString.Get("t"))
require.NoError(t, err)
return token
}
// findLinkDataFromEmail parses the d prefill param of the signup link in an invite email body.
func findLinkDataFromEmail(t *testing.T, emailHTML string) map[string]string {
t.Helper()
queryString := findSignupQueryFromEmail(t, emailHTML)
return model.MapFromJSON(strings.NewReader(queryString.Get("d")))
}
func TestSendCloudWelcomeEmail(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.ConfigureInbucketMail(t)
emailTo := "testclouduser@example.com"
t.Run("TestSendCloudWelcomeEmail", func(t *testing.T) {
verifyMailbox := func(t *testing.T) {
t.Helper()
var resultsMailbox mail.JSONMessageHeaderInbucket
err2 := mail.RetryInbucket(5, func() error {
var err error
resultsMailbox, err = mail.GetMailBox(emailTo)
return err
})
if err2 != nil {
t.Skipf("No email was received, maybe due load on the server: %v", err2)
}
require.Len(t, resultsMailbox, 1)
require.Contains(t, resultsMailbox[0].To[0], emailTo, "Wrong To: recipient")
resultsEmail, err := mail.GetMessageFromMailbox(emailTo, resultsMailbox[0].ID)
require.NoError(t, err, "Could not get message from mailbox")
require.Contains(t, resultsEmail.Subject, "Congratulations!", "Wrong subject message %s", resultsEmail.Subject)
require.Contains(t, resultsEmail.Body.Text, "Your workspace is ready to go!", "Wrong body %s", resultsEmail.Body.Text)
}
err := mail.DeleteMailBox(emailTo)
require.NoError(t, err, "Failed to delete mailbox")
err = th.service.SendCloudWelcomeEmail(emailTo, th.BasicUser.Locale, "inviteID", "SomeName", "example.com", "https://example.com")
require.NoError(t, err)
verifyMailbox(t)
})
}
func TestMailServiceConfig(t *testing.T) {
mainHelper.Parallel(t)
configuredReplyTo := "feedbackexample@test.com"
customReplyTo := "customreplyto@test.com"
emailService := Service{
config: func() *model.Config {
return &model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: new(""),
},
EmailSettings: model.EmailSettings{
EnableSignUpWithEmail: new(bool),
EnableSignInWithEmail: new(bool),
EnableSignInWithUsername: new(bool),
SendEmailNotifications: new(bool),
UseChannelInEmailNotifications: new(bool),
RequireEmailVerification: new(bool),
FeedbackName: new(string),
FeedbackEmail: new(string),
ReplyToAddress: new(configuredReplyTo),
FeedbackOrganization: new(string),
EnableSMTPAuth: new(bool),
SMTPUsername: new(string),
SMTPPassword: new(string),
SMTPServer: new(string),
SMTPPort: new(string),
SMTPServerTimeout: new(int),
ConnectionSecurity: new(string),
SendPushNotifications: new(bool),
PushNotificationServer: new(string),
PushNotificationContents: new(string),
PushNotificationBuffer: new(int),
EnableEmailBatching: new(bool),
EmailBatchingBufferSize: new(int),
EmailBatchingInterval: new(int),
EnablePreviewModeBanner: new(bool),
SkipServerCertificateVerification: new(bool),
EmailNotificationContentsType: new(string),
},
}
},
}
t.Run("use custom replyto instead of configured replyto", func(t *testing.T) {
mailConfig := emailService.mailServiceConfig(customReplyTo)
require.Equal(t, customReplyTo, mailConfig.ReplyToAddress)
})
t.Run("use configured replyto", func(t *testing.T) {
mailConfig := emailService.mailServiceConfig("")
require.Equal(t, configuredReplyTo, mailConfig.ReplyToAddress)
})
}

View File

@@ -0,0 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import "github.com/pkg/errors"
var (
CreateEmailTokenError = errors.New("could not create token")
SaveTokenError = errors.New("could not save token")
NoRateLimiterError = errors.New("the rate limit could not be found")
SetupRateLimiterError = errors.New("the rate limiter could not be set")
RateLimitExceededError = errors.New("the rate limit is exceeded")
SendMailError = errors.New("could not send the email")
)

View File

@@ -0,0 +1,286 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"os"
"path/filepath"
"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/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app/users"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
"github.com/mattermost/mattermost/server/v8/config"
"github.com/mattermost/mattermost/server/v8/platform/shared/templates"
"github.com/stretchr/testify/require"
)
type TestHelper struct {
service *Service
configStore *config.Store
store store.Store
workspace string
BasicTeam *model.Team
BasicChannel *model.Channel
BasicUser *model.User
BasicUser2 *model.User
SystemAdminUser *model.User
Context request.CTX
}
func Setup(tb testing.TB) *TestHelper {
if testing.Short() {
tb.SkipNow()
}
var dbStore store.Store
if mainHelper.Options.RunParallel {
dbStore, _, _, _ = mainHelper.GetNewStores(tb)
tb.Cleanup(func() {
dbStore.Close()
})
} else {
dbStore = mainHelper.GetStore()
dbStore.DropAllTables()
dbStore.MarkSystemRanUnitTests()
mainHelper.PreloadMigrations()
}
return setupTestHelper(dbStore, tb)
}
func SetupWithStoreMock(tb testing.TB) *TestHelper {
mockStore := testlib.GetMockStoreForSetupFunctions()
th := setupTestHelper(mockStore, tb)
statusMock := mocks.StatusStore{}
statusMock.On("UpdateExpiredDNDStatuses").Return([]*model.Status{}, nil)
statusMock.On("Get", "user1").Return(&model.Status{UserId: "user1", Status: model.StatusOnline}, nil)
statusMock.On("UpdateLastActivityAt", "user1", mock.Anything).Return(nil)
statusMock.On("SaveOrUpdate", mock.AnythingOfType("*model.Status")).Return(nil)
emptyMockStore := mocks.Store{}
emptyMockStore.On("Close").Return(nil)
emptyMockStore.On("Status").Return(&statusMock)
th.service.store = &emptyMockStore
return th
}
func setupTestHelper(s store.Store, tb testing.TB) *TestHelper {
tempWorkspace, err := os.MkdirTemp("", "userservicetest")
require.NoError(tb, err)
configStore := config.NewTestMemoryStore()
config := configStore.Get()
*config.PluginSettings.Directory = filepath.Join(tempWorkspace, "plugins")
*config.PluginSettings.ClientDirectory = filepath.Join(tempWorkspace, "webapp")
*config.PluginSettings.AutomaticPrepackagedPlugins = false
*config.LogSettings.EnableSentry = false // disable error reporting during tests
*config.AnnouncementSettings.AdminNoticesEnabled = false
*config.AnnouncementSettings.UserNoticesEnabled = false
*config.TeamSettings.MaxUsersPerTeam = 50
*config.RateLimitSettings.Enable = false
*config.TeamSettings.EnableOpenServer = true
_, _, err = configStore.Set(config)
require.NoError(tb, err)
licenseFn := func() *model.License { return model.NewTestLicense() }
us, err := users.New(users.ServiceConfig{
UserStore: s.User(),
SessionStore: s.Session(),
OAuthStore: s.OAuth(),
ConfigFn: configStore.Get,
LicenseFn: licenseFn,
})
require.NoError(tb, err)
templatesDir, ok := templates.GetTemplateDirectory()
require.True(tb, ok)
htmlTemplates, err := templates.New(templatesDir)
require.NoError(tb, err)
logger := mlog.CreateConsoleTestLogger(tb)
service := &Service{
store: s,
userService: us,
license: licenseFn,
config: configStore.Get,
logger: logger,
templatesContainer: htmlTemplates,
}
err = service.setUpRateLimiters()
require.NoError(tb, err)
tb.Cleanup(func() {
err := configStore.Close()
require.NoError(tb, err)
s.Close()
if tempWorkspace != "" {
os.RemoveAll(tempWorkspace)
}
})
return &TestHelper{
service: service,
configStore: configStore,
store: s,
workspace: tempWorkspace,
Context: request.TestContext(tb),
}
}
func (th *TestHelper) InitBasic(tb testing.TB) *TestHelper {
var err error
th.BasicTeam = th.CreateTeam(tb)
th.SystemAdminUser = th.CreateUser(tb)
th.SystemAdminUser, err = th.service.userService.GetUser(th.Context, th.SystemAdminUser.Id)
require.NoError(tb, err)
th.addUserToTeam(tb, th.BasicTeam, th.SystemAdminUser)
th.BasicUser = th.CreateUser(tb)
th.BasicUser, err = th.service.userService.GetUser(th.Context, th.BasicUser.Id)
require.NoError(tb, err)
th.addUserToTeam(tb, th.BasicTeam, th.BasicUser)
th.BasicUser2 = th.CreateUser(tb)
th.BasicUser2, err = th.service.userService.GetUser(th.Context, th.BasicUser2.Id)
require.NoError(tb, err)
th.addUserToTeam(tb, th.BasicTeam, th.BasicUser2)
th.BasicChannel = th.createChannel(tb, th.BasicTeam, string(model.ChannelTypeOpen))
th.addUserToChannel(tb, th.BasicChannel, th.SystemAdminUser)
th.addUserToChannel(tb, th.BasicChannel, th.BasicUser)
th.addUserToChannel(tb, th.BasicChannel, th.BasicUser2)
return th
}
func (th *TestHelper) CreateTeam(tb testing.TB) *model.Team {
id := model.NewId()
team := &model.Team{
DisplayName: "dn_" + id,
Name: "name" + id,
Email: "success+" + id + "@simulator.amazonses.com",
Type: model.TeamOpen,
}
team, err := th.store.Team().Save(team)
require.NoError(tb, err)
return team
}
func (th *TestHelper) createChannel(tb testing.TB, team *model.Team, channelType string) *model.Channel {
id := model.NewId()
channel := &model.Channel{
DisplayName: "dn_" + id,
Name: "name_" + id,
Type: model.ChannelType(channelType),
TeamId: team.Id,
CreatorId: th.BasicUser.Id,
}
channel, err := th.store.Channel().Save(th.Context, channel, *th.configStore.Get().TeamSettings.MaxChannelsPerTeam)
require.NoError(tb, err)
return channel
}
func (th *TestHelper) addUserToChannel(tb testing.TB, channel *model.Channel, user *model.User) *model.ChannelMember {
newMember := &model.ChannelMember{
ChannelId: channel.Id,
UserId: user.Id,
NotifyProps: model.GetDefaultChannelNotifyProps(),
SchemeGuest: user.IsGuest(),
SchemeUser: !user.IsGuest(),
}
newMember, err := th.store.Channel().SaveMember(th.Context, newMember)
require.NoError(tb, err)
return newMember
}
func (th *TestHelper) addUserToTeam(tb testing.TB, team *model.Team, user *model.User) *model.TeamMember {
tm := &model.TeamMember{
TeamId: team.Id,
UserId: user.Id,
SchemeGuest: user.IsGuest(),
SchemeUser: !user.IsGuest(),
}
tm, err := th.store.Team().SaveMember(th.Context, tm, *th.service.config().TeamSettings.MaxUsersPerTeam)
require.NoError(tb, err)
return tm
}
func (th *TestHelper) CreateUser(tb testing.TB) *model.User {
return th.CreateUserOrGuest(tb, false)
}
func (th *TestHelper) CreateGuest(tb testing.TB) *model.User {
return th.CreateUserOrGuest(tb, true)
}
func (th *TestHelper) CreateUserOrGuest(tb testing.TB, guest bool) *model.User {
id := model.NewId()
user := &model.User{
Email: "success+" + id + "@simulator.amazonses.com",
Username: "un_" + id,
Nickname: "nn_" + id,
Password: model.NewTestPassword(),
EmailVerified: true,
}
var err error
if guest {
user, err = th.service.userService.CreateUser(th.Context, user, users.UserCreateOptions{Guest: true})
require.NoError(tb, err)
} else {
user, err = th.service.userService.CreateUser(th.Context, user, users.UserCreateOptions{})
require.NoError(tb, err)
}
return user
}
func (th *TestHelper) UpdateConfig(tb testing.TB, f func(*model.Config)) {
if th.configStore.IsReadOnly() {
return
}
old := th.configStore.Get()
updated := old.Clone()
f(updated)
_, _, err := th.configStore.Set(updated)
require.NoError(tb, err)
}
func (th *TestHelper) ConfigureInbucketMail(tb testing.TB) {
inbucket_host := os.Getenv("CI_INBUCKET_HOST")
if inbucket_host == "" {
inbucket_host = "localhost"
}
inbucket_port := os.Getenv("CI_INBUCKET_SMTP_PORT")
if inbucket_port == "" {
inbucket_port = "10025"
}
th.UpdateConfig(tb, func(cfg *model.Config) {
*cfg.EmailSettings.SMTPServer = inbucket_host
*cfg.EmailSettings.SMTPPort = inbucket_port
})
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"flag"
"os"
"strconv"
"testing"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
)
var (
mainHelper *testlib.MainHelper
)
func TestMain(m *testing.M) {
var parallelism int
if f := flag.Lookup("test.parallel"); f != nil {
parallelism, _ = strconv.Atoi(f.Value.String())
}
runParallel := os.Getenv("ENABLE_FULLY_PARALLEL_TESTS") == "true" && parallelism > 1
if runParallel {
mlog.Info("Fully parallel tests enabled", mlog.Int("parallelism", parallelism))
}
options := testlib.HelperOptions{
EnableStore: true,
EnableResources: true,
RunParallel: runParallel,
Parallelism: parallelism,
}
mainHelper = testlib.NewMainHelperWithOptions(&options)
defer mainHelper.Close()
mainHelper.Main(m)
}

View File

@@ -0,0 +1,583 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Regenerate this file using `make email-mocks`.
package mocks
import (
io "io"
model "github.com/mattermost/mattermost/server/public/model"
i18n "github.com/mattermost/mattermost/server/public/shared/i18n"
request "github.com/mattermost/mattermost/server/public/shared/request"
email "github.com/mattermost/mattermost/server/v8/channels/app/email"
store "github.com/mattermost/mattermost/server/v8/channels/store"
templates "github.com/mattermost/mattermost/server/v8/platform/shared/templates"
mock "github.com/stretchr/testify/mock"
)
// ServiceInterface is an autogenerated mock type for the ServiceInterface type
type ServiceInterface struct {
mock.Mock
}
// AddNotificationEmailToBatch provides a mock function with given fields: user, post, team
func (_m *ServiceInterface) AddNotificationEmailToBatch(user *model.User, post *model.Post, team *model.Team) *model.AppError {
ret := _m.Called(user, post, team)
if len(ret) == 0 {
panic("no return value specified for AddNotificationEmailToBatch")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(*model.User, *model.Post, *model.Team) *model.AppError); ok {
r0 = rf(user, post, team)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// CreateVerifyEmailToken provides a mock function with given fields: userID, newEmail
func (_m *ServiceInterface) CreateVerifyEmailToken(userID string, newEmail string) (*model.Token, error) {
ret := _m.Called(userID, newEmail)
if len(ret) == 0 {
panic("no return value specified for CreateVerifyEmailToken")
}
var r0 *model.Token
var r1 error
if rf, ok := ret.Get(0).(func(string, string) (*model.Token, error)); ok {
return rf(userID, newEmail)
}
if rf, ok := ret.Get(0).(func(string, string) *model.Token); ok {
r0 = rf(userID, newEmail)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Token)
}
}
if rf, ok := ret.Get(1).(func(string, string) error); ok {
r1 = rf(userID, newEmail)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GenerateHyperlinkForChannels provides a mock function with given fields: postMessage, teamName, teamURL
func (_m *ServiceInterface) GenerateHyperlinkForChannels(postMessage string, teamName string, teamURL string) (string, error) {
ret := _m.Called(postMessage, teamName, teamURL)
if len(ret) == 0 {
panic("no return value specified for GenerateHyperlinkForChannels")
}
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func(string, string, string) (string, error)); ok {
return rf(postMessage, teamName, teamURL)
}
if rf, ok := ret.Get(0).(func(string, string, string) string); ok {
r0 = rf(postMessage, teamName, teamURL)
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func(string, string, string) error); ok {
r1 = rf(postMessage, teamName, teamURL)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetMessageForNotification provides a mock function with given fields: post, teamName, siteUrl, translateFunc
func (_m *ServiceInterface) GetMessageForNotification(post *model.Post, teamName string, siteUrl string, translateFunc i18n.TranslateFunc) string {
ret := _m.Called(post, teamName, siteUrl, translateFunc)
if len(ret) == 0 {
panic("no return value specified for GetMessageForNotification")
}
var r0 string
if rf, ok := ret.Get(0).(func(*model.Post, string, string, i18n.TranslateFunc) string); ok {
r0 = rf(post, teamName, siteUrl, translateFunc)
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// InitEmailBatching provides a mock function with no fields
func (_m *ServiceInterface) InitEmailBatching() {
_m.Called()
}
// NewEmailTemplateData provides a mock function with given fields: locale
func (_m *ServiceInterface) NewEmailTemplateData(locale string) templates.Data {
ret := _m.Called(locale)
if len(ret) == 0 {
panic("no return value specified for NewEmailTemplateData")
}
var r0 templates.Data
if rf, ok := ret.Get(0).(func(string) templates.Data); ok {
r0 = rf(locale)
} else {
r0 = ret.Get(0).(templates.Data)
}
return r0
}
// SendChangeUsernameEmail provides a mock function with given fields: newUsername, _a1, locale, siteURL
func (_m *ServiceInterface) SendChangeUsernameEmail(newUsername string, _a1 string, locale string, siteURL string) error {
ret := _m.Called(newUsername, _a1, locale, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendChangeUsernameEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string) error); ok {
r0 = rf(newUsername, _a1, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendCloudWelcomeEmail provides a mock function with given fields: userEmail, locale, teamInviteID, workSpaceName, dns, siteURL
func (_m *ServiceInterface) SendCloudWelcomeEmail(userEmail string, locale string, teamInviteID string, workSpaceName string, dns string, siteURL string) error {
ret := _m.Called(userEmail, locale, teamInviteID, workSpaceName, dns, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendCloudWelcomeEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string, string, string) error); ok {
r0 = rf(userEmail, locale, teamInviteID, workSpaceName, dns, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendDeactivateAccountEmail provides a mock function with given fields: _a0, locale, siteURL
func (_m *ServiceInterface) SendDeactivateAccountEmail(_a0 string, locale string, siteURL string) error {
ret := _m.Called(_a0, locale, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendDeactivateAccountEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string) error); ok {
r0 = rf(_a0, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendEmailChangeEmail provides a mock function with given fields: oldEmail, newEmail, locale, siteURL
func (_m *ServiceInterface) SendEmailChangeEmail(oldEmail string, newEmail string, locale string, siteURL string) error {
ret := _m.Called(oldEmail, newEmail, locale, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendEmailChangeEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string) error); ok {
r0 = rf(oldEmail, newEmail, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendEmailChangeVerifyEmail provides a mock function with given fields: newUserEmail, locale, siteURL, token
func (_m *ServiceInterface) SendEmailChangeVerifyEmail(newUserEmail string, locale string, siteURL string, token string) error {
ret := _m.Called(newUserEmail, locale, siteURL, token)
if len(ret) == 0 {
panic("no return value specified for SendEmailChangeVerifyEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string) error); ok {
r0 = rf(newUserEmail, locale, siteURL, token)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendGuestInviteEmails provides a mock function with given fields: rctx, team, channels, senderName, senderUserId, senderProfileImage, invites, siteURL, message, errorWhenNotSent, isSystemAdmin, isFirstAdmin, isGuestMagicLink
func (_m *ServiceInterface) SendGuestInviteEmails(rctx request.CTX, team *model.Team, channels []*model.Channel, senderName string, senderUserId string, senderProfileImage []byte, invites []string, siteURL string, message string, errorWhenNotSent bool, isSystemAdmin bool, isFirstAdmin bool, isGuestMagicLink bool) error {
ret := _m.Called(rctx, team, channels, senderName, senderUserId, senderProfileImage, invites, siteURL, message, errorWhenNotSent, isSystemAdmin, isFirstAdmin, isGuestMagicLink)
if len(ret) == 0 {
panic("no return value specified for SendGuestInviteEmails")
}
var r0 error
if rf, ok := ret.Get(0).(func(request.CTX, *model.Team, []*model.Channel, string, string, []byte, []string, string, string, bool, bool, bool, bool) error); ok {
r0 = rf(rctx, team, channels, senderName, senderUserId, senderProfileImage, invites, siteURL, message, errorWhenNotSent, isSystemAdmin, isFirstAdmin, isGuestMagicLink)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendIPFiltersChangedEmail provides a mock function with given fields: _a0, userWhoChangedFilter, siteURL, portalURL, locale, isWorkspaceOwner
func (_m *ServiceInterface) SendIPFiltersChangedEmail(_a0 string, userWhoChangedFilter *model.User, siteURL string, portalURL string, locale string, isWorkspaceOwner bool) error {
ret := _m.Called(_a0, userWhoChangedFilter, siteURL, portalURL, locale, isWorkspaceOwner)
if len(ret) == 0 {
panic("no return value specified for SendIPFiltersChangedEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, *model.User, string, string, string, bool) error); ok {
r0 = rf(_a0, userWhoChangedFilter, siteURL, portalURL, locale, isWorkspaceOwner)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendInviteEmails provides a mock function with given fields: rctx, inviteData
func (_m *ServiceInterface) SendInviteEmails(rctx request.CTX, inviteData email.InviteEmailData) error {
ret := _m.Called(rctx, inviteData)
if len(ret) == 0 {
panic("no return value specified for SendInviteEmails")
}
var r0 error
if rf, ok := ret.Get(0).(func(request.CTX, email.InviteEmailData) error); ok {
r0 = rf(rctx, inviteData)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendInviteEmailsToTeamAndChannels provides a mock function with given fields: rctx, inviteData
func (_m *ServiceInterface) SendInviteEmailsToTeamAndChannels(rctx request.CTX, inviteData email.InviteEmailData) ([]*model.EmailInviteWithError, error) {
ret := _m.Called(rctx, inviteData)
if len(ret) == 0 {
panic("no return value specified for SendInviteEmailsToTeamAndChannels")
}
var r0 []*model.EmailInviteWithError
var r1 error
if rf, ok := ret.Get(0).(func(request.CTX, email.InviteEmailData) ([]*model.EmailInviteWithError, error)); ok {
return rf(rctx, inviteData)
}
if rf, ok := ret.Get(0).(func(request.CTX, email.InviteEmailData) []*model.EmailInviteWithError); ok {
r0 = rf(rctx, inviteData)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.EmailInviteWithError)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, email.InviteEmailData) error); ok {
r1 = rf(rctx, inviteData)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// SendLicenseUpForRenewalEmail provides a mock function with given fields: _a0, locale, daysToExpiration
func (_m *ServiceInterface) SendLicenseUpForRenewalEmail(_a0 string, locale string, daysToExpiration int) error {
ret := _m.Called(_a0, locale, daysToExpiration)
if len(ret) == 0 {
panic("no return value specified for SendLicenseUpForRenewalEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, int) error); ok {
r0 = rf(_a0, locale, daysToExpiration)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendMagicLinkEmailSelfService provides a mock function with given fields: rctx, invite, siteURL
func (_m *ServiceInterface) SendMagicLinkEmailSelfService(rctx request.CTX, invite string, siteURL string) error {
ret := _m.Called(rctx, invite, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendMagicLinkEmailSelfService")
}
var r0 error
if rf, ok := ret.Get(0).(func(request.CTX, string, string) error); ok {
r0 = rf(rctx, invite, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendMailWithEmbeddedFiles provides a mock function with given fields: to, subject, htmlBody, embeddedFiles, messageID, inReplyTo, references, category
func (_m *ServiceInterface) SendMailWithEmbeddedFiles(to string, subject string, htmlBody string, embeddedFiles map[string]io.Reader, messageID string, inReplyTo string, references string, category string) error {
ret := _m.Called(to, subject, htmlBody, embeddedFiles, messageID, inReplyTo, references, category)
if len(ret) == 0 {
panic("no return value specified for SendMailWithEmbeddedFiles")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, map[string]io.Reader, string, string, string, string) error); ok {
r0 = rf(to, subject, htmlBody, embeddedFiles, messageID, inReplyTo, references, category)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendMfaChangeEmail provides a mock function with given fields: _a0, activated, locale, siteURL
func (_m *ServiceInterface) SendMfaChangeEmail(_a0 string, activated bool, locale string, siteURL string) error {
ret := _m.Called(_a0, activated, locale, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendMfaChangeEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, bool, string, string) error); ok {
r0 = rf(_a0, activated, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendNotificationMail provides a mock function with given fields: to, subject, htmlBody
func (_m *ServiceInterface) SendNotificationMail(to string, subject string, htmlBody string) error {
ret := _m.Called(to, subject, htmlBody)
if len(ret) == 0 {
panic("no return value specified for SendNotificationMail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string) error); ok {
r0 = rf(to, subject, htmlBody)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendPasswordChangeEmail provides a mock function with given fields: _a0, method, locale, siteURL
func (_m *ServiceInterface) SendPasswordChangeEmail(_a0 string, method string, locale string, siteURL string) error {
ret := _m.Called(_a0, method, locale, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendPasswordChangeEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string) error); ok {
r0 = rf(_a0, method, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendPasswordResetEmail provides a mock function with given fields: _a0, token, locale, siteURL
func (_m *ServiceInterface) SendPasswordResetEmail(_a0 string, token *model.Token, locale string, siteURL string) (bool, error) {
ret := _m.Called(_a0, token, locale, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendPasswordResetEmail")
}
var r0 bool
var r1 error
if rf, ok := ret.Get(0).(func(string, *model.Token, string, string) (bool, error)); ok {
return rf(_a0, token, locale, siteURL)
}
if rf, ok := ret.Get(0).(func(string, *model.Token, string, string) bool); ok {
r0 = rf(_a0, token, locale, siteURL)
} else {
r0 = ret.Get(0).(bool)
}
if rf, ok := ret.Get(1).(func(string, *model.Token, string, string) error); ok {
r1 = rf(_a0, token, locale, siteURL)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// SendRemoveExpiredLicenseEmail provides a mock function with given fields: _a0, locale
func (_m *ServiceInterface) SendRemoveExpiredLicenseEmail(_a0 string, locale string) error {
ret := _m.Called(_a0, locale)
if len(ret) == 0 {
panic("no return value specified for SendRemoveExpiredLicenseEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string) error); ok {
r0 = rf(_a0, locale)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendSignInChangeEmail provides a mock function with given fields: _a0, method, locale, siteURL
func (_m *ServiceInterface) SendSignInChangeEmail(_a0 string, method string, locale string, siteURL string) error {
ret := _m.Called(_a0, method, locale, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendSignInChangeEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string) error); ok {
r0 = rf(_a0, method, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendUserAccessTokenAddedEmail provides a mock function with given fields: _a0, locale, siteURL
func (_m *ServiceInterface) SendUserAccessTokenAddedEmail(_a0 string, locale string, siteURL string) error {
ret := _m.Called(_a0, locale, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendUserAccessTokenAddedEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string) error); ok {
r0 = rf(_a0, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendUserAccessTokenRotatedEmail provides a mock function with given fields: _a0, locale, siteURL
func (_m *ServiceInterface) SendUserAccessTokenRotatedEmail(_a0 string, locale string, siteURL string) error {
ret := _m.Called(_a0, locale, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendUserAccessTokenRotatedEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string) error); ok {
r0 = rf(_a0, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendVerifyEmail provides a mock function with given fields: userEmail, locale, siteURL, token, redirect
func (_m *ServiceInterface) SendVerifyEmail(userEmail string, locale string, siteURL string, token string, redirect string) error {
ret := _m.Called(userEmail, locale, siteURL, token, redirect)
if len(ret) == 0 {
panic("no return value specified for SendVerifyEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string, string) error); ok {
r0 = rf(userEmail, locale, siteURL, token, redirect)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendWelcomeEmail provides a mock function with given fields: userID, _a1, verified, disableWelcomeEmail, locale, siteURL, redirect
func (_m *ServiceInterface) SendWelcomeEmail(userID string, _a1 string, verified bool, disableWelcomeEmail bool, locale string, siteURL string, redirect string) error {
ret := _m.Called(userID, _a1, verified, disableWelcomeEmail, locale, siteURL, redirect)
if len(ret) == 0 {
panic("no return value specified for SendWelcomeEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, bool, bool, string, string, string) error); ok {
r0 = rf(userID, _a1, verified, disableWelcomeEmail, locale, siteURL, redirect)
} else {
r0 = ret.Error(0)
}
return r0
}
// SetStore provides a mock function with given fields: st
func (_m *ServiceInterface) SetStore(st store.Store) {
_m.Called(st)
}
// Stop provides a mock function with no fields
func (_m *ServiceInterface) Stop() {
_m.Called()
}
// NewServiceInterface creates a new instance of ServiceInterface. 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 NewServiceInterface(t interface {
mock.TestingT
Cleanup(func())
}) *ServiceInterface {
mock := &ServiceInterface{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,186 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"fmt"
"html"
"html/template"
"net/url"
"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/v8/channels/utils"
)
type FieldRow struct {
Cells []*model.MessageAttachmentField
}
type EmailMessageAttachment struct {
model.MessageAttachment
Pretext template.HTML
Text template.HTML
FieldRows []FieldRow
}
func (es *Service) GetMessageForNotification(post *model.Post, teamName, siteUrl string, translateFunc i18n.TranslateFunc) string {
if strings.TrimSpace(post.Message) != "" || len(post.FileIds) == 0 {
return es.prepareNotificationMessageForEmail(post.Message, teamName, siteUrl)
}
// normalize the filenames and determine what type of files are attached
infos, err := es.store.FileInfo().GetForPost(post.Id, true, false, true)
if err != nil {
mlog.Warn("Encountered error when getting files for notification message", mlog.String("post_id", post.Id), mlog.Err(err))
}
filenames := make([]string, len(infos))
onlyImages := true
for i, info := range infos {
filename := info.Name
if unescaped, err := url.QueryUnescape(filename); err == nil {
filename = unescaped
}
filenames[i] = filename
onlyImages = onlyImages && info.IsImage()
}
props := map[string]any{"Filenames": strings.Join(filenames, ", ")}
if onlyImages {
return string(i18n.TranslateAsHTML(translateFunc, "api.post.get_message_for_notification.images_sent", map[string]any{
"Count": len(filenames),
"Filenames": props["Filenames"],
}))
}
return string(i18n.TranslateAsHTML(translateFunc, "api.post.get_message_for_notification.files_sent", map[string]any{
"Count": len(filenames),
"Filenames": props["Filenames"],
}))
}
func ProcessMessageAttachments(post *model.Post, siteURL string) []*EmailMessageAttachment {
emailMessageAttachments := []*EmailMessageAttachment{}
for _, messageAttachment := range post.Attachments() {
emailMessageAttachment := &EmailMessageAttachment{
MessageAttachment: *messageAttachment,
Pretext: prepareTextForEmail(messageAttachment.Pretext, siteURL),
Text: prepareTextForEmail(messageAttachment.Text, siteURL),
}
stripedTitle, err := utils.StripMarkdown(emailMessageAttachment.Title)
if err != nil {
mlog.Warn("Failed parse to markdown from messageatatchment title", mlog.String("post_id", post.Id), mlog.Err(err))
stripedTitle = ""
}
emailMessageAttachment.Title = stripedTitle
shortFieldRow := FieldRow{}
for i := range messageAttachment.Fields {
// Create a new instance to avoid altering the original pointer reference
// We update field value to parse markdown.
// If we do that on the original pointer, the rendered text in mattermost
// becomes invalid as its no longer a markdown string, but rather an HTML string.
field := &model.MessageAttachmentField{
Title: messageAttachment.Fields[i].Title,
Value: messageAttachment.Fields[i].Value,
Short: messageAttachment.Fields[i].Short,
}
if stringValue, ok := field.Value.(string); ok {
field.Value = prepareTextForEmail(stringValue, siteURL)
}
if !field.Short {
if len(shortFieldRow.Cells) > 0 {
emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, shortFieldRow)
shortFieldRow = FieldRow{}
}
emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, FieldRow{[]*model.MessageAttachmentField{field}})
} else {
shortFieldRow.Cells = append(shortFieldRow.Cells, field)
if len(shortFieldRow.Cells) == 2 {
emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, shortFieldRow)
shortFieldRow = FieldRow{}
}
}
}
// collect any leftover short fields
if len(shortFieldRow.Cells) > 0 {
emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, shortFieldRow)
shortFieldRow = FieldRow{}
}
emailMessageAttachments = append(emailMessageAttachments, emailMessageAttachment)
}
return emailMessageAttachments
}
func prepareTextForEmail(text, siteURL string) template.HTML {
escapedText := html.EscapeString(text)
markdownText, err := utils.MarkdownToHTML(escapedText, siteURL)
if err != nil {
mlog.Warn("Encountered error while converting markdown to HTML", mlog.Err(err))
return template.HTML(text)
}
return template.HTML(markdownText)
}
func (es *Service) prepareNotificationMessageForEmail(postMessage, teamName, siteURL string) string {
postMessage = html.EscapeString(postMessage)
mdPostMessage, mdErr := utils.MarkdownToHTML(postMessage, siteURL)
if mdErr != nil {
mlog.Warn("Encountered error while converting markdown to HTML", mlog.Err(mdErr))
mdPostMessage = postMessage
}
landingURL := siteURL + "/landing#/" + teamName
normalizedPostMessage, err := es.GenerateHyperlinkForChannels(mdPostMessage, teamName, landingURL)
if err != nil {
mlog.Warn("Encountered error while generating hyperlink for channels", mlog.String("team_name", teamName), mlog.Err(err))
normalizedPostMessage = mdPostMessage
}
return normalizedPostMessage
}
func (es *Service) GenerateHyperlinkForChannels(postMessage, teamName, landingURL string) (string, error) {
channelNames := model.ChannelMentions(postMessage)
if len(channelNames) == 0 {
return postMessage, nil
}
team, err := es.Store().Team().GetByName(teamName)
if err != nil {
mlog.Error("Team not found with the name", mlog.String("team_name", teamName), mlog.Err(err))
return postMessage, nil
}
channels, err := es.store.Channel().GetByNames(team.Id, channelNames, true)
if err != nil {
return "", err
}
visited := make(map[string]bool)
for _, ch := range channels {
if !visited[ch.Id] && ch.Type == model.ChannelTypeOpen {
channelURL := landingURL + "/channels/" + ch.Name
channelHyperLink := fmt.Sprintf("<a href='%s'>%s</a>", channelURL, "~"+ch.Name)
postMessage = strings.Replace(postMessage, "~"+ch.Name, channelHyperLink, -1)
visited[ch.Id] = true
}
}
return postMessage, nil
}

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestProcessMessageAttachments(t *testing.T) {
mainHelper.Parallel(t)
post := &model.Post{
Message: "This is the message",
}
messageAttachments := []*model.MessageAttachment{
{
Color: "#FF0000",
Pretext: "message attachment 1 pretext",
AuthorName: "author name",
AuthorLink: "https://example.com/slack_attachment_1/author_link",
AuthorIcon: "https://example.com/slack_attachment_1/author_icon",
Title: "message attachment 1 title",
TitleLink: "https://example.com/slack_attachment_1/title_link",
Text: "message attachment 1 text",
ImageURL: "https://example.com/slack_attachment_1/image",
ThumbURL: "https://example.com/slack_attachment_1/thumb",
Fields: []*model.MessageAttachmentField{
{
Short: true,
Title: "message attachment 1 field 1 title",
Value: "message attachment 1 field 1 value",
},
{
Short: false,
Title: "message attachment 1 field 2 title",
Value: "message attachment 1 field 2 value",
},
{
Short: true,
Title: "message attachment 1 field 3 title",
Value: "message attachment 1 field 3 value",
},
{
Short: true,
Title: "message attachment 1 field 4 title",
Value: "message attachment 1 field 4 value",
},
},
},
{
Color: "#FF0000",
Pretext: "message attachment 2 pretext",
AuthorName: "author name 2",
Text: "message attachment 2 text",
},
}
model.ParseMessageAttachment(post, messageAttachments)
processedAttachmentsPost := ProcessMessageAttachments(post, "https://example.com")
require.NotNil(t, processedAttachmentsPost)
require.Len(t, processedAttachmentsPost, 2)
require.Equal(t, processedAttachmentsPost[0].Color, "#FF0000")
require.Equal(t, processedAttachmentsPost[0].FieldRows[0].Cells[0].Title, "message attachment 1 field 1 title")
require.Equal(t, processedAttachmentsPost[1].Color, "#FF0000")
}

View File

@@ -0,0 +1,225 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"io"
"net/url"
"path"
"strings"
"github.com/pkg/errors"
"github.com/throttled/throttled/v2"
"github.com/throttled/throttled/v2/store/memstore"
"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/app/users"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/platform/shared/templates"
)
const (
emailRateLimitingMemstoreSize = 65536
emailRateLimitingPerMinute = 60
emailRateLimitingPerHour = 20
emailRateLimitingMaxBurst = 20
TokenTypePasswordRecovery = "password_recovery"
TokenTypeVerifyEmail = "verify_email"
TokenTypeTeamInvitation = "team_invitation"
TokenTypeGuestInvitation = "guest_invitation"
TokenTypeCWSAccess = "cws_access_token"
TokenTypeGuestMagicLinkInvitation = "guest_magic_link_invitation"
TokenTypeGuestMagicLink = "guest_magic_link"
)
func condenseSiteURL(siteURL string) string {
parsedSiteURL, _ := url.Parse(siteURL)
if parsedSiteURL.Path == "" || parsedSiteURL.Path == "/" {
return parsedSiteURL.Host
}
return path.Join(parsedSiteURL.Host, parsedSiteURL.Path)
}
type Service struct {
config func() *model.Config
license func() *model.License
logger mlog.LoggerIFace
userService *users.UserService
store store.Store
templatesContainer *templates.Container
perMinuteEmailRateLimiter *throttled.GCRARateLimiterCtx
perHourEmailRateLimiter *throttled.GCRARateLimiterCtx
EmailBatching *EmailBatchingJob
postDeliveryRecorder func(userID string, post *model.Post)
}
type ServiceConfig struct {
ConfigFn func() *model.Config
LicenseFn func() *model.License
TemplatesContainer *templates.Container
UserService *users.UserService
Store store.Store
Logger mlog.LoggerIFace
PostDeliveryRecorderFn func(userID string, post *model.Post)
}
func NewService(config ServiceConfig) (*Service, error) {
if err := config.validate(); err != nil {
return nil, err
}
service := &Service{
config: config.ConfigFn,
templatesContainer: config.TemplatesContainer,
license: config.LicenseFn,
logger: config.Logger,
store: config.Store,
userService: config.UserService,
postDeliveryRecorder: config.PostDeliveryRecorderFn,
}
if err := service.setUpRateLimiters(); err != nil {
return nil, err
}
service.InitEmailBatching()
return service, nil
}
func (es *Service) Stop() {
mlog.Info("Shutting down Email batching service...")
if es.EmailBatching != nil {
es.EmailBatching.Stop()
}
}
func (c *ServiceConfig) validate() error {
if c.ConfigFn == nil || c.Store == nil || c.LicenseFn == nil || c.TemplatesContainer == nil {
return errors.New("invalid service config")
}
return nil
}
func (es *Service) setUpRateLimiters() error {
store, err := memstore.NewCtx(emailRateLimitingMemstoreSize)
if err != nil {
return errors.Wrap(err, "Unable to setup email rate limiting memstore.")
}
perMinuteQuota := throttled.RateQuota{
MaxRate: throttled.PerMin(emailRateLimitingPerMinute),
MaxBurst: emailRateLimitingMaxBurst,
}
perHourQuota := throttled.RateQuota{
MaxRate: throttled.PerHour(emailRateLimitingPerHour),
MaxBurst: emailRateLimitingMaxBurst,
}
perMinuteRateLimiter, err := throttled.NewGCRARateLimiterCtx(store, perMinuteQuota)
if err != nil {
return errors.Wrap(err, "Unable to setup email rate limiting GCRA rate limiter.")
}
perHourRateLimiter, err := throttled.NewGCRARateLimiterCtx(store, perHourQuota)
if err != nil {
return errors.Wrap(err, "Unable to setup email rate limiting GCRA rate limiter.")
}
es.perMinuteEmailRateLimiter = perMinuteRateLimiter
es.perHourEmailRateLimiter = perHourRateLimiter
return nil
}
type InviteEmailData struct {
Team *model.Team
Channels []*model.Channel
SenderName string
SenderUserID string
SenderProfileImage []byte
Invites []string
Profiles map[string]*model.MemberInviteProfile
SiteURL string
ReminderData *model.TeamInviteReminderData
Message string
ErrorWhenNotSent bool
IsSystemAdmin bool
IsFirstAdmin bool
}
type ServiceInterface interface {
NewEmailTemplateData(locale string) templates.Data
SendEmailChangeVerifyEmail(newUserEmail, locale, siteURL, token string) error
SendEmailChangeEmail(oldEmail, newEmail, locale, siteURL string) error
SendVerifyEmail(userEmail, locale, siteURL, token, redirect string) error
SendSignInChangeEmail(email, method, locale, siteURL string) error
SendWelcomeEmail(userID string, email string, verified bool, disableWelcomeEmail bool, locale, siteURL, redirect string) error
SendCloudWelcomeEmail(userEmail, locale, teamInviteID, workSpaceName, dns, siteURL string) error
SendPasswordChangeEmail(email, method, locale, siteURL string) error
SendUserAccessTokenAddedEmail(email, locale, siteURL string) error
SendUserAccessTokenRotatedEmail(email, locale, siteURL string) error
SendPasswordResetEmail(email string, token *model.Token, locale, siteURL string) (bool, error)
SendMfaChangeEmail(email string, activated bool, locale, siteURL string) error
SendInviteEmails(rctx request.CTX, inviteData InviteEmailData) error
SendGuestInviteEmails(rctx request.CTX, team *model.Team, channels []*model.Channel, senderName string, senderUserId string, senderProfileImage []byte, invites []string, siteURL string, message string, errorWhenNotSent bool, isSystemAdmin bool, isFirstAdmin bool, isGuestMagicLink bool) error
SendMagicLinkEmailSelfService(rctx request.CTX, invite string, siteURL string) error
SendInviteEmailsToTeamAndChannels(rctx request.CTX, inviteData InviteEmailData) ([]*model.EmailInviteWithError, error)
SendDeactivateAccountEmail(email string, locale, siteURL string) error
SendNotificationMail(to, subject, htmlBody string) error
SendMailWithEmbeddedFiles(to, subject, htmlBody string, embeddedFiles map[string]io.Reader, messageID string, inReplyTo string, references string, category string) error
SendLicenseUpForRenewalEmail(email, locale string, daysToExpiration int) error
SendRemoveExpiredLicenseEmail(email, locale string) error
AddNotificationEmailToBatch(user *model.User, post *model.Post, team *model.Team) *model.AppError
GetMessageForNotification(post *model.Post, teamName, siteUrl string, translateFunc i18n.TranslateFunc) string
GenerateHyperlinkForChannels(postMessage, teamName, teamURL string) (string, error)
InitEmailBatching()
SendChangeUsernameEmail(newUsername, email, locale, siteURL string) error
CreateVerifyEmailToken(userID string, newEmail string) (*model.Token, error)
SendIPFiltersChangedEmail(email string, userWhoChangedFilter *model.User, siteURL, portalURL, locale string, isWorkspaceOwner bool) error
SetStore(st store.Store)
Stop()
}
// getLicenseSkuName returns the license tier descriptor (e.g. "Professional",
// "Entry", "E20"), stripping the "Mattermost " prefix if the license server
// included it. Falls back to "Mattermost" when the SKU name is absent so
// that body text like "Your {{.SkuName}} license..." still reads naturally.
func (es *Service) getLicenseSkuName() string {
if license := es.license(); license != nil && license.SkuName != "" {
return strings.TrimPrefix(license.SkuName, "Mattermost ")
}
return "Mattermost"
}
// getPrefixedLicenseSkuName returns the full product name including "Mattermost"
// (e.g. "Mattermost Professional"), suitable for email subjects. Falls back
// to "Mattermost" when no license exists or the SKU name is empty.
func (es *Service) getPrefixedLicenseSkuName() string {
skuName := es.getLicenseSkuName()
if skuName == "Mattermost" {
return "Mattermost"
}
return "Mattermost " + skuName
}
func (es *Service) getConfigSiteName() string {
if siteName := *es.config().TeamSettings.SiteName; siteName != "" {
return siteName
}
return "Mattermost"
}
func (es *Service) Store() store.Store {
return es.store
}
func (es *Service) SetStore(st store.Store) {
es.store = st
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/mattermost/mattermost/server/v8/platform/shared/mail"
)
func (es *Service) mailServiceConfig(replyToAddress string) *mail.SMTPConfig {
emailSettings := es.config().EmailSettings
hostname := utils.GetHostnameFromSiteURL(*es.config().ServiceSettings.SiteURL)
if replyToAddress == "" {
replyToAddress = *emailSettings.ReplyToAddress
}
cfg := mail.SMTPConfig{
Hostname: hostname,
ConnectionSecurity: *emailSettings.ConnectionSecurity,
SkipServerCertificateVerification: *emailSettings.SkipServerCertificateVerification,
ServerName: *emailSettings.SMTPServer,
Server: *emailSettings.SMTPServer,
Port: *emailSettings.SMTPPort,
ServerTimeout: *emailSettings.SMTPServerTimeout,
Username: *emailSettings.SMTPUsername,
Password: *emailSettings.SMTPPassword,
EnableSMTPAuth: *emailSettings.EnableSMTPAuth,
SendEmailNotifications: *emailSettings.SendEmailNotifications,
FeedbackName: *emailSettings.FeedbackName,
FeedbackEmail: *emailSettings.FeedbackEmail,
ReplyToAddress: replyToAddress,
}
return &cfg
}
func (es *Service) GetTrackFlowStartedByRole(isFirstAdmin bool, isSystemAdmin bool) string {
trackFlowStartedByRole := "su"
if isFirstAdmin {
trackFlowStartedByRole = "fa"
} else if isSystemAdmin {
trackFlowStartedByRole = "sa"
}
return trackFlowStartedByRole
}

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"net/http"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestSendInviteEmailRateLimits(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.BasicTeam.AllowedDomains = "common.com"
_, err := th.App.UpdateTeam(th.BasicTeam)
require.Nilf(t, err, "%v, Should update the team", err)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.EnableEmailInvitations = true
})
memberInvite := &model.MemberInvite{}
memberInvite.Emails = make([]string, 22)
for i := range 22 {
memberInvite.Emails[i] = "test-" + strconv.Itoa(i) + "@common.com"
}
err = th.App.InviteNewUsersToTeam(th.Context, memberInvite.Emails, th.BasicTeam.Id, th.BasicUser.Id)
require.NotNil(t, err)
assert.Equal(t, "app.email.rate_limit_exceeded.app_error", err.Id)
assert.Equal(t, http.StatusRequestEntityTooLarge, err.StatusCode)
_, err = th.App.InviteNewUsersToTeamGracefully(th.Context, memberInvite, th.BasicTeam.Id, th.BasicUser.Id, "")
require.NotNil(t, err)
assert.Equal(t, "app.email.rate_limit_exceeded.app_error", err.Id)
assert.Equal(t, http.StatusRequestEntityTooLarge, err.StatusCode)
}

View File

@@ -0,0 +1,393 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"image"
"image/color/palette"
"image/draw"
"image/gif"
_ "image/jpeg"
"io"
"mime/multipart"
"net/http"
"path"
"github.com/mattermost/mattermost/server/v8/channels/app/imaging"
_ "golang.org/x/image/webp"
"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/channels/utils"
"github.com/mattermost/mattermost/server/v8/channels/utils/imgutils"
)
const (
MaxEmojiFileSize = 1 << 19 // 512 KiB
MaxEmojiWidth = 128
MaxEmojiHeight = 128
MaxEmojiOriginalWidth = 1028
MaxEmojiOriginalHeight = 1028
MaxEmojiGIFFrames = 70
)
func (a *App) CreateEmoji(rctx request.CTX, sessionUserId string, emoji *model.Emoji, multiPartImageData *multipart.Form) (*model.Emoji, *model.AppError) {
if !*a.Config().ServiceSettings.EnableCustomEmoji {
return nil, model.NewAppError("CreateEmoji", "api.emoji.disabled.app_error", nil, "", http.StatusForbidden)
}
if *a.Config().FileSettings.DriverName == "" {
return nil, model.NewAppError("CreateEmoji", "api.emoji.storage.app_error", nil, "", http.StatusForbidden)
}
// wipe the emoji id so that existing emojis can't get overwritten
emoji.Id = ""
// do our best to validate the emoji before committing anything to the DB so that we don't have to clean up
// orphaned files left over when validation fails later on
emoji.PreSave()
if appErr := emoji.IsValid(); appErr != nil {
return nil, appErr
}
if emoji.CreatorId != sessionUserId {
return nil, model.NewAppError("CreateEmoji", "api.emoji.create.other_user.app_error", nil, "", http.StatusForbidden)
}
if existingEmoji, err := a.Srv().Store().Emoji().GetByName(rctx, emoji.Name, true); err == nil && existingEmoji != nil {
return nil, model.NewAppError("CreateEmoji", "api.emoji.create.duplicate.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
imageData := multiPartImageData.File["image"]
if len(imageData) == 0 {
return nil, model.NewAppError("Context", "api.context.invalid_body_param.app_error", map[string]any{"Name": "createEmoji"}, "", http.StatusBadRequest)
}
filename := imageData[0].Filename
file, err := imageData[0].Open()
if err != nil {
return nil, model.NewAppError("CreateEmoji", "api.emoji.upload.open.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
defer file.Close()
if appErr := a.uploadEmojiImage(rctx, emoji.Id, filename, file); appErr != nil {
return nil, appErr
}
emoji, err = a.Srv().Store().Emoji().Save(emoji)
if err != nil {
return nil, model.NewAppError("CreateEmoji", "app.emoji.create.internal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
message := model.NewWebSocketEvent(model.WebsocketEventEmojiAdded, "", "", "", nil, "")
emojiJSON, jsonErr := json.Marshal(emoji)
if jsonErr != nil {
return nil, model.NewAppError("CreateEmoji", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
}
message.Add("emoji", string(emojiJSON))
a.Publish(message)
return emoji, nil
}
func (a *App) GetEmojiList(rctx request.CTX, page, perPage int, sort string) ([]*model.Emoji, *model.AppError) {
list, err := a.Srv().Store().Emoji().GetList(page*perPage, perPage, sort)
if err != nil {
return nil, model.NewAppError("GetEmojiList", "app.emoji.get_list.internal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return list, nil
}
func (a *App) uploadEmojiImage(rctx request.CTX, id string, filename string, file io.ReadSeeker) *model.AppError {
// make sure the file is an image and is within the required dimensions
config, _, err := image.DecodeConfig(file)
if err != nil {
return model.NewAppError("uploadEmojiImage", "api.emoji.upload.image.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
if config.Width > MaxEmojiOriginalWidth || config.Height > MaxEmojiOriginalHeight {
return model.NewAppError("uploadEmojiImage", "api.emoji.upload.large_image.too_large.app_error", map[string]any{
"MaxWidth": MaxEmojiOriginalWidth,
"MaxHeight": MaxEmojiOriginalHeight,
}, "", http.StatusBadRequest)
}
_, err = file.Seek(0, io.SeekStart)
if err != nil {
return model.NewAppError("uploadEmojiImage", "api.emoji.upload.seek.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Enforce the frame limit on every animated GIF, regardless of whether it
// needs resizing, so the cap applies to the direct-write path too.
isGIF := model.NewInfo(filename).MimeType == "image/gif"
if isGIF {
frameCount, err := imgutils.CountGIFFrames(file)
if err != nil {
return model.NewAppError("uploadEmojiImage", "api.emoji.upload.image.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
if frameCount > MaxEmojiGIFFrames {
return model.NewAppError("uploadEmojiImage", "api.emoji.upload.too_many_frames.app_error", map[string]any{
"MaxFrames": MaxEmojiGIFFrames,
}, "", http.StatusBadRequest)
}
if _, err = file.Seek(0, io.SeekStart); err != nil {
return model.NewAppError("uploadEmojiImage", "api.emoji.upload.seek.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
if config.Width <= MaxEmojiWidth && config.Height <= MaxEmojiHeight {
// No need to resize the image
_, appErr := a.WriteFile(file, getEmojiImagePath(id))
return appErr
}
// Create a buffer for the resized image
buf := &bytes.Buffer{}
if isGIF {
g, err := gif.DecodeAll(file)
if err != nil {
return model.NewAppError("uploadEmojiImage", "api.emoji.upload.large_image.gif_decode_error", nil, "", http.StatusBadRequest).Wrap(err)
}
resizeEmojiGif(g)
if err := gif.EncodeAll(buf, g); err != nil {
return model.NewAppError("uploadEmojiImage", "api.emoji.upload.large_image.gif_encode_error", nil, "", http.StatusBadRequest).Wrap(err)
}
} else {
img, _, err := image.Decode(file)
if err != nil {
return model.NewAppError("uploadEmojiImage", "api.emoji.upload.large_image.decode_error", nil, "", http.StatusBadRequest).Wrap(err)
}
resizedImg := resizeEmoji(img, config.Width, config.Height)
if err := a.ch.imgEncoder.EncodePNG(buf, resizedImg); err != nil {
return model.NewAppError("uploadEmojiImage", "api.emoji.upload.large_image.encode_error", nil, "", http.StatusBadRequest).Wrap(err)
}
}
_, appErr := a.WriteFile(buf, getEmojiImagePath(id))
return appErr
}
func (a *App) DeleteEmoji(rctx request.CTX, emoji *model.Emoji) *model.AppError {
if err := a.Srv().Store().Emoji().Delete(emoji, model.GetMillis()); err != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(err, &nfErr):
return model.NewAppError("DeleteEmoji", "app.emoji.delete.no_results", nil, "id="+emoji.Id, http.StatusNotFound).Wrap(err)
default:
return model.NewAppError("DeleteEmoji", "app.emoji.delete.app_error", nil, "id="+emoji.Id, http.StatusInternalServerError).Wrap(err)
}
}
a.deleteEmojiImage(rctx, emoji.Id)
a.deleteReactionsForEmoji(rctx, emoji.Name)
return nil
}
func (a *App) GetEmoji(rctx request.CTX, emojiId string) (*model.Emoji, *model.AppError) {
if !*a.Config().ServiceSettings.EnableCustomEmoji {
return nil, model.NewAppError("GetEmoji", "api.emoji.disabled.app_error", nil, "", http.StatusForbidden)
}
if *a.Config().FileSettings.DriverName == "" {
return nil, model.NewAppError("GetEmoji", "api.emoji.storage.app_error", nil, "", http.StatusForbidden)
}
emoji, err := a.Srv().Store().Emoji().Get(rctx, emojiId, true)
if err != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(err, &nfErr):
return emoji, model.NewAppError("GetEmoji", "app.emoji.get.no_result", nil, "", http.StatusNotFound).Wrap(err)
default:
return emoji, model.NewAppError("GetEmoji", "app.emoji.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
return emoji, nil
}
func (a *App) GetEmojiByName(rctx request.CTX, emojiName string) (*model.Emoji, *model.AppError) {
if !*a.Config().ServiceSettings.EnableCustomEmoji {
return nil, model.NewAppError("GetEmojiByName", "api.emoji.disabled.app_error", nil, "", http.StatusForbidden)
}
if *a.Config().FileSettings.DriverName == "" {
return nil, model.NewAppError("GetEmojiByName", "api.emoji.storage.app_error", nil, "", http.StatusForbidden)
}
emoji, err := a.Srv().Store().Emoji().GetByName(rctx, emojiName, true)
if err != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(err, &nfErr):
return emoji, model.NewAppError("GetEmojiByName", "app.emoji.get_by_name.no_result", nil, "", http.StatusNotFound).Wrap(err)
default:
return emoji, model.NewAppError("GetEmojiByName", "app.emoji.get_by_name.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
return emoji, nil
}
func (a *App) GetMultipleEmojiByName(rctx request.CTX, names []string) ([]*model.Emoji, *model.AppError) {
if !*a.Config().ServiceSettings.EnableCustomEmoji {
return nil, model.NewAppError("GetMultipleEmojiByName", "api.emoji.disabled.app_error", nil, "", http.StatusForbidden)
}
// Filtering out system emojis
i := 0
for _, n := range names {
if _, ok := model.GetSystemEmojiId(n); !ok {
names[i] = n
i++
}
}
names = names[:i]
if len(names) == 0 {
return []*model.Emoji{}, nil
}
emoji, err := a.Srv().Store().Emoji().GetMultipleByName(rctx, names)
if err != nil {
return nil, model.NewAppError("GetMultipleEmojiByName", "app.emoji.get_by_name.app_error", nil, fmt.Sprintf("names=%v", names), http.StatusInternalServerError).Wrap(err)
}
return emoji, nil
}
func (a *App) GetEmojiImage(rctx request.CTX, emojiId string) ([]byte, string, *model.AppError) {
_, storeErr := a.Srv().Store().Emoji().Get(rctx, emojiId, true)
if storeErr != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(storeErr, &nfErr):
return nil, "", model.NewAppError("GetEmojiImage", "app.emoji.get.no_result", nil, "", http.StatusNotFound).Wrap(storeErr)
default:
return nil, "", model.NewAppError("GetEmojiImage", "app.emoji.get.app_error", nil, "", http.StatusInternalServerError).Wrap(storeErr)
}
}
img, appErr := a.ReadFile(getEmojiImagePath(emojiId))
if appErr != nil {
return nil, "", model.NewAppError("getEmojiImage", "api.emoji.get_image.read.app_error", nil, "", http.StatusNotFound).Wrap(appErr)
}
_, imageType, err := image.DecodeConfig(bytes.NewReader(img))
if err != nil {
return nil, "", model.NewAppError("getEmojiImage", "api.emoji.get_image.decode.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return img, imageType, nil
}
func (a *App) SearchEmoji(rctx request.CTX, name string, prefixOnly bool, limit int) ([]*model.Emoji, *model.AppError) {
if !*a.Config().ServiceSettings.EnableCustomEmoji {
return nil, model.NewAppError("SearchEmoji", "api.emoji.disabled.app_error", nil, "", http.StatusForbidden)
}
list, err := a.Srv().Store().Emoji().Search(name, prefixOnly, limit)
if err != nil {
return nil, model.NewAppError("SearchEmoji", "app.emoji.get_by_name.app_error", nil, "name="+name, http.StatusInternalServerError).Wrap(err)
}
return list, nil
}
// GetEmojiStaticURL returns a relative static URL for system default emojis,
// and the API route for custom ones. Errors if not found or if custom and deleted.
func (a *App) GetEmojiStaticURL(rctx request.CTX, emojiName string) (string, *model.AppError) {
subPath, _ := utils.GetSubpathFromConfig(a.Config())
if id, found := model.GetSystemEmojiId(emojiName); found {
return path.Join(subPath, "/static/emoji", id+".png"), nil
}
emoji, err := a.Srv().Store().Emoji().GetByName(rctx, emojiName, true)
if err == nil {
return path.Join(subPath, "/api/v4/emoji", emoji.Id, "image"), nil
}
var nfErr *store.ErrNotFound
switch {
case errors.As(err, &nfErr):
return "", model.NewAppError("GetEmojiStaticURL", "app.emoji.get_by_name.no_result", nil, "", http.StatusNotFound).Wrap(err)
default:
return "", model.NewAppError("GetEmojiStaticURL", "app.emoji.get_by_name.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
func resizeEmojiGif(gifImg *gif.GIF) {
// Create a new RGBA image to hold the incremental frames.
firstFrame := gifImg.Image[0].Bounds()
b := image.Rect(0, 0, firstFrame.Dx(), firstFrame.Dy())
img := image.NewRGBA(b)
resizedImage := image.Image(nil)
// Resize each frame.
for index, frame := range gifImg.Image {
bounds := frame.Bounds()
draw.Draw(img, bounds, frame, bounds.Min, draw.Over)
resizedImage = resizeEmoji(img, firstFrame.Dx(), firstFrame.Dy())
gifImg.Image[index] = imageToPaletted(resizedImage)
}
// Set new gif width and height
gifImg.Config.Width = resizedImage.Bounds().Dx()
gifImg.Config.Height = resizedImage.Bounds().Dy()
}
func getEmojiImagePath(id string) string {
return "emoji/" + id + "/image"
}
func resizeEmoji(img image.Image, width int, height int) image.Image {
emojiWidth := float64(width)
emojiHeight := float64(height)
if emojiHeight <= MaxEmojiHeight && emojiWidth <= MaxEmojiWidth {
return img
}
return imaging.Fit(img, MaxEmojiWidth, MaxEmojiHeight)
}
func imageToPaletted(img image.Image) *image.Paletted {
b := img.Bounds()
pm := image.NewPaletted(b, palette.Plan9)
draw.FloydSteinberg.Draw(pm, b, img, image.Point{})
return pm
}
func (a *App) deleteEmojiImage(rctx request.CTX, id string) {
if err := a.MoveFile(getEmojiImagePath(id), "emoji/"+id+"/image_deleted"); err != nil {
rctx.Logger().Warn("Failed to rename image when deleting emoji", mlog.String("emoji_id", id))
}
}
func (a *App) deleteReactionsForEmoji(rctx request.CTX, emojiName string) {
if err := a.Srv().Store().Reaction().DeleteAllWithEmojiName(rctx, emojiName); err != nil {
rctx.Logger().Warn("Unable to delete reactions when deleting emoji", mlog.String("emoji_name", emojiName), mlog.Err(err))
}
}
func (a *App) confirmEmojiExists(rctx request.CTX, emojiName string) *model.AppError {
if model.IsSystemEmojiName(emojiName) {
return nil
}
err := model.IsValidEmojiName(emojiName)
if err != nil {
return err
}
_, err = a.GetEmojiByName(rctx, emojiName)
return err
}

View File

@@ -0,0 +1,130 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"strings"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/stretchr/testify/require"
)
func BenchmarkUploadEmojiImage(b *testing.B) {
th := Setup(b)
rctx := request.TestContext(b)
b.Run("gif", func(b *testing.B) {
filename := "image.gif"
b.Run("small", func(b *testing.B) {
file := strings.NewReader(string(utils.CreateTestGif(b, 10, 10)))
for b.Loop() {
id := model.NewId()
appErr := th.App.uploadEmojiImage(rctx, id, filename, file)
require.Nil(b, appErr)
_, err := file.Seek(0, 0)
require.NoError(b, err)
}
})
b.Run("max size", func(b *testing.B) {
file := strings.NewReader(string(utils.CreateTestGif(b, MaxEmojiWidth, MaxEmojiHeight)))
for b.Loop() {
id := model.NewId()
appErr := th.App.uploadEmojiImage(rctx, id, filename, file)
require.Nil(b, appErr)
_, err := file.Seek(0, 0)
require.NoError(b, err)
}
})
b.Run("too wide", func(b *testing.B) {
file := strings.NewReader(string(utils.CreateTestGif(b, MaxEmojiOriginalWidth, MaxEmojiHeight)))
for b.Loop() {
id := model.NewId()
appErr := th.App.uploadEmojiImage(rctx, id, filename, file)
require.Nil(b, appErr)
_, err := file.Seek(0, 0)
require.NoError(b, err)
}
})
b.Run("too tall", func(b *testing.B) {
file := strings.NewReader(string(utils.CreateTestGif(b, MaxEmojiWidth, MaxEmojiOriginalWidth)))
for b.Loop() {
id := model.NewId()
appErr := th.App.uploadEmojiImage(rctx, id, filename, file)
require.Nil(b, appErr)
_, err := file.Seek(0, 0)
require.NoError(b, err)
}
})
b.Run("too tall and too wide", func(b *testing.B) {
file := strings.NewReader(string(utils.CreateTestGif(b, MaxEmojiOriginalWidth, MaxEmojiOriginalWidth)))
for b.Loop() {
id := model.NewId()
appErr := th.App.uploadEmojiImage(rctx, id, filename, file)
require.Nil(b, appErr)
_, err := file.Seek(0, 0)
require.NoError(b, err)
}
})
})
b.Run("png", func(b *testing.B) {
filename := "image.png"
b.Run("small", func(b *testing.B) {
file := strings.NewReader(string(utils.CreateTestPng(b, 10, 10)))
for b.Loop() {
id := model.NewId()
appErr := th.App.uploadEmojiImage(rctx, id, filename, file)
require.Nil(b, appErr)
_, err := file.Seek(0, 0)
require.NoError(b, err)
}
})
b.Run("max size", func(b *testing.B) {
file := strings.NewReader(string(utils.CreateTestPng(b, MaxEmojiWidth, MaxEmojiHeight)))
for b.Loop() {
id := model.NewId()
appErr := th.App.uploadEmojiImage(rctx, id, filename, file)
require.Nil(b, appErr)
_, err := file.Seek(0, 0)
require.NoError(b, err)
}
})
b.Run("too wide", func(b *testing.B) {
file := strings.NewReader(string(utils.CreateTestPng(b, MaxEmojiOriginalWidth, MaxEmojiHeight)))
for b.Loop() {
id := model.NewId()
appErr := th.App.uploadEmojiImage(rctx, id, filename, file)
require.Nil(b, appErr)
_, err := file.Seek(0, 0)
require.NoError(b, err)
}
})
b.Run("too tall", func(b *testing.B) {
file := strings.NewReader(string(utils.CreateTestPng(b, MaxEmojiWidth, MaxEmojiOriginalWidth)))
for b.Loop() {
id := model.NewId()
appErr := th.App.uploadEmojiImage(rctx, id, filename, file)
require.Nil(b, appErr)
_, err := file.Seek(0, 0)
require.NoError(b, err)
}
})
b.Run("too tall and too wide", func(b *testing.B) {
file := strings.NewReader(string(utils.CreateTestPng(b, MaxEmojiOriginalWidth, MaxEmojiOriginalWidth)))
for b.Loop() {
id := model.NewId()
appErr := th.App.uploadEmojiImage(rctx, id, filename, file)
require.Nil(b, appErr)
_, err := file.Seek(0, 0)
require.NoError(b, err)
}
})
})
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetMultipleEmojiByName(t *testing.T) {
mainHelper.Parallel(t)
// The fact that we use mock store ensures that
// the call to the DB does not happen. If it did, we would have needed
// to provide the mock explicitly.
th := SetupWithStoreMock(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.EnableCustomEmoji = true
})
// Ensure it returns empty for system emojis
emojis, appErr := th.App.GetMultipleEmojiByName(th.Context, []string{"+1"})
require.Nil(t, appErr)
assert.Empty(t, emojis)
}

View File

@@ -0,0 +1,144 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"github.com/mattermost/mattermost/server/v8/einterfaces"
ejobs "github.com/mattermost/mattermost/server/v8/einterfaces/jobs"
)
var accountMigrationInterface func(*App) einterfaces.AccountMigrationInterface
func RegisterAccountMigrationInterface(f func(*App) einterfaces.AccountMigrationInterface) {
accountMigrationInterface = f
}
var complianceInterface func(*App) einterfaces.ComplianceInterface
func RegisterComplianceInterface(f func(*App) einterfaces.ComplianceInterface) {
complianceInterface = f
}
var dataRetentionInterface func(*App) einterfaces.DataRetentionInterface
func RegisterDataRetentionInterface(f func(*App) einterfaces.DataRetentionInterface) {
dataRetentionInterface = f
}
var jobsDataRetentionJobInterface func(*Server) ejobs.DataRetentionJobInterface
func RegisterJobsDataRetentionJobInterface(f func(*Server) ejobs.DataRetentionJobInterface) {
jobsDataRetentionJobInterface = f
}
var jobsMessageExportJobInterface func(*Server) ejobs.MessageExportJobInterface
func RegisterJobsMessageExportJobInterface(f func(*Server) ejobs.MessageExportJobInterface) {
jobsMessageExportJobInterface = f
}
var jobsElasticsearchAggregatorInterface func(*Server) ejobs.ElasticsearchAggregatorInterface
func RegisterJobsElasticsearchAggregatorInterface(f func(*Server) ejobs.ElasticsearchAggregatorInterface) {
jobsElasticsearchAggregatorInterface = f
}
var jobsElasticsearchIndexerInterface func(*Server) ejobs.IndexerJobInterface
func RegisterJobsElasticsearchIndexerInterface(f func(*Server) ejobs.IndexerJobInterface) {
jobsElasticsearchIndexerInterface = f
}
var jobsLdapSyncInterface func(*App) ejobs.LdapSyncInterface
func RegisterJobsLdapSyncInterface(f func(*App) ejobs.LdapSyncInterface) {
jobsLdapSyncInterface = f
}
var ldapInterface func(*App) einterfaces.LdapInterface
func RegisterLdapInterface(f func(*App) einterfaces.LdapInterface) {
ldapInterface = f
}
var messageExportInterface func(*App) einterfaces.MessageExportInterface
func RegisterMessageExportInterface(f func(*App) einterfaces.MessageExportInterface) {
messageExportInterface = f
}
var cloudInterface func(*Server) einterfaces.CloudInterface
func RegisterCloudInterface(f func(*Server) einterfaces.CloudInterface) {
cloudInterface = f
}
var samlInterface func(*App) einterfaces.SamlInterface
func RegisterSamlInterface(f func(*App) einterfaces.SamlInterface) {
samlInterface = f
}
var notificationInterface func(*App) einterfaces.NotificationInterface
func RegisterNotificationInterface(f func(*App) einterfaces.NotificationInterface) {
notificationInterface = f
}
var outgoingOauthConnectionInterface func(*App) einterfaces.OutgoingOAuthConnectionInterface
func RegisterOutgoingOAuthConnectionInterface(f func(*App) einterfaces.OutgoingOAuthConnectionInterface) {
outgoingOauthConnectionInterface = f
}
var ipFilteringInterface func(*App) einterfaces.IPFilteringInterface
func RegisterIPFilteringInterface(f func(*App) einterfaces.IPFilteringInterface) {
ipFilteringInterface = f
}
var accessControlServiceInterface func(*App) einterfaces.AccessControlServiceInterface
func RegisterAccessControlServiceInterface(f func(*App) einterfaces.AccessControlServiceInterface) {
accessControlServiceInterface = f
}
var jobsAccessControlSyncJobInterface func(*Server) ejobs.AccessControlSyncJobInterface
func RegisterJobsAccessControlSyncJobInterface(f func(*Server) ejobs.AccessControlSyncJobInterface) {
jobsAccessControlSyncJobInterface = f
}
var jobsAccessControlTeamSyncJobInterface func(*Server) ejobs.AccessControlSyncJobInterface
func RegisterJobsAccessControlTeamSyncJobInterface(f func(*Server) ejobs.AccessControlSyncJobInterface) {
jobsAccessControlTeamSyncJobInterface = f
}
var pushProxyInterface func(*App) einterfaces.PushProxyInterface
func RegisterPushProxyInterface(f func(*App) einterfaces.PushProxyInterface) {
pushProxyInterface = f
}
var autoTranslationInterface func(*Server) einterfaces.AutoTranslationInterface
func RegisterAutoTranslationInterface(f func(*Server) einterfaces.AutoTranslationInterface) {
autoTranslationInterface = f
}
var intuneInterface func(*App) einterfaces.IntuneInterface
func RegisterIntuneInterface(f func(*App) einterfaces.IntuneInterface) {
intuneInterface = f
}
func (s *Server) initEnterprise() {
if cloudInterface != nil {
s.Cloud = cloudInterface(s)
}
if autoTranslationInterface != nil {
s.AutoTranslation = autoTranslationInterface(s)
}
}

View File

@@ -0,0 +1,91 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
storemocks "github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
"github.com/mattermost/mattermost/server/v8/einterfaces"
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
)
func TestSAMLSettings(t *testing.T) {
mainHelper.Parallel(t)
tt := []struct {
name string
setInterface bool
isNil bool
metadata string
}{
{
name: "No SAML Interfaces, default setting",
setInterface: false,
isNil: true,
},
{
name: "No SAML Interfaces, set config true",
setInterface: false,
isNil: true,
},
{
name: "Both SAML Interfaces, default setting",
setInterface: true,
isNil: false,
metadata: "samlTwo",
},
{
name: "Both SAML Interfaces, config true",
setInterface: true,
isNil: false,
metadata: "samlTwo",
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
saml2 := &mocks.SamlInterface{}
saml2.Mock.On("ConfigureSP", mock.AnythingOfType("*request.Context")).Return(nil)
saml2.Mock.On("GetMetadata", mock.AnythingOfType("*request.Context")).Return("samlTwo", nil)
if tc.setInterface {
RegisterSamlInterface(func(_ *App) einterfaces.SamlInterface {
return saml2
})
} else {
RegisterSamlInterface(nil)
}
th := SetupEnterpriseWithStoreMock(t)
mockStore := th.App.Srv().Store().(*storemocks.Store)
mockUserStore := storemocks.UserStore{}
mockUserStore.On("Count", mock.Anything).Return(int64(10), nil)
mockPostStore := storemocks.PostStore{}
mockPostStore.On("GetMaxPostSize").Return(model.PostMessageMaxBytesV2, nil)
mockSystemStore := storemocks.SystemStore{}
mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil)
mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil)
mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil)
mockStore.On("GetDBSchemaVersion").Return(1, nil)
mockStore.On("User").Return(&mockUserStore)
mockStore.On("Post").Return(&mockPostStore)
mockStore.On("System").Return(&mockSystemStore)
if tc.isNil {
assert.Nil(t, th.App.Channels().Saml)
} else {
assert.NotNil(t, th.App.Channels().Saml)
metadata, err := th.App.Channels().Saml.GetMetadata(request.TestContext(t))
assert.Nil(t, err)
assert.Equal(t, tc.metadata, metadata)
}
})
}
}

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"errors"
"github.com/golang-jwt/jwt/v5"
"github.com/mattermost/mattermost/server/public/model"
)
// WipeSignatureClaims are the verified contents of a session wipe push signature.
type WipeSignatureClaims struct {
UserId string
DeviceId string
AckId string
}
// VerifyWipeSignature checks a signature a client took from the session wipe push it received.
// Only sendMobileWipeSignal sets the user id claim, so requiring it stops a signature lifted
// from an ordinary push notification being replayed as proof of a wipe.
func (a *App) VerifyWipeSignature(signature string) (*WipeSignatureClaims, error) {
key := a.AsymmetricSigningKey()
if key == nil {
return nil, errors.New("no asymmetric signing key is available")
}
var claims pushJWTClaims
if _, err := jwt.ParseWithClaims(signature, &claims, func(*jwt.Token) (any, error) {
return &key.PublicKey, nil
}, jwt.WithValidMethods([]string{jwt.SigningMethodES256.Alg()})); err != nil {
return nil, err
}
if !model.IsValidId(claims.UserId) {
return nil, errors.New("wipe signature does not carry a valid user id claim")
}
return &WipeSignatureClaims{
UserId: claims.UserId,
DeviceId: claims.DeviceId,
AckId: claims.AckId,
}, nil
}

View File

@@ -0,0 +1,122 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestVerifyWipeSignature(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
signWith := func(t *testing.T, key *ecdsa.PrivateKey, claims jwt.Claims) string {
t.Helper()
signature, err := jwt.NewWithClaims(jwt.SigningMethodES256, claims).SignedString(key)
require.NoError(t, err)
return signature
}
t.Run("returns the user, device and ack a wipe push was signed for", func(t *testing.T) {
signature := signWith(t, th.App.AsymmetricSigningKey(), pushJWTClaims{
AckId: "ackid",
DeviceId: "testdevice",
UserId: th.BasicUser.Id,
})
claims, err := th.App.VerifyWipeSignature(signature)
require.NoError(t, err)
require.Equal(t, &WipeSignatureClaims{
UserId: th.BasicUser.Id,
DeviceId: "testdevice",
AckId: "ackid",
}, claims)
})
t.Run("rejects a signature carrying a malformed user id", func(t *testing.T) {
signature := signWith(t, th.App.AsymmetricSigningKey(), pushJWTClaims{
AckId: "ackid",
DeviceId: "testdevice",
UserId: "not-a-valid-user-id",
})
_, err := th.App.VerifyWipeSignature(signature)
require.Error(t, err)
})
t.Run("rejects a signature signed by another server's key", func(t *testing.T) {
otherKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
signature := signWith(t, otherKey, pushJWTClaims{
AckId: "ackid",
DeviceId: "testdevice",
UserId: th.BasicUser.Id,
})
_, err = th.App.VerifyWipeSignature(signature)
require.Error(t, err)
})
t.Run("rejects an unsigned token using the none algorithm", func(t *testing.T) {
signature, err := jwt.NewWithClaims(jwt.SigningMethodNone, pushJWTClaims{
AckId: "ackid",
DeviceId: "testdevice",
UserId: th.BasicUser.Id,
}).SignedString(jwt.UnsafeAllowNoneSignatureType)
require.NoError(t, err)
_, err = th.App.VerifyWipeSignature(signature)
require.Error(t, err)
})
t.Run("rejects an empty signature", func(t *testing.T) {
_, err := th.App.VerifyWipeSignature("")
require.Error(t, err)
})
}
// VerifyWipeSignature trusts the user id claim because only sendMobileWipeSignal sets it. If the
// regular notification path ever starts setting it too, every push signature becomes a valid
// wipe proof and this fails.
func TestRegularPushSignatureIsNotAcceptedAsAWipeSignature(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
handler := &testPushNotificationHandler{t: t, behavior: "simple"}
pushServer := httptest.NewServer(http.HandlerFunc(handler.handleReq))
t.Cleanup(pushServer.Close)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.EmailSettings.SendPushNotifications = true
*cfg.EmailSettings.PushNotificationServer = pushServer.URL
})
_, appErr := th.App.CreateSession(th.Context, &model.Session{
UserId: th.BasicUser.Id,
DeviceId: model.PushNotifyAppleReactNative + ":testdevice",
ExpiresAt: model.GetMillis() + 100000,
})
require.Nil(t, appErr)
appErr = th.App.sendPushNotificationToAllSessions(th.Context, &model.PushNotification{Type: model.PushTypeMessage}, th.BasicUser.Id, "")
require.Nil(t, appErr)
require.Eventually(t, func() bool {
return len(handler.notifications()) == 1
}, 2*time.Second, 10*time.Millisecond)
_, err := th.App.VerifyWipeSignature(handler.notifications()[0].Signature)
require.Error(t, err)
}

View File

@@ -0,0 +1,78 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"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"
)
// NotifyExpiredAccessTokensDeleted DMs the owner of each expired personal
// access token that the cleanup_expired_access_tokens job is about to delete.
//
// This is the at-expiry safety net that complements the pre-expiry warning
// cascade (pat_expiry_notify): it fires from the cleanup job at the moment a
// token is removed, so an owner whose fire-and-forget integration silently
// swallowed the rejection error still learns their token stopped working.
//
// It is best-effort. It is invoked before the tokens are deleted so the
// token→owner mapping is still available, and every failure is logged and
// swallowed so it never blocks the cleanup. Bot-owned tokens and tokens owned
// by deactivated users are skipped, matching pat_expiry_notify — GetExpiredBefore
// returns those too because the cleanup job must still delete them.
func (a *App) NotifyExpiredAccessTokensDeleted(rctx request.CTX, tokens []*model.UserAccessToken) {
if len(tokens) == 0 {
return
}
systemBot, appErr := a.GetSystemBot(rctx)
if appErr != nil {
rctx.Logger().Error("Failed to get system bot to notify expired personal access token owners", mlog.Err(appErr))
return
}
for _, token := range tokens {
user, appErr := a.GetUser(rctx, token.UserId)
if appErr != nil {
rctx.Logger().Warn("Failed to get user for expired personal access token notification",
mlog.String("user_id", token.UserId),
mlog.Err(appErr),
)
continue
}
// Skip bot accounts and deactivated users, matching pat_expiry_notify.
if user.IsBot || user.DeleteAt != 0 {
continue
}
channel, appErr := a.GetOrCreateDirectChannel(rctx, token.UserId, systemBot.UserId)
if appErr != nil {
rctx.Logger().Warn("Failed to get direct channel for expired personal access token notification",
mlog.String("user_id", token.UserId),
mlog.Err(appErr),
)
continue
}
T := i18n.GetUserTranslations(user.Locale)
post := &model.Post{
ChannelId: channel.Id,
Message: T("app.user_access_token.expired_deleted_notification", model.StringInterface{
"Description": token.Description,
}),
Type: model.PostTypeDefault,
UserId: systemBot.UserId,
}
if _, _, appErr := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); appErr != nil {
rctx.Logger().Warn("Failed to send expired personal access token notification",
mlog.String("user_id", token.UserId),
mlog.Err(appErr),
)
}
}
}

Some files were not shown because too many files have changed in this diff Show More