snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
This commit is contained in:
2869
server/channels/app/access_control.go
Normal file
2869
server/channels/app/access_control.go
Normal file
File diff suppressed because it is too large
Load Diff
168
server/channels/app/access_control_decision.go
Normal file
168
server/channels/app/access_control_decision.go
Normal 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,
|
||||
}
|
||||
}
|
||||
313
server/channels/app/access_control_decision_test.go
Normal file
313
server/channels/app/access_control_decision_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
880
server/channels/app/access_control_masking.go
Normal file
880
server/channels/app/access_control_masking.go
Normal 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
|
||||
}
|
||||
}
|
||||
1573
server/channels/app/access_control_masking_test.go
Normal file
1573
server/channels/app/access_control_masking_test.go
Normal file
File diff suppressed because it is too large
Load Diff
60
server/channels/app/access_control_merge_test.go
Normal file
60
server/channels/app/access_control_merge_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
17
server/channels/app/access_control_validation_test.go
Normal file
17
server/channels/app/access_control_validation_test.go
Normal 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)
|
||||
}
|
||||
242
server/channels/app/admin.go
Normal file
242
server/channels/app/admin.go
Normal 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")
|
||||
}
|
||||
96
server/channels/app/admin_test.go
Normal file
96
server/channels/app/admin_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
99
server/channels/app/agents.go
Normal file
99
server/channels/app/agents.go
Normal 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
|
||||
}
|
||||
247
server/channels/app/agents_bridge.go
Normal file
247
server/channels/app/agents_bridge.go
Normal 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)
|
||||
186
server/channels/app/agents_bridge_test.go
Normal file
186
server/channels/app/agents_bridge_test.go
Normal 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)
|
||||
67
server/channels/app/ai_bridge_test_helper.go
Normal file
67
server/channels/app/ai_bridge_test_helper.go
Normal 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
|
||||
}
|
||||
350
server/channels/app/analytics.go
Normal file
350
server/channels/app/analytics.go
Normal 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
162
server/channels/app/app.go
Normal 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()
|
||||
}
|
||||
316
server/channels/app/app_test.go
Normal file
316
server/channels/app/app_test.go
Normal 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)
|
||||
}
|
||||
237
server/channels/app/audit.go
Normal file
237
server/channels/app/audit.go
Normal 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
|
||||
}
|
||||
534
server/channels/app/authentication.go
Normal file
534
server/channels/app/authentication.go
Normal 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
|
||||
}
|
||||
818
server/channels/app/authentication_test.go
Normal file
818
server/channels/app/authentication_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
806
server/channels/app/authorization.go
Normal file
806
server/channels/app/authorization.go
Normal 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
|
||||
}
|
||||
2685
server/channels/app/authorization_test.go
Normal file
2685
server/channels/app/authorization_test.go
Normal file
File diff suppressed because it is too large
Load Diff
121
server/channels/app/auto_responder.go
Normal file
121
server/channels/app/auto_responder.go
Normal 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
|
||||
}
|
||||
390
server/channels/app/auto_responder_test.go
Normal file
390
server/channels/app/auto_responder_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
157
server/channels/app/board.go
Normal file
157
server/channels/app/board.go
Normal 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, <Err):
|
||||
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
|
||||
}
|
||||
94
server/channels/app/board_test.go
Normal file
94
server/channels/app/board_test.go
Normal 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
705
server/channels/app/bot.go
Normal 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
|
||||
}
|
||||
1365
server/channels/app/bot_test.go
Normal file
1365
server/channels/app/bot_test.go
Normal file
File diff suppressed because it is too large
Load Diff
105
server/channels/app/brand.go
Normal file
105
server/channels/app/brand.go
Normal 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
155
server/channels/app/busy.go
Normal 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
|
||||
}
|
||||
180
server/channels/app/busy_test.go
Normal file
180
server/channels/app/busy_test.go
Normal 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() {}
|
||||
5047
server/channels/app/channel.go
Normal file
5047
server/channels/app/channel.go
Normal file
File diff suppressed because it is too large
Load Diff
152
server/channels/app/channel_bookmark.go
Normal file
152
server/channels/app/channel_bookmark.go
Normal file
@@ -0,0 +1,152 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"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
|
||||
}
|
||||
630
server/channels/app/channel_bookmark_test.go
Normal file
630
server/channels/app/channel_bookmark_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
383
server/channels/app/channel_category.go
Normal file
383
server/channels/app/channel_category.go
Normal 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
|
||||
}
|
||||
792
server/channels/app/channel_category_test.go
Normal file
792
server/channels/app/channel_category_test.go
Normal 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,
|
||||
)
|
||||
})
|
||||
}
|
||||
384
server/channels/app/channel_discoverable_visibility.go
Normal file
384
server/channels/app/channel_discoverable_visibility.go
Normal 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
|
||||
}
|
||||
85
server/channels/app/channel_discoverable_visibility_test.go
Normal file
85
server/channels/app/channel_discoverable_visibility_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
216
server/channels/app/channel_guards.go
Normal file
216
server/channels/app/channel_guards.go
Normal 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
|
||||
}
|
||||
}
|
||||
383
server/channels/app/channel_guards_test.go
Normal file
383
server/channels/app/channel_guards_test.go
Normal 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)
|
||||
}
|
||||
447
server/channels/app/channel_join_request.go
Normal file
447
server/channels/app/channel_join_request.go
Normal 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)
|
||||
}
|
||||
379
server/channels/app/channel_join_request_test.go
Normal file
379
server/channels/app/channel_join_request_test.go
Normal 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
|
||||
}
|
||||
399
server/channels/app/channels.go
Normal file
399
server/channels/app/channels.go
Normal 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)
|
||||
}
|
||||
70
server/channels/app/cloud.go
Normal file
70
server/channels/app/cloud.go
Normal 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
|
||||
}
|
||||
20
server/channels/app/cluster.go
Normal file
20
server/channels/app/cluster.go
Normal 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()
|
||||
}
|
||||
16
server/channels/app/cluster_discovery.go
Normal file
16
server/channels/app/cluster_discovery.go
Normal 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()
|
||||
}
|
||||
68
server/channels/app/cluster_handlers.go
Normal file
68
server/channels/app/cluster_handlers.go
Normal 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()
|
||||
}
|
||||
944
server/channels/app/command.go
Normal file
944
server/channels/app/command.go
Normal 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
|
||||
}
|
||||
334
server/channels/app/command_autocomplete.go
Normal file
334
server/channels/app/command_autocomplete.go
Normal 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)
|
||||
}
|
||||
682
server/channels/app/command_autocomplete_test.go
Normal file
682
server/channels/app/command_autocomplete_test.go
Normal 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)
|
||||
86
server/channels/app/command_test.go
Normal file
86
server/channels/app/command_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/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)
|
||||
}
|
||||
}
|
||||
}
|
||||
86
server/channels/app/compliance.go
Normal file
86
server/channels/app/compliance.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"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
|
||||
}
|
||||
351
server/channels/app/config.go
Normal file
351
server/channels/app/config.go
Normal 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
|
||||
}
|
||||
142
server/channels/app/config_test.go
Normal file
142
server/channels/app/config_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
8
server/channels/app/constants.go
Normal file
8
server/channels/app/constants.go
Normal 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"
|
||||
1590
server/channels/app/content_flagging.go
Normal file
1590
server/channels/app/content_flagging.go
Normal file
File diff suppressed because it is too large
Load Diff
258
server/channels/app/content_flagging_exposure_report.go
Normal file
258
server/channels/app/content_flagging_exposure_report.go
Normal 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
|
||||
}
|
||||
596
server/channels/app/content_flagging_exposure_report_test.go
Normal file
596
server/channels/app/content_flagging_exposure_report_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
542
server/channels/app/content_flagging_report.go
Normal file
542
server/channels/app/content_flagging_report.go
Normal 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))
|
||||
}
|
||||
}
|
||||
774
server/channels/app/content_flagging_report_test.go
Normal file
774
server/channels/app/content_flagging_report_test.go
Normal 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
3438
server/channels/app/content_flagging_test.go
Normal file
3438
server/channels/app/content_flagging_test.go
Normal file
File diff suppressed because it is too large
Load Diff
51
server/channels/app/context.go
Normal file
51
server/channels/app/context.go
Normal 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
|
||||
}
|
||||
46
server/channels/app/context_test.go
Normal file
46
server/channels/app/context_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
45
server/channels/app/cpa_value_audit.go
Normal file
45
server/channels/app/cpa_value_audit.go
Normal 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)
|
||||
}
|
||||
151
server/channels/app/cpa_value_audit_test.go
Normal file
151
server/channels/app/cpa_value_audit_test.go
Normal 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"))
|
||||
})
|
||||
}
|
||||
290
server/channels/app/custom_profile_attributes_test.go
Normal file
290
server/channels/app/custom_profile_attributes_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
120
server/channels/app/data_retention.go
Normal file
120
server/channels/app/data_retention.go
Normal 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)
|
||||
}
|
||||
240
server/channels/app/delivery_tracking.go
Normal file
240
server/channels/app/delivery_tracking.go
Normal 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)
|
||||
}
|
||||
124
server/channels/app/delivery_tracking_config.go
Normal file
124
server/channels/app/delivery_tracking_config.go
Normal 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(),
|
||||
)
|
||||
}
|
||||
191
server/channels/app/delivery_tracking_config_test.go
Normal file
191
server/channels/app/delivery_tracking_config_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
806
server/channels/app/delivery_tracking_test.go
Normal file
806
server/channels/app/delivery_tracking_test.go
Normal 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()
|
||||
})
|
||||
}
|
||||
55
server/channels/app/desktop_login.go
Normal file
55
server/channels/app/desktop_login.go
Normal 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
|
||||
}
|
||||
75
server/channels/app/desktop_login_test.go
Normal file
75
server/channels/app/desktop_login_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"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)
|
||||
})
|
||||
}
|
||||
85
server/channels/app/doc.go
Normal file
85
server/channels/app/doc.go
Normal 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
|
||||
67
server/channels/app/download.go
Normal file
67
server/channels/app/download.go
Normal 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)
|
||||
}
|
||||
72
server/channels/app/download_test.go
Normal file
72
server/channels/app/download_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
192
server/channels/app/draft.go
Normal file
192
server/channels/app/draft.go
Normal 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
|
||||
}
|
||||
424
server/channels/app/draft_test.go
Normal file
424
server/channels/app/draft_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
285
server/channels/app/e2e_agents_bridge.go
Normal file
285
server/channels/app/e2e_agents_bridge.go
Normal 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)
|
||||
11
server/channels/app/email/.mockery.yaml
Normal file
11
server/channels/app/email/.mockery.yaml
Normal 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:
|
||||
1115
server/channels/app/email/email.go
Normal file
1115
server/channels/app/email/email.go
Normal file
File diff suppressed because it is too large
Load Diff
388
server/channels/app/email/email_batching.go
Normal file
388
server/channels/app/email/email_batching.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
285
server/channels/app/email/email_batching_test.go
Normal file
285
server/channels/app/email/email_batching_test.go
Normal 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")
|
||||
}
|
||||
486
server/channels/app/email/email_test.go
Normal file
486
server/channels/app/email/email_test.go
Normal 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, "&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, "&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, "&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)
|
||||
})
|
||||
}
|
||||
15
server/channels/app/email/errors.go
Normal file
15
server/channels/app/email/errors.go
Normal 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")
|
||||
)
|
||||
286
server/channels/app/email/helper_test.go
Normal file
286
server/channels/app/email/helper_test.go
Normal 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
|
||||
})
|
||||
}
|
||||
41
server/channels/app/email/main_test.go
Normal file
41
server/channels/app/email/main_test.go
Normal 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)
|
||||
}
|
||||
583
server/channels/app/email/mocks/ServiceInterface.go
Normal file
583
server/channels/app/email/mocks/ServiceInterface.go
Normal 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
|
||||
}
|
||||
186
server/channels/app/email/notification_email.go
Normal file
186
server/channels/app/email/notification_email.go
Normal 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
|
||||
}
|
||||
72
server/channels/app/email/notification_email_test.go
Normal file
72
server/channels/app/email/notification_email_test.go
Normal 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")
|
||||
}
|
||||
225
server/channels/app/email/service.go
Normal file
225
server/channels/app/email/service.go
Normal 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
|
||||
}
|
||||
48
server/channels/app/email/utils.go
Normal file
48
server/channels/app/email/utils.go
Normal 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
|
||||
}
|
||||
43
server/channels/app/email_test.go
Normal file
43
server/channels/app/email_test.go
Normal 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)
|
||||
}
|
||||
393
server/channels/app/emoji.go
Normal file
393
server/channels/app/emoji.go
Normal 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
|
||||
}
|
||||
130
server/channels/app/emoji_bench_test.go
Normal file
130
server/channels/app/emoji_bench_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
29
server/channels/app/emoji_test.go
Normal file
29
server/channels/app/emoji_test.go
Normal 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)
|
||||
}
|
||||
144
server/channels/app/enterprise.go
Normal file
144
server/channels/app/enterprise.go
Normal file
@@ -0,0 +1,144 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"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)
|
||||
}
|
||||
}
|
||||
91
server/channels/app/enterprise_test.go
Normal file
91
server/channels/app/enterprise_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
46
server/channels/app/ephemeral_mode.go
Normal file
46
server/channels/app/ephemeral_mode.go
Normal 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
|
||||
}
|
||||
122
server/channels/app/ephemeral_mode_test.go
Normal file
122
server/channels/app/ephemeral_mode_test.go
Normal 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)
|
||||
}
|
||||
78
server/channels/app/expired_access_token_notify.go
Normal file
78
server/channels/app/expired_access_token_notify.go
Normal 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
Reference in New Issue
Block a user