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

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

View File

@@ -0,0 +1,322 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
eMocks "github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
)
// These tests cover the endpoints that build their own file metadata instead of going
// through sanitizeFileAttachmentsForUser. With the channel's Access Control Policy denying
// download_file_attachment, every direct file endpoint returns 403, and none of these
// endpoints may hand back the file's rendered thumbnail for the same file, user and moment.
//
// Sent posts and their historical versions are redacted outright. Drafts and scheduled posts
// keep their metadata, because the author still has to see and send their own unsent
// content, and only the thumbnail is withheld.
//
// The policy is switched to deny only after the upload, which is the case Access Control
// Policies exist to cover: revoking access to content the user could previously reach.
// setFileDownloadPolicy enables ABAC and installs an access control service that returns
// the given decision for download_file_attachment while allowing every other action.
func setFileDownloadPolicy(t *testing.T, th *TestHelper, allowed bool) *eMocks.AccessControlServiceInterface {
t.Helper()
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = true
})
mockACS := &eMocks.AccessControlServiceInterface{}
original := th.App.Srv().Channels().AccessControl
th.App.Srv().Channels().AccessControl = mockACS
t.Cleanup(func() { th.App.Srv().Channels().AccessControl = original })
mockACS.On("AccessEvaluation", mock.Anything, mock.MatchedBy(func(req model.AccessRequest) bool {
return req.Action == model.AccessControlPolicyActionDownloadFileAttachment
})).Return(model.AccessDecision{Decision: allowed}, (*model.AppError)(nil))
mockACS.On("AccessEvaluation", mock.Anything, mock.Anything).
Return(model.AccessDecision{Decision: true}, (*model.AppError)(nil)).Maybe()
// Channel teardown and policy reads touch the service without being part of the
// behaviour under test.
mockACS.On("GetPolicy", mock.Anything, mock.AnythingOfType("string")).
Return(nil, (*model.AppError)(nil)).Maybe()
mockACS.On("DeletePolicy", mock.Anything, mock.AnythingOfType("string")).
Return((*model.AppError)(nil)).Maybe()
return mockACS
}
// requireDirectFileEndpointsDenied is the precondition shared by every case below: the
// endpoints that already enforce the policy must reject this user for this file.
func requireDirectFileEndpointsDenied(t *testing.T, th *TestHelper, fileID string) {
t.Helper()
_, resp, err := th.Client.GetFile(context.Background(), fileID)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
_, resp, err = th.Client.GetFileThumbnail(context.Background(), fileID)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
_, resp, err = th.Client.GetFilePreview(context.Background(), fileID)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
_, resp, err = th.Client.GetFileInfo(context.Background(), fileID)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
}
// requireNoThumbnailLeaked asserts that no rendered thumbnail reached a user the policy
// denies. This is the file's actual content, so no endpoint may serve it.
func requireNoThumbnailLeaked(t *testing.T, metadata *model.PostMetadata, where string) {
t.Helper()
if metadata == nil {
return
}
for _, info := range metadata.Files {
if info.MiniPreview != nil {
require.Failf(t, "leaked a thumbnail", "%s: returned a %d byte mini preview for file %s", where, len(*info.MiniPreview), info.Id)
}
}
}
// requireNoLeakedFileMetadata additionally asserts that no file metadata survived, which is
// what sent posts and their historical versions must do.
func requireNoLeakedFileMetadata(t *testing.T, metadata *model.PostMetadata, where string) {
t.Helper()
requireNoThumbnailLeaked(t, metadata, where)
if metadata == nil {
return
}
require.Emptyf(t, metadata.Files, "%s: leaked file metadata to a user denied download_file_attachment", where)
}
func uploadTestImage(t *testing.T, th *TestHelper, channelID, name string) *model.FileInfo {
t.Helper()
data, err := testutils.ReadTestFile("test.png")
require.NoError(t, err)
uploaded, _, err := th.Client.UploadFile(context.Background(), data, channelID, name)
require.NoError(t, err)
require.Len(t, uploaded.FileInfos, 1)
require.NotNil(t, uploaded.FileInfos[0].MiniPreview, "precondition: the uploaded image should have a mini preview to leak")
return uploaded.FileInfos[0]
}
func TestGetDraftsWithholdsThumbnailWhenPolicyDenies(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.PermissionPolicies = true
}).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true })
info := uploadTestImage(t, th, th.BasicChannel.Id, "draft-attachment.png")
_, _, err := th.Client.UpsertDraft(context.Background(), &model.Draft{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "draft with a gated attachment",
FileIds: model.StringArray{info.Id},
})
require.NoError(t, err)
setFileDownloadPolicy(t, th, false)
requireDirectFileEndpointsDenied(t, th, info.Id)
drafts, _, err := th.Client.GetDrafts(context.Background(), th.BasicUser.Id, th.BasicTeam.Id)
require.NoError(t, err)
require.Len(t, drafts, 1)
requireNoThumbnailLeaked(t, drafts[0].Metadata, "GET /users/me/teams/{team_id}/drafts")
// The author must still be able to see and send their own unsent draft. The web client
// builds the outgoing post's file_ids from metadata.files, so dropping the metadata
// would silently strip the attachment when the draft is sent.
require.Len(t, drafts[0].Metadata.Files, 1, "the author should still see their own draft attachment")
require.Equal(t, info.Id, drafts[0].Metadata.Files[0].Id)
require.Equal(t, model.StringArray{info.Id}, drafts[0].FileIds)
}
func TestGetScheduledPostsWithholdsThumbnailWhenPolicyDenies(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.PermissionPolicies = true
}).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional))
info := uploadTestImage(t, th, th.BasicChannel.Id, "scheduled-attachment.png")
created, _, err := th.Client.CreateScheduledPost(context.Background(), &model.ScheduledPost{
Draft: model.Draft{
CreateAt: model.GetMillis(),
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "scheduled post with a gated attachment",
FileIds: model.StringArray{info.Id},
},
ScheduledAt: model.GetMillis() + 100000,
})
require.NoError(t, err)
require.NotNil(t, created)
setFileDownloadPolicy(t, th, false)
requireDirectFileEndpointsDenied(t, th, info.Id)
scheduledPosts, _, err := th.Client.GetUserScheduledPosts(context.Background(), th.BasicTeam.Id, false)
require.NoError(t, err)
require.Len(t, scheduledPosts[th.BasicTeam.Id], 1)
scheduled := scheduledPosts[th.BasicTeam.Id][0]
requireNoThumbnailLeaked(t, scheduled.Metadata, "GET /posts/scheduled/team/{team_id}")
// The server sends this post from the stored FileIds when it fires, so dropping the
// metadata would make a client-side "send now" and the scheduled send disagree.
require.Len(t, scheduled.Metadata.Files, 1, "the author should still see their own scheduled attachment")
require.Equal(t, info.Id, scheduled.Metadata.Files[0].Id)
require.Equal(t, model.StringArray{info.Id}, scheduled.FileIds)
}
func TestGetEditHistoryForPostDoesNotLeakFilesDeniedByPolicy(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.PermissionPolicies = true
}).InitBasic(t)
info := uploadTestImage(t, th, th.BasicChannel.Id, "edit-history-attachment.png")
post, _, err := th.Client.CreatePost(context.Background(), &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "post with a gated attachment",
FileIds: model.StringArray{info.Id},
})
require.NoError(t, err)
_, _, err = th.Client.PatchPost(context.Background(), post.Id, &model.PostPatch{
Message: new("post with a gated attachment, edited"),
})
require.NoError(t, err)
setFileDownloadPolicy(t, th, false)
requireDirectFileEndpointsDenied(t, th, info.Id)
// The live post is redacted for this user. The historical versions of the same post are
// expected to match that, and are the control that proves the redaction works at all.
live, _, err := th.Client.GetPost(context.Background(), post.Id, "")
require.NoError(t, err)
requireNoLeakedFileMetadata(t, live.Metadata, "GET /posts/{post_id} (control)")
require.Empty(t, live.FileIds, "control: the live post should not expose file ids either")
history, _, err := th.Client.GetEditHistoryForPost(context.Background(), post.Id)
require.NoError(t, err)
require.NotEmpty(t, history)
for _, version := range history {
require.NotNil(t, version.Metadata)
requireNoLeakedFileMetadata(t, version.Metadata, "GET /posts/{post_id}/edit_history")
require.Empty(t, version.FileIds, "the historical version should not expose file ids either")
require.Equal(t, 1, version.Metadata.RedactedFileCount)
}
}
// TestFileMetadataServedWhenPolicyAllows is the other half of the contract. With a policy
// active but allowing the action, all three endpoints must still serve the metadata and the
// thumbnail, so enforcement cannot be satisfied by redacting unconditionally.
func TestFileMetadataServedWhenPolicyAllows(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.PermissionPolicies = true
}).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional))
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true })
requireFileServed := func(metadata *model.PostMetadata, fileID, where string) {
t.Helper()
require.NotNil(t, metadata, where)
require.Len(t, metadata.Files, 1, "%s: should serve the file metadata", where)
require.Equal(t, fileID, metadata.Files[0].Id, where)
require.NotNil(t, metadata.Files[0].MiniPreview, "%s: should serve the thumbnail", where)
require.Zero(t, metadata.RedactedFileCount, where)
}
draftFile := uploadTestImage(t, th, th.BasicChannel.Id, "draft-allowed.png")
_, _, err := th.Client.UpsertDraft(context.Background(), &model.Draft{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "draft with an allowed attachment",
FileIds: model.StringArray{draftFile.Id},
})
require.NoError(t, err)
scheduledFile := uploadTestImage(t, th, th.BasicChannel.Id, "scheduled-allowed.png")
_, _, err = th.Client.CreateScheduledPost(context.Background(), &model.ScheduledPost{
Draft: model.Draft{
CreateAt: model.GetMillis(),
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "scheduled post with an allowed attachment",
FileIds: model.StringArray{scheduledFile.Id},
},
ScheduledAt: model.GetMillis() + 100000,
})
require.NoError(t, err)
postFile := uploadTestImage(t, th, th.BasicChannel.Id, "edit-history-allowed.png")
post, _, err := th.Client.CreatePost(context.Background(), &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "post with an allowed attachment",
FileIds: model.StringArray{postFile.Id},
})
require.NoError(t, err)
_, _, err = th.Client.PatchPost(context.Background(), post.Id, &model.PostPatch{
Message: new("post with an allowed attachment, edited"),
})
require.NoError(t, err)
setFileDownloadPolicy(t, th, true)
_, _, err = th.Client.GetFileThumbnail(context.Background(), postFile.Id)
require.NoError(t, err, "precondition: the policy should allow download_file_attachment")
drafts, _, err := th.Client.GetDrafts(context.Background(), th.BasicUser.Id, th.BasicTeam.Id)
require.NoError(t, err)
require.Len(t, drafts, 1)
requireFileServed(drafts[0].Metadata, draftFile.Id, "GET /users/me/teams/{team_id}/drafts")
scheduledPosts, _, err := th.Client.GetUserScheduledPosts(context.Background(), th.BasicTeam.Id, false)
require.NoError(t, err)
require.Len(t, scheduledPosts[th.BasicTeam.Id], 1)
requireFileServed(scheduledPosts[th.BasicTeam.Id][0].Metadata, scheduledFile.Id, "GET /posts/scheduled/team/{team_id}")
history, _, err := th.Client.GetEditHistoryForPost(context.Background(), post.Id)
require.NoError(t, err)
require.NotEmpty(t, history)
for _, version := range history {
requireFileServed(version.Metadata, postFile.Id, "GET /posts/{post_id}/edit_history")
require.Equal(t, model.StringArray{postFile.Id}, version.FileIds)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,175 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
"github.com/stretchr/testify/require"
)
func TestSearchAccessControlDecisionActions(t *testing.T) {
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.PermissionPolicies = true
}).InitBasic(t)
channelResource := model.Resource{Type: model.AccessControlPolicyTypeChannel, ID: th.BasicChannel.Id}
t.Run("requires a session", func(t *testing.T) {
client := th.CreateClient() // unauthenticated
_, resp, err := client.SearchAccessControlDecisionActions(context.Background(), model.ActionSearchRequest{
Resource: channelResource,
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
})
require.Error(t, err)
CheckUnauthorizedStatus(t, resp)
})
t.Run("unsupported action returns bad request", func(t *testing.T) {
_, resp, err := th.Client.SearchAccessControlDecisionActions(context.Background(), model.ActionSearchRequest{
Resource: channelResource,
Actions: []string{"definitely_not_a_real_action"},
})
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("invalid request returns bad request", func(t *testing.T) {
_, resp, err := th.Client.SearchAccessControlDecisionActions(context.Background(), model.ActionSearchRequest{
Resource: model.Resource{Type: "", ID: th.BasicChannel.Id},
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
})
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("unsupported resource type returns bad request", func(t *testing.T) {
// A non-channel resource type must be rejected outright rather than silently skipping
// the resource-access check. Discovery mode (no actions) previously returned an empty 200.
_, resp, err := th.Client.SearchAccessControlDecisionActions(context.Background(), model.ActionSearchRequest{
Resource: model.Resource{Type: model.AccessControlPolicyTypeTeam, ID: th.BasicTeam.Id},
})
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("returns allowed when ABAC is inactive", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = false
})
out, resp, err := th.Client.SearchAccessControlDecisionActions(context.Background(), model.ActionSearchRequest{
Resource: channelResource,
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment, model.AccessControlPolicyActionDownloadFileAttachment},
})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Len(t, out.Decisions, 2)
require.True(t, out.Decisions[model.AccessControlPolicyActionUploadFileAttachment].Allowed)
require.True(t, out.Decisions[model.AccessControlPolicyActionUploadFileAttachment].Evaluated)
})
t.Run("returns PDP deny for the session user", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = true
})
mockACS := &mocks.AccessControlServiceInterface{}
original := th.App.Srv().Channels().AccessControl
th.App.Srv().Channels().AccessControl = mockACS
defer func() { th.App.Srv().Channels().AccessControl = original }()
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))
out, resp, err := th.Client.SearchAccessControlDecisionActions(context.Background(), model.ActionSearchRequest{
Resource: channelResource,
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.False(t, out.Decisions[model.AccessControlPolicyActionUploadFileAttachment].Allowed)
require.True(t, out.Decisions[model.AccessControlPolicyActionUploadFileAttachment].Evaluated)
require.Empty(t, out.Results) // denied actions must not appear in the AuthZEN results list
})
t.Run("discovery mode returns all actions when ABAC inactive", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = false
})
out, resp, err := th.Client.SearchAccessControlDecisionActions(context.Background(), model.ActionSearchRequest{
Resource: channelResource,
})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Len(t, out.Decisions, 2)
require.Len(t, out.Results, 2)
})
t.Run("subject not matching session user returns 403", func(t *testing.T) {
_, resp, err := th.Client.SearchAccessControlDecisionActions(context.Background(), model.ActionSearchRequest{
Resource: channelResource,
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
Subject: &model.ActionSearchSubject{ID: model.NewId()},
})
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("subject matching session user is accepted", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = false
})
out, resp, err := th.Client.SearchAccessControlDecisionActions(context.Background(), model.ActionSearchRequest{
Resource: channelResource,
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
Subject: &model.ActionSearchSubject{ID: th.BasicUser.Id},
})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.True(t, out.Decisions[model.AccessControlPolicyActionUploadFileAttachment].Allowed)
})
t.Run("non-member of private channel returns 403", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = false
})
// Create a private channel as system admin; BasicUser is not a member.
privateCh, _, err := th.SystemAdminClient.CreateChannel(context.Background(), &model.Channel{
TeamId: th.BasicTeam.Id,
Name: model.NewId(),
DisplayName: "Private Not Member",
Type: model.ChannelTypePrivate,
})
require.NoError(t, err)
_, resp, err := th.Client.SearchAccessControlDecisionActions(context.Background(), model.ActionSearchRequest{
Resource: model.Resource{Type: model.AccessControlPolicyTypeChannel, ID: privateCh.Id},
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
})
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("page field accepted and ignored", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = false
})
out, resp, err := th.Client.SearchAccessControlDecisionActions(context.Background(), model.ActionSearchRequest{
Resource: channelResource,
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
Page: &model.ActionSearchPage{NextToken: "tok"},
})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Nil(t, out.Page)
})
}

View File

@@ -0,0 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import "net/http"
func (api *API) InitAccessControlPolicyLocal() {
api.BaseRoutes.AccessControlPolicies.Handle("", api.APILocal(createAccessControlPolicy)).Methods(http.MethodPut)
api.BaseRoutes.AccessControlPolicies.Handle("/search", api.APILocal(searchAccessControlPolicies)).Methods(http.MethodPost)
api.BaseRoutes.AccessControlPolicies.Handle("/activate", api.APILocal(setActiveStatus)).Methods(http.MethodPut)
api.BaseRoutes.AccessControlPolicies.Handle("/cel/check", api.APILocal(checkExpression)).Methods(http.MethodPost)
api.BaseRoutes.AccessControlPolicies.Handle("/cel/test", api.APILocal(testExpression)).Methods(http.MethodPost)
api.BaseRoutes.AccessControlPolicies.Handle("/cel/validate_requester", api.APILocal(validateExpressionAgainstRequester)).Methods(http.MethodPost)
api.BaseRoutes.AccessControlPolicies.Handle("/cel/autocomplete/fields", api.APILocal(getFieldsAutocomplete)).Methods(http.MethodGet)
api.BaseRoutes.AccessControlPolicies.Handle("/cel/visual_ast", api.APILocal(convertToVisualAST)).Methods(http.MethodPost)
api.BaseRoutes.AccessControlPolicy.Handle("", api.APILocal(getAccessControlPolicy)).Methods(http.MethodGet)
api.BaseRoutes.AccessControlPolicy.Handle("", api.APILocal(deleteAccessControlPolicy)).Methods(http.MethodDelete)
api.BaseRoutes.AccessControlPolicy.Handle("/assign", api.APILocal(assignAccessPolicy)).Methods(http.MethodPost)
api.BaseRoutes.AccessControlPolicy.Handle("/unassign", api.APILocal(unassignAccessPolicy)).Methods(http.MethodDelete)
api.BaseRoutes.AccessControlPolicy.Handle("/resources/channels", api.APILocal(getChannelsForAccessControlPolicy)).Methods(http.MethodGet)
api.BaseRoutes.AccessControlPolicy.Handle("/resources/channels/search", api.APILocal(searchChannelsForAccessControlPolicy)).Methods(http.MethodPost)
}

View File

@@ -0,0 +1,76 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitAgents() {
// GET /api/v4/agents
api.BaseRoutes.Agents.Handle("", api.APISessionRequired(getAgents)).Methods(http.MethodGet)
// GET /api/v4/agents/status
api.BaseRoutes.Agents.Handle("/status", api.APISessionRequired(getAgentsStatus)).Methods(http.MethodGet)
// GET /api/v4/llmservices
api.BaseRoutes.LLMServices.Handle("", api.APISessionRequired(getLLMServices)).Methods(http.MethodGet)
}
func getAgentsStatus(c *Context, w http.ResponseWriter, r *http.Request) {
available, reason := c.App.GetAIPluginBridgeStatus(c.AppContext)
resp := &model.AgentsIntegrityResponse{
Available: available,
Reason: reason,
}
jsonData, err := json.Marshal(resp)
if err != nil {
c.Err = model.NewAppError("Api4.getAgentsStatus", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(jsonData); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getAgents(c *Context, w http.ResponseWriter, r *http.Request) {
agents, appErr := c.App.GetAgents(c.AppContext, c.AppContext.Session().UserId)
if appErr != nil {
c.Err = model.NewAppError("Api4.getAgents", "app.agents.get_agents.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
return
}
jsonData, err := json.Marshal(agents)
if err != nil {
c.Err = model.NewAppError("Api4.getAgents", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(jsonData); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getLLMServices(c *Context, w http.ResponseWriter, r *http.Request) {
services, appErr := c.App.GetLLMServices(c.AppContext, c.AppContext.Session().UserId)
if appErr != nil {
c.Err = model.NewAppError("Api4.getLLMServices", "app.agents.get_services.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
return
}
jsonData, err := json.Marshal(services)
if err != nil {
c.Err = model.NewAppError("Api4.getLLMServices", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(jsonData); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitAIBridgeTestHelper() {
api.BaseRoutes.System.Handle("/e2e/ai_bridge", api.APISessionRequired(putAIBridgeTestHelper)).Methods(http.MethodPut)
api.BaseRoutes.System.Handle("/e2e/ai_bridge", api.APISessionRequired(getAIBridgeTestHelper)).Methods(http.MethodGet)
api.BaseRoutes.System.Handle("/e2e/ai_bridge", api.APISessionRequired(deleteAIBridgeTestHelper)).Methods(http.MethodDelete)
}
func requireAIBridgeTestHelperEnabled(c *Context) {
if !*c.App.Config().ServiceSettings.EnableTesting {
c.Err = model.NewAppError("requireAIBridgeTestHelperEnabled", "api.ai_bridge_test_helper.disabled.app_error", nil, "", http.StatusNotImplemented)
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
}
}
func putAIBridgeTestHelper(c *Context, w http.ResponseWriter, r *http.Request) {
requireAIBridgeTestHelperEnabled(c)
if c.Err != nil {
return
}
var config model.AIBridgeTestHelperConfig
if err := json.NewDecoder(r.Body).Decode(&config); err != nil {
c.SetInvalidParamWithErr("body", err)
return
}
if appErr := c.App.SetAIBridgeTestHelperConfig(&config); appErr != nil {
c.Err = appErr
return
}
state := c.App.GetAIBridgeTestHelperState()
if err := json.NewEncoder(w).Encode(state); err != nil {
c.Logger.Warn("Error encoding response", mlog.Err(err))
}
}
func getAIBridgeTestHelper(c *Context, w http.ResponseWriter, r *http.Request) {
requireAIBridgeTestHelperEnabled(c)
if c.Err != nil {
return
}
if err := json.NewEncoder(w).Encode(c.App.GetAIBridgeTestHelperState()); err != nil {
c.Logger.Warn("Error encoding response", mlog.Err(err))
}
}
func deleteAIBridgeTestHelper(c *Context, w http.ResponseWriter, r *http.Request) {
requireAIBridgeTestHelperEnabled(c)
if c.Err != nil {
return
}
c.App.ResetAIBridgeTestHelper()
ReturnStatusOK(w)
}

View File

@@ -0,0 +1,218 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"testing"
"github.com/mattermost/mattermost/server/public/model"
app "github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAIBridgeTestHelperAdminPUTGETDELETE(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
*cfg.ServiceSettings.EnableTesting = true
}).InitBasic(t)
recordRequests := true
agentID := model.NewId()
serviceID := model.NewId()
config := &model.AIBridgeTestHelperConfig{
Status: &model.AIBridgeTestHelperStatus{
Available: true,
},
FeatureFlags: &model.AIBridgeTestHelperFeatureFlags{
EnableAIPluginBridge: new(true),
EnableAIRecaps: new(true),
},
Agents: []model.BridgeAgentInfo{{
ID: agentID,
DisplayName: "Claude",
Username: "claude.bot",
ServiceID: serviceID,
ServiceType: "anthropic",
IsDefault: true,
}},
Services: []model.BridgeServiceInfo{{
ID: serviceID,
Name: "Anthropic",
Type: "anthropic",
}},
AgentCompletions: map[string][]model.AIBridgeTestHelperCompletion{
string(app.BridgeOperationRewrite): {{
Completion: `{"rewritten_text":"Rewritten text"}`,
}},
},
RecordRequests: &recordRequests,
}
state, resp, err := th.SystemAdminClient.SetAIBridgeTestHelper(context.Background(), config)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotNil(t, state)
assert.True(t, state.RecordRequests)
require.Len(t, state.Agents, 1)
assert.Equal(t, agentID, state.Agents[0].ID)
require.NotNil(t, state.FeatureFlags)
require.NotNil(t, state.FeatureFlags.EnableAIPluginBridge)
require.NotNil(t, state.FeatureFlags.EnableAIRecaps)
assert.True(t, *state.FeatureFlags.EnableAIPluginBridge)
assert.True(t, *state.FeatureFlags.EnableAIRecaps)
state, resp, err = th.SystemAdminClient.GetAIBridgeTestHelper(context.Background())
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotNil(t, state)
assert.True(t, state.Status.Available)
require.Len(t, state.Services, 1)
assert.Equal(t, serviceID, state.Services[0].ID)
require.NotNil(t, state.FeatureFlags)
assert.True(t, *state.FeatureFlags.EnableAIPluginBridge)
assert.True(t, *state.FeatureFlags.EnableAIRecaps)
resp, err = th.SystemAdminClient.DeleteAIBridgeTestHelper(context.Background())
require.NoError(t, err)
CheckOKStatus(t, resp)
state, resp, err = th.SystemAdminClient.GetAIBridgeTestHelper(context.Background())
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotNil(t, state)
assert.Nil(t, state.Status)
assert.Empty(t, state.Agents)
assert.Empty(t, state.Services)
assert.Empty(t, state.AgentCompletions)
assert.Empty(t, state.RecordedRequests)
assert.False(t, state.RecordRequests)
require.NotNil(t, state.FeatureFlags)
assert.True(t, *state.FeatureFlags.EnableAIPluginBridge)
assert.True(t, *state.FeatureFlags.EnableAIRecaps)
}
func TestAIBridgeTestHelperRejectsNonAdmin(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
*cfg.ServiceSettings.EnableTesting = true
}).InitBasic(t)
_, resp, err := th.Client.SetAIBridgeTestHelper(context.Background(), &model.AIBridgeTestHelperConfig{
Status: &model.AIBridgeTestHelperStatus{Available: true},
})
require.Error(t, err)
CheckForbiddenStatus(t, resp)
}
func TestAIBridgeTestHelperDisabledWithoutEnableTesting(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
_, resp, err := th.SystemAdminClient.SetAIBridgeTestHelper(context.Background(), &model.AIBridgeTestHelperConfig{
Status: &model.AIBridgeTestHelperStatus{Available: true},
})
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
}
func TestAIBridgeTestHelperMocksRealEndpoints(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
*cfg.ServiceSettings.EnableTesting = true
}).InitBasic(t)
recordRequests := true
agentID := model.NewId()
serviceID := model.NewId()
_, resp, err := th.SystemAdminClient.SetAIBridgeTestHelper(context.Background(), &model.AIBridgeTestHelperConfig{
Status: &model.AIBridgeTestHelperStatus{
Available: true,
},
FeatureFlags: &model.AIBridgeTestHelperFeatureFlags{
EnableAIPluginBridge: new(true),
EnableAIRecaps: new(true),
},
Agents: []model.BridgeAgentInfo{{
ID: agentID,
DisplayName: "Claude",
Username: "claude.bot",
ServiceID: serviceID,
ServiceType: "anthropic",
IsDefault: true,
}},
Services: []model.BridgeServiceInfo{{
ID: serviceID,
Name: "Anthropic",
Type: "anthropic",
}},
AgentCompletions: map[string][]model.AIBridgeTestHelperCompletion{
string(app.BridgeOperationRewrite): {{
Completion: `{"rewritten_text":"Polished text"}`,
}},
},
RecordRequests: &recordRequests,
})
require.NoError(t, err)
CheckOKStatus(t, resp)
assert.True(t, th.App.Config().FeatureFlags.EnableAIPluginBridge)
assert.True(t, th.App.Config().FeatureFlags.EnableAIRecaps)
statusResp, httpResp, err := getAPIResponse[model.AgentsIntegrityResponse](t, th.Client, "/agents/status")
require.NoError(t, err)
CheckOKStatus(t, httpResp)
assert.True(t, statusResp.Available)
agentsResp, httpResp, err := getAPIResponse[[]model.BridgeAgentInfo](t, th.Client, "/agents")
require.NoError(t, err)
CheckOKStatus(t, httpResp)
require.Len(t, agentsResp, 1)
assert.Equal(t, agentID, agentsResp[0].ID)
servicesResp, httpResp, err := getAPIResponse[[]model.BridgeServiceInfo](t, th.Client, "/llmservices")
require.NoError(t, err)
CheckOKStatus(t, httpResp)
require.Len(t, servicesResp, 1)
assert.Equal(t, serviceID, servicesResp[0].ID)
rewriteReq := &model.RewriteRequest{
AgentID: agentID,
Message: "the status update",
Action: model.RewriteActionFixSpelling,
}
rewriteHTTPResp, err := th.Client.DoAPIPostJSON(context.Background(), "/posts/rewrite", rewriteReq)
require.NoError(t, err)
defer rewriteHTTPResp.Body.Close()
rewriteResp, rewriteModelResp, err := model.DecodeJSONFromResponse[model.RewriteResponse](rewriteHTTPResp)
require.NoError(t, err)
CheckOKStatus(t, rewriteModelResp)
assert.Equal(t, "Polished text", rewriteResp.RewrittenText)
state, helperResp, err := th.SystemAdminClient.GetAIBridgeTestHelper(context.Background())
require.NoError(t, err)
CheckOKStatus(t, helperResp)
require.Len(t, state.RecordedRequests, 1)
assert.Equal(t, string(app.BridgeOperationRewrite), state.RecordedRequests[0].Operation)
assert.Equal(t, agentID, state.RecordedRequests[0].AgentID)
}
func getAPIResponse[T any](tb testing.TB, client *model.Client4, route string) (T, *model.Response, error) {
tb.Helper()
httpResp, err := client.DoAPIGet(context.Background(), route, "")
if err != nil {
var zero T
return zero, model.BuildResponse(httpResp), err
}
defer httpResp.Body.Close()
return model.DecodeJSONFromResponse[T](httpResp)
}

548
server/channels/api4/api.go Normal file
View File

@@ -0,0 +1,548 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"net/http"
"github.com/gorilla/mux"
_ "github.com/mattermost/go-i18n/i18n"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/manualtesting"
"github.com/mattermost/mattermost/server/v8/channels/web"
)
type Routes struct {
Root *mux.Router // ''
APIRoot *mux.Router // 'api/v4'
APIRoot5 *mux.Router // 'api/v5'
Users *mux.Router // 'api/v4/users'
User *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}'
UserByUsername *mux.Router // 'api/v4/users/username/{username:[A-Za-z0-9\\_\\-\\.]+}'
UserByEmail *mux.Router // 'api/v4/users/email/{email:.+}'
Bots *mux.Router // 'api/v4/bots'
Bot *mux.Router // 'api/v4/bots/{bot_user_id:[A-Za-z0-9]+}'
Teams *mux.Router // 'api/v4/teams'
TeamsForUser *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/teams'
Team *mux.Router // 'api/v4/teams/{team_id:[A-Za-z0-9]+}'
TeamForUser *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/teams/{team_id:[A-Za-z0-9]+}'
UserThreads *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/teams/{team_id:[A-Za-z0-9]+}/threads'
UserThread *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/teams/{team_id:[A-Za-z0-9]+}/threads/{thread_id:[A-Za-z0-9]+}'
TeamByName *mux.Router // 'api/v4/teams/name/{team_name:[A-Za-z0-9_-]+}'
TeamMembers *mux.Router // 'api/v4/teams/{team_id:[A-Za-z0-9]+}/members'
TeamMember *mux.Router // 'api/v4/teams/{team_id:[A-Za-z0-9]+}/members/{user_id:[A-Za-z0-9]+}'
TeamMembersForUser *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/teams/members'
Channels *mux.Router // 'api/v4/channels'
Channel *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}'
ChannelForUser *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/channels/{channel_id:[A-Za-z0-9]+}'
ChannelByName *mux.Router // 'api/v4/teams/{team_id:[A-Za-z0-9]+}/channels/name/{channel_name:[A-Za-z0-9_-]+}'
ChannelByNameForTeamName *mux.Router // 'api/v4/teams/name/{team_name:[A-Za-z0-9_-]+}/channels/name/{channel_name:[A-Za-z0-9_-]+}'
ChannelsForTeam *mux.Router // 'api/v4/teams/{team_id:[A-Za-z0-9]+}/channels'
ChannelMembers *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}/members'
ChannelMember *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}/members/{user_id:[A-Za-z0-9]+}'
ChannelMembersForUser *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/teams/{team_id:[A-Za-z0-9]+}/channels/members'
ChannelModerations *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}/moderations'
ChannelCategories *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/teams/{team_id:[A-Za-z0-9]+}/channels/categories'
ChannelBookmarks *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}/bookmarks'
ChannelBookmark *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}/bookmarks/{bookmark_id:[A-Za-z0-9]+}'
ChannelViews *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}/views'
ChannelView *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}/views/{view_id:[A-Za-z0-9]+}'
ChannelViewPosts *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}/views/{view_id:[A-Za-z0-9]+}/posts'
Posts *mux.Router // 'api/v4/posts'
Post *mux.Router // 'api/v4/posts/{post_id:[A-Za-z0-9]+}'
PostsForChannel *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}/posts'
PostsForUser *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/posts'
PostForUser *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/posts/{post_id:[A-Za-z0-9]+}'
Files *mux.Router // 'api/v4/files'
File *mux.Router // 'api/v4/files/{file_id:[A-Za-z0-9]+}'
Uploads *mux.Router // 'api/v4/uploads'
Upload *mux.Router // 'api/v4/uploads/{upload_id:[A-Za-z0-9]+}'
Plugins *mux.Router // 'api/v4/plugins'
Plugin *mux.Router // 'api/v4/plugins/{plugin_id:[A-Za-z0-9\\_\\-\\.]+}'
PublicFile *mux.Router // '/files/{file_id:[A-Za-z0-9]+}/public'
Commands *mux.Router // 'api/v4/commands'
Command *mux.Router // 'api/v4/commands/{command_id:[A-Za-z0-9]+}'
Hooks *mux.Router // 'api/v4/hooks'
IncomingHooks *mux.Router // 'api/v4/hooks/incoming'
IncomingHook *mux.Router // 'api/v4/hooks/incoming/{hook_id:[A-Za-z0-9]+}'
OutgoingHooks *mux.Router // 'api/v4/hooks/outgoing'
OutgoingHook *mux.Router // 'api/v4/hooks/outgoing/{hook_id:[A-Za-z0-9]+}'
OAuth *mux.Router // 'api/v4/oauth'
OAuthApps *mux.Router // 'api/v4/oauth/apps'
OAuthApp *mux.Router // 'api/v4/oauth/apps/{app_id:[A-Za-z0-9]+}'
SAML *mux.Router // 'api/v4/saml'
Compliance *mux.Router // 'api/v4/compliance'
Cluster *mux.Router // 'api/v4/cluster'
Image *mux.Router // 'api/v4/image'
LDAP *mux.Router // 'api/v4/ldap'
Elasticsearch *mux.Router // 'api/v4/elasticsearch'
DataRetention *mux.Router // 'api/v4/data_retention'
EphemeralMode *mux.Router // 'api/v4/ephemeral_mode'
Brand *mux.Router // 'api/v4/brand'
System *mux.Router // 'api/v4/system'
Jobs *mux.Router // 'api/v4/jobs'
Recaps *mux.Router // 'api/v4/recaps'
ScheduledRecaps *mux.Router // 'api/v4/scheduled_recaps'
ScheduledRecap *mux.Router // 'api/v4/scheduled_recaps/{scheduled_recap_id:[A-Za-z0-9]+}'
Preferences *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/preferences'
License *mux.Router // 'api/v4/license'
Public *mux.Router // 'api/v4/public'
Reactions *mux.Router // 'api/v4/reactions'
Roles *mux.Router // 'api/v4/roles'
Schemes *mux.Router // 'api/v4/schemes'
Emojis *mux.Router // 'api/v4/emoji'
Emoji *mux.Router // 'api/v4/emoji/{emoji_id:[A-Za-z0-9]+}'
EmojiByName *mux.Router // 'api/v4/emoji/name/{emoji_name:[A-Za-z0-9\\_\\-\\+]+}'
ReactionByNameForPostForUser *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/posts/{post_id:[A-Za-z0-9]+}/reactions/{emoji_name:[A-Za-z0-9\\_\\-\\+]+}'
TermsOfService *mux.Router // 'api/v4/terms_of_service'
Groups *mux.Router // 'api/v4/groups'
Cloud *mux.Router // 'api/v4/cloud'
Imports *mux.Router // 'api/v4/imports'
Import *mux.Router // 'api/v4/imports/{import_name:.+\\.zip}'
Exports *mux.Router // 'api/v4/exports'
Export *mux.Router // 'api/v4/exports/{export_name:.+\\.zip}'
RemoteCluster *mux.Router // 'api/v4/remotecluster'
SharedChannels *mux.Router // 'api/v4/sharedchannels'
ChannelForRemote *mux.Router // 'api/v4/remotecluster/{remote_id:[A-Za-z0-9]+}/channels/{channel_id:[A-Za-z0-9]+}'
SharedChannelRemotes *mux.Router // 'api/v4/remotecluster/{remote_id:[A-Za-z0-9]+}/sharedchannelremotes'
Permissions *mux.Router // 'api/v4/permissions'
Usage *mux.Router // 'api/v4/usage'
HostedCustomer *mux.Router // 'api/v4/hosted_customer'
Drafts *mux.Router // 'api/v4/drafts'
IPFiltering *mux.Router // 'api/v4/ip_filtering'
Reports *mux.Router // 'api/v4/reports'
Limits *mux.Router // 'api/v4/limits'
OutgoingOAuthConnections *mux.Router // 'api/v4/oauth/outgoing_connections'
OutgoingOAuthConnection *mux.Router // 'api/v4/oauth/outgoing_connections/{outgoing_oauth_connection_id:[A-Za-z0-9]+}'
CustomProfileAttributes *mux.Router // 'api/v4/custom_profile_attributes'
CustomProfileAttributesFields *mux.Router // 'api/v4/custom_profile_attributes/fields'
CustomProfileAttributesField *mux.Router // 'api/v4/custom_profile_attributes/fields/{field_id:[A-Za-z0-9]+}'
CustomProfileAttributesValues *mux.Router // 'api/v4/custom_profile_attributes/values'
AuditLogs *mux.Router // 'api/v4/audit_logs'
AccessControlPolicies *mux.Router // 'api/v4/access_control_policies'
AccessControlPolicy *mux.Router // 'api/v4/access_control_policies/{policy_id:[A-Za-z0-9]+}'
AccessControlDecisions *mux.Router // 'api/v4/access_control/decisions'
ContentFlagging *mux.Router // 'api/v4/content_flagging'
DeliveryTracking *mux.Router // 'api/v4/delivery_tracking'
Agents *mux.Router // 'api/v4/agents'
LLMServices *mux.Router // 'api/v4/llmservices'
Boards *mux.Router // 'api/v4/boards'
Properties *mux.Router // 'api/v4/properties'
PropertyFields *mux.Router // 'api/v4/properties/groups/{group_name:[a-z][a-z0-9_]*}/{object_type:[a-z]+}/fields'
PropertyField *mux.Router // 'api/v4/properties/groups/{group_name:[a-z][a-z0-9_]*}/{object_type:[a-z]+}/fields/{field_id:[A-Za-z0-9]+}'
PropertyFieldsSearch *mux.Router // 'api/v4/properties/groups/{group_name:[a-z][a-z0-9_]*}/fields/search'
PropertyValues *mux.Router // 'api/v4/properties/groups/{group_name:[a-z][a-z0-9_]*}/{object_type:[a-z]+}/values/{target_id:[A-Za-z0-9]+}'
PropertySystemValues *mux.Router // 'api/v4/properties/groups/{group_name:[a-z][a-z0-9_]*}/system/values'
}
type API struct {
srv *app.Server
BaseRoutes *Routes
}
func Init(srv *app.Server) (*API, error) {
api := &API{
srv: srv,
BaseRoutes: &Routes{},
}
api.BaseRoutes.Root = srv.Router
api.BaseRoutes.APIRoot = srv.Router.PathPrefix(model.APIURLSuffix).Subrouter()
api.BaseRoutes.APIRoot5 = srv.Router.PathPrefix(model.APIURLSuffixV5).Subrouter()
api.BaseRoutes.Users = api.BaseRoutes.APIRoot.PathPrefix("/users").Subrouter()
api.BaseRoutes.User = api.BaseRoutes.APIRoot.PathPrefix("/users/{user_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.UserByUsername = api.BaseRoutes.Users.PathPrefix("/username/{username:[A-Za-z0-9\\_\\-\\.]+}").Subrouter()
api.BaseRoutes.UserByEmail = api.BaseRoutes.Users.PathPrefix("/email/{email:.+}").Subrouter()
api.BaseRoutes.Bots = api.BaseRoutes.APIRoot.PathPrefix("/bots").Subrouter()
api.BaseRoutes.Bot = api.BaseRoutes.APIRoot.PathPrefix("/bots/{bot_user_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.Teams = api.BaseRoutes.APIRoot.PathPrefix("/teams").Subrouter()
api.BaseRoutes.TeamsForUser = api.BaseRoutes.User.PathPrefix("/teams").Subrouter()
api.BaseRoutes.Team = api.BaseRoutes.Teams.PathPrefix("/{team_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.TeamForUser = api.BaseRoutes.TeamsForUser.PathPrefix("/{team_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.UserThreads = api.BaseRoutes.TeamForUser.PathPrefix("/threads").Subrouter()
api.BaseRoutes.UserThread = api.BaseRoutes.TeamForUser.PathPrefix("/threads/{thread_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.TeamByName = api.BaseRoutes.Teams.PathPrefix("/name/{team_name:[A-Za-z0-9_-]+}").Subrouter()
api.BaseRoutes.TeamMembers = api.BaseRoutes.Team.PathPrefix("/members").Subrouter()
api.BaseRoutes.TeamMember = api.BaseRoutes.TeamMembers.PathPrefix("/{user_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.TeamMembersForUser = api.BaseRoutes.User.PathPrefix("/teams/members").Subrouter()
api.BaseRoutes.Channels = api.BaseRoutes.APIRoot.PathPrefix("/channels").Subrouter()
api.BaseRoutes.Channel = api.BaseRoutes.Channels.PathPrefix("/{channel_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.ChannelForUser = api.BaseRoutes.User.PathPrefix("/channels/{channel_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.ChannelByName = api.BaseRoutes.Team.PathPrefix("/channels/name/{channel_name:[A-Za-z0-9_-]+}").Subrouter()
api.BaseRoutes.ChannelByNameForTeamName = api.BaseRoutes.TeamByName.PathPrefix("/channels/name/{channel_name:[A-Za-z0-9_-]+}").Subrouter()
api.BaseRoutes.ChannelsForTeam = api.BaseRoutes.Team.PathPrefix("/channels").Subrouter()
api.BaseRoutes.ChannelMembers = api.BaseRoutes.Channel.PathPrefix("/members").Subrouter()
api.BaseRoutes.ChannelMember = api.BaseRoutes.ChannelMembers.PathPrefix("/{user_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.ChannelMembersForUser = api.BaseRoutes.User.PathPrefix("/teams/{team_id:[A-Za-z0-9]+}/channels/members").Subrouter()
api.BaseRoutes.ChannelModerations = api.BaseRoutes.Channel.PathPrefix("/moderations").Subrouter()
api.BaseRoutes.ChannelCategories = api.BaseRoutes.User.PathPrefix("/teams/{team_id:[A-Za-z0-9]+}/channels/categories").Subrouter()
api.BaseRoutes.ChannelBookmarks = api.BaseRoutes.Channel.PathPrefix("/bookmarks").Subrouter()
api.BaseRoutes.ChannelBookmark = api.BaseRoutes.ChannelBookmarks.PathPrefix("/{bookmark_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.ChannelViews = api.BaseRoutes.Channel.PathPrefix("/views").Subrouter()
api.BaseRoutes.ChannelView = api.BaseRoutes.ChannelViews.PathPrefix("/{view_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.ChannelViewPosts = api.BaseRoutes.ChannelView.PathPrefix("/posts").Subrouter()
api.BaseRoutes.Posts = api.BaseRoutes.APIRoot.PathPrefix("/posts").Subrouter()
api.BaseRoutes.Post = api.BaseRoutes.Posts.PathPrefix("/{post_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.PostsForChannel = api.BaseRoutes.Channel.PathPrefix("/posts").Subrouter()
api.BaseRoutes.PostsForUser = api.BaseRoutes.User.PathPrefix("/posts").Subrouter()
api.BaseRoutes.PostForUser = api.BaseRoutes.PostsForUser.PathPrefix("/{post_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.Files = api.BaseRoutes.APIRoot.PathPrefix("/files").Subrouter()
api.BaseRoutes.File = api.BaseRoutes.Files.PathPrefix("/{file_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.PublicFile = api.BaseRoutes.Root.PathPrefix("/files/{file_id:[A-Za-z0-9]+}/public").Subrouter()
api.BaseRoutes.Uploads = api.BaseRoutes.APIRoot.PathPrefix("/uploads").Subrouter()
api.BaseRoutes.Upload = api.BaseRoutes.Uploads.PathPrefix("/{upload_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.Plugins = api.BaseRoutes.APIRoot.PathPrefix("/plugins").Subrouter()
api.BaseRoutes.Plugin = api.BaseRoutes.Plugins.PathPrefix("/{plugin_id:[A-Za-z0-9\\_\\-\\.]+}").Subrouter()
api.BaseRoutes.Commands = api.BaseRoutes.APIRoot.PathPrefix("/commands").Subrouter()
api.BaseRoutes.Command = api.BaseRoutes.Commands.PathPrefix("/{command_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.Hooks = api.BaseRoutes.APIRoot.PathPrefix("/hooks").Subrouter()
api.BaseRoutes.IncomingHooks = api.BaseRoutes.Hooks.PathPrefix("/incoming").Subrouter()
api.BaseRoutes.IncomingHook = api.BaseRoutes.IncomingHooks.PathPrefix("/{hook_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.OutgoingHooks = api.BaseRoutes.Hooks.PathPrefix("/outgoing").Subrouter()
api.BaseRoutes.OutgoingHook = api.BaseRoutes.OutgoingHooks.PathPrefix("/{hook_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.SAML = api.BaseRoutes.APIRoot.PathPrefix("/saml").Subrouter()
api.BaseRoutes.OAuth = api.BaseRoutes.APIRoot.PathPrefix("/oauth").Subrouter()
api.BaseRoutes.OAuthApps = api.BaseRoutes.OAuth.PathPrefix("/apps").Subrouter()
api.BaseRoutes.OAuthApp = api.BaseRoutes.OAuthApps.PathPrefix("/{app_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.Compliance = api.BaseRoutes.APIRoot.PathPrefix("/compliance").Subrouter()
api.BaseRoutes.Cluster = api.BaseRoutes.APIRoot.PathPrefix("/cluster").Subrouter()
api.BaseRoutes.LDAP = api.BaseRoutes.APIRoot.PathPrefix("/ldap").Subrouter()
api.BaseRoutes.Brand = api.BaseRoutes.APIRoot.PathPrefix("/brand").Subrouter()
api.BaseRoutes.System = api.BaseRoutes.APIRoot.PathPrefix("/system").Subrouter()
api.BaseRoutes.Preferences = api.BaseRoutes.User.PathPrefix("/preferences").Subrouter()
api.BaseRoutes.License = api.BaseRoutes.APIRoot.PathPrefix("/license").Subrouter()
api.BaseRoutes.Public = api.BaseRoutes.APIRoot.PathPrefix("/public").Subrouter()
api.BaseRoutes.Reactions = api.BaseRoutes.APIRoot.PathPrefix("/reactions").Subrouter()
api.BaseRoutes.Jobs = api.BaseRoutes.APIRoot.PathPrefix("/jobs").Subrouter()
api.BaseRoutes.Recaps = api.BaseRoutes.APIRoot.PathPrefix("/recaps").Subrouter()
api.BaseRoutes.ScheduledRecaps = api.BaseRoutes.APIRoot.PathPrefix("/scheduled_recaps").Subrouter()
api.BaseRoutes.ScheduledRecap = api.BaseRoutes.ScheduledRecaps.PathPrefix("/{scheduled_recap_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.Elasticsearch = api.BaseRoutes.APIRoot.PathPrefix("/elasticsearch").Subrouter()
api.BaseRoutes.DataRetention = api.BaseRoutes.APIRoot.PathPrefix("/data_retention").Subrouter()
api.BaseRoutes.EphemeralMode = api.BaseRoutes.APIRoot.PathPrefix("/ephemeral_mode").Subrouter()
api.BaseRoutes.Emojis = api.BaseRoutes.APIRoot.PathPrefix("/emoji").Subrouter()
api.BaseRoutes.Emoji = api.BaseRoutes.APIRoot.PathPrefix("/emoji/{emoji_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.EmojiByName = api.BaseRoutes.Emojis.PathPrefix("/name/{emoji_name:[A-Za-z0-9\\_\\-\\+]+}").Subrouter()
api.BaseRoutes.ReactionByNameForPostForUser = api.BaseRoutes.PostForUser.PathPrefix("/reactions/{emoji_name:[A-Za-z0-9\\_\\-\\+]+}").Subrouter()
api.BaseRoutes.Roles = api.BaseRoutes.APIRoot.PathPrefix("/roles").Subrouter()
api.BaseRoutes.Schemes = api.BaseRoutes.APIRoot.PathPrefix("/schemes").Subrouter()
api.BaseRoutes.Image = api.BaseRoutes.APIRoot.PathPrefix("/image").Subrouter()
api.BaseRoutes.TermsOfService = api.BaseRoutes.APIRoot.PathPrefix("/terms_of_service").Subrouter()
api.BaseRoutes.Groups = api.BaseRoutes.APIRoot.PathPrefix("/groups").Subrouter()
api.BaseRoutes.Cloud = api.BaseRoutes.APIRoot.PathPrefix("/cloud").Subrouter()
api.BaseRoutes.Imports = api.BaseRoutes.APIRoot.PathPrefix("/imports").Subrouter()
api.BaseRoutes.Import = api.BaseRoutes.Imports.PathPrefix("/{import_name:.+\\.zip}").Subrouter()
api.BaseRoutes.Exports = api.BaseRoutes.APIRoot.PathPrefix("/exports").Subrouter()
api.BaseRoutes.Export = api.BaseRoutes.Exports.PathPrefix("/{export_name:.+\\.zip}").Subrouter()
api.BaseRoutes.RemoteCluster = api.BaseRoutes.APIRoot.PathPrefix("/remotecluster").Subrouter()
api.BaseRoutes.SharedChannels = api.BaseRoutes.APIRoot.PathPrefix("/sharedchannels").Subrouter()
api.BaseRoutes.SharedChannelRemotes = api.BaseRoutes.RemoteCluster.PathPrefix("/{remote_id:[A-Za-z0-9]+}/sharedchannelremotes").Subrouter()
api.BaseRoutes.ChannelForRemote = api.BaseRoutes.RemoteCluster.PathPrefix("/{remote_id:[A-Za-z0-9]+}/channels/{channel_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.Permissions = api.BaseRoutes.APIRoot.PathPrefix("/permissions").Subrouter()
api.BaseRoutes.Usage = api.BaseRoutes.APIRoot.PathPrefix("/usage").Subrouter()
api.BaseRoutes.HostedCustomer = api.BaseRoutes.APIRoot.PathPrefix("/hosted_customer").Subrouter()
api.BaseRoutes.Drafts = api.BaseRoutes.APIRoot.PathPrefix("/drafts").Subrouter()
api.BaseRoutes.IPFiltering = api.BaseRoutes.APIRoot.PathPrefix("/ip_filtering").Subrouter()
api.BaseRoutes.Reports = api.BaseRoutes.APIRoot.PathPrefix("/reports").Subrouter()
api.BaseRoutes.Limits = api.BaseRoutes.APIRoot.PathPrefix("/limits").Subrouter()
api.BaseRoutes.OutgoingOAuthConnections = api.BaseRoutes.APIRoot.PathPrefix("/oauth/outgoing_connections").Subrouter()
api.BaseRoutes.OutgoingOAuthConnection = api.BaseRoutes.OutgoingOAuthConnections.PathPrefix("/{outgoing_oauth_connection_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.CustomProfileAttributes = api.BaseRoutes.APIRoot.PathPrefix("/custom_profile_attributes").Subrouter()
api.BaseRoutes.CustomProfileAttributesFields = api.BaseRoutes.CustomProfileAttributes.PathPrefix("/fields").Subrouter()
api.BaseRoutes.CustomProfileAttributesField = api.BaseRoutes.CustomProfileAttributesFields.PathPrefix("/{field_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.CustomProfileAttributesValues = api.BaseRoutes.CustomProfileAttributes.PathPrefix("/values").Subrouter()
api.BaseRoutes.AuditLogs = api.BaseRoutes.APIRoot.PathPrefix("/audit_logs").Subrouter()
api.BaseRoutes.AccessControlPolicies = api.BaseRoutes.APIRoot.PathPrefix("/access_control_policies").Subrouter()
api.BaseRoutes.AccessControlPolicy = api.BaseRoutes.APIRoot.PathPrefix("/access_control_policies/{policy_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.AccessControlDecisions = api.BaseRoutes.APIRoot.PathPrefix("/access_control/decisions").Subrouter()
api.BaseRoutes.ContentFlagging = api.BaseRoutes.APIRoot.PathPrefix("/content_flagging").Subrouter()
api.BaseRoutes.DeliveryTracking = api.BaseRoutes.APIRoot.PathPrefix("/delivery_tracking").Subrouter()
api.BaseRoutes.Agents = api.BaseRoutes.APIRoot.PathPrefix("/agents").Subrouter()
api.BaseRoutes.LLMServices = api.BaseRoutes.APIRoot.PathPrefix("/llmservices").Subrouter()
api.BaseRoutes.Boards = api.BaseRoutes.APIRoot.PathPrefix("/boards").Subrouter()
api.BaseRoutes.Properties = api.BaseRoutes.APIRoot.PathPrefix("/properties").Subrouter()
api.BaseRoutes.PropertyFields = api.BaseRoutes.Properties.PathPrefix("/groups/{group_name:[a-z][a-z0-9_]*}/{object_type:[a-z]+}/fields").Subrouter()
api.BaseRoutes.PropertyField = api.BaseRoutes.PropertyFields.PathPrefix("/{field_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.PropertyFieldsSearch = api.BaseRoutes.Properties.PathPrefix("/groups/{group_name:[a-z][a-z0-9_]*}/fields/search").Subrouter()
api.BaseRoutes.PropertyValues = api.BaseRoutes.Properties.PathPrefix("/groups/{group_name:[a-z][a-z0-9_]*}/{object_type:[a-z]+}/values/{target_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.PropertySystemValues = api.BaseRoutes.Properties.PathPrefix("/groups/{group_name:[a-z][a-z0-9_]*}/system/values").Subrouter()
api.InitUser()
api.InitBot()
api.InitTeam()
api.InitChannel()
api.InitPost()
api.InitFile()
api.InitUpload()
api.InitSystem()
api.InitAIBridgeTestHelper()
api.InitLicense()
api.InitConfig()
api.InitWebhook()
api.InitPreference()
api.InitSaml()
api.InitCompliance()
api.InitCluster()
api.InitLdap()
api.InitElasticsearch()
api.InitDataRetention()
api.InitEphemeralMode()
api.InitBrand()
api.InitJob()
api.InitRecap()
api.InitScheduledRecap()
api.InitCommand()
api.InitStatus()
api.InitWebSocket()
api.InitEmoji()
api.InitOAuth()
api.InitReaction()
api.InitPlugin()
api.InitRole()
api.InitScheme()
api.InitImage()
api.InitTermsOfService()
api.InitGroup()
api.InitAction()
api.InitCloud()
api.InitImport()
api.InitRemoteCluster()
api.InitSharedChannels()
api.InitPermissions()
api.InitExport()
api.InitUsage()
api.InitHostedCustomer()
api.InitDrafts()
api.InitIPFiltering()
api.InitChannelBookmarks()
api.InitView()
api.InitBoard()
api.InitReports()
api.InitLimits()
api.InitOutgoingOAuthConnection()
api.InitClientPerformanceMetrics()
api.InitScheduledPost()
api.InitCustomProfileAttributes()
api.InitAuditLogging()
api.InitAccessControlPolicy()
api.InitContentFlagging()
api.InitDeliveryTracking()
api.InitAgents()
api.InitProperties()
// If we allow testing then listen for manual testing URL hits
if *srv.Config().ServiceSettings.EnableTesting {
api.BaseRoutes.Root.Handle("/manualtest", api.APIHandler(manualtesting.ManualTest)).Methods(http.MethodGet)
}
srv.Router.Handle("/api/v4/{anything:.*}", http.HandlerFunc(api.Handle404))
InitLocal(srv)
return api, nil
}
func InitLocal(srv *app.Server) *API {
api := &API{
srv: srv,
BaseRoutes: &Routes{},
}
api.BaseRoutes.Root = srv.LocalRouter
api.BaseRoutes.APIRoot = srv.LocalRouter.PathPrefix(model.APIURLSuffix).Subrouter()
api.BaseRoutes.Users = api.BaseRoutes.APIRoot.PathPrefix("/users").Subrouter()
api.BaseRoutes.User = api.BaseRoutes.Users.PathPrefix("/{user_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.UserByUsername = api.BaseRoutes.Users.PathPrefix("/username/{username:[A-Za-z0-9\\_\\-\\.]+}").Subrouter()
api.BaseRoutes.UserByEmail = api.BaseRoutes.Users.PathPrefix("/email/{email:.+}").Subrouter()
api.BaseRoutes.Bots = api.BaseRoutes.APIRoot.PathPrefix("/bots").Subrouter()
api.BaseRoutes.Bot = api.BaseRoutes.APIRoot.PathPrefix("/bots/{bot_user_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.Teams = api.BaseRoutes.APIRoot.PathPrefix("/teams").Subrouter()
api.BaseRoutes.Team = api.BaseRoutes.Teams.PathPrefix("/{team_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.TeamByName = api.BaseRoutes.Teams.PathPrefix("/name/{team_name:[A-Za-z0-9_-]+}").Subrouter()
api.BaseRoutes.TeamMembers = api.BaseRoutes.Team.PathPrefix("/members").Subrouter()
api.BaseRoutes.TeamMember = api.BaseRoutes.TeamMembers.PathPrefix("/{user_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.Channels = api.BaseRoutes.APIRoot.PathPrefix("/channels").Subrouter()
api.BaseRoutes.Channel = api.BaseRoutes.Channels.PathPrefix("/{channel_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.ChannelByName = api.BaseRoutes.Team.PathPrefix("/channels/name/{channel_name:[A-Za-z0-9_-]+}").Subrouter()
api.BaseRoutes.ChannelByNameForTeamName = api.BaseRoutes.TeamByName.PathPrefix("/channels/name/{channel_name:[A-Za-z0-9_-]+}").Subrouter()
api.BaseRoutes.ChannelsForTeam = api.BaseRoutes.Team.PathPrefix("/channels").Subrouter()
api.BaseRoutes.ChannelMembers = api.BaseRoutes.Channel.PathPrefix("/members").Subrouter()
api.BaseRoutes.ChannelMember = api.BaseRoutes.ChannelMembers.PathPrefix("/{user_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.ChannelMembersForUser = api.BaseRoutes.User.PathPrefix("/teams/{team_id:[A-Za-z0-9]+}/channels/members").Subrouter()
api.BaseRoutes.Plugins = api.BaseRoutes.APIRoot.PathPrefix("/plugins").Subrouter()
api.BaseRoutes.Plugin = api.BaseRoutes.Plugins.PathPrefix("/{plugin_id:[A-Za-z0-9\\_\\-\\.]+}").Subrouter()
api.BaseRoutes.Commands = api.BaseRoutes.APIRoot.PathPrefix("/commands").Subrouter()
api.BaseRoutes.Command = api.BaseRoutes.Commands.PathPrefix("/{command_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.Hooks = api.BaseRoutes.APIRoot.PathPrefix("/hooks").Subrouter()
api.BaseRoutes.IncomingHooks = api.BaseRoutes.Hooks.PathPrefix("/incoming").Subrouter()
api.BaseRoutes.IncomingHook = api.BaseRoutes.IncomingHooks.PathPrefix("/{hook_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.OutgoingHooks = api.BaseRoutes.Hooks.PathPrefix("/outgoing").Subrouter()
api.BaseRoutes.OutgoingHook = api.BaseRoutes.OutgoingHooks.PathPrefix("/{hook_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.License = api.BaseRoutes.APIRoot.PathPrefix("/license").Subrouter()
api.BaseRoutes.Groups = api.BaseRoutes.APIRoot.PathPrefix("/groups").Subrouter()
api.BaseRoutes.LDAP = api.BaseRoutes.APIRoot.PathPrefix("/ldap").Subrouter()
api.BaseRoutes.System = api.BaseRoutes.APIRoot.PathPrefix("/system").Subrouter()
api.BaseRoutes.Preferences = api.BaseRoutes.User.PathPrefix("/preferences").Subrouter()
api.BaseRoutes.Posts = api.BaseRoutes.APIRoot.PathPrefix("/posts").Subrouter()
api.BaseRoutes.Post = api.BaseRoutes.Posts.PathPrefix("/{post_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.PostsForChannel = api.BaseRoutes.Channel.PathPrefix("/posts").Subrouter()
api.BaseRoutes.Roles = api.BaseRoutes.APIRoot.PathPrefix("/roles").Subrouter()
api.BaseRoutes.Uploads = api.BaseRoutes.APIRoot.PathPrefix("/uploads").Subrouter()
api.BaseRoutes.Upload = api.BaseRoutes.Uploads.PathPrefix("/{upload_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.Imports = api.BaseRoutes.APIRoot.PathPrefix("/imports").Subrouter()
api.BaseRoutes.Import = api.BaseRoutes.Imports.PathPrefix("/{import_name:.+\\.zip}").Subrouter()
api.BaseRoutes.Exports = api.BaseRoutes.APIRoot.PathPrefix("/exports").Subrouter()
api.BaseRoutes.Export = api.BaseRoutes.Exports.PathPrefix("/{export_name:.+\\.zip}").Subrouter()
api.BaseRoutes.Jobs = api.BaseRoutes.APIRoot.PathPrefix("/jobs").Subrouter()
api.BaseRoutes.SAML = api.BaseRoutes.APIRoot.PathPrefix("/saml").Subrouter()
api.BaseRoutes.CustomProfileAttributes = api.BaseRoutes.APIRoot.PathPrefix("/custom_profile_attributes").Subrouter()
api.BaseRoutes.CustomProfileAttributesFields = api.BaseRoutes.CustomProfileAttributes.PathPrefix("/fields").Subrouter()
api.BaseRoutes.CustomProfileAttributesField = api.BaseRoutes.CustomProfileAttributesFields.PathPrefix("/{field_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.CustomProfileAttributesValues = api.BaseRoutes.CustomProfileAttributes.PathPrefix("/values").Subrouter()
api.BaseRoutes.AccessControlPolicies = api.BaseRoutes.APIRoot.PathPrefix("/access_control_policies").Subrouter()
api.BaseRoutes.AccessControlPolicy = api.BaseRoutes.APIRoot.PathPrefix("/access_control_policies/{policy_id:[A-Za-z0-9]+}").Subrouter()
api.InitUserLocal()
api.InitTeamLocal()
api.InitChannelLocal()
api.InitConfigLocal()
api.InitWebhookLocal()
api.InitPluginLocal()
api.InitCommandLocal()
api.InitLicenseLocal()
api.InitBotLocal()
api.InitGroupLocal()
api.InitLdapLocal()
api.InitSystemLocal()
api.InitPostLocal()
api.InitPreferenceLocal()
api.InitRoleLocal()
api.InitUploadLocal()
api.InitImportLocal()
api.InitExportLocal()
api.InitJobLocal()
api.InitSamlLocal()
api.InitCustomProfileAttributesLocal()
api.InitAccessControlPolicyLocal()
api.InitStatusLocal()
srv.LocalRouter.Handle("/api/v4/{anything:.*}", http.HandlerFunc(api.Handle404))
return api
}
func (api *API) Handle404(w http.ResponseWriter, r *http.Request) {
app := app.New(app.ServerConnector(api.srv.Channels()))
web.Handle404(app, w, r)
}
var ReturnStatusOK = web.ReturnStatusOK

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"os"
"testing"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/stretchr/testify/assert"
)
func TestEnvironmentVariableHandling(t *testing.T) {
// TestEnvironmentVariableHandling should NEVER be run with t.Parallel()
originalConsoleLevel := os.Getenv("MM_LOGSETTINGS_CONSOLELEVEL")
defer func() {
// Restore original environment variables
if originalConsoleLevel != "" {
os.Setenv("MM_LOGSETTINGS_CONSOLELEVEL", originalConsoleLevel)
} else {
os.Unsetenv("MM_LOGSETTINGS_CONSOLELEVEL")
}
}()
t.Run("MM_LOGSETTINGS_CONSOLELEVEL should be respected when set", func(t *testing.T) {
// never run with t.Parallel()
// Set the console level environment variable
os.Setenv("MM_LOGSETTINGS_CONSOLELEVEL", "ERROR")
defer os.Unsetenv("MM_LOGSETTINGS_CONSOLELEVEL")
th := SetupEnterprise(t)
// Verify the console level was set from the environment variable
config := th.App.Config()
assert.Equal(t, "ERROR", *config.LogSettings.ConsoleLevel)
})
t.Run("Only MM_LOGSETTINGS_CONSOLELEVEL is manually processed", func(t *testing.T) {
// never run with t.Parallel()
// This test verifies that we haven't accidentally enabled general environment
// variable processing - we only manually handle MM_LOGSETTINGS_CONSOLELEVEL
// First, test without MM_LOGSETTINGS_CONSOLELEVEL set
os.Unsetenv("MM_LOGSETTINGS_CONSOLELEVEL")
th1 := SetupEnterprise(t)
config1 := th1.App.Config()
defaultConsoleLevel := *config1.LogSettings.ConsoleLevel
// Now test with MM_LOGSETTINGS_CONSOLELEVEL set
os.Setenv("MM_LOGSETTINGS_CONSOLELEVEL", "DEBUG")
defer os.Unsetenv("MM_LOGSETTINGS_CONSOLELEVEL")
th2 := SetupEnterprise(t)
config2 := th2.App.Config()
customConsoleLevel := *config2.LogSettings.ConsoleLevel
// Verify our manual implementation works
assert.Equal(t, mlog.LvlDebug.Name, defaultConsoleLevel, "Default should be debug")
assert.Equal(t, "DEBUG", customConsoleLevel, "Environment variable should be respected")
assert.NotEqual(t, defaultConsoleLevel, customConsoleLevel, "Values should be different")
})
}

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"mime/multipart"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
)
func (api *API) InitAuditLogging() {
api.BaseRoutes.AuditLogs.Handle("/certificate", api.APISessionRequired(addAuditLogCertificate)).Methods(http.MethodPost)
api.BaseRoutes.AuditLogs.Handle("/certificate", api.APISessionRequired(removeAuditLogCertificate)).Methods(http.MethodDelete)
}
func parseAuditLogCertificateRequest(r *http.Request, maxFileSize int64) (*multipart.FileHeader, *model.AppError) {
err := r.ParseMultipartForm(maxFileSize)
if err != nil {
return nil, model.NewAppError("addAuditLogCertificate", "api.admin.add_certificate.no_file.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
m := r.MultipartForm
fileArray, ok := m.File["certificate"]
if !ok || len(fileArray) == 0 {
return nil, model.NewAppError("addAuditLogCertificate", "api.admin.add_certificate.no_file.app_error", nil, "", http.StatusBadRequest)
}
if len(fileArray) > 1 {
return nil, model.NewAppError("addAuditLogCertificate", "api.admin.add_certificate.multiple_files.app_error", nil, "", http.StatusBadRequest)
}
return fileArray[0], nil
}
func addAuditLogCertificate(c *Context, w http.ResponseWriter, r *http.Request) {
c.Logger.Debug("addAuditLogCertificate")
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteExperimentalFeatures) {
c.SetPermissionError(model.PermissionSysconsoleWriteExperimentalFeatures)
return
}
fileData, err := parseAuditLogCertificateRequest(r, *c.App.Config().FileSettings.MaxFileSize)
if err != nil {
c.Err = err
return
}
auditRec := c.MakeAuditRecord(model.AuditEventAddAuditLogCertificate, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "filename", fileData.Filename)
if err := c.App.AddAuditLogCertificate(c.AppContext, fileData); err != nil {
c.Err = err
return
}
auditRec.Success()
ReturnStatusOK(w)
}
func removeAuditLogCertificate(c *Context, w http.ResponseWriter, r *http.Request) {
c.Logger.Debug("removeAuditLogCertificate")
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteExperimentalFeatures) {
c.SetPermissionError(model.PermissionSysconsoleWriteExperimentalFeatures)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventRemoveAuditLogCertificate, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
if err := c.App.RemoveAuditLogCertificate(c.AppContext); err != nil {
c.Err = err
return
}
auditRec.Success()
ReturnStatusOK(w)
}

View File

@@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitBoard() {
if api.srv.Config().FeatureFlags.IntegratedBoards {
api.BaseRoutes.Boards.Handle("", api.APISessionRequired(createBoard)).Methods(http.MethodPost)
}
}
func createBoard(c *Context, w http.ResponseWriter, r *http.Request) {
var channel model.Channel
if err := json.NewDecoder(r.Body).Decode(&channel); err != nil {
c.SetInvalidParamWithDetails("body", err.Error())
return
}
if !channel.IsBoard() {
c.SetInvalidParamWithDetails("type", "must be BO or BP")
return
}
if channel.TeamId == "" {
c.SetInvalidParam("team_id")
return
}
// Permission check
if channel.IsOpenBoard() {
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionCreatePublicChannel) {
c.SetPermissionError(model.PermissionCreatePublicChannel)
return
}
} else {
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionCreatePrivateChannel) {
c.SetPermissionError(model.PermissionCreatePrivateChannel)
return
}
}
channel.CreatorId = c.AppContext.Session().UserId
auditRec := c.MakeAuditRecord(model.AuditEventCreateBoard, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "channel", &channel)
board, appErr := c.App.CreateBoardChannel(c.AppContext, &channel)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
auditRec.AddEventResultState(board)
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(board); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

View File

@@ -0,0 +1,146 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func setupBoardTest(t *testing.T) *TestHelper {
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.IntegratedBoards = true
}).InitBasic(t)
return th
}
func TestCreateBoard(t *testing.T) {
mainHelper.Parallel(t)
th := setupBoardTest(t)
client := th.Client
t.Run("create open board", func(t *testing.T) {
board := &model.Channel{
DisplayName: "My Board",
Name: GenerateTestChannelName(),
Type: model.ChannelTypeOpenBoard,
TeamId: th.BasicTeam.Id,
}
created, resp, err := client.CreateBoard(context.Background(), board)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
require.NotNil(t, created)
assert.Equal(t, model.ChannelTypeOpenBoard, created.Type)
assert.NotEmpty(t, created.Id)
// Verify view was created with kanban props
views, appErr := th.App.GetViewsForChannel(th.Context, created.Id, model.ViewQueryOpts{PerPage: 10})
require.Nil(t, appErr)
require.Len(t, views, 1)
assert.Equal(t, model.ViewTypeKanban, views[0].Type)
kanban, kErr := model.KanbanPropsFromProps(views[0].Props)
require.NoError(t, kErr)
assert.NotEmpty(t, kanban.GroupBy.FieldID, "kanban should reference a field")
require.Len(t, kanban.GroupBy.Columns, 3, "should have 3 default columns")
assert.Equal(t, model.BoardsStatusOptionTodo, kanban.GroupBy.Columns[0].Name)
assert.Equal(t, model.BoardsStatusOptionInProgress, kanban.GroupBy.Columns[1].Name)
assert.Equal(t, model.BoardsStatusOptionComplete, kanban.GroupBy.Columns[2].Name)
for _, col := range kanban.GroupBy.Columns {
assert.Len(t, col.OptionIDs, 1, "each default column maps to one option")
assert.NotEmpty(t, col.ID, "column should have a stable ID")
}
// Verify linked_properties on channel
linkedProps, ok := created.Props[model.ChannelPropsBoardLinkedProperties]
require.True(t, ok, "channel should have board:linked_properties")
linkedList, ok := linkedProps.([]any)
require.True(t, ok, "linked_properties should be a list")
assert.Len(t, linkedList, 2, "should have status and assignee field IDs")
})
t.Run("create private board", func(t *testing.T) {
board := &model.Channel{
DisplayName: "Private Board",
Name: GenerateTestChannelName(),
Type: model.ChannelTypePrivateBoard,
TeamId: th.BasicTeam.Id,
}
_, resp, err := client.CreateBoard(context.Background(), board)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
})
t.Run("rejected when feature flag off", func(t *testing.T) {
// Routes are registered at startup based on feature flag.
// When the flag is off at startup, /boards route does not exist -> 404.
// Use a separate TestHelper with the flag off.
th2 := Setup(t).InitBasic(t)
board := &model.Channel{
DisplayName: "Disabled Board",
Name: GenerateTestChannelName(),
Type: model.ChannelTypeOpenBoard,
TeamId: th2.BasicTeam.Id,
}
_, resp, err := th2.Client.CreateBoard(context.Background(), board)
require.Error(t, err)
CheckNotFoundStatus(t, resp)
})
t.Run("rejected with empty display name", func(t *testing.T) {
board := &model.Channel{
DisplayName: "",
Name: GenerateTestChannelName(),
Type: model.ChannelTypeOpenBoard,
TeamId: th.BasicTeam.Id,
}
_, resp, err := client.CreateBoard(context.Background(), board)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("rejected with whitespace-only display name", func(t *testing.T) {
board := &model.Channel{
DisplayName: " ",
Name: GenerateTestChannelName(),
Type: model.ChannelTypeOpenBoard,
TeamId: th.BasicTeam.Id,
}
_, resp, err := client.CreateBoard(context.Background(), board)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("board not in sidebar", func(t *testing.T) {
board := &model.Channel{
DisplayName: "Sidebar Check Board",
Name: GenerateTestChannelName(),
Type: model.ChannelTypeOpenBoard,
TeamId: th.BasicTeam.Id,
}
created, _, err := client.CreateBoard(context.Background(), board)
require.NoError(t, err)
require.NotNil(t, created)
// Verify board doesn't appear in sidebar
categories, _, catErr := client.GetSidebarCategoriesForTeamForUser(context.Background(), th.BasicUser.Id, th.BasicTeam.Id, "")
require.NoError(t, catErr)
for _, cat := range categories.Categories {
for _, chID := range cat.Channels {
assert.NotEqual(t, created.Id, chID, "board should not appear in sidebar")
}
}
})
}

319
server/channels/api4/bot.go Normal file
View File

@@ -0,0 +1,319 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"strconv"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitBot() {
api.BaseRoutes.Bots.Handle("", api.APISessionRequired(createBot)).Methods(http.MethodPost)
api.BaseRoutes.Bot.Handle("", api.APISessionRequired(patchBot)).Methods(http.MethodPut)
api.BaseRoutes.Bot.Handle("", api.APISessionRequired(getBot)).Methods(http.MethodGet)
api.BaseRoutes.Bots.Handle("", api.APISessionRequired(getBots)).Methods(http.MethodGet)
api.BaseRoutes.Bot.Handle("/disable", api.APISessionRequired(disableBot)).Methods(http.MethodPost)
api.BaseRoutes.Bot.Handle("/enable", api.APISessionRequired(enableBot)).Methods(http.MethodPost)
api.BaseRoutes.Bot.Handle("/convert_to_user", api.APISessionRequired(convertBotToUser)).Methods(http.MethodPost)
api.BaseRoutes.Bot.Handle("/assign/{user_id:[A-Za-z0-9]+}", api.APISessionRequired(assignBot)).Methods(http.MethodPost)
}
func createBot(c *Context, w http.ResponseWriter, r *http.Request) {
var botPatch *model.BotPatch
err := json.NewDecoder(r.Body).Decode(&botPatch)
if err != nil || botPatch == nil {
c.SetInvalidParamWithErr("bot", err)
return
}
bot := &model.Bot{
OwnerId: c.AppContext.Session().UserId,
}
bot.Patch(botPatch)
auditRec := c.MakeAuditRecord(model.AuditEventCreateBot, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "bot", bot)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionCreateBot) {
c.SetPermissionError(model.PermissionCreateBot)
return
}
if user, err := c.App.GetUser(c.AppContext, c.AppContext.Session().UserId); err == nil {
if user.IsBot {
c.SetPermissionError(model.PermissionCreateBot)
return
}
}
if !*c.App.Config().ServiceSettings.EnableBotAccountCreation {
c.Err = model.NewAppError("createBot", "api.bot.create_disabled", nil, "", http.StatusForbidden)
return
}
createdBot, appErr := c.App.CreateBot(c.AppContext, bot)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
auditRec.AddEventObjectType("bot")
auditRec.AddEventResultState(createdBot) // overwrite meta
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(createdBot); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func patchBot(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireBotUserId()
if c.Err != nil {
return
}
botUserId := c.Params.BotUserId
var botPatch *model.BotPatch
err := json.NewDecoder(r.Body).Decode(&botPatch)
if err != nil || botPatch == nil {
c.SetInvalidParamWithErr("bot", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventPatchBot, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "id", botUserId)
model.AddEventParameterAuditableToAuditRec(auditRec, "bot", botPatch)
if err := c.App.SessionHasPermissionToManageBot(c.AppContext, *c.AppContext.Session(), botUserId); err != nil {
c.Err = err
return
}
updatedBot, appErr := c.App.PatchBot(c.AppContext, botUserId, botPatch)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
auditRec.AddEventResultState(updatedBot)
auditRec.AddEventObjectType("bot")
if err := json.NewEncoder(w).Encode(updatedBot); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getBot(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireBotUserId()
if c.Err != nil {
return
}
botUserId := c.Params.BotUserId
includeDeleted, _ := strconv.ParseBool(r.URL.Query().Get("include_deleted"))
bot, appErr := c.App.GetBot(c.AppContext, botUserId, includeDeleted)
if appErr != nil {
c.Err = appErr
return
}
if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionReadOthersBots) {
// Allow access to any bot.
} else if bot.OwnerId == c.AppContext.Session().UserId {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionReadBots) {
// Pretend like the bot doesn't exist at all to avoid revealing that the
// user is a bot. It's kind of silly in this case, sine we created the bot,
// but we don't have read bot permissions.
c.Err = model.MakeBotNotFoundError("permissions", botUserId)
return
}
} else {
// Pretend like the bot doesn't exist at all, to avoid revealing that the
// user is a bot.
c.Err = model.MakeBotNotFoundError("permissions", botUserId)
return
}
if c.HandleEtag(bot.Etag(), "Get Bot", w, r) {
return
}
if err := json.NewEncoder(w).Encode(bot); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getBots(c *Context, w http.ResponseWriter, r *http.Request) {
includeDeleted, _ := strconv.ParseBool(r.URL.Query().Get("include_deleted"))
onlyOrphaned, _ := strconv.ParseBool(r.URL.Query().Get("only_orphaned"))
var OwnerId string
if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionReadOthersBots) {
// Get bots created by any user.
OwnerId = ""
} else if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionReadBots) {
// Only get bots created by this user.
OwnerId = c.AppContext.Session().UserId
} else {
c.SetPermissionError(model.PermissionReadBots)
return
}
bots, appErr := c.App.GetBots(c.AppContext, &model.BotGetOptions{
Page: c.Params.Page,
PerPage: c.Params.PerPage,
OwnerId: OwnerId,
IncludeDeleted: includeDeleted,
OnlyOrphaned: onlyOrphaned,
})
if appErr != nil {
c.Err = appErr
return
}
if c.HandleEtag(bots.Etag(), "Get Bots", w, r) {
return
}
if err := json.NewEncoder(w).Encode(bots); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func disableBot(c *Context, w http.ResponseWriter, _ *http.Request) {
updateBotActive(c, w, false)
}
func enableBot(c *Context, w http.ResponseWriter, _ *http.Request) {
updateBotActive(c, w, true)
}
func updateBotActive(c *Context, w http.ResponseWriter, active bool) {
c.RequireBotUserId()
if c.Err != nil {
return
}
botUserId := c.Params.BotUserId
auditRec := c.MakeAuditRecord(model.AuditEventUpdateBotActive, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "id", botUserId)
model.AddEventParameterToAuditRec(auditRec, "enable", active)
if err := c.App.SessionHasPermissionToManageBot(c.AppContext, *c.AppContext.Session(), botUserId); err != nil {
c.Err = err
return
}
bot, err := c.App.UpdateBotActive(c.AppContext, botUserId, active)
if err != nil {
c.Err = err
return
}
auditRec.Success()
auditRec.AddEventResultState(bot)
auditRec.AddEventObjectType("bot")
if err := json.NewEncoder(w).Encode(bot); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func assignBot(c *Context, w http.ResponseWriter, _ *http.Request) {
c.RequireUserId()
c.RequireBotUserId()
if c.Err != nil {
return
}
botUserId := c.Params.BotUserId
userId := c.Params.UserId
auditRec := c.MakeAuditRecord(model.AuditEventAssignBot, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "id", botUserId)
model.AddEventParameterToAuditRec(auditRec, "user_id", userId)
if err := c.App.SessionHasPermissionToManageBot(c.AppContext, *c.AppContext.Session(), botUserId); err != nil {
c.Err = err
return
}
if user, err := c.App.GetUser(c.AppContext, userId); err == nil {
if user.IsBot {
c.SetPermissionError(model.PermissionAssignBot)
return
}
}
bot, err := c.App.UpdateBotOwner(c.AppContext, botUserId, userId)
if err != nil {
c.Err = err
return
}
auditRec.Success()
auditRec.AddEventResultState(bot)
auditRec.AddEventObjectType("bot")
if err := json.NewEncoder(w).Encode(bot); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func convertBotToUser(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireBotUserId()
if c.Err != nil {
return
}
bot, err := c.App.GetBot(c.AppContext, c.Params.BotUserId, false)
if err != nil {
c.Err = err
return
}
var userPatch model.UserPatch
jsonErr := json.NewDecoder(r.Body).Decode(&userPatch)
if jsonErr != nil || userPatch.Password == nil || *userPatch.Password == "" {
c.SetInvalidParamWithErr("userPatch", jsonErr)
return
}
systemAdmin, _ := strconv.ParseBool(r.URL.Query().Get("set_system_admin"))
auditRec := c.MakeAuditRecord(model.AuditEventConvertBotToUser, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "bot", bot)
model.AddEventParameterAuditableToAuditRec(auditRec, "user_patch", &userPatch)
model.AddEventParameterToAuditRec(auditRec, "set_system_admin", systemAdmin)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
user, err := c.App.ConvertBotToUser(c.AppContext, bot, &userPatch, systemAdmin)
if err != nil {
c.Err = err
return
}
auditRec.Success()
auditRec.AddEventResultState(user)
auditRec.AddEventObjectType("user")
if err := json.NewEncoder(w).Encode(user); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

View File

@@ -0,0 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import "net/http"
func (api *API) InitBotLocal() {
api.BaseRoutes.Bot.Handle("", api.APILocal(getBot)).Methods(http.MethodGet)
api.BaseRoutes.Bot.Handle("", api.APILocal(patchBot)).Methods(http.MethodPut)
api.BaseRoutes.Bot.Handle("/disable", api.APILocal(disableBot)).Methods(http.MethodPost)
api.BaseRoutes.Bot.Handle("/enable", api.APILocal(enableBot)).Methods(http.MethodPost)
api.BaseRoutes.Bot.Handle("/convert_to_user", api.APILocal(convertBotToUser)).Methods(http.MethodPost)
api.BaseRoutes.Bot.Handle("/assign/{user_id:[A-Za-z0-9]+}", api.APILocal(assignBot)).Methods(http.MethodPost)
api.BaseRoutes.Bots.Handle("", api.APILocal(getBots)).Methods(http.MethodGet)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,105 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"io"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitBrand() {
api.BaseRoutes.Brand.Handle("/image", api.APIHandlerTrustRequester(getBrandImage)).Methods(http.MethodGet)
api.BaseRoutes.Brand.Handle("/image", api.APISessionRequired(uploadBrandImage, handlerParamFileAPI)).Methods(http.MethodPost)
api.BaseRoutes.Brand.Handle("/image", api.APISessionRequired(deleteBrandImage)).Methods(http.MethodDelete)
}
func getBrandImage(c *Context, w http.ResponseWriter, r *http.Request) {
// No permission check required
img, err := c.App.GetBrandImage(c.AppContext)
if err != nil {
w.WriteHeader(http.StatusNotFound)
if _, err := w.Write(nil); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
return
}
w.Header().Set("Content-Type", "image/png")
if _, err := w.Write(img); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func uploadBrandImage(c *Context, w http.ResponseWriter, r *http.Request) {
defer func() {
if _, err := io.Copy(io.Discard, r.Body); err != nil {
c.Logger.Warn("Error discarding request body", mlog.Err(err))
}
}()
if r.ContentLength > *c.App.Config().FileSettings.MaxFileSize {
c.Err = model.NewAppError("uploadBrandImage", "api.admin.upload_brand_image.too_large.app_error", nil, "", http.StatusRequestEntityTooLarge)
return
}
if err := r.ParseMultipartForm(*c.App.Config().FileSettings.MaxFileSize); err != nil {
c.Err = model.NewAppError("uploadBrandImage", "api.admin.upload_brand_image.parse.app_error", nil, "", http.StatusBadRequest)
return
}
m := r.MultipartForm
imageArray, ok := m.File["image"]
if !ok {
c.Err = model.NewAppError("uploadBrandImage", "api.admin.upload_brand_image.no_file.app_error", nil, "", http.StatusBadRequest)
return
}
if len(imageArray) <= 0 {
c.Err = model.NewAppError("uploadBrandImage", "api.admin.upload_brand_image.array.app_error", nil, "", http.StatusBadRequest)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventUploadBrandImage, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionEditBrand) {
c.SetPermissionError(model.PermissionEditBrand)
return
}
if err := c.App.SaveBrandImage(c.AppContext, imageArray[0]); err != nil {
c.Err = err
return
}
auditRec.Success()
c.LogAudit("")
w.WriteHeader(http.StatusCreated)
ReturnStatusOK(w)
}
func deleteBrandImage(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord(model.AuditEventDeleteBrandImage, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionEditBrand) {
c.SetPermissionError(model.PermissionEditBrand)
return
}
if err := c.App.DeleteBrandImage(c.AppContext); err != nil {
c.Err = err
return
}
auditRec.Success()
ReturnStatusOK(w)
}

View File

@@ -0,0 +1,166 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
)
func TestGetBrandImage(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
client := th.Client
_, resp, err := client.GetBrandImage(context.Background())
require.Error(t, err)
CheckNotFoundStatus(t, resp)
_, err = client.Logout(context.Background())
require.NoError(t, err)
_, resp, err = client.GetBrandImage(context.Background())
require.Error(t, err)
CheckNotFoundStatus(t, resp)
_, resp, err = th.SystemAdminClient.GetBrandImage(context.Background())
require.Error(t, err)
CheckNotFoundStatus(t, resp)
}
func TestUploadBrandImage(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
client := th.Client
data, err := testutils.ReadTestFile("test.png")
require.NoError(t, err)
resp, err := client.UploadBrandImage(context.Background(), data)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
// status code returns either forbidden or unauthorized
// note: forbidden is set as default at Client4.SetProfileImage when request is terminated early by server
_, err = client.Logout(context.Background())
require.NoError(t, err)
resp, err = client.UploadBrandImage(context.Background(), data)
require.Error(t, err)
if resp.StatusCode == http.StatusForbidden {
CheckForbiddenStatus(t, resp)
} else if resp.StatusCode == http.StatusUnauthorized {
CheckUnauthorizedStatus(t, resp)
} else {
require.Fail(t, "Should have failed either forbidden or unauthorized")
}
resp, err = th.SystemAdminClient.UploadBrandImage(context.Background(), data)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
}
func TestUploadBrandImageTwice(t *testing.T) {
th := Setup(t)
data, err := testutils.ReadTestFile("test.png")
require.NoError(t, err)
// First upload as system admin
resp, err := th.SystemAdminClient.UploadBrandImage(context.Background(), data)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
// Verify the image exists and contents match what was uploaded
receivedImg, resp, err := th.SystemAdminClient.GetBrandImage(context.Background())
require.NoError(t, err)
require.NotNil(t, receivedImg)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, receivedImg, "Received image data should not be empty")
// Get the list of files in the brand directory
files, err := th.App.FileBackend().ListDirectory("brand/")
require.NoError(t, err)
require.Len(t, files, 1, "Expected only the original image file")
// ListDirectory returns paths with the directory prefix included
fileName := files[0]
fileName = strings.TrimPrefix(fileName, "brand/")
require.Equal(t, "image.png", fileName, "Expected the original image file")
// Second upload (which should back up the previous one)
data2, err := testutils.ReadTestFile("test.tiff")
require.NoError(t, err)
resp, err = th.SystemAdminClient.UploadBrandImage(context.Background(), data2)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
// Get the list of files in the brand directory again
files, err = th.App.FileBackend().ListDirectory("brand/")
require.NoError(t, err)
// Should now have the new image.png and a backup with timestamp
require.Len(t, files, 2, "Expected the original and backup files")
// Check that one of the files is image.png
hasOriginal := false
hasBackup := false
for _, file := range files {
// ListDirectory returns paths with the directory prefix included
fileName := strings.TrimPrefix(file, "brand/")
if fileName == "image.png" {
hasOriginal = true
} else if strings.HasSuffix(fileName, ".png") && strings.Contains(fileName, "-") {
// Backup file should have a timestamp format like 2006-01-02T15:04:05.png
hasBackup = true
}
}
require.True(t, hasOriginal, "Original image.png file should exist")
require.True(t, hasBackup, "Backup image file should exist")
// Verify the new image is available through the API and matches what was uploaded
receivedImg2, resp, err := th.SystemAdminClient.GetBrandImage(context.Background())
require.NoError(t, err)
require.NotNil(t, receivedImg2)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, receivedImg2, "Received image data should not be empty")
}
func TestDeleteBrandImage(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
data, err := testutils.ReadTestFile("test.png")
require.NoError(t, err)
resp, err := th.SystemAdminClient.UploadBrandImage(context.Background(), data)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
resp, err = th.Client.DeleteBrandImage(context.Background())
require.Error(t, err)
CheckForbiddenStatus(t, resp)
_, err = th.Client.Logout(context.Background())
require.NoError(t, err)
resp, err = th.Client.DeleteBrandImage(context.Background())
require.Error(t, err)
CheckUnauthorizedStatus(t, resp)
resp, err = th.SystemAdminClient.DeleteBrandImage(context.Background())
require.NoError(t, err)
CheckOKStatus(t, resp)
resp, err = th.SystemAdminClient.DeleteBrandImage(context.Background())
require.Error(t, err)
CheckNotFoundStatus(t, resp)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,485 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func rejectExternallyManagedBookmarkWrite(op string) *model.AppError {
return model.NewAppError(op, "api.channel.bookmark.board.readonly.app_error", nil,
"bookmark type is managed outside the channel bookmarks API", http.StatusBadRequest)
}
func (api *API) InitChannelBookmarks() {
api.BaseRoutes.ChannelBookmarks.Handle("", api.APISessionRequired(createChannelBookmark)).Methods(http.MethodPost)
api.BaseRoutes.ChannelBookmark.Handle("", api.APISessionRequired(updateChannelBookmark)).Methods(http.MethodPatch)
api.BaseRoutes.ChannelBookmark.Handle("/sort_order", api.APISessionRequired(updateChannelBookmarkSortOrder)).Methods(http.MethodPost)
api.BaseRoutes.ChannelBookmark.Handle("", api.APISessionRequired(deleteChannelBookmark)).Methods(http.MethodDelete)
api.BaseRoutes.ChannelBookmarks.Handle("", api.APISessionRequired(listChannelBookmarksForChannel)).Methods(http.MethodGet)
}
func createChannelBookmark(c *Context, w http.ResponseWriter, r *http.Request) {
if c.App.Channels().License() == nil {
c.Err = model.NewAppError("createChannelBookmark", "api.channel.bookmark.channel_bookmark.license.error", nil, "", http.StatusNotImplemented)
return
}
connectionID := r.Header.Get(model.ConnectionId)
c.RequireChannelId()
if c.Err != nil {
return
}
var channelBookmark *model.ChannelBookmark
err := json.NewDecoder(r.Body).Decode(&channelBookmark)
if err != nil || channelBookmark == nil {
c.SetInvalidParamWithErr("channelBookmark", err)
return
}
channelBookmark.ChannelId = c.Params.ChannelId
auditRec := c.MakeAuditRecord(model.AuditEventCreateChannelBookmark, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "channelBookmark", channelBookmark)
if model.IsExternallyManagedChannelBookmarkType(channelBookmark.Type) {
c.Err = rejectExternallyManagedBookmarkWrite("createChannelBookmark")
return
}
channel, appErr := c.App.GetChannel(c.AppContext, c.Params.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
if channel.DeleteAt != 0 {
c.Err = model.NewAppError("createChannelBookmark", "api.channel.bookmark.create_channel_bookmark.deleted_channel.forbidden.app_error", nil, "", http.StatusForbidden)
return
}
switch channel.Type {
case model.ChannelTypeOpen:
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionAddBookmarkPublicChannel); !ok {
c.SetPermissionError(model.PermissionAddBookmarkPublicChannel)
return
}
case model.ChannelTypePrivate:
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionAddBookmarkPrivateChannel); !ok {
c.SetPermissionError(model.PermissionAddBookmarkPrivateChannel)
return
}
case model.ChannelTypeGroup, model.ChannelTypeDirect:
// Any member of DM/GMs but guests can manage channel bookmarks
if _, errGet := c.App.GetChannelMember(c.AppContext, channel.Id, c.AppContext.Session().UserId); errGet != nil {
c.Err = model.NewAppError("createChannelBookmark", "api.channel.bookmark.create_channel_bookmark.direct_or_group_channels.forbidden.app_error", nil, errGet.Message, http.StatusForbidden)
return
}
user, gAppErr := c.App.GetUser(c.AppContext, c.AppContext.Session().UserId)
if gAppErr != nil {
c.Err = gAppErr
return
}
if user.IsGuest() {
c.Err = model.NewAppError("createChannelBookmark", "api.channel.bookmark.create_channel_bookmark.direct_or_group_channels_by_guests.forbidden.app_error", nil, "", http.StatusForbidden)
return
}
default:
c.Err = model.NewAppError("createChannelBookmark", "api.channel.bookmark.create_channel_bookmark.forbidden.app_error", nil, "", http.StatusForbidden)
return
}
newChannelBookmark, appErr := c.App.CreateChannelBookmark(c.AppContext, channelBookmark, connectionID)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
auditRec.AddEventResultState(newChannelBookmark)
auditRec.AddEventObjectType("channelBookmarkWithFileInfo")
c.LogAudit("display_name=" + newChannelBookmark.DisplayName)
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(newChannelBookmark); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func updateChannelBookmark(c *Context, w http.ResponseWriter, r *http.Request) {
if c.App.Channels().License() == nil {
c.Err = model.NewAppError("updateChannelBookmark", "api.channel.bookmark.channel_bookmark.license.error", nil, "", http.StatusNotImplemented)
return
}
connectionID := r.Header.Get(model.ConnectionId)
c.RequireChannelId()
if c.Err != nil {
return
}
var patch *model.ChannelBookmarkPatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil || patch == nil {
c.SetInvalidParamWithErr("channelBookmarkPatch", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventUpdateChannelBookmark, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "channelBookmark", patch)
channel, appErr := c.App.GetChannel(c.AppContext, c.Params.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
if channel.DeleteAt != 0 {
c.Err = model.NewAppError("updateChannelBookmark", "api.channel.bookmark.update_channel_bookmark.deleted_channel.forbidden.app_error", nil, "", http.StatusForbidden)
return
}
isMember := false
switch channel.Type {
case model.ChannelTypeOpen:
ok, member := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionEditBookmarkPublicChannel)
if !ok {
c.SetPermissionError(model.PermissionEditBookmarkPublicChannel)
return
}
isMember = member
case model.ChannelTypePrivate:
ok, member := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionEditBookmarkPrivateChannel)
if !ok {
c.SetPermissionError(model.PermissionEditBookmarkPrivateChannel)
return
}
isMember = member
case model.ChannelTypeGroup, model.ChannelTypeDirect:
// Any member of DM/GMs but guests can manage channel bookmarks
if _, errGet := c.App.GetChannelMember(c.AppContext, channel.Id, c.AppContext.Session().UserId); errGet != nil {
c.Err = model.NewAppError("updateChannelBookmark", "api.channel.bookmark.update_channel_bookmark.direct_or_group_channels.forbidden.app_error", nil, errGet.Message, http.StatusForbidden)
return
}
isMember = true
user, gAppErr := c.App.GetUser(c.AppContext, c.AppContext.Session().UserId)
if gAppErr != nil {
c.Err = gAppErr
return
}
if user.IsGuest() {
c.Err = model.NewAppError("updateChannelBookmark", "api.channel.bookmark.update_channel_bookmark.direct_or_group_channels_by_guests.forbidden.app_error", nil, "", http.StatusForbidden)
return
}
default:
c.Err = model.NewAppError("updateChannelBookmark", "api.channel.bookmark.update_channel_bookmark.forbidden.app_error", nil, "", http.StatusForbidden)
return
}
originalChannelBookmark, appErr := c.App.GetBookmark(c.Params.ChannelBookmarkId, false)
if appErr != nil {
c.Err = appErr
return
}
// The channel bookmark should belong to the same channel specified in the URL
if originalChannelBookmark.ChannelId != c.Params.ChannelId {
c.SetInvalidParam("channel_id")
return
}
if model.IsExternallyManagedChannelBookmarkType(originalChannelBookmark.Type) {
c.Err = rejectExternallyManagedBookmarkWrite("updateChannelBookmark")
return
}
patchedBookmark := originalChannelBookmark.Clone()
auditRec.AddEventPriorState(originalChannelBookmark)
patchedBookmark.Patch(patch)
updateChannelBookmarkResponse, appErr := c.App.UpdateChannelBookmark(c.AppContext, patchedBookmark, connectionID)
if appErr != nil {
c.Err = appErr
return
}
if !isMember {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
}
auditRec.Success()
auditRec.AddEventResultState(updateChannelBookmarkResponse)
auditRec.AddEventObjectType("updateChannelBookmarkResponse")
c.LogAudit("")
if err := json.NewEncoder(w).Encode(updateChannelBookmarkResponse); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func updateChannelBookmarkSortOrder(c *Context, w http.ResponseWriter, r *http.Request) {
if c.App.Channels().License() == nil {
c.Err = model.NewAppError("updateChannelBookmarkSortOrder", "api.channel.bookmark.channel_bookmark.license.error", nil, "", http.StatusNotImplemented)
return
}
connectionID := r.Header.Get(model.ConnectionId)
c.RequireChannelId()
if c.Err != nil {
return
}
var newSortOrder int64
if err := json.NewDecoder(r.Body).Decode(&newSortOrder); err != nil {
c.SetInvalidParamWithErr("channelBookmarkSortOrder", err)
return
}
if newSortOrder < 0 {
c.SetInvalidParam("channelBookmarkSortOrder")
return
}
auditRec := c.MakeAuditRecord(model.AuditEventUpdateChannelBookmarkSortOrder, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "id", c.Params.ChannelBookmarkId)
channel, appErr := c.App.GetChannel(c.AppContext, c.Params.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
if channel.DeleteAt != 0 {
c.Err = model.NewAppError("updateChannelBookmarkSortOrder", "api.channel.bookmark.update_channel_bookmark_sort_order.deleted_channel.forbidden.app_error", nil, "", http.StatusForbidden)
return
}
isMember := false
switch channel.Type {
case model.ChannelTypeOpen:
ok, member := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionOrderBookmarkPublicChannel)
if !ok {
c.SetPermissionError(model.PermissionOrderBookmarkPublicChannel)
return
}
isMember = member
case model.ChannelTypePrivate:
ok, member := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionOrderBookmarkPrivateChannel)
if !ok {
c.SetPermissionError(model.PermissionOrderBookmarkPrivateChannel)
return
}
isMember = member
case model.ChannelTypeGroup, model.ChannelTypeDirect:
// Any member of DM/GMs but guests can manage channel bookmarks
if _, errGet := c.App.GetChannelMember(c.AppContext, channel.Id, c.AppContext.Session().UserId); errGet != nil {
c.Err = model.NewAppError("updateChannelBookmarkSortOrder", "api.channel.bookmark.update_channel_bookmark_sort_order.direct_or_group_channels.forbidden.app_error", nil, errGet.Message, http.StatusForbidden)
return
}
isMember = true
user, gAppErr := c.App.GetUser(c.AppContext, c.AppContext.Session().UserId)
if gAppErr != nil {
c.Err = gAppErr
return
}
if user.IsGuest() {
c.Err = model.NewAppError("updateChannelBookmarkSortOrder", "api.channel.bookmark.update_channel_bookmark_sort_order.direct_or_group_channels_by_guests.forbidden.app_error", nil, "", http.StatusForbidden)
return
}
default:
c.Err = model.NewAppError("updateChannelBookmarkSortOrder", "api.channel.bookmark.update_channel_bookmark_sort_order.forbidden.app_error", nil, "", http.StatusForbidden)
return
}
bookmarks, appErr := c.App.UpdateChannelBookmarkSortOrder(c.Params.ChannelBookmarkId, c.Params.ChannelId, newSortOrder, connectionID)
if appErr != nil {
c.Err = appErr
return
}
if !isMember {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
}
for _, b := range bookmarks {
if b.Id == c.Params.ChannelBookmarkId {
auditRec.AddEventResultState(b)
auditRec.AddEventObjectType("channelBookmarkWithFileInfo")
break
}
}
auditRec.Success()
c.LogAudit("")
if err := json.NewEncoder(w).Encode(bookmarks); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func deleteChannelBookmark(c *Context, w http.ResponseWriter, r *http.Request) {
if c.App.Channels().License() == nil {
c.Err = model.NewAppError("deleteChannelBookmark", "api.channel.bookmark.channel_bookmark.license.error", nil, "", http.StatusNotImplemented)
return
}
connectionID := r.Header.Get(model.ConnectionId)
c.RequireChannelId()
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventDeleteChannelBookmark, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "id", c.Params.ChannelBookmarkId)
channel, appErr := c.App.GetChannel(c.AppContext, c.Params.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
if channel.DeleteAt != 0 {
c.Err = model.NewAppError("deleteChannelBookmark", "api.channel.bookmark.delete_channel_bookmark.deleted_channel.forbidden.app_error", nil, "", http.StatusForbidden)
return
}
isMember := false
switch channel.Type {
case model.ChannelTypeOpen:
ok, member := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionDeleteBookmarkPublicChannel)
if !ok {
c.SetPermissionError(model.PermissionDeleteBookmarkPublicChannel)
return
}
isMember = member
case model.ChannelTypePrivate:
ok, member := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionDeleteBookmarkPrivateChannel)
if !ok {
c.SetPermissionError(model.PermissionDeleteBookmarkPrivateChannel)
return
}
isMember = member
case model.ChannelTypeGroup, model.ChannelTypeDirect:
// Any member of DM/GMs but guests can manage channel bookmarks
if _, errGet := c.App.GetChannelMember(c.AppContext, channel.Id, c.AppContext.Session().UserId); errGet != nil {
c.Err = model.NewAppError("deleteChannelBookmark", "api.channel.bookmark.delete_channel_bookmark.direct_or_group_channels.forbidden.app_error", nil, errGet.Message, http.StatusForbidden)
return
}
isMember = true
user, gAppErr := c.App.GetUser(c.AppContext, c.AppContext.Session().UserId)
if gAppErr != nil {
c.Err = gAppErr
return
}
if user.IsGuest() {
c.Err = model.NewAppError("deleteChannelBookmark", "api.channel.bookmark.delete_channel_bookmark.direct_or_group_channels_by_guests.forbidden.app_error", nil, "", http.StatusForbidden)
return
}
default:
c.Err = model.NewAppError("deleteChannelBookmark", "api.channel.bookmark.delete_channel_bookmark.forbidden.app_error", nil, "", http.StatusForbidden)
return
}
oldBookmark, obErr := c.App.GetBookmark(c.Params.ChannelBookmarkId, false)
if obErr != nil {
c.Err = obErr
return
}
// The channel bookmark should belong to the same channel specified in the URL
if oldBookmark.ChannelId != c.Params.ChannelId {
c.SetInvalidParam("channel_id")
return
}
if model.IsExternallyManagedChannelBookmarkType(oldBookmark.Type) {
c.Err = rejectExternallyManagedBookmarkWrite("deleteChannelBookmark")
return
}
auditRec.AddEventPriorState(oldBookmark)
bookmark, appErr := c.App.DeleteChannelBookmark(c.Params.ChannelBookmarkId, connectionID)
if appErr != nil {
c.Err = appErr
return
}
if !isMember {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
}
auditRec.Success()
auditRec.AddEventResultState(bookmark)
c.LogAudit("bookmark=" + bookmark.DisplayName)
if err := json.NewEncoder(w).Encode(bookmark); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func listChannelBookmarksForChannel(c *Context, w http.ResponseWriter, r *http.Request) {
if c.App.Channels().License() == nil {
c.Err = model.NewAppError("listChannelBookmarksForChannel", "api.channel.bookmark.channel_bookmark.license.error", nil, "", http.StatusNotImplemented)
return
}
c.RequireChannelId()
if c.Err != nil {
return
}
channel, appErr := c.App.GetChannel(c.AppContext, c.Params.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
hasPermission, isMember := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel)
if !hasPermission {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
bookmarks, appErr := c.App.GetChannelBookmarks(c.Params.ChannelId, c.Params.BookmarksSince)
if appErr != nil {
c.Err = appErr
return
}
auditRec := c.MakeAuditRecord(model.AuditEventListChannelBookmarksForChannel, model.AuditStatusSuccess)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
if !isMember {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
}
if err := json.NewEncoder(w).Encode(bookmarks); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,395 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func getCategoriesForTeamForUser(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId().RequireTeamId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
categories, appErr := c.App.GetSidebarCategoriesForTeamForUser(c.AppContext, c.Params.UserId, c.Params.TeamId)
if appErr != nil {
c.Err = appErr
return
}
categoriesJSON, err := json.Marshal(categories)
if err != nil {
c.Err = model.NewAppError("getCategoriesForTeamForUser", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(categoriesJSON); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func createCategoryForTeamForUser(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId().RequireTeamId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventCreateCategoryForTeamForUser, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
var categoryCreateRequest model.SidebarCategoryWithChannels
err := json.NewDecoder(r.Body).Decode(&categoryCreateRequest)
if err != nil || c.Params.UserId != categoryCreateRequest.UserId || c.Params.TeamId != categoryCreateRequest.TeamId {
c.SetInvalidParamWithErr("category", err)
return
}
if appErr := validateSidebarCategory(c, c.Params.TeamId, c.Params.UserId, &categoryCreateRequest); appErr != nil {
c.Err = appErr
return
}
category, appErr := c.App.CreateSidebarCategory(c.AppContext, c.Params.UserId, c.Params.TeamId, &categoryCreateRequest)
if appErr != nil {
c.Err = appErr
return
}
categoryJSON, err := json.Marshal(category)
if err != nil {
c.Err = model.NewAppError("createCategoryForTeamForUser", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.Success()
if _, err := w.Write(categoryJSON); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getCategoryOrderForTeamForUser(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId().RequireTeamId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
order, appErr := c.App.GetSidebarCategoryOrder(c.AppContext, c.Params.UserId, c.Params.TeamId)
if appErr != nil {
c.Err = appErr
return
}
err := json.NewEncoder(w).Encode(order)
if err != nil {
c.Logger.Warn("Error writing response", mlog.Err(err))
}
}
func updateCategoryOrderForTeamForUser(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId().RequireTeamId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventUpdateCategoryOrderForTeamForUser, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
categoryOrder, err := model.NonSortedArrayFromJSON(r.Body)
if err != nil {
c.Err = model.NewAppError("updateCategoryOrderForTeamForUser", model.PayloadParseError, nil, "", http.StatusBadRequest).Wrap(err)
return
}
for _, categoryId := range categoryOrder {
if !c.App.SessionHasPermissionToCategory(c.AppContext, *c.AppContext.Session(), c.Params.UserId, c.Params.TeamId, categoryId) {
c.SetInvalidParam("category")
return
}
}
appErr := c.App.UpdateSidebarCategoryOrder(c.AppContext, c.Params.UserId, c.Params.TeamId, categoryOrder)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
if _, err := w.Write([]byte(model.ArrayToJSON(categoryOrder))); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getCategoryForTeamForUser(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId().RequireTeamId().RequireCategoryId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionToCategory(c.AppContext, *c.AppContext.Session(), c.Params.UserId, c.Params.TeamId, c.Params.CategoryId) {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
categories, appErr := c.App.GetSidebarCategory(c.AppContext, c.Params.CategoryId)
if appErr != nil {
c.Err = appErr
return
}
categoriesJSON, err := json.Marshal(categories)
if err != nil {
c.Err = model.NewAppError("getCategoryForTeamForUser", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(categoriesJSON); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func updateCategoriesForTeamForUser(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId().RequireTeamId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventUpdateCategoriesForTeamForUser, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
var categoriesUpdateRequest []*model.SidebarCategoryWithChannels
err := json.NewDecoder(r.Body).Decode(&categoriesUpdateRequest)
if err != nil {
c.SetInvalidParamWithErr("category", err)
return
}
for _, category := range categoriesUpdateRequest {
if !c.App.SessionHasPermissionToCategory(c.AppContext, *c.AppContext.Session(), c.Params.UserId, c.Params.TeamId, category.Id) {
c.SetInvalidParam("category")
return
}
}
if appErr := validateSidebarCategories(c, c.Params.TeamId, c.Params.UserId, categoriesUpdateRequest); appErr != nil {
c.Err = appErr
return
}
categories, appErr := c.App.UpdateSidebarCategories(c.AppContext, c.Params.UserId, c.Params.TeamId, categoriesUpdateRequest)
if appErr != nil {
c.Err = appErr
return
}
categoriesJSON, err := json.Marshal(categories)
if err != nil {
c.Err = model.NewAppError("updateCategoriesForTeamForUser", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.Success()
if _, err := w.Write(categoriesJSON); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func validateSidebarCategory(c *Context, teamId, userId string, category *model.SidebarCategoryWithChannels) *model.AppError {
channels, appErr := c.App.GetChannelsForTeamForUser(c.AppContext, teamId, userId, &model.ChannelSearchOpts{
IncludeDeleted: true,
LastDeleteAt: 0,
})
if appErr != nil {
return model.NewAppError("validateSidebarCategory", "api.invalid_channel", nil, "", http.StatusBadRequest).Wrap(appErr)
}
category.Channels = validateSidebarCategoryChannels(c, userId, category.Channels, channelListToMap(channels))
return nil
}
func validateSidebarCategories(c *Context, teamId, userId string, categories []*model.SidebarCategoryWithChannels) *model.AppError {
channels, err := c.App.GetChannelsForTeamForUser(c.AppContext, teamId, userId, &model.ChannelSearchOpts{
IncludeDeleted: true,
LastDeleteAt: 0,
})
if err != nil {
return model.NewAppError("validateSidebarCategory", "api.invalid_channel", nil, "", http.StatusBadRequest).Wrap(err)
}
channelMap := channelListToMap(channels)
for _, category := range categories {
category.Channels = validateSidebarCategoryChannels(c, userId, category.Channels, channelMap)
}
return nil
}
func channelListToMap(channelList model.ChannelList) map[string]*model.Channel {
channelMap := make(map[string]*model.Channel, len(channelList))
for _, channel := range channelList {
channelMap[channel.Id] = channel
}
return channelMap
}
// validateSidebarCategoryChannels returns a normalized slice of channel IDs by removing duplicates from it and
// ensuring that it only contains IDs of channels in the given map of Channels by IDs.
func validateSidebarCategoryChannels(c *Context, userId string, channelIds []string, channelMap map[string]*model.Channel) []string {
var filtered []string
for _, channelId := range channelIds {
if _, ok := channelMap[channelId]; ok {
filtered = append(filtered, channelId)
} else {
c.Logger.Info("Stopping user from adding channel to their sidebar when they are not a member", mlog.String("user_id", userId), mlog.String("channel_id", channelId))
}
}
filtered = model.RemoveDuplicateStringsNonSort(filtered)
return filtered
}
func updateCategoryForTeamForUser(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId().RequireTeamId().RequireCategoryId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionToCategory(c.AppContext, *c.AppContext.Session(), c.Params.UserId, c.Params.TeamId, c.Params.CategoryId) {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventUpdateCategoryForTeamForUser, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
var categoryUpdateRequest model.SidebarCategoryWithChannels
err := json.NewDecoder(r.Body).Decode(&categoryUpdateRequest)
if err != nil || categoryUpdateRequest.TeamId != c.Params.TeamId || categoryUpdateRequest.UserId != c.Params.UserId {
c.SetInvalidParamWithErr("category", err)
return
}
if appErr := validateSidebarCategory(c, c.Params.TeamId, c.Params.UserId, &categoryUpdateRequest); appErr != nil {
c.Err = appErr
return
}
categoryUpdateRequest.Id = c.Params.CategoryId
categories, appErr := c.App.UpdateSidebarCategories(c.AppContext, c.Params.UserId, c.Params.TeamId, []*model.SidebarCategoryWithChannels{&categoryUpdateRequest})
if appErr != nil {
c.Err = appErr
return
}
categoryJSON, err := json.Marshal(categories[0])
if err != nil {
c.Err = model.NewAppError("updateCategoryForTeamForUser", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.Success()
if _, err := w.Write(categoryJSON); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func deleteCategoryForTeamForUser(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId().RequireTeamId().RequireCategoryId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionToCategory(c.AppContext, *c.AppContext.Session(), c.Params.UserId, c.Params.TeamId, c.Params.CategoryId) {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventDeleteCategoryForTeamForUser, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
appErr := c.App.DeleteSidebarCategory(c.AppContext, c.Params.UserId, c.Params.TeamId, c.Params.CategoryId)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
ReturnStatusOK(w)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,180 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetDirectOrGroupMessageMembersCommonTeams(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
client := th.Client
t.Run("requires authentication", func(t *testing.T) {
user1 := th.BasicUser
user2 := th.BasicUser2
testClient := th.CreateClient()
_, _, err := testClient.Login(context.Background(), user1.Email, user1.Password)
require.NoError(t, err)
dmChannel, _, err := testClient.CreateDirectChannel(context.Background(), user1.Id, user2.Id)
require.NoError(t, err)
_, err = testClient.Logout(context.Background())
require.NoError(t, err)
_, resp, err := testClient.GetDirectOrGroupMessageMembersCommonTeams(context.Background(), dmChannel.Id)
require.Error(t, err)
CheckUnauthorizedStatus(t, resp)
})
t.Run("forbids guest users", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.GuestAccountsSettings.Enable = true
})
th.App.Srv().SetLicense(model.NewTestLicense())
guestUser, guestClient := th.CreateGuestAndClient(t)
team1 := th.BasicTeam
th.LinkUserToTeam(t, guestUser, team1)
user2 := th.BasicUser2
th.LinkUserToTeam(t, user2, team1)
dmChannel, _, err := th.SystemAdminClient.CreateDirectChannel(context.Background(), guestUser.Id, user2.Id)
require.NoError(t, err)
_, resp, err := guestClient.GetDirectOrGroupMessageMembersCommonTeams(context.Background(), dmChannel.Id)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("requires read permission on channel", func(t *testing.T) {
user1 := th.CreateUser(t)
user2 := th.CreateUser(t)
team1 := th.CreateTeam(t)
th.LinkUserToTeam(t, user1, team1)
th.LinkUserToTeam(t, user2, team1)
dmChannel, _, err := th.SystemAdminClient.CreateDirectChannel(context.Background(), user1.Id, user2.Id)
require.NoError(t, err)
otherUser := th.CreateUser(t)
th.LinkUserToTeam(t, otherUser, team1)
otherClient := th.CreateClient()
_, _, err = otherClient.Login(context.Background(), otherUser.Email, otherUser.Password)
require.NoError(t, err)
_, resp, err := otherClient.GetDirectOrGroupMessageMembersCommonTeams(context.Background(), dmChannel.Id)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("returns bad request for non-DM/GM channel", func(t *testing.T) {
testClient := th.CreateClient()
_, _, err := testClient.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
require.NoError(t, err)
_, resp, err := testClient.GetDirectOrGroupMessageMembersCommonTeams(context.Background(), th.BasicChannel.Id)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("returns common teams for DM channel members", func(t *testing.T) {
user1 := th.BasicUser
user2 := th.BasicUser2
team1 := th.BasicTeam
team2 := th.CreateTeam(t)
th.LinkUserToTeam(t, user1, team1)
th.LinkUserToTeam(t, user1, team2)
th.LinkUserToTeam(t, user2, team1)
dmChannel, _, err := client.CreateDirectChannel(context.Background(), user1.Id, user2.Id)
require.NoError(t, err)
teams, _, err := client.GetDirectOrGroupMessageMembersCommonTeams(context.Background(), dmChannel.Id)
require.NoError(t, err)
require.Len(t, teams, 1, "should only return team1 since user2 is not in team2")
assert.Equal(t, team1.Id, teams[0].Id)
})
t.Run("returns common teams for GM channel members", func(t *testing.T) {
user1 := th.BasicUser
user2 := th.BasicUser2
user3 := th.CreateUser(t)
team1 := th.BasicTeam
team2 := th.CreateTeam(t)
team3 := th.CreateTeam(t)
th.LinkUserToTeam(t, user1, team1)
th.LinkUserToTeam(t, user1, team2)
th.LinkUserToTeam(t, user2, team1)
th.LinkUserToTeam(t, user2, team3)
th.LinkUserToTeam(t, user3, team1)
gmChannel, _, err := client.CreateGroupChannel(context.Background(), []string{user1.Id, user2.Id, user3.Id})
require.NoError(t, err)
teams, _, err := client.GetDirectOrGroupMessageMembersCommonTeams(context.Background(), gmChannel.Id)
require.NoError(t, err)
require.Len(t, teams, 1, "should only return team1 since it's the only team all three users share")
assert.Equal(t, team1.Id, teams[0].Id)
})
t.Run("returns empty list when requesting user in channel but has no common teams with other members", func(t *testing.T) {
user1 := th.CreateUser(t)
user2 := th.CreateUser(t)
team1 := th.CreateTeam(t)
team2 := th.CreateTeam(t)
th.LinkUserToTeam(t, user1, team1)
th.LinkUserToTeam(t, user2, team2)
testClient := th.CreateClient()
_, _, err := testClient.Login(context.Background(), user1.Email, user1.Password)
require.NoError(t, err)
dmChannel, _, err := testClient.CreateDirectChannel(context.Background(), user1.Id, user2.Id)
require.NoError(t, err)
teams, _, err := testClient.GetDirectOrGroupMessageMembersCommonTeams(context.Background(), dmChannel.Id)
require.NoError(t, err)
require.Empty(t, teams)
})
t.Run("filters teams to only those common with requesting user", func(t *testing.T) {
user1 := th.CreateUser(t)
user2 := th.CreateUser(t)
user3 := th.BasicUser
team1 := th.CreateTeam(t)
team2 := th.CreateTeam(t)
team3 := th.CreateTeam(t)
th.LinkUserToTeam(t, user1, team1)
th.LinkUserToTeam(t, user1, team2)
th.LinkUserToTeam(t, user2, team1)
th.LinkUserToTeam(t, user2, team3)
th.LinkUserToTeam(t, user3, team1)
th.LinkUserToTeam(t, user3, team3)
gmChannel, _, err := client.CreateGroupChannel(context.Background(), []string{user1.Id, user2.Id, user3.Id})
require.NoError(t, err)
teams, _, err := client.GetDirectOrGroupMessageMembersCommonTeams(context.Background(), gmChannel.Id)
require.NoError(t, err)
require.Len(t, teams, 1)
assert.Equal(t, team1.Id, teams[0].Id)
})
}

View File

@@ -0,0 +1,293 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"strconv"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
// initChannelJoinRequestRoutes registers the discoverable-private-channel
// join request endpoints. The route group is split into its own file so the
// handlers stay isolated from the rest of api4/channel.go.
func (api *API) initChannelJoinRequestRoutes() {
if !api.srv.Config().FeatureFlags.DiscoverableChannels {
return
}
api.BaseRoutes.Channel.Handle("/join_request", api.APISessionRequired(requestJoinChannel)).Methods(http.MethodPost)
api.BaseRoutes.Channel.Handle("/join_request", api.APISessionRequired(getMyChannelJoinRequest)).Methods(http.MethodGet)
api.BaseRoutes.Channel.Handle("/join_request", api.APISessionRequired(withdrawMyChannelJoinRequest)).Methods(http.MethodDelete)
api.BaseRoutes.Channel.Handle("/join_requests", api.APISessionRequired(getChannelJoinRequests)).Methods(http.MethodGet)
api.BaseRoutes.Channel.Handle("/join_requests/count", api.APISessionRequired(countPendingChannelJoinRequests)).Methods(http.MethodGet)
api.BaseRoutes.Channel.Handle("/join_requests/{request_id:[A-Za-z0-9]+}", api.APISessionRequired(patchChannelJoinRequest)).Methods(http.MethodPatch)
api.BaseRoutes.User.Handle("/channel_join_requests", api.APISessionRequired(getMyChannelJoinRequests)).Methods(http.MethodGet)
}
// channelJoinRequestBody is the POST body shape for /channels/{id}/join_request.
type channelJoinRequestBody struct {
Message string `json:"message"`
}
func requireDiscoverableChannelsEnabled(c *Context, where string) bool {
if !c.App.Config().FeatureFlags.DiscoverableChannels {
c.Err = model.NewAppError(where, "api.channel.discoverable_join_request.feature_disabled.app_error", nil, "", http.StatusNotFound)
return false
}
return true
}
func requestJoinChannel(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId()
if c.Err != nil {
return
}
if !requireDiscoverableChannelsEnabled(c, "requestJoinChannel") {
return
}
var body channelJoinRequestBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
c.SetInvalidParamWithErr("body", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventCreateChannelJoinRequest, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
model.AddEventParameterToAuditRec(auditRec, "user_id", c.AppContext.Session().UserId)
joined, req, appErr := c.App.RequestJoinChannel(c.AppContext, c.AppContext.Session().UserId, c.Params.ChannelId, body.Message)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
if req != nil {
auditRec.AddEventResultState(req)
}
if joined {
// Mirror the membership endpoint's "no body, just status" semantics
// when the user was added directly via the ABAC fast path.
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(map[string]string{"status": model.ChannelJoinRequestStatusApproved}); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
return
}
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(req); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getMyChannelJoinRequest(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId()
if c.Err != nil {
return
}
if !requireDiscoverableChannelsEnabled(c, "getMyChannelJoinRequest") {
return
}
req, appErr := c.App.GetMyChannelJoinRequest(c.AppContext, c.AppContext.Session().UserId, c.Params.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
if req == nil {
// Mirror REST conventions: not-found instead of an explicit `null`
// so clients can distinguish "no pending request" from "service down".
w.WriteHeader(http.StatusNotFound)
return
}
if err := json.NewEncoder(w).Encode(req); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func withdrawMyChannelJoinRequest(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId()
if c.Err != nil {
return
}
if !requireDiscoverableChannelsEnabled(c, "withdrawMyChannelJoinRequest") {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventWithdrawChannelJoinRequest, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
model.AddEventParameterToAuditRec(auditRec, "user_id", c.AppContext.Session().UserId)
req, appErr := c.App.GetMyChannelJoinRequest(c.AppContext, c.AppContext.Session().UserId, c.Params.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
if req == nil {
c.Err = model.NewAppError("withdrawMyChannelJoinRequest", "app.channel.join_request.not_found.app_error", nil, "channel_id="+c.Params.ChannelId, http.StatusNotFound)
return
}
updated, appErr := c.App.WithdrawChannelJoinRequest(c.AppContext, req.Id, c.AppContext.Session().UserId)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
auditRec.AddEventResultState(updated)
if err := json.NewEncoder(w).Encode(updated); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getChannelJoinRequests(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId()
if c.Err != nil {
return
}
if !requireDiscoverableChannelsEnabled(c, "getChannelJoinRequests") {
return
}
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManageChannelJoinRequests); !ok {
c.SetPermissionError(model.PermissionManageChannelJoinRequests)
return
}
opts := model.GetChannelJoinRequestsOpts{
Status: r.URL.Query().Get("status"),
Page: c.Params.Page,
PerPage: c.Params.PerPage,
}
list, appErr := c.App.GetChannelJoinRequests(c.AppContext, c.Params.ChannelId, opts)
if appErr != nil {
c.Err = appErr
return
}
if err := json.NewEncoder(w).Encode(list); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func countPendingChannelJoinRequests(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId()
if c.Err != nil {
return
}
if !requireDiscoverableChannelsEnabled(c, "countPendingChannelJoinRequests") {
return
}
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManageChannelJoinRequests); !ok {
c.SetPermissionError(model.PermissionManageChannelJoinRequests)
return
}
count, appErr := c.App.CountPendingChannelJoinRequests(c.AppContext, c.Params.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
if err := json.NewEncoder(w).Encode(map[string]int64{"count": count}); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func patchChannelJoinRequest(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId()
if c.Err != nil {
return
}
if !requireDiscoverableChannelsEnabled(c, "patchChannelJoinRequest") {
return
}
if !model.IsValidId(c.Params.RequestId) {
c.SetInvalidURLParam("request_id")
return
}
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManageChannelJoinRequests); !ok {
c.SetPermissionError(model.PermissionManageChannelJoinRequests)
return
}
var patch model.ChannelJoinRequestPatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
c.SetInvalidParamWithErr("channel_join_request_patch", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventUpdateChannelJoinRequest, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
model.AddEventParameterToAuditRec(auditRec, "request_id", c.Params.RequestId)
model.AddEventParameterToAuditRec(auditRec, "status", patch.Status)
// Capture only the presence of a denial reason in the audit log; the
// free-text contents are intentionally excluded.
model.AddEventParameterToAuditRec(auditRec, "has_denial_reason", strconv.FormatBool(patch.DenialReason != nil && *patch.DenialReason != ""))
updated, appErr := c.App.UpdateChannelJoinRequest(c.AppContext, c.Params.RequestId, c.Params.ChannelId, &patch, c.AppContext.Session().UserId)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
auditRec.AddEventResultState(updated)
if err := json.NewEncoder(w).Encode(updated); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getMyChannelJoinRequests(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId()
if c.Err != nil {
return
}
if !requireDiscoverableChannelsEnabled(c, "getMyChannelJoinRequests") {
return
}
// Only the calling user can list their own requests; admins should use
// the per-channel queue endpoint.
if c.Params.UserId != c.AppContext.Session().UserId {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
opts := model.GetChannelJoinRequestsOpts{
Status: r.URL.Query().Get("status"),
Page: c.Params.Page,
PerPage: c.Params.PerPage,
}
list, appErr := c.App.GetMyChannelJoinRequests(c.AppContext, c.AppContext.Session().UserId, opts)
if appErr != nil {
c.Err = appErr
return
}
if err := json.NewEncoder(w).Encode(list); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

View File

@@ -0,0 +1,154 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"encoding/json"
"io"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
// setupDiscoverableTH spins up an api4 fixture with the discoverable channels
// feature flag enabled so the new routes are registered.
func setupDiscoverableTH(t *testing.T) *TestHelper {
t.Helper()
return SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.DiscoverableChannels = true
}).InitBasic(t)
}
// markDiscoverableViaAdmin patches `channel` to discoverable=true using the
// SystemAdminClient so the permission check is satisfied without needing to
// rebind the channel-admin role on the test fixture.
func markDiscoverableViaAdmin(t *testing.T, th *TestHelper, channel *model.Channel) *model.Channel {
t.Helper()
on := true
patched, _, err := th.SystemAdminClient.PatchChannel(context.Background(), channel.Id, &model.ChannelPatch{Discoverable: &on})
require.NoError(t, err)
require.True(t, patched.Discoverable)
return patched
}
func TestRequestJoinChannelAPI_HappyPath(t *testing.T) {
mainHelper.Parallel(t)
th := setupDiscoverableTH(t)
channel := th.CreatePrivateChannel(t)
channel = markDiscoverableViaAdmin(t, th, channel)
other := th.CreateUser(t)
th.LinkUserToTeam(t, other, th.BasicTeam)
_, _, err := th.Client.Login(context.Background(), other.Email, other.Password)
require.NoError(t, err)
body := []byte(`{"message":"hi"}`)
resp, err := th.Client.DoAPIPost(context.Background(), "/channels/"+channel.Id+"/join_request", string(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusCreated, resp.StatusCode)
var req model.ChannelJoinRequest
require.NoError(t, json.NewDecoder(resp.Body).Decode(&req))
assert.Equal(t, model.ChannelJoinRequestStatusPending, req.Status)
assert.Equal(t, channel.Id, req.ChannelId)
assert.Equal(t, other.Id, req.UserId)
}
func TestRequestJoinChannelAPI_FeatureDisabled(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
channel := th.CreatePrivateChannel(t)
body := []byte(`{"message":"hi"}`)
resp, err := th.Client.DoAPIPost(context.Background(), "/channels/"+channel.Id+"/join_request", string(body))
defer closeBodyOrNil(resp)
require.Error(t, err)
require.NotNil(t, resp)
assert.Equal(t, http.StatusNotFound, resp.StatusCode, "route must be unregistered when feature flag is off")
}
func TestPatchChannelDiscoverable_RejectsNonPrivate(t *testing.T) {
mainHelper.Parallel(t)
th := setupDiscoverableTH(t)
publicChannel := th.CreatePublicChannel(t)
on := true
_, resp, err := th.SystemAdminClient.PatchChannel(context.Background(), publicChannel.Id, &model.ChannelPatch{Discoverable: &on})
require.Error(t, err)
require.NotNil(t, resp)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
func TestAddChannelMember_BlocksSelfAddOnDiscoverable(t *testing.T) {
mainHelper.Parallel(t)
th := setupDiscoverableTH(t)
channel := th.CreatePrivateChannel(t)
channel = markDiscoverableViaAdmin(t, th, channel)
// Add a user that has manage-private-channel-members on a different
// channel but not this one. Use Client (BasicUser2) - they're a team
// member but not yet a channel member here.
_, _, err := th.Client.Login(context.Background(), th.BasicUser2.Email, th.BasicUser2.Password)
require.NoError(t, err)
_, resp, err := th.Client.AddChannelMember(context.Background(), channel.Id, th.BasicUser2.Id)
require.Error(t, err)
require.NotNil(t, resp)
// Without channel admin permission the underlying permission check
// fails first; either way the request flow is what they need to use.
assert.True(t, resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized,
"got %d", resp.StatusCode)
}
func TestGetChannelByName_HiddenForNonQualifyingNonMember(t *testing.T) {
mainHelper.Parallel(t)
th := setupDiscoverableTH(t)
// Plain (non-discoverable) private channel: a non-member must still get
// 404 — this guards against a regression in the existing read paths.
channel := th.CreatePrivateChannel(t)
_, _, err := th.Client.Login(context.Background(), th.BasicUser2.Email, th.BasicUser2.Password)
require.NoError(t, err)
_, resp, err := th.Client.GetChannelByName(context.Background(), channel.Name, th.BasicTeam.Id, "")
require.Error(t, err)
require.NotNil(t, resp)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}
func TestGetChannelByName_VisibleForQualifyingNonMemberOnDiscoverable(t *testing.T) {
mainHelper.Parallel(t)
th := setupDiscoverableTH(t)
channel := th.CreatePrivateChannel(t)
channel = markDiscoverableViaAdmin(t, th, channel)
_, _, err := th.Client.Login(context.Background(), th.BasicUser2.Email, th.BasicUser2.Password)
require.NoError(t, err)
got, _, err := th.Client.GetChannelByName(context.Background(), channel.Name, th.BasicTeam.Id, "")
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, channel.Id, got.Id)
assert.True(t, got.Discoverable)
}
// closeBodyOrNil is a tiny helper so the negative-path tests don't need to
// branch on a nil response body before deferring Close.
func closeBodyOrNil(resp *http.Response) {
if resp == nil || resp.Body == nil {
return
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}

View File

@@ -0,0 +1,445 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
func (api *API) InitChannelLocal() {
api.BaseRoutes.Channels.Handle("", api.APILocal(getAllChannels)).Methods(http.MethodGet)
api.BaseRoutes.Channels.Handle("", api.APILocal(localCreateChannel)).Methods(http.MethodPost)
api.BaseRoutes.Channel.Handle("", api.APILocal(getChannel)).Methods(http.MethodGet)
api.BaseRoutes.ChannelByName.Handle("", api.APILocal(getChannelByName)).Methods(http.MethodGet)
api.BaseRoutes.Channel.Handle("", api.APILocal(localDeleteChannel)).Methods(http.MethodDelete)
api.BaseRoutes.Channel.Handle("/patch", api.APILocal(localPatchChannel)).Methods(http.MethodPut)
api.BaseRoutes.Channel.Handle("/move", api.APILocal(localMoveChannel)).Methods(http.MethodPost)
api.BaseRoutes.Channel.Handle("/privacy", api.APILocal(localUpdateChannelPrivacy)).Methods(http.MethodPut)
api.BaseRoutes.Channel.Handle("/restore", api.APILocal(localRestoreChannel)).Methods(http.MethodPost)
api.BaseRoutes.ChannelMember.Handle("", api.APILocal(localRemoveChannelMember)).Methods(http.MethodDelete)
api.BaseRoutes.ChannelMember.Handle("", api.APILocal(getChannelMember)).Methods(http.MethodGet)
api.BaseRoutes.ChannelMembers.Handle("", api.APILocal(localAddChannelMember)).Methods(http.MethodPost)
api.BaseRoutes.ChannelMembers.Handle("", api.APILocal(getChannelMembers)).Methods(http.MethodGet)
api.BaseRoutes.ChannelsForTeam.Handle("", api.APILocal(getPublicChannelsForTeam)).Methods(http.MethodGet)
api.BaseRoutes.ChannelsForTeam.Handle("/deleted", api.APILocal(getDeletedChannelsForTeam)).Methods(http.MethodGet)
api.BaseRoutes.ChannelsForTeam.Handle("/private", api.APILocal(getPrivateChannelsForTeam)).Methods(http.MethodGet)
api.BaseRoutes.ChannelByName.Handle("", api.APILocal(getChannelByName)).Methods(http.MethodGet)
api.BaseRoutes.ChannelByNameForTeamName.Handle("", api.APILocal(getChannelByNameForTeamName)).Methods(http.MethodGet)
}
func localCreateChannel(c *Context, w http.ResponseWriter, r *http.Request) {
var channel *model.Channel
err := json.NewDecoder(r.Body).Decode(&channel)
if err != nil || channel == nil {
c.SetInvalidParamWithErr("channel", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventLocalCreateChannel, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "channel", channel)
sc, appErr := c.App.CreateChannel(c.AppContext, channel, false)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
auditRec.AddEventResultState(sc)
auditRec.AddEventObjectType("channel")
c.LogAudit("name=" + channel.Name)
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(sc); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func localUpdateChannelPrivacy(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId()
if c.Err != nil {
return
}
props := model.StringInterfaceFromJSON(r.Body)
privacy, ok := props["privacy"].(string)
if !ok || (model.ChannelType(privacy) != model.ChannelTypeOpen && model.ChannelType(privacy) != model.ChannelTypePrivate) {
c.SetInvalidParam("privacy")
return
}
channel, err := c.App.GetChannel(c.AppContext, c.Params.ChannelId)
if err != nil {
c.Err = err
return
}
auditRec := c.MakeAuditRecord(model.AuditEventLocalUpdateChannelPrivacy, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "privacy", privacy)
if channel.Name == model.DefaultChannelName && model.ChannelType(privacy) == model.ChannelTypePrivate {
c.Err = model.NewAppError("updateChannelPrivacy", "api.channel.update_channel_privacy.default_channel_error", nil, "", http.StatusBadRequest)
return
}
channel.Type = model.ChannelType(privacy)
updatedChannel, err := c.App.UpdateChannelPrivacy(c.AppContext, channel, nil)
if err != nil {
c.Err = err
return
}
auditRec.AddEventResultState(channel)
auditRec.AddEventObjectType("channel")
auditRec.Success()
c.LogAudit("name=" + updatedChannel.Name)
if err := json.NewEncoder(w).Encode(updatedChannel); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func localRestoreChannel(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId()
if c.Err != nil {
return
}
channel, err := c.App.GetChannel(c.AppContext, c.Params.ChannelId)
if err != nil {
c.Err = err
return
}
auditRec := c.MakeAuditRecord(model.AuditEventLocalRestoreChannel, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
channel, err = c.App.RestoreChannel(c.AppContext, channel, "")
if err != nil {
c.Err = err
return
}
auditRec.AddEventResultState(channel)
auditRec.AddEventObjectType("channel")
auditRec.Success()
c.LogAudit("name=" + channel.Name)
if err := json.NewEncoder(w).Encode(channel); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func localAddChannelMember(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId()
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventLocalAddChannelMember, model.AuditStatusFail)
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
defer c.LogAuditRec(auditRec)
props := model.StringInterfaceFromJSON(r.Body)
userId, ok := props["user_id"].(string)
if !ok || !model.IsValidId(userId) {
c.SetInvalidParam("user_id")
return
}
model.AddEventParameterToAuditRec(auditRec, "user_id", userId)
member := &model.ChannelMember{
ChannelId: c.Params.ChannelId,
UserId: userId,
}
postRootId, ok := props["post_root_id"].(string)
if ok && postRootId != "" && !model.IsValidId(postRootId) {
c.SetInvalidParam("post_root_id")
return
}
model.AddEventParameterToAuditRec(auditRec, "post_root_id", postRootId)
if ok && len(postRootId) == 26 {
rootPost, err := c.App.GetSinglePost(c.AppContext, postRootId, false)
if err != nil {
c.Err = err
return
}
if rootPost.ChannelId != member.ChannelId {
c.SetInvalidParam("post_root_id")
return
}
}
channel, err := c.App.GetChannel(c.AppContext, member.ChannelId)
if err != nil {
c.Err = err
return
}
model.AddEventParameterAuditableToAuditRec(auditRec, "channel", channel)
if channel.Type == model.ChannelTypeDirect || channel.Type == model.ChannelTypeGroup {
c.Err = model.NewAppError("localAddChannelMember", "api.channel.add_user_to_channel.type.app_error", nil, "", http.StatusBadRequest)
return
}
if channel.IsGroupConstrained() {
nonMembers, err := c.App.FilterNonGroupChannelMembers(c.AppContext, []string{member.UserId}, channel)
if err != nil {
if v, ok := err.(*model.AppError); ok {
c.Err = v
} else {
c.Err = model.NewAppError("localAddChannelMember", "api.channel.add_members.error", nil, "", http.StatusBadRequest).Wrap(err)
}
return
}
if len(nonMembers) > 0 {
c.Err = model.NewAppError("localAddChannelMember", "api.channel.add_members.user_denied", map[string]any{"UserIDs": nonMembers}, "", http.StatusBadRequest)
return
}
}
cm, err := c.App.AddChannelMember(c.AppContext, member.UserId, channel, app.ChannelMemberOpts{
PostRootID: postRootId,
})
if err != nil {
c.Err = err
return
}
auditRec.Success()
auditRec.AddMeta("add_user_id", cm.UserId)
auditRec.AddEventResultState(cm)
auditRec.AddEventObjectType("channel_member")
c.LogAudit("name=" + channel.Name + " user_id=" + cm.UserId)
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(cm); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func localRemoveChannelMember(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId().RequireUserId()
if c.Err != nil {
return
}
channel, err := c.App.GetChannel(c.AppContext, c.Params.ChannelId)
if err != nil {
c.Err = err
return
}
user, err := c.App.GetUser(c.AppContext, c.Params.UserId)
if err != nil {
c.Err = err
return
}
if !(channel.Type == model.ChannelTypeOpen || channel.Type == model.ChannelTypePrivate) {
c.Err = model.NewAppError("removeChannelMember", "api.channel.remove_channel_member.type.app_error", nil, "", http.StatusBadRequest)
return
}
if channel.IsGroupConstrained() && !user.IsBot {
c.Err = model.NewAppError("removeChannelMember", "api.channel.remove_member.group_constrained.app_error", nil, "", http.StatusBadRequest)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventLocalRemoveChannelMember, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
model.AddEventParameterToAuditRec(auditRec, "remove_user_id", c.Params.UserId)
if err = c.App.RemoveUserFromChannel(c.AppContext, c.Params.UserId, "", channel); err != nil {
c.Err = err
return
}
auditRec.Success()
c.LogAudit("name=" + channel.Name + " user_id=" + c.Params.UserId)
ReturnStatusOK(w)
}
func localPatchChannel(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId()
if c.Err != nil {
return
}
var patch *model.ChannelPatch
err := json.NewDecoder(r.Body).Decode(&patch)
if err != nil || patch == nil {
c.SetInvalidParamWithErr("channel", err)
return
}
originalOldChannel, appErr := c.App.GetChannel(c.AppContext, c.Params.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
channel := originalOldChannel.DeepCopy()
auditRec := c.MakeAuditRecord(model.AuditEventLocalPatchChannel, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "channel_patch", patch)
channel.Patch(patch)
rchannel, appErr := c.App.UpdateChannel(c.AppContext, channel)
if appErr != nil {
c.Err = appErr
return
}
appErr = c.App.FillInChannelProps(c.AppContext, rchannel)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
c.LogAudit("")
auditRec.AddEventResultState(rchannel)
auditRec.AddEventObjectType("channel")
if err := json.NewEncoder(w).Encode(rchannel); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func localMoveChannel(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId()
if c.Err != nil {
return
}
channel, err := c.App.GetChannel(c.AppContext, c.Params.ChannelId)
if err != nil {
c.Err = err
return
}
props := model.StringInterfaceFromJSON(r.Body)
teamId, ok := props["team_id"].(string)
if !ok {
c.SetInvalidParam("team_id")
return
}
force, ok := props["force"].(bool)
if !ok {
c.SetInvalidParam("force")
return
}
team, err := c.App.GetTeam(teamId)
if err != nil {
c.Err = err
return
}
auditRec := c.MakeAuditRecord(model.AuditEventLocalMoveChannel, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "team_id", teamId)
model.AddEventParameterToAuditRec(auditRec, "force", force)
// TODO do we need these?
auditRec.AddMeta("channel_id", channel.Id)
auditRec.AddMeta("channel_name", channel.Name)
auditRec.AddMeta("team_id", team.Id)
auditRec.AddMeta("team_name", team.Name)
if channel.Type == model.ChannelTypeDirect || channel.Type == model.ChannelTypeGroup {
c.Err = model.NewAppError("moveChannel", "api.channel.move_channel.type.invalid", nil, "", http.StatusForbidden)
return
}
err = c.App.RemoveAllDeactivatedMembersFromChannel(c.AppContext, channel)
if err != nil {
c.Err = err
return
}
if force {
err = c.App.RemoveUsersFromChannelNotMemberOfTeam(c.AppContext, nil, channel, team)
if err != nil {
c.Err = err
return
}
}
err = c.App.MoveChannel(c.AppContext, team, channel, nil)
if err != nil {
c.Err = err
return
}
auditRec.AddEventResultState(channel)
auditRec.AddEventObjectType("channel")
auditRec.Success()
c.LogAudit("channel=" + channel.Name)
c.LogAudit("team=" + team.Name)
if err := json.NewEncoder(w).Encode(channel); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func localDeleteChannel(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId()
if c.Err != nil {
return
}
channel, err := c.App.GetChannel(c.AppContext, c.Params.ChannelId)
if err != nil {
c.Err = err
return
}
auditRec := c.MakeAuditRecord(model.AuditEventLocalDeleteChannel, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
auditRec.AddEventPriorState(channel)
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
if channel.Type == model.ChannelTypeDirect || channel.Type == model.ChannelTypeGroup {
c.Err = model.NewAppError("localDeleteChannel", "api.channel.delete_channel.type.invalid", nil, "", http.StatusBadRequest)
return
}
if c.Params.Permanent {
err = c.App.PermanentDeleteChannel(c.AppContext, channel)
} else {
err = c.App.DeleteChannel(c.AppContext, channel, "")
}
if err != nil {
c.Err = err
return
}
auditRec.Success()
auditRec.AddEventResultState(channel)
auditRec.AddEventObjectType("channel")
c.LogAudit("name=" + channel.Name)
ReturnStatusOK(w)
}

View File

@@ -0,0 +1,634 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"bytes"
"encoding/binary"
"encoding/json"
"io"
"net/http"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/platform/shared/web"
)
func (api *API) InitCloud() {
// GET /api/v4/cloud/products
api.BaseRoutes.Cloud.Handle("/products", api.APISessionRequired(getCloudProducts)).Methods(http.MethodGet)
// GET /api/v4/cloud/limits
api.BaseRoutes.Cloud.Handle("/limits", api.APISessionRequired(getCloudLimits)).Methods(http.MethodGet)
// GET /api/v4/cloud/customer
// PUT /api/v4/cloud/customer
// PUT /api/v4/cloud/customer/address
api.BaseRoutes.Cloud.Handle("/customer", api.APISessionRequired(getCloudCustomer)).Methods(http.MethodGet)
api.BaseRoutes.Cloud.Handle("/customer", api.APISessionRequired(updateCloudCustomer)).Methods(http.MethodPut)
api.BaseRoutes.Cloud.Handle("/customer/address", api.APISessionRequired(updateCloudCustomerAddress)).Methods(http.MethodPut)
// GET /api/v4/cloud/subscription
api.BaseRoutes.Cloud.Handle("/subscription", api.APISessionRequired(getSubscription)).Methods(http.MethodGet)
api.BaseRoutes.Cloud.Handle("/subscription/invoices", api.APISessionRequired(getInvoicesForSubscription)).Methods(http.MethodGet)
api.BaseRoutes.Cloud.Handle("/subscription/invoices/{invoice_id:[_A-Za-z0-9]+}/pdf", api.APISessionRequired(getSubscriptionInvoicePDF)).Methods(http.MethodGet)
// GET /api/v4/cloud/validate-business-email
api.BaseRoutes.Cloud.Handle("/validate-business-email", api.APISessionRequired(validateBusinessEmail)).Methods(http.MethodPost)
api.BaseRoutes.Cloud.Handle("/validate-workspace-business-email", api.APISessionRequired(validateWorkspaceBusinessEmail)).Methods(http.MethodPost)
// POST /api/v4/cloud/webhook
api.BaseRoutes.Cloud.Handle("/webhook", api.CloudAPIKeyRequired(handleCWSWebhook)).Methods(http.MethodPost)
// GET /api/v4/cloud/installation
api.BaseRoutes.Cloud.Handle("/installation", api.APISessionRequired(getInstallation)).Methods(http.MethodGet)
// GET /api/v4/cloud/check-cws-connection
api.BaseRoutes.Cloud.Handle("/check-cws-connection", api.APIHandler(handleCheckCWSConnection)).Methods(http.MethodGet)
// GET /api/v4/cloud/preview/modal_data
api.BaseRoutes.Cloud.Handle("/preview/modal_data", api.APISessionRequired(getPreviewModalData)).Methods(http.MethodGet)
}
func ensureCloudInterface(c *Context, where string) bool {
cloud := c.App.Cloud()
disabled := c.App.Config().CloudSettings.Disable != nil && *c.App.Config().CloudSettings.Disable
if cloud == nil {
c.Err = model.NewAppError(where, "api.server.cws.needs_enterprise_edition", nil, "", http.StatusBadRequest)
return false
}
if disabled {
c.Err = model.NewAppError(where, "api.server.cws.disabled", nil, "", http.StatusUnprocessableEntity)
return false
}
return true
}
func getPreviewSubscription(c *Context, w http.ResponseWriter, r *http.Request) {
license := c.App.Channels().License()
subscription := &model.Subscription{
ID: "cloud-preview",
ProductID: license.SkuName,
StartAt: license.StartsAt,
TrialEndAt: license.ExpiresAt,
EndAt: license.ExpiresAt,
IsFreeTrial: "true",
IsCloudPreview: true,
}
json, err := json.Marshal(subscription)
if err != nil {
c.Err = model.NewAppError("Api4.getSubscription", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(json); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getSubscription(c *Context, w http.ResponseWriter, r *http.Request) {
// Preview subscription is a special case for cloud preview licenses.
if c.App.Channels().License().IsCloudPreview() {
getPreviewSubscription(c, w, r)
return
}
ensured := ensureCloudInterface(c, "Api4.getSubscription")
if !ensured {
return
}
if !c.App.Channels().License().IsCloud() {
c.Err = model.NewAppError("Api4.getSubscription", "api.cloud.license_error", nil, "", http.StatusForbidden)
return
}
subscription, err := c.App.Cloud().GetSubscription(c.AppContext.Session().UserId)
if err != nil {
c.Err = model.NewAppError("Api4.getSubscription", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
// if it is an end user, return basic subscription data without sensitive information
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadBilling) {
subscription = &model.Subscription{
ID: subscription.ID,
ProductID: subscription.ProductID,
IsFreeTrial: subscription.IsFreeTrial,
TrialEndAt: subscription.TrialEndAt,
EndAt: subscription.EndAt,
DelinquentSince: subscription.DelinquentSince,
CustomerID: "",
AddOns: []string{},
StartAt: 0,
CreateAt: 0,
Seats: 0,
Status: "",
DNS: "",
LastInvoice: &model.Invoice{},
BillingType: "",
}
}
if model.GetServiceEnvironment() != model.ServiceEnvironmentTest {
subscription.SimulatedCurrentTimeMs = nil
}
// The annual renewals feature is deprecated; renewal and cancellation timing are not exposed to clients.
subscription.WillRenew = ""
subscription.CancelAt = nil
json, err := json.Marshal(subscription)
if err != nil {
c.Err = model.NewAppError("Api4.getSubscription", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(json); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func validateBusinessEmail(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.validateBusinessEmail")
if !ensured {
return
}
user, appErr := c.App.GetUser(c.AppContext, c.AppContext.Session().UserId)
if appErr != nil {
c.Err = model.NewAppError("Api4.validateBusinessEmail", "api.cloud.request_error", nil, "", http.StatusForbidden).Wrap(appErr)
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
var emailToValidate *model.ValidateBusinessEmailRequest
err = json.Unmarshal(bodyBytes, &emailToValidate)
if err != nil || emailToValidate == nil {
c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
err = c.App.Cloud().ValidateBusinessEmail(user.Id, emailToValidate.Email)
if err != nil {
c.Err = model.NewAppError("Api4.validateBusinessEmail", "api.cloud.request_error", nil, "", http.StatusForbidden).Wrap(err)
emailResp := model.ValidateBusinessEmailResponse{IsValid: false}
if err := json.NewEncoder(w).Encode(emailResp); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
return
}
emailResp := model.ValidateBusinessEmailResponse{IsValid: true}
if err := json.NewEncoder(w).Encode(emailResp); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func validateWorkspaceBusinessEmail(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.validateWorkspaceBusinessEmail")
if !ensured {
return
}
if !c.App.Channels().License().IsCloud() {
c.Err = model.NewAppError("Api4.validateWorkspaceBusinessEmail", "api.cloud.license_error", nil, "", http.StatusForbidden)
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteBilling) {
c.SetPermissionError(model.PermissionSysconsoleWriteBilling)
return
}
user, userErr := c.App.GetUser(c.AppContext, c.AppContext.Session().UserId)
if userErr != nil {
c.Err = userErr
return
}
// get the cloud customer email to validate if is a valid business email
cloudCustomer, err := c.App.Cloud().GetCloudCustomer(user.Id)
if err != nil {
c.Err = model.NewAppError("Api4.validateWorkspaceBusinessEmail", "api.cloud.request_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
emailErr := c.App.Cloud().ValidateBusinessEmail(user.Id, cloudCustomer.Email)
// if the current workspace email is not a valid business email
if emailErr != nil {
// grab the current admin email and validate it
errValidatingAdminEmail := c.App.Cloud().ValidateBusinessEmail(user.Id, user.Email)
if errValidatingAdminEmail != nil {
c.Err = model.NewAppError("Api4.validateWorkspaceBusinessEmail", "api.cloud.request_error", nil, "", http.StatusForbidden).Wrap(errValidatingAdminEmail)
emailResp := model.ValidateBusinessEmailResponse{IsValid: false}
if err := json.NewEncoder(w).Encode(emailResp); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
return
}
}
// if any of the emails is valid, return ok
emailResp := model.ValidateBusinessEmailResponse{IsValid: true}
if err := json.NewEncoder(w).Encode(emailResp); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getCloudProducts(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.getCloudProducts")
if !ensured {
return
}
if !c.App.Channels().License().IsCloud() {
c.Err = model.NewAppError("Api4.getCloudProducts", "api.cloud.license_error", nil, "", http.StatusForbidden)
return
}
includeLegacyProducts := r.URL.Query().Get("include_legacy") == "true"
products, err := c.App.Cloud().GetCloudProducts(c.AppContext.Session().UserId, includeLegacyProducts)
if err != nil {
c.Err = model.NewAppError("Api4.getCloudProducts", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
byteProductsData, err := json.Marshal(products)
if err != nil {
c.Err = model.NewAppError("Api4.getCloudProducts", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadBilling) {
sanitizedProducts := []model.UserFacingProduct{}
err = json.Unmarshal(byteProductsData, &sanitizedProducts)
if err != nil {
c.Err = model.NewAppError("Api4.getCloudProducts", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
byteSanitizedProductsData, err := json.Marshal(sanitizedProducts)
if err != nil {
c.Err = model.NewAppError("Api4.getCloudProducts", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(byteSanitizedProductsData); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
return
}
if _, err := w.Write(byteProductsData); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getCloudLimits(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.getCloudLimits")
if !ensured {
return
}
if !c.App.Channels().License().IsCloud() {
c.Err = model.NewAppError("Api4.getCloudLimits", "api.cloud.license_error", nil, "", http.StatusForbidden)
return
}
limits, err := c.App.Cloud().GetCloudLimits(c.AppContext.Session().UserId)
if err != nil {
c.Err = model.NewAppError("Api4.getCloudLimits", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
json, err := json.Marshal(limits)
if err != nil {
c.Err = model.NewAppError("Api4.getCloudLimits", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(json); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getCloudCustomer(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.getCloudCustomer")
if !ensured {
return
}
if !c.App.Channels().License().IsCloud() {
c.Err = model.NewAppError("Api4.getCloudCustomer", "api.cloud.license_error", nil, "", http.StatusForbidden)
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadBilling) {
c.SetPermissionError(model.PermissionSysconsoleReadBilling)
return
}
customer, err := c.App.Cloud().GetCloudCustomer(c.AppContext.Session().UserId)
if err != nil {
c.Err = model.NewAppError("Api4.getCloudCustomer", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
json, err := json.Marshal(customer)
if err != nil {
c.Err = model.NewAppError("Api4.getCloudCustomer", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(json); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getInstallation(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.getInstallation")
if !ensured {
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadIPFilters) {
c.SetPermissionError(model.PermissionSysconsoleReadIPFilters)
return
}
installation, err := c.App.Cloud().GetInstallation(c.AppContext.Session().UserId)
if err != nil {
c.Err = model.NewAppError("Api4.getInstallation", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if err := json.NewEncoder(w).Encode(installation); err != nil {
c.Err = model.NewAppError("Api4.getInstallation", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
}
func updateCloudCustomer(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.updateCloudCustomer")
if !ensured {
return
}
if !c.App.Channels().License().IsCloud() {
c.Err = model.NewAppError("Api4.updateCloudCustomer", "api.cloud.license_error", nil, "", http.StatusForbidden)
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteBilling) {
c.SetPermissionError(model.PermissionSysconsoleWriteBilling)
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
c.Err = model.NewAppError("Api4.updateCloudCustomer", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
var customerInfo *model.CloudCustomerInfo
if err = json.Unmarshal(bodyBytes, &customerInfo); err != nil || customerInfo == nil {
c.Err = model.NewAppError("Api4.updateCloudCustomer", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
customer, appErr := c.App.Cloud().UpdateCloudCustomer(c.AppContext.Session().UserId, customerInfo)
if appErr != nil {
c.Err = model.NewAppError("Api4.updateCloudCustomer", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
return
}
json, err := json.Marshal(customer)
if err != nil {
c.Err = model.NewAppError("Api4.updateCloudCustomer", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(json); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func updateCloudCustomerAddress(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.updateCloudCustomerAddress")
if !ensured {
return
}
if !c.App.Channels().License().IsCloud() {
c.Err = model.NewAppError("Api4.updateCloudCustomerAddress", "api.cloud.license_error", nil, "", http.StatusForbidden)
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteBilling) {
c.SetPermissionError(model.PermissionSysconsoleWriteBilling)
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
c.Err = model.NewAppError("Api4.updateCloudCustomerAddress", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
var address *model.Address
if err = json.Unmarshal(bodyBytes, &address); err != nil || address == nil {
c.Err = model.NewAppError("Api4.updateCloudCustomerAddress", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
customer, appErr := c.App.Cloud().UpdateCloudCustomerAddress(c.AppContext.Session().UserId, address)
if appErr != nil {
c.Err = model.NewAppError("Api4.updateCloudCustomerAddress", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
return
}
json, err := json.Marshal(customer)
if err != nil {
c.Err = model.NewAppError("Api4.updateCloudCustomerAddress", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(json); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getInvoicesForSubscription(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.getInvoicesForSubscription")
if !ensured {
return
}
if !c.App.Channels().License().IsCloud() {
c.Err = model.NewAppError("Api4.getInvoicesForSubscription", "api.cloud.license_error", nil, "", http.StatusForbidden)
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadBilling) {
c.SetPermissionError(model.PermissionSysconsoleReadBilling)
return
}
invoices, appErr := c.App.Cloud().GetInvoicesForSubscription(c.AppContext.Session().UserId)
if appErr != nil {
c.Err = model.NewAppError("Api4.getInvoicesForSubscription", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
return
}
json, err := json.Marshal(invoices)
if err != nil {
c.Err = model.NewAppError("Api4.getInvoicesForSubscription", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(json); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getSubscriptionInvoicePDF(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.getSubscriptionInvoicePDF")
if !ensured {
return
}
if !c.App.Channels().License().IsCloud() {
c.Err = model.NewAppError("Api4.getSubscriptionInvoicePDF", "api.cloud.license_error", nil, "", http.StatusForbidden)
return
}
c.RequireInvoiceId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadBilling) {
c.SetPermissionError(model.PermissionSysconsoleReadBilling)
return
}
pdfData, filename, appErr := c.App.Cloud().GetInvoicePDF(c.AppContext.Session().UserId, c.Params.InvoiceId)
if appErr != nil {
c.Err = model.NewAppError("Api4.getSubscriptionInvoicePDF", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
return
}
web.WriteFileResponse(
filename,
"application/pdf",
int64(binary.Size(pdfData)),
time.Now(),
*c.App.Config().ServiceSettings.WebserverMode,
bytes.NewReader(pdfData),
false,
w,
r,
)
}
func handleCWSWebhook(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.handleCWSWebhook")
if !ensured {
return
}
if !c.App.Channels().License().IsCloud() {
c.Err = model.NewAppError("Api4.handleCWSWebhook", "api.cloud.license_error", nil, "", http.StatusForbidden)
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
c.Err = model.NewAppError("Api4.handleCWSWebhook", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
defer r.Body.Close()
var event *model.CWSWebhookPayload
if err = json.Unmarshal(bodyBytes, &event); err != nil || event == nil {
c.Err = model.NewAppError("Api4.handleCWSWebhook", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
switch event.Event {
case model.EventTypeSendAdminWelcomeEmail:
user, appErr := c.App.GetUserByUsername(event.CloudWorkspaceOwner.UserName)
if appErr != nil {
c.Err = model.NewAppError("Api4.handleCWSWebhook", appErr.Id, nil, "", appErr.StatusCode).Wrap(appErr)
return
}
teams, appErr := c.App.GetAllTeams()
if appErr != nil {
c.Err = model.NewAppError("Api4.handleCWSWebhook", appErr.Id, nil, "", appErr.StatusCode).Wrap(appErr)
return
}
team := teams[0]
subscription, err := c.App.Cloud().GetSubscription(user.Id)
if err != nil {
c.Err = model.NewAppError("Api4.handleCWSWebhook", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if err := c.App.Srv().EmailService.SendCloudWelcomeEmail(user.Email, user.Locale, team.InviteId, subscription.GetWorkSpaceNameFromDNS(), subscription.DNS, *c.App.Config().ServiceSettings.SiteURL); err != nil {
c.Err = model.NewAppError("SendCloudWelcomeEmail", "api.user.send_cloud_welcome_email.error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
default:
c.Err = model.NewAppError("Api4.handleCWSWebhook", "api.cloud.cws_webhook_event_missing_error", nil, "", http.StatusNotFound)
return
}
ReturnStatusOK(w)
}
func handleCheckCWSConnection(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.handleCheckCWSConnection")
if !ensured {
return
}
status := "available"
if err := c.App.Cloud().CheckCWSConnection(c.AppContext.Session().UserId); err != nil {
status = "unavailable"
}
response := map[string]string{"status": status}
if err := json.NewEncoder(w).Encode(response); err != nil {
c.Err = model.NewAppError("Api4.handleCheckCWSConnection", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
}
func getPreviewModalData(c *Context, w http.ResponseWriter, r *http.Request) {
modalData, err := c.App.GetPreviewModalData()
if err != nil {
c.Err = err
return
}
responseData, jsonErr := json.Marshal(modalData)
if jsonErr != nil {
c.Err = model.NewAppError("Api4.getPreviewModalData", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
return
}
if _, writeErr := w.Write(responseData); writeErr != nil {
c.Logger.Warn("Error while writing response", mlog.Err(writeErr))
}
}

View File

@@ -0,0 +1,488 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"encoding/json"
"errors"
"net/http"
"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/einterfaces/mocks"
)
func TestGetSubscription(t *testing.T) {
mainHelper.Parallel(t)
deliquencySince := int64(2000000000)
subscription := &model.Subscription{
ID: "MySubscriptionID",
CustomerID: "MyCustomer",
ProductID: "SomeProductId",
AddOns: []string{},
StartAt: 1000000000,
EndAt: 2000000000,
CreateAt: 1000000000,
Seats: 10,
IsFreeTrial: "true",
DNS: "some.dns.server",
TrialEndAt: 2000000000,
LastInvoice: &model.Invoice{},
DelinquentSince: &deliquencySince,
}
userFacingSubscription := &model.Subscription{
ID: "MySubscriptionID",
CustomerID: "",
ProductID: "SomeProductId",
AddOns: []string{},
StartAt: 0,
EndAt: 2000000000,
CreateAt: 0,
Seats: 0,
IsFreeTrial: "true",
DNS: "",
TrialEndAt: 2000000000,
LastInvoice: &model.Invoice{},
DelinquentSince: &deliquencySince,
}
t.Run("NON Admin users receive the user facing subscription", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
require.NoError(t, err)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
cloud := mocks.CloudInterface{}
cloud.Mock.On("GetSubscription", mock.Anything).Return(subscription, nil)
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
subscriptionReturned, r, err := th.Client.GetSubscription(context.Background())
require.NoError(t, err)
require.Equal(t, subscriptionReturned, userFacingSubscription)
require.Equal(t, http.StatusOK, r.StatusCode, "Status OK")
})
t.Run("Admin users receive the full subscription information", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
require.NoError(t, err)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
cloud := mocks.CloudInterface{}
cloud.Mock.On("GetSubscription", mock.Anything).Return(subscription, nil)
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
subscriptionReturned, r, err := th.SystemAdminClient.GetSubscription(context.Background())
require.NoError(t, err)
require.Equal(t, subscriptionReturned, subscription)
require.Equal(t, http.StatusOK, r.StatusCode, "Status OK")
})
}
func TestValidateBusinessEmail(t *testing.T) {
mainHelper.Parallel(t)
t.Run("Returns forbidden for invalid business email", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
require.NoError(t, err)
validBusinessEmail := model.ValidateBusinessEmailRequest{Email: "invalid@slacker.com"}
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
cloud := mocks.CloudInterface{}
cloud.Mock.On("ValidateBusinessEmail", th.SystemAdminUser.Id, validBusinessEmail.Email).Return(errors.New("invalid email"))
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
res, err := th.SystemAdminClient.ValidateBusinessEmail(context.Background(), &validBusinessEmail)
require.Error(t, err)
require.Equal(t, http.StatusForbidden, res.StatusCode, "403")
})
t.Run("Validate business email for admin", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
require.NoError(t, err)
validBusinessEmail := model.ValidateBusinessEmailRequest{Email: "valid@mattermost.com"}
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
cloud := mocks.CloudInterface{}
cloud.Mock.On("ValidateBusinessEmail", th.SystemAdminUser.Id, validBusinessEmail.Email).Return(nil)
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
res, err := th.SystemAdminClient.ValidateBusinessEmail(context.Background(), &validBusinessEmail)
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode, "200")
})
t.Run("Empty body returns bad request", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
require.NoError(t, err)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
r, err := th.SystemAdminClient.DoAPIPost(context.Background(), "/cloud/validate-business-email", "")
require.Error(t, err)
closeBody(r)
require.Equal(t, http.StatusBadRequest, r.StatusCode, "Status Bad Request")
})
}
func TestValidateWorkspaceBusinessEmail(t *testing.T) {
mainHelper.Parallel(t)
t.Run("validate the Cloud Customer has used a valid email to create the workspace", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
require.NoError(t, err)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
cloud := mocks.CloudInterface{}
cloudCustomerInfo := model.CloudCustomerInfo{
Email: "valid@mattermost.com",
}
cloudCustomer := &model.CloudCustomer{
CloudCustomerInfo: cloudCustomerInfo,
}
cloud.Mock.On("GetCloudCustomer", th.SystemAdminUser.Id).Return(cloudCustomer, nil)
cloud.Mock.On("ValidateBusinessEmail", th.SystemAdminUser.Id, cloudCustomerInfo.Email).Return(nil)
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
_, err = th.SystemAdminClient.ValidateWorkspaceBusinessEmail(context.Background())
require.NoError(t, err)
})
t.Run("validate the Cloud Customer has used a invalid email to create the workspace and must validate admin email", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
require.NoError(t, err)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
cloud := mocks.CloudInterface{}
cloudCustomerInfo := model.CloudCustomerInfo{
Email: "invalid@gmail.com",
}
cloudCustomer := &model.CloudCustomer{
CloudCustomerInfo: cloudCustomerInfo,
}
cloud.Mock.On("GetCloudCustomer", th.SystemAdminUser.Id).Return(cloudCustomer, nil)
// first call to validate the cloud customer email
cloud.Mock.On("ValidateBusinessEmail", th.SystemAdminUser.Id, cloudCustomerInfo.Email).Return(errors.New("invalid email"))
// second call to validate the user admin email
cloud.Mock.On("ValidateBusinessEmail", th.SystemAdminUser.Id, th.SystemAdminUser.Email).Return(nil)
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
_, err = th.SystemAdminClient.ValidateWorkspaceBusinessEmail(context.Background())
require.NoError(t, err)
})
t.Run("Error while grabbing the cloud customer returns bad request", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
require.NoError(t, err)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
cloud := mocks.CloudInterface{}
cloudCustomerInfo := model.CloudCustomerInfo{
Email: "badrequest@gmail.com",
}
// return an error while getting the cloud customer so we validate the forbidden error return
cloud.Mock.On("GetCloudCustomer", th.SystemAdminUser.Id).Return(nil, errors.New("error while gettings the cloud customer"))
// required cloud mocks so the request doesn't fail
cloud.Mock.On("ValidateBusinessEmail", th.SystemAdminUser.Id, cloudCustomerInfo.Email).Return(errors.New("invalid email"))
cloud.Mock.On("ValidateBusinessEmail", th.SystemAdminUser.Id, th.SystemAdminUser.Email).Return(nil)
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
r, err := th.SystemAdminClient.DoAPIPost(context.Background(), "/cloud/validate-workspace-business-email", "")
require.Error(t, err)
closeBody(r)
require.Equal(t, http.StatusBadRequest, r.StatusCode, "Status Bad Request")
})
}
func TestGetCloudProducts(t *testing.T) {
mainHelper.Parallel(t)
cloudProducts := []*model.Product{
{
ID: "prod_test1",
Name: "name",
Description: "description",
PricePerSeat: 10,
SKU: "sku",
PriceID: "price_id",
Family: "family",
RecurringInterval: "monthly",
BillingScheme: "billing_scheme",
CrossSellsTo: "",
},
{
ID: "prod_test2",
Name: "name2",
Description: "description2",
PricePerSeat: 100,
SKU: "sku2",
PriceID: "price_id2",
Family: "family2",
RecurringInterval: "monthly",
BillingScheme: "billing_scheme2",
CrossSellsTo: "prod_test3",
},
{
ID: "prod_test3",
Name: "name3",
Description: "description3",
PricePerSeat: 1000,
SKU: "sku3",
PriceID: "price_id3",
Family: "family3",
RecurringInterval: "yearly",
BillingScheme: "billing_scheme3",
CrossSellsTo: "prod_test2",
},
}
sanitizedProducts := []*model.Product{
{
ID: "prod_test1",
Name: "name",
PricePerSeat: 10,
SKU: "sku",
RecurringInterval: "monthly",
CrossSellsTo: "",
},
{
ID: "prod_test2",
Name: "name2",
PricePerSeat: 100,
SKU: "sku2",
RecurringInterval: "monthly",
CrossSellsTo: "prod_test3",
},
{
ID: "prod_test3",
Name: "name3",
PricePerSeat: 1000,
SKU: "sku3",
RecurringInterval: "yearly",
CrossSellsTo: "prod_test2",
},
}
t.Run("get products for admins", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
_, _, err := th.Client.Login(context.Background(), th.SystemAdminUser.Email, th.SystemAdminUser.Password)
require.NoError(t, err)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
cloud := mocks.CloudInterface{}
cloud.Mock.On("GetCloudProducts", mock.Anything, mock.Anything).Return(cloudProducts, nil)
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
returnedProducts, r, err := th.Client.GetCloudProducts(context.Background())
require.NoError(t, err)
require.Equal(t, http.StatusOK, r.StatusCode, "Status OK")
require.Equal(t, returnedProducts, cloudProducts)
})
t.Run("get products for non admins", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
require.NoError(t, err)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
cloud := mocks.CloudInterface{}
cloud.Mock.On("GetCloudProducts", mock.Anything, mock.Anything).Return(cloudProducts, nil)
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
returnedProducts, r, err := th.Client.GetCloudProducts(context.Background())
require.NoError(t, err)
require.Equal(t, http.StatusOK, r.StatusCode, "Status OK")
require.Equal(t, returnedProducts, sanitizedProducts)
// make a more explicit check
require.Equal(t, returnedProducts[0].ID, "prod_test1")
require.Equal(t, returnedProducts[0].Name, "name")
require.Equal(t, returnedProducts[0].SKU, "sku")
require.Equal(t, returnedProducts[0].PricePerSeat, float64(10))
require.Equal(t, returnedProducts[0].Description, "")
require.Equal(t, returnedProducts[0].PriceID, "")
require.Equal(t, returnedProducts[0].Family, model.SubscriptionFamily(""))
require.Equal(t, returnedProducts[0].RecurringInterval, model.RecurringInterval("monthly"))
require.Equal(t, returnedProducts[0].BillingScheme, model.BillingScheme(""))
require.Equal(t, returnedProducts[0].CrossSellsTo, "")
require.Equal(t, returnedProducts[1].ID, "prod_test2")
require.Equal(t, returnedProducts[1].Name, "name2")
require.Equal(t, returnedProducts[1].SKU, "sku2")
require.Equal(t, returnedProducts[1].PricePerSeat, float64(100))
require.Equal(t, returnedProducts[1].Description, "")
require.Equal(t, returnedProducts[1].PriceID, "")
require.Equal(t, returnedProducts[1].Family, model.SubscriptionFamily(""))
require.Equal(t, returnedProducts[1].RecurringInterval, model.RecurringInterval("monthly"))
require.Equal(t, returnedProducts[1].BillingScheme, model.BillingScheme(""))
require.Equal(t, returnedProducts[1].CrossSellsTo, "prod_test3")
require.Equal(t, returnedProducts[2].ID, "prod_test3")
require.Equal(t, returnedProducts[2].Name, "name3")
require.Equal(t, returnedProducts[2].SKU, "sku3")
require.Equal(t, returnedProducts[2].PricePerSeat, float64(1000))
require.Equal(t, returnedProducts[2].Description, "")
require.Equal(t, returnedProducts[2].PriceID, "")
require.Equal(t, returnedProducts[2].Family, model.SubscriptionFamily(""))
require.Equal(t, returnedProducts[2].RecurringInterval, model.RecurringInterval("yearly"))
require.Equal(t, returnedProducts[2].BillingScheme, model.BillingScheme(""))
require.Equal(t, returnedProducts[2].CrossSellsTo, "prod_test2")
})
}
func TestCheckCWSConnection(t *testing.T) {
mainHelper.Parallel(t)
t.Run("returns available when CWS is reachable", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicense())
cloud := mocks.CloudInterface{}
cloud.Mock.On("CheckCWSConnection", mock.Anything).Return(nil)
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
r, err := th.Client.DoAPIGet(context.Background(), "/cloud/check-cws-connection", "")
require.NoError(t, err)
defer closeBody(r)
require.Equal(t, http.StatusOK, r.StatusCode)
var response map[string]string
require.NoError(t, json.NewDecoder(r.Body).Decode(&response))
assert.Equal(t, "available", response["status"])
})
t.Run("returns unavailable when CWS is not reachable", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicense())
cloud := mocks.CloudInterface{}
cloud.Mock.On("CheckCWSConnection", mock.Anything).Return(errors.New("connection failed"))
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
r, err := th.Client.DoAPIGet(context.Background(), "/cloud/check-cws-connection", "")
require.NoError(t, err)
defer closeBody(r)
require.Equal(t, http.StatusOK, r.StatusCode)
var response map[string]string
require.NoError(t, json.NewDecoder(r.Body).Decode(&response))
assert.Equal(t, "unavailable", response["status"])
})
}

View File

@@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitCluster() {
api.BaseRoutes.Cluster.Handle("/status", api.APISessionRequired(getClusterStatus)).Methods(http.MethodGet)
}
func getClusterStatus(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionToAndNotRestrictedAdmin(*c.AppContext.Session(), model.PermissionSysconsoleReadEnvironmentHighAvailability) {
c.SetPermissionError(model.PermissionSysconsoleReadEnvironmentHighAvailability)
return
}
infos, err := c.App.GetClusterStatus(c.AppContext)
if err != nil {
c.Err = model.NewAppError("getClusterStatus", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
js, err := json.Marshal(infos)
if err != nil {
c.Err = model.NewAppError("getClusterStatus", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestGetClusterStatus(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
t.Run("as system user", func(t *testing.T) {
_, resp, err := th.Client.GetClusterStatus(context.Background())
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("as system admin", func(t *testing.T) {
infos, _, err := th.SystemAdminClient.GetClusterStatus(context.Background())
require.NoError(t, err)
require.NotNil(t, infos, "cluster status should not be nil")
})
t.Run("as restricted system admin", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = true })
_, resp, err := th.SystemAdminClient.GetClusterStatus(context.Background())
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
}

View File

@@ -0,0 +1,554 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitCommand() {
api.BaseRoutes.Commands.Handle("", api.APISessionRequired(createCommand)).Methods(http.MethodPost)
api.BaseRoutes.Commands.Handle("", api.APISessionRequired(listCommands)).Methods(http.MethodGet)
api.BaseRoutes.Commands.Handle("/execute", api.APISessionRequired(executeCommand)).Methods(http.MethodPost)
api.BaseRoutes.Command.Handle("", api.APISessionRequired(getCommand)).Methods(http.MethodGet)
api.BaseRoutes.Command.Handle("", api.APISessionRequired(updateCommand)).Methods(http.MethodPut)
api.BaseRoutes.Command.Handle("/move", api.APISessionRequired(moveCommand)).Methods(http.MethodPut)
api.BaseRoutes.Command.Handle("", api.APISessionRequired(deleteCommand)).Methods(http.MethodDelete)
api.BaseRoutes.Team.Handle("/commands/autocomplete", api.APISessionRequired(listAutocompleteCommands)).Methods(http.MethodGet)
api.BaseRoutes.Team.Handle("/commands/autocomplete_suggestions", api.APISessionRequired(listCommandAutocompleteSuggestions)).Methods(http.MethodGet)
api.BaseRoutes.Command.Handle("/regen_token", api.APISessionRequired(regenCommandToken)).Methods(http.MethodPut)
}
func createCommand(c *Context, w http.ResponseWriter, r *http.Request) {
var cmd model.Command
if jsonErr := json.NewDecoder(r.Body).Decode(&cmd); jsonErr != nil {
c.SetInvalidParamWithErr("command", jsonErr)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventCreateCommand, model.AuditStatusFail)
model.AddEventParameterAuditableToAuditRec(auditRec, "command", &cmd)
defer c.LogAuditRec(auditRec)
c.LogAudit("attempt")
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), cmd.TeamId, model.PermissionManageOwnSlashCommands) {
c.SetPermissionError(model.PermissionManageOwnSlashCommands)
return
}
userId := c.AppContext.Session().UserId
if cmd.CreatorId != "" && cmd.CreatorId != userId {
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), cmd.TeamId, model.PermissionManageOthersSlashCommands) {
c.LogAudit("fail - inappropriate permissions")
c.SetPermissionError(model.PermissionManageOthersSlashCommands)
return
}
if _, err := c.App.GetUser(c.AppContext, cmd.CreatorId); err != nil {
c.Err = err
return
}
userId = cmd.CreatorId
}
cmd.CreatorId = userId
rcmd, err := c.App.CreateCommand(&cmd)
if err != nil {
c.Err = err
return
}
auditRec.Success()
c.LogAudit("success")
auditRec.AddEventResultState(rcmd)
auditRec.AddEventObjectType("command")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(rcmd); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func updateCommand(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireCommandId()
if c.Err != nil {
return
}
var cmd model.Command
if jsonErr := json.NewDecoder(r.Body).Decode(&cmd); jsonErr != nil || cmd.Id != c.Params.CommandId {
c.SetInvalidParamWithErr("command", jsonErr)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventUpdateCommand, model.AuditStatusFail)
model.AddEventParameterAuditableToAuditRec(auditRec, "command", &cmd)
defer c.LogAuditRec(auditRec)
c.LogAudit("attempt")
oldCmd, err := c.App.GetCommand(c.Params.CommandId)
if err != nil {
model.AddEventParameterToAuditRec(auditRec, "command_id", c.Params.CommandId)
c.SetCommandNotFoundError()
return
}
auditRec.AddEventPriorState(oldCmd)
if cmd.TeamId != oldCmd.TeamId {
c.Err = model.NewAppError("updateCommand", "api.command.team_mismatch.app_error", nil, "user_id="+c.AppContext.Session().UserId, http.StatusBadRequest)
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), oldCmd.TeamId, model.PermissionManageOwnSlashCommands) {
c.LogAudit("fail - inappropriate permissions")
// here we return Not_found instead of a permissions error so we don't leak the existence of
// a command to someone without permissions for the team it belongs to.
c.SetCommandNotFoundError()
return
}
if c.AppContext.Session().UserId != oldCmd.CreatorId && !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), oldCmd.TeamId, model.PermissionManageOthersSlashCommands) {
c.LogAudit("fail - inappropriate permissions")
c.SetPermissionError(model.PermissionManageOthersSlashCommands)
return
}
rcmd, err := c.App.UpdateCommand(oldCmd, &cmd)
if err != nil {
c.Err = err
return
}
auditRec.AddEventResultState(rcmd)
auditRec.AddEventObjectType("command")
auditRec.Success()
c.LogAudit("success")
if err := json.NewEncoder(w).Encode(rcmd); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func moveCommand(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireCommandId()
if c.Err != nil {
return
}
var cmr model.CommandMoveRequest
if jsonErr := json.NewDecoder(r.Body).Decode(&cmr); jsonErr != nil {
c.SetInvalidParamWithErr("team_id", jsonErr)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventMoveCommand, model.AuditStatusFail)
model.AddEventParameterToAuditRec(auditRec, "command_move_request", cmr.TeamId)
defer c.LogAuditRec(auditRec)
c.LogAudit("attempt")
newTeam, appErr := c.App.GetTeam(cmr.TeamId)
if appErr != nil {
c.Err = appErr
return
}
model.AddEventParameterAuditableToAuditRec(auditRec, "team", newTeam)
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), newTeam.Id, model.PermissionManageOwnSlashCommands) {
c.LogAudit("fail - inappropriate permissions")
c.SetPermissionError(model.PermissionManageOwnSlashCommands)
return
}
cmd, appErr := c.App.GetCommand(c.Params.CommandId)
if appErr != nil {
c.SetCommandNotFoundError()
return
}
auditRec.AddEventPriorState(cmd)
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), cmd.TeamId, model.PermissionManageOwnSlashCommands) {
c.LogAudit("fail - inappropriate permissions")
// here we return Not_found instead of a permissions error so we don't leak the existence of
// a command to someone without permissions for the team it belongs to.
c.SetCommandNotFoundError()
return
}
if c.AppContext.Session().UserId != cmd.CreatorId && !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), cmd.TeamId, model.PermissionManageOthersSlashCommands) {
c.LogAudit("fail - inappropriate permissions")
c.SetPermissionError(model.PermissionManageOthersSlashCommands)
return
}
// Verify that the command creator has permission to the new team
// This prevents moving a command to a team where its creator doesn't have access
if !c.App.HasPermissionToTeam(c.AppContext, cmd.CreatorId, newTeam.Id, model.PermissionManageOwnSlashCommands) {
c.LogAudit("fail - command creator does not have permission to new team")
c.Err = model.NewAppError("moveCommand", "api.command.move_command.creator_no_permission.app_error", nil, "creator_id="+cmd.CreatorId+" team_id="+newTeam.Id, http.StatusBadRequest)
return
}
if appErr = c.App.MoveCommand(newTeam, cmd); appErr != nil {
c.Err = appErr
return
}
auditRec.AddEventResultState(cmd)
auditRec.AddEventObjectType("command")
auditRec.Success()
c.LogAudit("success")
ReturnStatusOK(w)
}
func deleteCommand(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireCommandId()
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventDeleteCommand, model.AuditStatusFail)
model.AddEventParameterToAuditRec(auditRec, "command_id", c.Params.CommandId)
defer c.LogAuditRec(auditRec)
c.LogAudit("attempt")
cmd, err := c.App.GetCommand(c.Params.CommandId)
if err != nil {
c.SetCommandNotFoundError()
return
}
auditRec.AddEventPriorState(cmd)
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), cmd.TeamId, model.PermissionManageOwnSlashCommands) {
c.LogAudit("fail - inappropriate permissions")
// here we return Not_found instead of a permissions error so we don't leak the existence of
// a command to someone without permissions for the team it belongs to.
c.SetCommandNotFoundError()
return
}
if c.AppContext.Session().UserId != cmd.CreatorId && !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), cmd.TeamId, model.PermissionManageOthersSlashCommands) {
c.LogAudit("fail - inappropriate permissions")
c.SetPermissionError(model.PermissionManageOthersSlashCommands)
return
}
err = c.App.DeleteCommand(cmd.Id)
if err != nil {
c.Err = err
return
}
auditRec.AddEventObjectType("command")
auditRec.Success()
c.LogAudit("success")
ReturnStatusOK(w)
}
func listCommands(c *Context, w http.ResponseWriter, r *http.Request) {
customOnly, _ := strconv.ParseBool(r.URL.Query().Get("custom_only"))
teamId := r.URL.Query().Get("team_id")
if teamId == "" {
c.SetInvalidParam("team_id")
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
var commands []*model.Command
var err *model.AppError
if customOnly {
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamId, model.PermissionManageOwnSlashCommands) {
c.SetPermissionError(model.PermissionManageOwnSlashCommands)
return
}
// Filter to only commands the user can manage
userIdFilter := c.AppContext.Session().UserId
if c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamId, model.PermissionManageOthersSlashCommands) {
userIdFilter = "" // Empty means return all commands
}
commands, err = c.App.ListTeamCommandsByUser(teamId, userIdFilter)
if err != nil {
c.Err = err
return
}
} else {
//User with no permission should see only system commands
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamId, model.PermissionManageOwnSlashCommands) {
commands, err = c.App.ListAutocompleteCommands(teamId, c.AppContext.T)
if err != nil {
c.Err = err
return
}
} else {
// Filter custom commands to only those the user can manage
userIdFilter := c.AppContext.Session().UserId
if c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamId, model.PermissionManageOthersSlashCommands) {
userIdFilter = "" // Empty means return all commands
}
commands, err = c.App.ListAllCommandsByUser(teamId, userIdFilter, c.AppContext.T)
if err != nil {
c.Err = err
return
}
}
}
if err := json.NewEncoder(w).Encode(commands); err != nil {
c.Logger.Warn("Error writing response", mlog.Err(err))
}
}
func getCommand(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireCommandId()
if c.Err != nil {
return
}
cmd, err := c.App.GetCommand(c.Params.CommandId)
if err != nil {
c.SetCommandNotFoundError()
return
}
// check for permissions to view this command; must have perms to view team and
// PERMISSION_MANAGE_SLASH_COMMANDS for the team the command belongs to.
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), cmd.TeamId, model.PermissionViewTeam) {
// here we return Not_found instead of a permissions error so we don't leak the existence of
// a command to someone without permissions for the team it belongs to.
c.SetCommandNotFoundError()
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), cmd.TeamId, model.PermissionManageOwnSlashCommands) {
// again, return not_found to ensure id existence does not leak.
c.SetCommandNotFoundError()
return
}
if c.AppContext.Session().UserId != cmd.CreatorId && !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), cmd.TeamId, model.PermissionManageOthersSlashCommands) {
c.SetCommandNotFoundError()
return
}
if err := json.NewEncoder(w).Encode(cmd); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func executeCommand(c *Context, w http.ResponseWriter, r *http.Request) {
var commandArgs model.CommandArgs
if jsonErr := json.NewDecoder(r.Body).Decode(&commandArgs); jsonErr != nil {
c.SetInvalidParamWithErr("command_args", jsonErr)
return
}
if len(commandArgs.Command) <= 1 || strings.Index(commandArgs.Command, "/") != 0 || !model.IsValidId(commandArgs.ChannelId) {
c.Err = model.NewAppError("executeCommand", "api.command.execute_command.start.app_error", nil, "", http.StatusBadRequest)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventExecuteCommand, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "command_args", &commandArgs)
// Checks that user is a member of the specified channel, and that they have permission to create a post in it.
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), commandArgs.ChannelId, model.PermissionCreatePost); !ok {
c.SetPermissionError(model.PermissionCreatePost)
return
}
channel, err := c.App.GetChannel(c.AppContext, commandArgs.ChannelId)
if err != nil {
c.Err = err
return
}
if channel.DeleteAt != 0 {
c.Err = model.NewAppError("createPost", "api.command.execute_command.deleted.error", nil, "", http.StatusBadRequest)
return
}
if channel.Type != model.ChannelTypeDirect && channel.Type != model.ChannelTypeGroup {
// if this isn't a DM or GM, the team id is implicitly taken from the channel so that slash commands created on
// some other team can't be run against this one
commandArgs.TeamId = channel.TeamId
} else {
restrictDM, appErr := c.App.CheckIfChannelIsRestrictedDM(c.AppContext, channel)
if appErr != nil {
c.Err = err
return
}
if restrictDM {
c.Err = model.NewAppError("createPost", "api.command.execute_command.restricted_dm.error", nil, "", http.StatusBadRequest)
return
}
// if the slash command was used in a DM or GM, ensure that the user is a member of the specified team, so that
// they can't just execute slash commands against arbitrary teams
if c.AppContext.Session().GetTeamByTeamId(commandArgs.TeamId) == nil {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionCreatePost) {
c.SetPermissionError(model.PermissionCreatePost)
return
}
}
}
commandArgs.UserId = c.AppContext.Session().UserId
commandArgs.T = c.AppContext.T
commandArgs.SiteURL = c.GetSiteURLHeader()
commandArgs.ConnectionId = r.Header.Get(model.ConnectionId)
response, err := c.App.ExecuteCommand(c.AppContext, &commandArgs)
if err != nil {
c.Err = err
return
}
auditRec.Success()
if err := json.NewEncoder(w).Encode(response); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func listAutocompleteCommands(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireTeamId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
commands, err := c.App.ListAutocompleteCommands(c.Params.TeamId, c.AppContext.T)
if err != nil {
c.Err = err
return
}
if err := json.NewEncoder(w).Encode(commands); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func listCommandAutocompleteSuggestions(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireTeamId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
roleId := model.SystemUserRoleId
if c.IsSystemAdmin() {
roleId = model.SystemAdminRoleId
}
query := r.URL.Query()
userInput := query.Get("user_input")
if userInput == "" {
c.SetInvalidParam("userInput")
return
}
userInput = strings.TrimPrefix(userInput, "/")
commands, appErr := c.App.ListAutocompleteCommands(c.Params.TeamId, c.AppContext.T)
if appErr != nil {
c.Err = appErr
return
}
commandArgs := &model.CommandArgs{
ChannelId: query.Get("channel_id"),
TeamId: c.Params.TeamId,
RootId: query.Get("root_id"),
UserId: c.AppContext.Session().UserId,
T: c.AppContext.T,
SiteURL: c.GetSiteURLHeader(),
Command: userInput,
}
suggestions := c.App.GetSuggestions(c.AppContext, commandArgs, commands, roleId)
js, err := json.Marshal(suggestions)
if err != nil {
c.Err = model.NewAppError("listCommandAutocompleteSuggestions", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func regenCommandToken(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireCommandId()
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventRegenCommandToken, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
c.LogAudit("attempt")
cmd, err := c.App.GetCommand(c.Params.CommandId)
if err != nil {
model.AddEventParameterToAuditRec(auditRec, "command_id", c.Params.CommandId)
c.SetCommandNotFoundError()
return
}
auditRec.AddEventPriorState(cmd)
model.AddEventParameterToAuditRec(auditRec, "command_id", c.Params.CommandId)
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), cmd.TeamId, model.PermissionManageOwnSlashCommands) {
c.LogAudit("fail - inappropriate permissions")
// here we return Not_found instead of a permissions error so we don't leak the existence of
// a command to someone without permissions for the team it belongs to.
c.SetCommandNotFoundError()
return
}
if c.AppContext.Session().UserId != cmd.CreatorId && !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), cmd.TeamId, model.PermissionManageOthersSlashCommands) {
c.LogAudit("fail - inappropriate permissions")
c.SetPermissionError(model.PermissionManageOthersSlashCommands)
return
}
rcmd, err := c.App.RegenCommandToken(cmd)
if err != nil {
c.Err = err
return
}
auditRec.AddEventResultState(rcmd)
auditRec.Success()
c.LogAudit("success")
resp := make(map[string]string)
resp["token"] = rcmd.Token
if _, err := w.Write([]byte(model.MapToJSON(resp))); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestHelpCommand(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
client := th.Client
channel := th.BasicChannel
HelpLink := *th.App.Config().SupportSettings.HelpLink
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.SupportSettings.HelpLink = HelpLink })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.SupportSettings.HelpLink = "" })
rs1, _, err := client.ExecuteCommand(context.Background(), channel.Id, "/help ")
require.NoError(t, err)
assert.Contains(t, rs1.Text, model.SupportSettingsDefaultHelpLink, "failed to default help link")
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.SupportSettings.HelpLink = "https://docs.mattermost.com/guides/user.html"
})
rs2, _, err := client.ExecuteCommand(context.Background(), channel.Id, "/help ")
require.NoError(t, err)
assert.Contains(t, rs2.Text, "https://docs.mattermost.com/guides/user.html", "failed to help link")
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitCommandLocal() {
api.BaseRoutes.Commands.Handle("", api.APILocal(localCreateCommand)).Methods(http.MethodPost)
api.BaseRoutes.Commands.Handle("", api.APILocal(listCommands)).Methods(http.MethodGet)
api.BaseRoutes.Command.Handle("", api.APILocal(getCommand)).Methods(http.MethodGet)
api.BaseRoutes.Command.Handle("", api.APILocal(updateCommand)).Methods(http.MethodPut)
api.BaseRoutes.Command.Handle("/move", api.APILocal(moveCommand)).Methods(http.MethodPut)
api.BaseRoutes.Command.Handle("", api.APILocal(deleteCommand)).Methods(http.MethodDelete)
}
func localCreateCommand(c *Context, w http.ResponseWriter, r *http.Request) {
var cmd model.Command
if jsonErr := json.NewDecoder(r.Body).Decode(&cmd); jsonErr != nil {
c.SetInvalidParamWithErr("command", jsonErr)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventLocalCreateCommand, model.AuditStatusFail)
model.AddEventParameterAuditableToAuditRec(auditRec, "command", &cmd)
defer c.LogAuditRec(auditRec)
c.LogAudit("attempt")
rcmd, err := c.App.CreateCommand(&cmd)
if err != nil {
c.Err = err
return
}
auditRec.Success()
c.LogAudit("success")
auditRec.AddEventResultState(rcmd)
auditRec.AddEventObjectType("command")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(rcmd); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,477 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
_ "github.com/mattermost/mattermost/server/v8/channels/app/slashcommands"
"github.com/mattermost/mattermost/server/public/model"
)
func TestEchoCommand(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
client := th.Client
channel1 := th.BasicChannel
echoTestString := "/echo test"
r1, _, err := client.ExecuteCommand(context.Background(), channel1.Id, echoTestString)
require.NoError(t, err)
require.NotNil(t, r1, "Echo command failed to execute")
r1, _, err = client.ExecuteCommand(context.Background(), channel1.Id, "/echo ")
require.NoError(t, err)
require.NotNil(t, r1, "Echo command failed to execute")
time.Sleep(time.Second)
p1, _, err := client.GetPostsForChannel(context.Background(), channel1.Id, 0, 2, "", false, false)
require.NoError(t, err)
require.Len(t, p1.Order, 2, "Echo command failed to send")
}
func TestGroupmsgCommands(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
client := th.Client
team := th.BasicTeam
user1 := th.BasicUser
user2 := th.BasicUser2
user3 := th.CreateUser(t)
user4 := th.CreateUser(t)
user5 := th.CreateUser(t)
user6 := th.CreateUser(t)
user7 := th.CreateUser(t)
user8 := th.CreateUser(t)
user9 := th.CreateUser(t)
th.LinkUserToTeam(t, user3, team)
th.LinkUserToTeam(t, user4, team)
rs1, _, err := client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/groupmsg "+user2.Username+","+user3.Username)
require.NoError(t, err)
group1 := model.GetGroupNameFromUserIds([]string{user1.Id, user2.Id, user3.Id})
require.True(t, strings.HasSuffix(rs1.GotoLocation, "/"+team.Name+"/channels/"+group1), "failed to create group channel")
rs2, _, err := client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/groupmsg "+user3.Username+","+user4.Username+" foobar")
require.NoError(t, err)
group2 := model.GetGroupNameFromUserIds([]string{user1.Id, user3.Id, user4.Id})
require.True(t, strings.HasSuffix(rs2.GotoLocation, "/"+team.Name+"/channels/"+group2), "failed to create second direct channel")
result, _, err := client.SearchPosts(context.Background(), team.Id, "foobar", false)
require.NoError(t, err)
require.NotEqual(t, 0, len(result.Order), "post did not get sent to direct message")
rs3, _, err := client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/groupmsg "+user2.Username+","+user3.Username)
require.NoError(t, err)
require.True(t, strings.HasSuffix(rs3.GotoLocation, "/"+team.Name+"/channels/"+group1), "failed to go back to existing group channel")
_, _, err = client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/groupmsg "+user2.Username+" foobar")
require.NoError(t, err)
_, _, err = client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/groupmsg "+user2.Username+","+user3.Username+","+user4.Username+","+user5.Username+","+user6.Username+","+user7.Username+","+user8.Username+","+user9.Username+" foobar")
require.NoError(t, err)
_, _, err = client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/groupmsg junk foobar")
require.NoError(t, err)
_, _, err = client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/groupmsg junk,junk2 foobar")
require.NoError(t, err)
}
func TestInvitePeopleCommand(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
client := th.Client
channel := th.BasicChannel
r1, _, err := client.ExecuteCommand(context.Background(), channel.Id, "/invite_people test@example.com")
require.NoError(t, err)
require.NotNil(t, r1, "Command failed to execute")
r2, _, err := client.ExecuteCommand(context.Background(), channel.Id, "/invite_people test1@example.com test2@example.com")
require.NoError(t, err)
require.NotNil(t, r2, "Command failed to execute")
r3, _, err := client.ExecuteCommand(context.Background(), channel.Id, "/invite_people")
require.NoError(t, err)
require.NotNil(t, r3, "Command failed to execute")
}
// also used to test /open (see command_open_test.go)
func testJoinCommands(t *testing.T, alias string) {
th := Setup(t).InitBasic(t)
client := th.Client
team := th.BasicTeam
user2 := th.BasicUser2
channel0 := &model.Channel{DisplayName: "00", Name: "00" + model.NewId() + "a", Type: model.ChannelTypeOpen, TeamId: team.Id}
channel0, _, err := client.CreateChannel(context.Background(), channel0)
require.NoError(t, err)
channel1 := &model.Channel{DisplayName: "AA", Name: "aa" + model.NewId() + "a", Type: model.ChannelTypeOpen, TeamId: team.Id}
channel1, _, err = client.CreateChannel(context.Background(), channel1)
require.NoError(t, err)
_, err = client.RemoveUserFromChannel(context.Background(), channel1.Id, th.BasicUser.Id)
require.NoError(t, err)
channel2 := &model.Channel{DisplayName: "BB", Name: "bb" + model.NewId() + "a", Type: model.ChannelTypeOpen, TeamId: team.Id}
channel2, _, err = client.CreateChannel(context.Background(), channel2)
require.NoError(t, err)
_, err = client.RemoveUserFromChannel(context.Background(), channel2.Id, th.BasicUser.Id)
require.NoError(t, err)
channel3, _, err := client.CreateDirectChannel(context.Background(), th.BasicUser.Id, user2.Id)
require.NoError(t, err)
rs5, _, err := client.ExecuteCommand(context.Background(), channel0.Id, "/"+alias+" "+channel2.Name)
require.NoError(t, err)
require.True(t, strings.HasSuffix(rs5.GotoLocation, "/"+team.Name+"/channels/"+channel2.Name), "failed to join channel")
rs6, _, err := client.ExecuteCommand(context.Background(), channel0.Id, "/"+alias+" "+channel3.Name)
require.NoError(t, err)
require.False(t, strings.HasSuffix(rs6.GotoLocation, "/"+team.Name+"/channels/"+channel3.Name), "should not have joined direct message channel")
c1, _, err := client.GetChannelsForTeamForUser(context.Background(), th.BasicTeam.Id, th.BasicUser.Id, false, "")
require.NoError(t, err)
found := false
for _, c := range c1 {
if c.Id == channel2.Id {
found = true
}
}
require.True(t, found, "did not join channel")
// test case insensitively
channel4 := &model.Channel{DisplayName: "BB", Name: "bb" + model.NewId() + "a", Type: model.ChannelTypeOpen, TeamId: team.Id}
channel4, _, err = client.CreateChannel(context.Background(), channel4)
require.NoError(t, err)
_, err = client.RemoveUserFromChannel(context.Background(), channel4.Id, th.BasicUser.Id)
require.NoError(t, err)
rs7, _, err := client.ExecuteCommand(context.Background(), channel0.Id, "/"+alias+" "+strings.ToUpper(channel4.Name))
require.NoError(t, err)
require.True(t, strings.HasSuffix(rs7.GotoLocation, "/"+team.Name+"/channels/"+channel4.Name), "failed to join channel")
}
func TestJoinCommands(t *testing.T) {
mainHelper.Parallel(t)
testJoinCommands(t, "join")
}
func TestLoadTestHelpCommands(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
client := th.Client
channel := th.BasicChannel
enableTesting := *th.App.Config().ServiceSettings.EnableTesting
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableTesting = enableTesting })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableTesting = true })
rs, _, err := client.ExecuteCommand(context.Background(), channel.Id, "/test help")
require.NoError(t, err)
require.True(t, strings.Contains(rs.Text, "Mattermost testing commands to help"), rs.Text)
}
func TestLoadTestSetupCommands(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
client := th.Client
channel := th.BasicChannel
enableTesting := *th.App.Config().ServiceSettings.EnableTesting
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableTesting = enableTesting })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableTesting = true })
rs, _, err := client.ExecuteCommand(context.Background(), channel.Id, "/test setup fuzz 1 1 1")
require.NoError(t, err)
require.Equal(t, "Created environment", rs.Text, rs.Text)
}
func TestLoadTestUsersCommands(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
client := th.Client
channel := th.BasicChannel
enableTesting := *th.App.Config().ServiceSettings.EnableTesting
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableTesting = enableTesting })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableTesting = true })
rs, _, err := client.ExecuteCommand(context.Background(), channel.Id, "/test users fuzz 1 2")
require.NoError(t, err)
require.Equal(t, "Added users", rs.Text, rs.Text)
}
func TestLoadTestChannelsCommands(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
client := th.Client
channel := th.BasicChannel
enableTesting := *th.App.Config().ServiceSettings.EnableTesting
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableTesting = enableTesting })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableTesting = true })
rs, _, err := client.ExecuteCommand(context.Background(), channel.Id, "/test channels fuzz 1 2")
require.NoError(t, err)
require.Equal(t, "Added channels", rs.Text, rs.Text)
}
func TestLoadTestPostsCommands(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
client := th.Client
channel := th.BasicChannel
enableTesting := *th.App.Config().ServiceSettings.EnableTesting
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableTesting = enableTesting })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableTesting = true })
rs, _, err := client.ExecuteCommand(context.Background(), channel.Id, "/test posts fuzz 2 3 2")
require.NoError(t, err)
require.Equal(t, "Added posts", rs.Text, rs.Text)
}
func TestLeaveCommands(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
client := th.Client
team := th.BasicTeam
user2 := th.BasicUser2
channel1 := &model.Channel{DisplayName: "AA", Name: "aa" + model.NewId() + "a", Type: model.ChannelTypeOpen, TeamId: team.Id}
channel1, _, err := client.CreateChannel(context.Background(), channel1)
require.NoError(t, err)
_, _, err = client.AddChannelMember(context.Background(), channel1.Id, th.BasicUser.Id)
require.NoError(t, err)
channel2 := &model.Channel{DisplayName: "BB", Name: "bb" + model.NewId() + "a", Type: model.ChannelTypePrivate, TeamId: team.Id}
channel2, _, err = client.CreateChannel(context.Background(), channel2)
require.NoError(t, err)
_, _, err = client.AddChannelMember(context.Background(), channel2.Id, th.BasicUser.Id)
require.NoError(t, err)
_, _, err = client.AddChannelMember(context.Background(), channel2.Id, user2.Id)
require.NoError(t, err)
channel3, _, err := client.CreateDirectChannel(context.Background(), th.BasicUser.Id, user2.Id)
require.NoError(t, err)
rs1, _, err := client.ExecuteCommand(context.Background(), channel1.Id, "/leave")
require.NoError(t, err)
require.True(t, strings.HasSuffix(rs1.GotoLocation, "/"+team.Name+"/channels/"+model.DefaultChannelName), "failed to leave open channel 1")
rs2, _, err := client.ExecuteCommand(context.Background(), channel2.Id, "/leave")
require.NoError(t, err)
require.True(t, strings.HasSuffix(rs2.GotoLocation, "/"+team.Name+"/channels/"+model.DefaultChannelName), "failed to leave private channel 1")
_, _, err = client.ExecuteCommand(context.Background(), channel3.Id, "/leave")
require.Error(t, err)
cdata, _, err := client.GetChannelsForTeamForUser(context.Background(), th.BasicTeam.Id, th.BasicUser.Id, false, "")
require.NoError(t, err)
found := false
for _, c := range cdata {
if c.Id == channel1.Id || c.Id == channel2.Id {
found = true
}
}
require.False(t, found, "did not leave right channels")
for _, c := range cdata {
if c.Name == model.DefaultChannelName {
_, err := client.RemoveUserFromChannel(context.Background(), c.Id, th.BasicUser.Id)
require.Error(t, err, "should have errored on leaving default channel")
break
}
}
}
func TestLogoutTestCommand(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
_, _, err := th.Client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/logout")
require.NoError(t, err)
}
func TestMeCommand(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
client := th.Client
channel := th.BasicChannel
testString := "/me hello"
r1, _, err := client.ExecuteCommand(context.Background(), channel.Id, testString)
require.NoError(t, err)
require.NotNil(t, r1, "Command failed to execute")
time.Sleep(time.Second)
p1, _, err := client.GetPostsForChannel(context.Background(), channel.Id, 0, 2, "", false, false)
require.NoError(t, err)
require.Len(t, p1.Order, 2, "Command failed to send")
pt := p1.Posts[p1.Order[0]].Type
require.Equal(t, model.PostTypeMe, pt, "invalid post type")
msg := p1.Posts[p1.Order[0]].Message
want := "*hello*"
require.Equal(t, want, msg, "invalid me response")
}
func TestMsgCommands(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
client := th.Client
team := th.BasicTeam
user1 := th.BasicUser
user2 := th.BasicUser2
user3 := th.CreateUser(t)
th.LinkUserToTeam(t, user3, team)
_, _, err := client.CreateDirectChannel(context.Background(), th.BasicUser.Id, user2.Id)
require.NoError(t, err)
_, _, err = client.CreateDirectChannel(context.Background(), th.BasicUser.Id, user3.Id)
require.NoError(t, err)
rs1, _, err := client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/msg "+user2.Username)
require.NoError(t, err)
require.Condition(t, func() bool {
return strings.HasSuffix(rs1.GotoLocation, "/"+team.Name+"/channels/"+user1.Id+"__"+user2.Id) ||
strings.HasSuffix(rs1.GotoLocation, "/"+team.Name+"/channels/"+user2.Id+"__"+user1.Id)
}, "failed to create direct channel")
rs2, _, err := client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/msg "+user3.Username+" foobar")
require.NoError(t, err)
require.Condition(t, func() bool {
return strings.HasSuffix(rs2.GotoLocation, "/"+team.Name+"/channels/"+user1.Id+"__"+user3.Id) ||
strings.HasSuffix(rs2.GotoLocation, "/"+team.Name+"/channels/"+user3.Id+"__"+user1.Id)
}, "failed to create second direct channel")
result, _, err := client.SearchPosts(context.Background(), th.BasicTeam.Id, "foobar", false)
require.NoError(t, err)
require.NotEqual(t, 0, len(result.Order), "post did not get sent to direct message")
rs3, _, err := client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/msg "+user2.Username)
require.NoError(t, err)
require.Condition(t, func() bool {
return strings.HasSuffix(rs3.GotoLocation, "/"+team.Name+"/channels/"+user1.Id+"__"+user2.Id) ||
strings.HasSuffix(rs3.GotoLocation, "/"+team.Name+"/channels/"+user2.Id+"__"+user1.Id)
}, "failed to go back to existing direct channel")
_, _, err = client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/msg "+th.BasicUser.Username+" foobar")
require.NoError(t, err)
_, _, err = client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/msg junk foobar")
require.NoError(t, err)
}
func TestOpenCommands(t *testing.T) {
mainHelper.Parallel(t)
testJoinCommands(t, "open")
}
func TestSearchCommand(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
_, _, err := th.Client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/search")
require.NoError(t, err)
}
func TestSettingsCommand(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
_, _, err := th.Client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/settings")
require.NoError(t, err)
}
func TestShortcutsCommand(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
_, _, err := th.Client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/shortcuts")
require.NoError(t, err)
}
func TestShrugCommand(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
client := th.Client
channel := th.BasicChannel
testString := "/shrug"
r1, _, err := client.ExecuteCommand(context.Background(), channel.Id, testString)
require.NoError(t, err)
require.NotNil(t, r1, "Command failed to execute")
time.Sleep(time.Second)
p1, _, err := client.GetPostsForChannel(context.Background(), channel.Id, 0, 2, "", false, false)
require.NoError(t, err)
require.Len(t, p1.Order, 2, "Command failed to send")
require.Equal(t, `¯\\\_(ツ)\_/¯`, p1.Posts[p1.Order[0]].Message, "invalid shrug response")
}
func TestStatusCommands(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
commandAndTest(t, th, "away")
commandAndTest(t, th, "offline")
commandAndTest(t, th, "online")
}
func commandAndTest(t *testing.T, th *TestHelper, status string) {
client := th.Client
channel := th.BasicChannel
user := th.BasicUser
r1, _, err := client.ExecuteCommand(context.Background(), channel.Id, "/"+status)
require.NoError(t, err)
require.NotEqual(t, "Command failed to execute", r1)
time.Sleep(2 * time.Second)
rstatus, _, err := client.GetUserStatus(context.Background(), user.Id, "")
require.NoError(t, err)
require.Equal(t, status, rstatus.Status, "Error setting status")
}

View File

@@ -0,0 +1,163 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"strconv"
"github.com/avct/uasurfer"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitCompliance() {
api.BaseRoutes.Compliance.Handle("/reports", api.APISessionRequired(createComplianceReport)).Methods(http.MethodPost)
api.BaseRoutes.Compliance.Handle("/reports", api.APISessionRequired(getComplianceReports)).Methods(http.MethodGet)
api.BaseRoutes.Compliance.Handle("/reports/{report_id:[A-Za-z0-9]+}", api.APISessionRequired(getComplianceReport)).Methods(http.MethodGet)
api.BaseRoutes.Compliance.Handle("/reports/{report_id:[A-Za-z0-9]+}/download", api.APISessionRequiredTrustRequester(downloadComplianceReport)).Methods(http.MethodGet)
}
func createComplianceReport(c *Context, w http.ResponseWriter, r *http.Request) {
var job model.Compliance
if jsonErr := json.NewDecoder(r.Body).Decode(&job); jsonErr != nil {
c.SetInvalidParamWithErr("compliance", jsonErr)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventCreateComplianceReport, model.AuditStatusFail)
model.AddEventParameterAuditableToAuditRec(auditRec, "compliance", &job)
defer c.LogAuditRec(auditRec)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionCreateComplianceExportJob) {
c.SetPermissionError(model.PermissionCreateComplianceExportJob)
return
}
job.UserId = c.AppContext.Session().UserId
rjob, err := c.App.SaveComplianceReport(c.AppContext, &job)
if err != nil {
c.Err = err
return
}
auditRec.Success()
auditRec.AddEventResultState(rjob)
auditRec.AddEventObjectType("compliance")
auditRec.AddMeta("compliance_id", rjob.Id)
auditRec.AddMeta("compliance_desc", rjob.Desc)
c.LogAudit("")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(rjob); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getComplianceReports(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionReadComplianceExportJob) {
c.SetPermissionError(model.PermissionReadComplianceExportJob)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventGetComplianceReports, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
crs, err := c.App.GetComplianceReports(c.Params.Page, c.Params.PerPage)
if err != nil {
c.Err = err
return
}
auditRec.Success()
if err := json.NewEncoder(w).Encode(crs); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getComplianceReport(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireReportId()
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventGetComplianceReport, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionReadComplianceExportJob) {
c.SetPermissionError(model.PermissionReadComplianceExportJob)
return
}
model.AddEventParameterToAuditRec(auditRec, "report_id", c.Params.ReportId)
job, err := c.App.GetComplianceReport(c.Params.ReportId)
if err != nil {
c.Err = err
return
}
auditRec.Success()
auditRec.AddMeta("compliance_id", job.Id)
auditRec.AddMeta("compliance_desc", job.Desc)
if err := json.NewEncoder(w).Encode(job); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func downloadComplianceReport(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireReportId()
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventDownloadComplianceReport, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "compliance_id", c.Params.ReportId)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionDownloadComplianceExportResult) {
c.SetPermissionError(model.PermissionDownloadComplianceExportResult)
return
}
job, err := c.App.GetComplianceReport(c.Params.ReportId)
if err != nil {
c.Err = err
return
}
auditRec.AddEventResultState(job)
auditRec.AddEventObjectType("compliance")
reportBytes, err := c.App.GetComplianceFile(job)
if err != nil {
c.Err = err
return
}
auditRec.AddMeta("length", len(reportBytes))
c.LogAudit("downloaded " + job.Desc)
w.Header().Set("Cache-Control", "max-age=2592000, private")
w.Header().Set("Content-Length", strconv.Itoa(len(reportBytes)))
w.Header().Del("Content-Type") // Content-Type will be set automatically by the http writer
// attach extra headers to trigger a download on IE, Edge, and Safari
ua := uasurfer.Parse(r.UserAgent())
w.Header().Set("Content-Disposition", "attachment;filename=\""+job.JobName()+".zip\"")
if ua.Browser.Name == uasurfer.BrowserIE || ua.Browser.Name == uasurfer.BrowserSafari {
// trim off anything before the final / so we just get the file's name
w.Header().Set("Content-Type", "application/octet-stream")
}
auditRec.Success()
if _, err := w.Write(reportBytes); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

View File

@@ -0,0 +1,469 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"fmt"
"net/http"
"reflect"
"strconv"
"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"
"github.com/mattermost/mattermost/server/v8/config"
)
var (
writeFilter func(c *Context, structField reflect.StructField) bool
readFilter func(c *Context, structField reflect.StructField) bool
permissionMap map[string]*model.Permission
)
type filterType string
const (
FilterTypeWrite filterType = "write"
FilterTypeRead filterType = "read"
)
func (api *API) InitConfig() {
api.BaseRoutes.APIRoot.Handle("/config", api.APISessionRequired(getConfig)).Methods(http.MethodGet)
api.BaseRoutes.APIRoot.Handle("/config", api.APISessionRequired(updateConfig)).Methods(http.MethodPut)
api.BaseRoutes.APIRoot.Handle("/config/patch", api.APISessionRequired(patchConfig)).Methods(http.MethodPut)
api.BaseRoutes.APIRoot.Handle("/config/reload", api.APISessionRequired(configReload)).Methods(http.MethodPost)
api.BaseRoutes.APIRoot.Handle("/config/client", api.APIHandler(getClientConfig)).Methods(http.MethodGet)
api.BaseRoutes.APIRoot.Handle("/config/environment", api.APISessionRequired(getEnvironmentConfig)).Methods(http.MethodGet)
}
func init() {
writeFilter = makeFilterConfigByPermission(FilterTypeWrite)
readFilter = makeFilterConfigByPermission(FilterTypeRead)
permissionMap = map[string]*model.Permission{}
for _, p := range model.AllPermissions {
permissionMap[p.Id] = p
}
}
func getConfig(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionToAny(*c.AppContext.Session(), model.SysconsoleReadPermissions) {
c.SetPermissionError(model.SysconsoleReadPermissions...)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventGetConfig, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
cfg, err := config.Merge(&model.Config{}, c.App.GetSanitizedConfig(), &utils.MergeConfig{
StructFieldFilter: func(structField reflect.StructField, base, patch reflect.Value) bool {
return readFilter(c, structField)
},
})
if err != nil {
c.Err = model.NewAppError("getConfig", "api.config.get_config.restricted_merge.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
filterMasked, _ := strconv.ParseBool(r.URL.Query().Get("remove_masked"))
filterDefaults, _ := strconv.ParseBool(r.URL.Query().Get("remove_defaults"))
auditRec.Success()
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
filterOpts := model.ConfigFilterOptions{
GetConfigOptions: model.GetConfigOptions{
RemoveDefaults: filterDefaults,
RemoveMasked: filterMasked,
},
}
if c.App.Channels().License().IsCloud() {
filterOpts.TagFilters = append(filterOpts.TagFilters, model.FilterTag{
TagType: model.ConfigAccessTagType,
TagName: model.ConfigAccessTagCloudRestrictable,
})
}
m, err := model.FilterConfig(cfg, filterOpts)
if err != nil {
c.Err = model.NewAppError("getConfig", "api.filter_config_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if err := json.NewEncoder(w).Encode(m); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func configReload(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord(model.AuditEventConfigReload, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
if !c.App.SessionHasPermissionToAndNotRestrictedAdmin(*c.AppContext.Session(), model.PermissionReloadConfig) {
c.SetPermissionError(model.PermissionReloadConfig)
return
}
if err := c.App.ReloadConfig(); err != nil {
c.Err = model.NewAppError("configReload", "api.config.reload_config.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.Success()
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
ReturnStatusOK(w)
}
func updateConfig(c *Context, w http.ResponseWriter, r *http.Request) {
var cfg *model.Config
err := json.NewDecoder(r.Body).Decode(&cfg)
if err != nil || cfg == nil {
c.SetInvalidParamWithErr("config", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventUpdateConfig, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
cfg.SetDefaults()
if !c.App.SessionHasPermissionToAny(*c.AppContext.Session(), model.SysconsoleWritePermissions) {
c.SetPermissionError(model.SysconsoleWritePermissions...)
return
}
appCfg := c.App.Config()
if *appCfg.ServiceSettings.SiteURL != "" && *cfg.ServiceSettings.SiteURL == "" {
c.Err = model.NewAppError("updateConfig", "api.config.update_config.clear_siteurl.app_error", nil, "", http.StatusBadRequest)
return
}
cfg, err = config.Merge(appCfg, cfg, &utils.MergeConfig{
StructFieldFilter: func(structField reflect.StructField, base, patch reflect.Value) bool {
return writeFilter(c, structField)
},
})
if err != nil {
c.Err = model.NewAppError("updateConfig", "api.config.update_config.restricted_merge.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
// Do not allow plugin uploads to be toggled through the API
*cfg.PluginSettings.EnableUploads = *appCfg.PluginSettings.EnableUploads
// Do not allow certificates to be changed through the API
// This shallow-copies the slice header. So be careful if there are concurrent
// modifications to the slice.
cfg.PluginSettings.SignaturePublicKeyFiles = appCfg.PluginSettings.SignaturePublicKeyFiles
// Do not allow import directory to be changed through the API
*cfg.ImportSettings.Directory = *appCfg.ImportSettings.Directory
// Do not allow marketplace URL to be toggled through the API if EnableUploads are disabled.
if cfg.PluginSettings.EnableUploads != nil && !*appCfg.PluginSettings.EnableUploads {
*cfg.PluginSettings.MarketplaceURL = *appCfg.PluginSettings.MarketplaceURL
}
// There are some settings that cannot be changed in a cloud env
if c.App.Channels().License().IsCloud() {
// Both of them cannot be nil since cfg.SetDefaults is called earlier for cfg,
// and appCfg is the existing earlier config and if it's nil, server sets a default value.
if *appCfg.ComplianceSettings.Directory != *cfg.ComplianceSettings.Directory {
c.Err = model.NewAppError("updateConfig", "api.config.update_config.not_allowed_security.app_error", map[string]any{"Name": "ComplianceSettings.Directory"}, "", http.StatusForbidden)
return
}
}
// if ES autocomplete was enabled, we need to make sure that index has been checked.
// we need to stop enabling ES autocomplete otherwise.
if !*appCfg.ElasticsearchSettings.EnableAutocomplete && *cfg.ElasticsearchSettings.EnableAutocomplete {
if !c.App.SearchEngine().ElasticsearchEngine.IsAutocompletionEnabled() {
c.Err = model.NewAppError("updateConfig", "api.config.update.elasticsearch.autocomplete_cannot_be_enabled_error", nil, "", http.StatusBadRequest)
return
}
}
c.App.HandleMessageExportConfig(cfg, appCfg)
if appErr := cfg.IsValid(); appErr != nil {
c.Err = appErr
return
}
oldCfg, newCfg, appErr := c.App.SaveConfig(cfg, true)
if appErr != nil {
c.Err = appErr
return
}
// If the config for default server locale has changed, reinitialize the server's translations.
if oldCfg.LocalizationSettings.DefaultServerLocale != newCfg.LocalizationSettings.DefaultServerLocale {
s := newCfg.LocalizationSettings
if err = i18n.InitTranslations(*s.DefaultServerLocale, *s.DefaultClientLocale); err != nil {
c.Err = model.NewAppError("updateConfig", "api.config.update_config.translations.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
}
diffs, err := config.Diff(oldCfg, newCfg)
if err != nil {
c.Err = model.NewAppError("updateConfig", "api.config.update_config.diff.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.AddEventPriorState(&diffs)
c.App.SanitizedConfig(newCfg)
cfg, err = config.Merge(&model.Config{}, newCfg, &utils.MergeConfig{
StructFieldFilter: func(structField reflect.StructField, base, patch reflect.Value) bool {
return readFilter(c, structField)
},
})
if err != nil {
c.Err = model.NewAppError("updateConfig", "api.config.update_config.restricted_merge.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
// auditRec.AddEventResultState(cfg) // TODO we can do this too but do we want to? the config object is huge
auditRec.AddEventObjectType("config")
auditRec.Success()
c.LogAudit("updateConfig")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
if c.App.Channels().License().IsCloud() {
js, err := cfg.ToJSONFiltered(model.ConfigAccessTagType, model.ConfigAccessTagCloudRestrictable)
if err != nil {
c.Err = model.NewAppError("updateConfig", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
return
}
if err := json.NewEncoder(w).Encode(cfg); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getClientConfig(c *Context, w http.ResponseWriter, r *http.Request) {
var config map[string]string
if c.AppContext.Session().UserId == "" {
config = c.App.Srv().Platform().LimitedClientConfigWithComputed()
} else {
config = c.App.Srv().Platform().ClientConfigWithComputed()
}
if err := json.NewEncoder(w).Encode(config); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getEnvironmentConfig(c *Context, w http.ResponseWriter, r *http.Request) {
// Only return the environment variables for the subsections which the client is
// allowed to see
envConfig := c.App.GetEnvironmentConfig(func(structField reflect.StructField) bool {
return readFilter(c, structField)
})
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
if _, err := w.Write([]byte(model.StringInterfaceToJSON(envConfig))); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func patchConfig(c *Context, w http.ResponseWriter, r *http.Request) {
var cfg *model.Config
err := json.NewDecoder(r.Body).Decode(&cfg)
if err != nil || cfg == nil {
c.SetInvalidParamWithErr("config", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventPatchConfig, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
if !c.App.SessionHasPermissionToAny(*c.AppContext.Session(), model.SysconsoleWritePermissions) {
c.SetPermissionError(model.SysconsoleWritePermissions...)
return
}
appCfg := c.App.Config()
if *appCfg.ServiceSettings.SiteURL != "" && cfg.ServiceSettings.SiteURL != nil && *cfg.ServiceSettings.SiteURL == "" {
c.Err = model.NewAppError("patchConfig", "api.config.update_config.clear_siteurl.app_error", nil, "", http.StatusBadRequest)
return
}
filterFn := func(structField reflect.StructField, base, patch reflect.Value) bool {
return writeFilter(c, structField)
}
// Do not allow plugin uploads to be toggled through the API
if cfg.PluginSettings.EnableUploads != nil && *cfg.PluginSettings.EnableUploads != *appCfg.PluginSettings.EnableUploads {
c.Err = model.NewAppError("patchConfig", "api.config.update_config.not_allowed_security.app_error", map[string]any{"Name": "PluginSettings.EnableUploads"}, "", http.StatusForbidden)
return
}
// Do not allow certificates to be changed through the API. Mirror the full
// update endpoint by silently preserving the existing value rather than
// rejecting the request.
cfg.PluginSettings.SignaturePublicKeyFiles = appCfg.PluginSettings.SignaturePublicKeyFiles
// Do not allow import directory to be changed through the API
if cfg.ImportSettings.Directory != nil && *cfg.ImportSettings.Directory != *appCfg.ImportSettings.Directory {
c.Err = model.NewAppError("patchConfig", "api.config.update_config.not_allowed_security.app_error", map[string]any{"Name": "ImportSettings.Directory"}, "", http.StatusForbidden)
return
}
// Do not allow marketplace URL to be toggled if plugin uploads are disabled.
if cfg.PluginSettings.MarketplaceURL != nil && cfg.PluginSettings.EnableUploads != nil {
// Breaking it down to 2 conditions to make it simple.
if *cfg.PluginSettings.MarketplaceURL != *appCfg.PluginSettings.MarketplaceURL && !*cfg.PluginSettings.EnableUploads {
c.Err = model.NewAppError("patchConfig", "api.config.update_config.not_allowed_security.app_error", map[string]any{"Name": "PluginSettings.MarketplaceURL"}, "", http.StatusForbidden)
return
}
}
// There are some settings that cannot be changed in a cloud env
if c.App.Channels().License().IsCloud() {
if cfg.ComplianceSettings.Directory != nil && *appCfg.ComplianceSettings.Directory != *cfg.ComplianceSettings.Directory {
c.Err = model.NewAppError("patchConfig", "api.config.update_config.not_allowed_security.app_error", map[string]any{"Name": "ComplianceSettings.Directory"}, "", http.StatusForbidden)
return
}
}
if cfg.MessageExportSettings.EnableExport != nil {
c.App.HandleMessageExportConfig(cfg, appCfg)
}
// Preserve the existing configs for those not present in the patch.
if cfg.PluginSettings.Plugins != nil {
for id, storedSettings := range appCfg.PluginSettings.Plugins {
if _, present := cfg.PluginSettings.Plugins[id]; !present {
cfg.PluginSettings.Plugins[id] = storedSettings
}
}
}
updatedCfg, err := config.Merge(appCfg, cfg, &utils.MergeConfig{
StructFieldFilter: filterFn,
})
if err != nil {
c.Err = model.NewAppError("patchConfig", "api.config.update_config.restricted_merge.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
appErr := updatedCfg.IsValid()
if appErr != nil {
c.Err = appErr
return
}
oldCfg, newCfg, appErr := c.App.SaveConfig(updatedCfg, true)
if appErr != nil {
c.Err = appErr
return
}
diffs, err := config.Diff(oldCfg, newCfg)
if err != nil {
c.Err = model.NewAppError("patchConfig", "api.config.patch_config.diff.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.AddEventPriorState(&diffs)
c.App.SanitizedConfig(newCfg)
auditRec.Success()
cfg, err = config.Merge(&model.Config{}, newCfg, &utils.MergeConfig{
StructFieldFilter: func(structField reflect.StructField, base, patch reflect.Value) bool {
return readFilter(c, structField)
},
})
if err != nil {
c.Err = model.NewAppError("patchConfig", "api.config.patch_config.restricted_merge.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
if c.App.Channels().License().IsCloud() {
js, err := cfg.ToJSONFiltered(model.ConfigAccessTagType, model.ConfigAccessTagCloudRestrictable)
if err != nil {
c.Err = model.NewAppError("patchConfig", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
return
}
if err := json.NewEncoder(w).Encode(cfg); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func makeFilterConfigByPermission(accessType filterType) func(c *Context, structField reflect.StructField) bool {
return func(c *Context, structField reflect.StructField) bool {
if structField.Type.Kind() == reflect.Struct {
return true
}
tagPermissions := strings.Split(structField.Tag.Get("access"), ",")
if c.AppContext.Session().IsUnrestricted() {
return true
}
// one iteration for write_restrictable value, it could be anywhere in the order of values
for _, val := range tagPermissions {
tagValue := strings.TrimSpace(val)
if tagValue == "" {
continue
}
// ConfigAccessTagWriteRestrictable trumps all other permissions
if tagValue == model.ConfigAccessTagWriteRestrictable || tagValue == model.ConfigAccessTagCloudRestrictable {
if *c.App.Config().ExperimentalSettings.RestrictSystemAdmin && accessType == FilterTypeWrite {
return false
}
continue
}
}
// another iteration for permissions checks of other tag values
for _, val := range tagPermissions {
tagValue := strings.TrimSpace(val)
if tagValue == "" {
continue
}
if tagValue == model.ConfigAccessTagWriteRestrictable {
continue
}
if tagValue == model.ConfigAccessTagCloudRestrictable {
continue
}
if tagValue == model.ConfigAccessTagAnySysConsoleRead && accessType == FilterTypeRead &&
c.App.SessionHasPermissionToAny(*c.AppContext.Session(), model.SysconsoleReadPermissions) {
return true
}
permissionID := fmt.Sprintf("sysconsole_%s_%s", accessType, tagValue)
if permission, ok := permissionMap[permissionID]; ok {
if c.App.SessionHasPermissionTo(*c.AppContext.Session(), permission) {
return true
}
} else {
c.Logger.Warn("Unrecognized config permissions tag value.", mlog.String("tag_value", permissionID))
}
}
// with manage_system, default to allow, otherwise default not-allow
return c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
}
}

View File

@@ -0,0 +1,205 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"reflect"
"strconv"
"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/config"
)
func (api *API) InitConfigLocal() {
api.BaseRoutes.APIRoot.Handle("/config", api.APILocal(localGetConfig)).Methods(http.MethodGet)
api.BaseRoutes.APIRoot.Handle("/config", api.APILocal(localUpdateConfig)).Methods(http.MethodPut)
api.BaseRoutes.APIRoot.Handle("/config/patch", api.APILocal(localPatchConfig)).Methods(http.MethodPut)
api.BaseRoutes.APIRoot.Handle("/config/reload", api.APILocal(configReload)).Methods(http.MethodPost)
api.BaseRoutes.APIRoot.Handle("/config/migrate", api.APILocal(localMigrateConfig)).Methods(http.MethodPost)
api.BaseRoutes.APIRoot.Handle("/config/client", api.APILocal(localGetClientConfig)).Methods(http.MethodGet)
}
func localGetConfig(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord(model.AuditEventLocalGetConfig, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
filterMasked, _ := strconv.ParseBool(r.URL.Query().Get("remove_masked"))
filterDefaults, _ := strconv.ParseBool(r.URL.Query().Get("remove_defaults"))
filterOpts := model.ConfigFilterOptions{
GetConfigOptions: model.GetConfigOptions{
RemoveDefaults: filterDefaults,
RemoveMasked: filterMasked,
},
}
m, err := model.FilterConfig(c.App.Config(), filterOpts)
if err != nil {
c.Err = model.NewAppError("getConfig", "api.filter_config_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.Success()
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
if err := json.NewEncoder(w).Encode(m); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func localUpdateConfig(c *Context, w http.ResponseWriter, r *http.Request) {
var cfg *model.Config
err := json.NewDecoder(r.Body).Decode(&cfg)
if err != nil || cfg == nil {
c.SetInvalidParamWithErr("config", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventLocalUpdateConfig, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
cfg.SetDefaults()
appCfg := c.App.Config()
c.App.HandleMessageExportConfig(cfg, appCfg)
appErr := cfg.IsValid()
if appErr != nil {
c.Err = appErr
return
}
oldCfg, newCfg, appErr := c.App.SaveConfig(cfg, true)
if appErr != nil {
c.Err = appErr
return
}
diffs, diffErr := config.Diff(oldCfg, newCfg)
if diffErr != nil {
c.Err = model.NewAppError("updateConfig", "api.config.update_config.diff.app_error", nil, "", http.StatusInternalServerError).Wrap(diffErr)
return
}
auditRec.AddEventPriorState(&diffs)
c.App.SanitizedConfig(newCfg)
auditRec.Success()
c.LogAudit("updateConfig")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
if err := json.NewEncoder(w).Encode(newCfg); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func localPatchConfig(c *Context, w http.ResponseWriter, r *http.Request) {
var cfg *model.Config
err := json.NewDecoder(r.Body).Decode(&cfg)
if err != nil || cfg == nil {
c.SetInvalidParamWithErr("config", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventLocalPatchConfig, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
appCfg := c.App.Config()
filterFn := func(structField reflect.StructField, base, patch reflect.Value) bool {
return true
}
if cfg.MessageExportSettings.EnableExport != nil {
c.App.HandleMessageExportConfig(cfg, appCfg)
}
// Preserve the existing configs for those not present in the patch.
if cfg.PluginSettings.Plugins != nil {
for id, storedSettings := range appCfg.PluginSettings.Plugins {
if _, present := cfg.PluginSettings.Plugins[id]; !present {
cfg.PluginSettings.Plugins[id] = storedSettings
}
}
}
updatedCfg, mergeErr := config.Merge(appCfg, cfg, &utils.MergeConfig{
StructFieldFilter: filterFn,
})
if mergeErr != nil {
c.Err = model.NewAppError("patchConfig", "api.config.update_config.restricted_merge.app_error", nil, "", http.StatusInternalServerError).Wrap(mergeErr)
return
}
appErr := updatedCfg.IsValid()
if appErr != nil {
c.Err = appErr
return
}
oldCfg, newCfg, appErr := c.App.SaveConfig(updatedCfg, true)
if appErr != nil {
c.Err = appErr
return
}
diffs, err := config.Diff(oldCfg, newCfg)
if err != nil {
c.Err = model.NewAppError("patchConfig", "api.config.patch_config.diff.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.AddEventPriorState(&diffs)
auditRec.Success()
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
if err := json.NewEncoder(w).Encode(c.App.GetSanitizedConfig()); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func localMigrateConfig(c *Context, w http.ResponseWriter, r *http.Request) {
props := model.StringInterfaceFromJSON(r.Body)
from, ok := props["from"].(string)
if !ok {
c.SetInvalidParam("from")
return
}
to, ok := props["to"].(string)
if !ok {
c.SetInvalidParam("to")
return
}
auditRec := c.MakeAuditRecord(model.AuditEventMigrateConfig, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
err := config.Migrate(from, to)
if err != nil {
c.Err = model.NewAppError("migrateConfig", "api.config.migrate_config.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.Success()
ReturnStatusOK(w)
}
func localGetClientConfig(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord(model.AuditEventLocalGetClientConfig, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
auditRec.Success()
_, err := w.Write([]byte(model.MapToJSON(c.App.Srv().Platform().ClientConfigWithComputed())))
if err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,657 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"slices"
"strings"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitContentFlagging() {
if !api.srv.Config().FeatureFlags.ContentFlagging {
return
}
api.BaseRoutes.ContentFlagging.Handle("/flag/config", api.APISessionRequired(contentFlaggingRequired(getFlaggingConfiguration))).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/team/{team_id:[A-Za-z0-9]+}/status", api.APISessionRequired(contentFlaggingRequired(getTeamPostFlaggingFeatureStatus))).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/flag", api.APISessionRequired(contentFlaggingRequired(flagPost))).Methods(http.MethodPost)
api.BaseRoutes.ContentFlagging.Handle("/fields", api.APISessionRequired(contentFlaggingRequired(getContentFlaggingFields))).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/field_values", api.APISessionRequired(contentFlaggingRequired(getPostPropertyValues))).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}", api.APISessionRequired(contentFlaggingRequired(getFlaggedPost))).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/remove", api.APISessionRequired(contentFlaggingRequired(removeFlaggedPost))).Methods(http.MethodPut)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/keep", api.APISessionRequired(contentFlaggingRequired(keepFlaggedPost))).Methods(http.MethodPut)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/report", api.APISessionRequired(contentFlaggingRequired(generateFlaggedPostReport))).Methods(http.MethodPost)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/exposure_report", api.APISessionRequired(contentFlaggingRequired(generatePostExposureReport))).Methods(http.MethodPost)
api.BaseRoutes.ContentFlagging.Handle("/team/{team_id:[A-Za-z0-9]+}/reviewers/search", api.APISessionRequired(contentFlaggingRequired(searchReviewers))).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/assign/{content_reviewer_id:[A-Za-z0-9]+}", api.APISessionRequired(contentFlaggingRequired(assignFlaggedPostReviewer))).Methods(http.MethodPost)
api.BaseRoutes.ContentFlagging.Handle("/config", api.APISessionRequired(saveContentFlaggingSettings)).Methods(http.MethodPut)
api.BaseRoutes.ContentFlagging.Handle("/config", api.APISessionRequired(getContentFlaggingSettings)).Methods(http.MethodGet)
}
func requireContentFlaggingAvailable(c *Context) {
if !model.MinimumEnterpriseAdvancedLicense(c.App.License()) {
c.Err = model.NewAppError("requireContentFlaggingEnabled", "api.data_spillage.error.license", nil, "", http.StatusNotImplemented)
return
}
}
func requireContentFlaggingEnabled(c *Context) {
requireContentFlaggingAvailable(c)
if c.Err != nil {
return
}
contentFlaggingEnabled := c.App.Config().ContentFlaggingSettings.EnableContentFlagging
if contentFlaggingEnabled == nil || !*contentFlaggingEnabled {
c.Err = model.NewAppError("requireContentFlaggingEnabled", "api.data_spillage.error.disabled", nil, "", http.StatusNotImplemented)
return
}
}
func contentFlaggingRequired(h handlerFunc) handlerFunc {
return func(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return
}
h(c, w, r)
}
}
func requireTeamContentReviewer(c *Context, userId, teamId string) {
isReviewer, appErr := c.App.IsUserTeamContentReviewer(userId, teamId)
if appErr != nil {
c.Err = appErr
return
}
if !isReviewer {
c.Err = model.NewAppError("requireTeamContentReviewer", "api.data_spillage.error.user_not_reviewer", nil, "", http.StatusForbidden)
return
}
}
func requireFlaggedPost(c *Context, postId string) {
if postId == "" {
c.SetInvalidParam("flagged_post_id")
return
}
_, appErr := c.App.GetPostContentFlaggingPropertyValue(c.AppContext, postId, app.ContentFlaggingPropertyNameStatus)
if appErr != nil {
c.Err = appErr
return
}
}
func getFlaggingConfiguration(c *Context, w http.ResponseWriter, r *http.Request) {
if c.Err != nil {
return
}
// A team ID is expected to be specified by a content reviewer.
// When specified, we verify that the user is a content reviewer of the team.
// If the user is indeed a content reviewer, we return the configuration along with some extra fields
// that only a reviewer should be aware of.
// If no team ID is specified, we return the configuration as is, without the extra fields.
// This is the expected usage for non-reviewers.
teamId := r.URL.Query().Get("team_id")
asReviewer := false
if teamId != "" {
requireTeamContentReviewer(c, c.AppContext.Session().UserId, teamId)
if c.Err != nil {
return
}
asReviewer = true
}
config := getFlaggingConfig(c.App.Config().ContentFlaggingSettings, asReviewer)
if err := json.NewEncoder(w).Encode(config); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
c.Err = model.NewAppError("getFlaggingConfiguration", "api.encoding_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
func getTeamPostFlaggingFeatureStatus(c *Context, w http.ResponseWriter, r *http.Request) {
if c.Err != nil {
return
}
c.RequireTeamId()
if c.Err != nil {
return
}
teamID := c.Params.TeamId
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamID, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
enabled, appErr := c.App.ContentFlaggingEnabledForTeam(teamID)
if appErr != nil {
c.Err = appErr
return
}
payload := map[string]bool{
"enabled": enabled,
}
if err := json.NewEncoder(w).Encode(payload); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
c.Err = model.NewAppError("getTeamPostFlaggingFeatureStatus", "api.encoding_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
func flagPost(c *Context, w http.ResponseWriter, r *http.Request) {
if c.Err != nil {
return
}
c.RequirePostId()
if c.Err != nil {
return
}
var flagRequest model.FlagContentRequest
if err := json.NewDecoder(r.Body).Decode(&flagRequest); err != nil {
c.SetInvalidParamWithErr("flagPost", err)
return
}
postId := c.Params.PostId
userId := c.AppContext.Session().UserId
auditRec := c.MakeAuditRecord(model.AuditEventFlagPost, model.AuditStatusFail)
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
model.AddEventParameterToAuditRec(auditRec, "postId", postId)
model.AddEventParameterToAuditRec(auditRec, "userId", userId)
post, appErr, _ := c.App.GetPostIfAuthorized(c.AppContext, postId, c.AppContext.Session(), false)
if appErr != nil {
c.Err = appErr
return
}
checkPostTypeFlaggable(c, post)
if c.Err != nil {
return
}
channel, appErr := c.App.GetChannel(c.AppContext, post.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
checkChannelFlaggable(c, channel)
if c.Err != nil {
return
}
enabled, appErr := c.App.ContentFlaggingEnabledForTeam(channel.TeamId)
if appErr != nil {
c.Err = appErr
return
}
if !enabled {
c.Err = model.NewAppError("flagPost", "api.data_spillage.error.not_available_on_team", nil, "", http.StatusBadRequest)
return
}
appErr = c.App.FlagPost(c.AppContext, post, channel.TeamId, userId, flagRequest)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
auditRec.AddEventObjectType("post")
writeOKResponse(w)
}
func getFlaggingConfig(contentFlaggingSettings model.ContentFlaggingSettings, asReviewer bool) *model.ContentFlaggingReportingConfig {
config := &model.ContentFlaggingReportingConfig{
Reasons: contentFlaggingSettings.AdditionalSettings.Reasons,
ReporterCommentRequired: contentFlaggingSettings.AdditionalSettings.ReporterCommentRequired,
ReviewerCommentRequired: contentFlaggingSettings.AdditionalSettings.ReviewerCommentRequired,
}
if asReviewer {
config.NotifyReporterOnRemoval = new(slices.Contains(contentFlaggingSettings.NotificationSettings.EventTargetMapping[model.EventContentRemoved], model.TargetReporter))
config.NotifyReporterOnDismissal = new(slices.Contains(contentFlaggingSettings.NotificationSettings.EventTargetMapping[model.EventContentDismissed], model.TargetReporter))
}
return config
}
func getContentFlaggingFields(c *Context, w http.ResponseWriter, r *http.Request) {
if c.Err != nil {
return
}
groupId, err := c.App.ContentFlaggingGroupId()
if err != nil {
c.Err = model.NewAppError("getContentFlaggingGroupId", "app.data_spillage.get_group.error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
mappedFields, appErr := c.App.GetContentFlaggingMappedFields(c.AppContext, groupId)
if appErr != nil {
c.Err = appErr
return
}
if err := json.NewEncoder(w).Encode(mappedFields); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
c.Err = model.NewAppError("getContentFlaggingFields", "api.encoding_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
func getPostPropertyValues(c *Context, w http.ResponseWriter, r *http.Request) {
if c.Err != nil {
return
}
c.RequirePostId()
if c.Err != nil {
return
}
// The requesting user must be a reviewer of the post's team
// to be able to fetch the post's Content Flagging property values
postId := c.Params.PostId
post, appErr := c.App.GetSinglePost(c.AppContext, postId, true)
if appErr != nil {
c.Err = appErr
return
}
channel, appErr := c.App.GetChannel(c.AppContext, post.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
checkChannelFlaggable(c, channel)
if c.Err != nil {
return
}
userId := c.AppContext.Session().UserId
requireTeamContentReviewer(c, userId, channel.TeamId)
if c.Err != nil {
return
}
propertyValues, appErr := c.App.GetPostContentFlaggingPropertyValues(c.AppContext, postId)
if appErr != nil {
c.Err = appErr
return
}
if err := json.NewEncoder(w).Encode(propertyValues); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
c.Err = model.NewAppError("getPostPropertyValues", "api.encoding_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
func getFlaggedPost(c *Context, w http.ResponseWriter, r *http.Request) {
if c.Err != nil {
return
}
c.RequirePostId()
if c.Err != nil {
return
}
// A user can obtain a flagged post if-
// 1. The post is currently flagged and in any status
// 2. The user is a reviewer of the post's team
// check if user is a reviewer of the post's team
postId := c.Params.PostId
userId := c.AppContext.Session().UserId
auditRec := c.MakeAuditRecord(model.AuditEventGetFlaggedPost, model.AuditStatusFail)
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
model.AddEventParameterToAuditRec(auditRec, "postId", postId)
model.AddEventParameterToAuditRec(auditRec, "userId", userId)
post, appErr := c.App.GetSinglePost(c.AppContext, postId, true)
if appErr != nil {
c.Err = appErr
return
}
channel, appErr := c.App.GetChannel(c.AppContext, post.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
checkChannelFlaggable(c, channel)
if c.Err != nil {
return
}
requireTeamContentReviewer(c, userId, channel.TeamId)
if c.Err != nil {
return
}
// This validates that the post is flagged
requireFlaggedPost(c, postId)
if c.Err != nil {
return
}
post = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, post, &model.PreparePostForClientOpts{IncludePriority: true, RetainContent: true, IncludeDeleted: true})
post, isMemberForPreviews, err := c.App.SanitizePostMetadataForUser(c.AppContext, post, c.AppContext.Session().UserId)
if err != nil {
c.Err = err
return
}
if err := post.EncodeJSON(w); err != nil {
c.Err = model.NewAppError("getFlaggedPost", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
c.App.RecordPostDelivery(c.AppContext, c.AppContext.Session().UserId, post, model.DeliveryMechanismProduct)
if !isMemberForPreviews {
previewPost := post.GetPreviewPost()
if previewPost != nil {
model.AddEventParameterToAuditRec(auditRec, "preview_post_id", previewPost.Post.Id)
}
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
}
auditRec.Success()
}
func removeFlaggedPost(c *Context, w http.ResponseWriter, r *http.Request) {
actionRequest, userId, post := keepRemoveFlaggedPostChecks(c, r)
if c.Err != nil {
c.Err.Where = "removeFlaggedPost"
return
}
auditRec := c.MakeAuditRecord(model.AuditEventPermanentlyRemoveFlaggedPost, model.AuditStatusFail)
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
model.AddEventParameterToAuditRec(auditRec, "postId", post.Id)
model.AddEventParameterToAuditRec(auditRec, "userId", userId)
if appErr := c.App.PermanentDeleteFlaggedPost(c.AppContext, actionRequest, userId, post); appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
writeOKResponse(w)
}
func keepFlaggedPost(c *Context, w http.ResponseWriter, r *http.Request) {
actionRequest, userId, post := keepRemoveFlaggedPostChecks(c, r)
if c.Err != nil {
c.Err.Where = "keepFlaggedPost"
return
}
auditRec := c.MakeAuditRecord(model.AuditEventKeepFlaggedPost, model.AuditStatusFail)
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
model.AddEventParameterToAuditRec(auditRec, "postId", post.Id)
model.AddEventParameterToAuditRec(auditRec, "userId", userId)
if appErr := c.App.KeepFlaggedPost(c.AppContext, actionRequest, userId, post); appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
writeOKResponse(w)
}
func keepRemoveFlaggedPostChecks(c *Context, r *http.Request) (*model.FlagContentActionRequest, string, *model.Post) {
if c.Err != nil {
return nil, "", nil
}
c.RequirePostId()
if c.Err != nil {
return nil, "", nil
}
var actionRequest model.FlagContentActionRequest
if err := json.NewDecoder(r.Body).Decode(&actionRequest); err != nil {
c.SetInvalidParamWithErr("flagContentActionRequestBody", err)
return nil, "", nil
}
postId := c.Params.PostId
userId := c.AppContext.Session().UserId
post, appErr := c.App.GetSinglePost(c.AppContext, postId, true)
if appErr != nil {
c.Err = appErr
return nil, "", nil
}
channel, appErr := c.App.GetChannel(c.AppContext, post.ChannelId)
if appErr != nil {
c.Err = appErr
return nil, "", nil
}
checkChannelFlaggable(c, channel)
if c.Err != nil {
return nil, "", nil
}
requireTeamContentReviewer(c, userId, channel.TeamId)
if c.Err != nil {
return nil, "", nil
}
commentRequired := c.App.Config().ContentFlaggingSettings.AdditionalSettings.ReviewerCommentRequired
if err := actionRequest.IsValid(*commentRequired); err != nil {
c.Err = err
return nil, "", nil
}
return &actionRequest, userId, post
}
func saveContentFlaggingSettings(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingAvailable(c)
if c.Err != nil {
return
}
var config model.ContentFlaggingSettingsRequest
if err := json.NewDecoder(r.Body).Decode(&config); err != nil {
c.SetInvalidParamWithErr("config", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventUpdateContentFlaggingConfig, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
config.SetDefaults()
if appErr := config.IsValid(); appErr != nil {
c.Err = appErr
return
}
appErr := c.App.SaveContentFlaggingConfig(config)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
writeOKResponse(w)
}
func getContentFlaggingSettings(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingAvailable(c)
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
reviewerIDs, appErr := c.App.GetContentFlaggingConfigReviewerIDs()
if appErr != nil {
c.Err = appErr
return
}
config := c.App.Config().ContentFlaggingSettings
fullConfig := model.ContentFlaggingSettingsRequest{
ReviewerSettings: &model.ReviewSettingsRequest{
ReviewerSettings: *config.ReviewerSettings,
ReviewerIDsSettings: *reviewerIDs,
},
ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{
EnableContentFlagging: config.EnableContentFlagging,
NotificationSettings: config.NotificationSettings,
AdditionalSettings: config.AdditionalSettings,
},
}
if err := json.NewEncoder(w).Encode(fullConfig); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
c.Err = model.NewAppError("getContentFlaggingSettings", "api.encoding_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
func searchReviewers(c *Context, w http.ResponseWriter, r *http.Request) {
if c.Err != nil {
return
}
c.RequireTeamId()
if c.Err != nil {
return
}
teamId := c.Params.TeamId
userId := c.AppContext.Session().UserId
searchTerm := strings.TrimSpace(r.URL.Query().Get("term"))
requireTeamContentReviewer(c, userId, teamId)
if c.Err != nil {
return
}
reviewers, appErr := c.App.SearchReviewers(c.AppContext, searchTerm, teamId)
if appErr != nil {
c.Err = appErr
return
}
if err := json.NewEncoder(w).Encode(reviewers); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
c.Err = model.NewAppError("searchReviewers", "api.encoding_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
func assignFlaggedPostReviewer(c *Context, w http.ResponseWriter, r *http.Request) {
if c.Err != nil {
return
}
c.RequirePostId()
if c.Err != nil {
return
}
c.RequireContentReviewerId()
if c.Err != nil {
return
}
postId := c.Params.PostId
post, appErr := c.App.GetSinglePost(c.AppContext, postId, true)
if appErr != nil {
c.Err = appErr
return
}
channel, appErr := c.App.GetChannel(c.AppContext, post.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
checkChannelFlaggable(c, channel)
if c.Err != nil {
return
}
assignedBy := c.AppContext.Session().UserId
requireTeamContentReviewer(c, assignedBy, channel.TeamId)
if c.Err != nil {
return
}
reviewerId := c.Params.ContentReviewerId
requireTeamContentReviewer(c, reviewerId, channel.TeamId)
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventSetReviewer, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "assigningUserId", assignedBy)
model.AddEventParameterToAuditRec(auditRec, "reviewerUserId", reviewerId)
appErr = c.App.AssignFlaggedPostReviewer(c.AppContext, postId, channel.TeamId, reviewerId, assignedBy)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
writeOKResponse(w)
}
func checkPostTypeFlaggable(c *Context, post *model.Post) {
if post.Type == model.PostTypeBurnOnRead || strings.HasPrefix(post.Type, model.PostSystemMessagePrefix) {
c.Err = model.NewAppError("checkPostTypeFlaggable", "api.data_spillage.error.invalid_post_type", map[string]any{"PostType": post.Type}, "", http.StatusBadRequest)
}
}
func checkChannelFlaggable(c *Context, channel *model.Channel) {
if channel.IsGroupOrDirect() {
c.Err = model.NewAppError("checkChannelFlaggable", "api.data_spillage.error.invalid_channel_type", nil, "", http.StatusBadRequest)
}
}

View File

@@ -0,0 +1,183 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func generateFlaggedPostReport(c *Context, w http.ResponseWriter, r *http.Request) {
if c.Err != nil {
return
}
c.RequirePostId()
if c.Err != nil {
return
}
var actionRequest model.FlagContentActionRequest
if err := json.NewDecoder(r.Body).Decode(&actionRequest); err != nil && !errors.Is(err, io.EOF) {
c.SetInvalidParamWithErr("flagContentActionRequestBody", err)
return
}
postId := c.Params.PostId
userId := c.AppContext.Session().UserId
auditRec := c.MakeAuditRecord(model.AuditEventGenerateFlaggedPostReport, model.AuditStatusFail)
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
model.AddEventParameterToAuditRec(auditRec, "flaggedPostId", postId)
model.AddEventParameterToAuditRec(auditRec, "userId", userId)
model.AddEventParameterToAuditRec(auditRec, "comment", actionRequest.Comment)
model.AddEventParameterToAuditRec(auditRec, "action", actionRequest.Action)
post, appErr := c.App.GetSinglePost(c.AppContext, postId, true)
if appErr != nil {
c.Err = appErr
return
}
channel, appErr := c.App.GetChannel(c.AppContext, post.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
checkChannelFlaggable(c, channel)
if c.Err != nil {
return
}
requireTeamContentReviewer(c, userId, channel.TeamId)
if c.Err != nil {
return
}
// This validates that the post is flagged
requireFlaggedPost(c, postId)
if c.Err != nil {
return
}
includeAttachments := c.App.HasPermissionToFileAction(c.AppContext, userId, c.AppContext.Session().Roles, channel.Id, model.AccessControlPolicyActionDownloadFileAttachment)
reportPath, omittedAttachments, appErr := c.App.GenerateFlaggedPostReport(c.AppContext, postId, userId, actionRequest.Comment, actionRequest.Action, includeAttachments)
if appErr != nil {
c.Err = appErr
return
}
defer func() {
if err := os.Remove(reportPath); err != nil && !os.IsNotExist(err) {
c.Logger.Warn("Failed to remove flagged post report temp file", mlog.String("path", reportPath), mlog.Err(err))
}
}()
if omittedAttachments > 0 {
model.AddEventParameterToAuditRec(auditRec, "omitted_attachment_count", omittedAttachments)
}
f, err := os.Open(reportPath)
if err != nil {
c.Err = model.NewAppError("generateFlaggedPostReport", "api.data_spillage.report.open.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
c.Err = model.NewAppError("generateFlaggedPostReport", "api.data_spillage.report.stat.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
// Notify all team reviewers that a report has been generated. Best-effort:
// must run before http.ServeContent (which writes the response and may block).
c.App.NotifyReviewersOfFlaggedPostReportGeneration(c.AppContext, postId, userId)
if omittedAttachments > 0 {
c.App.NotifyRequestingReviewerOfSkippedAttachments(c.AppContext, postId, userId)
}
filename := fmt.Sprintf("flagged-post-%s-%d.zip", postId, model.GetMillis())
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
http.ServeContent(w, r, filename, stat.ModTime(), f)
auditRec.Success()
}
func generatePostExposureReport(c *Context, w http.ResponseWriter, r *http.Request) {
if c.Err != nil {
return
}
c.RequirePostId()
if c.Err != nil {
return
}
postId := c.Params.PostId
userId := c.AppContext.Session().UserId
auditRec := c.MakeAuditRecord(model.AuditEventGeneratePostExposureReport, model.AuditStatusFail)
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
model.AddEventParameterToAuditRec(auditRec, "flaggedPostId", postId)
model.AddEventParameterToAuditRec(auditRec, "userId", userId)
post, appErr := c.App.GetSinglePost(c.AppContext, postId, true)
if appErr != nil {
c.Err = appErr
return
}
channel, appErr := c.App.GetChannel(c.AppContext, post.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
requireTeamContentReviewer(c, userId, channel.TeamId)
if c.Err != nil {
return
}
requireFlaggedPost(c, postId)
if c.Err != nil {
return
}
report, appErr := c.App.ComputePostExposure(c.AppContext, postId)
if appErr != nil {
c.Err = appErr
return
}
var buf bytes.Buffer
if err := app.WritePostExposureCSV(&buf, report, c.AppContext.GetT()); err != nil {
c.Err = model.NewAppError("generatePostExposureReport", "api.data_spillage.exposure.write.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
c.App.NotifyReviewersOfPostExposureReportGeneration(c.AppContext, postId, userId)
filename := fmt.Sprintf("post-exposure-%s-%d.csv", postId, model.GetMillis())
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
if _, err := w.Write(buf.Bytes()); err != nil {
c.Logger.Warn("Failed to write post exposure report response", mlog.String("post_id", postId), mlog.Err(err))
return
}
auditRec.Success()
}

View File

@@ -0,0 +1,695 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"archive/zip"
"bytes"
"context"
"encoding/csv"
"io"
"net/http"
"os"
"strings"
"testing"
"github.com/goccy/go-yaml"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
"github.com/stretchr/testify/require"
)
func TestGenerateFlaggedPostReport(t *testing.T) {
th := Setup(t).InitBasic(t)
client := th.Client
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense(t)
t.Run("Should return 501 when feature is disabled", func(t *testing.T) {
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(false)
config.ContentFlaggingSettings.SetDefaults()
})
post := th.CreatePost(t)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{})
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
require.Empty(t, report)
})
t.Run("Should return 400 when post ID is invalid", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), "invalid", &model.FlagContentActionRequest{})
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Empty(t, report)
})
t.Run("Should return 403 when user is not a reviewer", func(t *testing.T) {
appErr := setNonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{})
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
require.Empty(t, report)
})
t.Run("Should return 404 when post is not flagged", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{})
require.Error(t, err)
require.Equal(t, http.StatusNotFound, resp.StatusCode)
require.Empty(t, report)
})
t.Run("Should successfully generate report for a flagged post", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
flagPostViaAPI(t, client, post.Id)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{Comment: "investigation note"})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, report)
zr, err := zip.NewReader(bytes.NewReader(report), int64(len(report)))
require.NoError(t, err)
entries := map[string]bool{}
for _, f := range zr.File {
entries[f.Name] = true
}
require.Contains(t, entries, "report_metadata.yaml")
require.Contains(t, entries, "post/post.yaml")
require.Contains(t, entries, "content_review.yaml")
})
t.Run("Should successfully generate report when user is a team reviewer", func(t *testing.T) {
appErr := setBasicTeamReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
flagPostViaAPI(t, client, post.Id)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{Comment: "investigation note"})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, report)
})
t.Run("Should include file attachments in the generated report", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post, fileInfo := uploadFileAndCreatePost(t, th, client)
flagPostViaAPI(t, client, post.Id)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{Comment: "investigation note"})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, report)
zr, err := zip.NewReader(bytes.NewReader(report), int64(len(report)))
require.NoError(t, err)
var foundAttachment bool
for _, f := range zr.File {
if f.Name == "post/attachments/"+fileInfo.Id+"_"+fileInfo.Name {
foundAttachment = true
break
}
}
require.True(t, foundAttachment, "attachment for the flagged post should be present in the report archive")
})
t.Run("Should include reviewer decision from request action", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
flagPostViaAPI(t, client, post.Id)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{
Comment: "investigation note",
Action: model.ContentFlaggingActionRemove,
})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, report)
zr, err := zip.NewReader(bytes.NewReader(report), int64(len(report)))
require.NoError(t, err)
var review model.FlaggedPostReportContentReview
var found bool
for _, f := range zr.File {
if f.Name != "content_review.yaml" {
continue
}
rc, err := f.Open()
require.NoError(t, err)
b, err := io.ReadAll(rc)
require.NoError(t, err)
_ = rc.Close()
require.NoError(t, yaml.Unmarshal(b, &review))
found = true
break
}
require.True(t, found, "content_review.yaml should be present in the report archive")
require.Equal(t, "remove", review.ActorDecision)
require.Equal(t, th.BasicUser.Id, review.ActorUserId)
require.Equal(t, th.BasicUser.Username, review.ActorUsername)
})
t.Run("Should include edit history entries in the generated report", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
post.Message = "Updated message to create edit history"
_, _, err := client.UpdatePost(context.Background(), post.Id, post)
require.NoError(t, err)
editHistory, appErr := th.App.GetEditHistoryForPost(th.Context, post.Id)
require.Nil(t, appErr)
require.NotEmpty(t, editHistory)
editId := editHistory[0].Id
flagPostViaAPI(t, client, post.Id)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{Comment: "investigation note"})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, report)
zr, err := zip.NewReader(bytes.NewReader(report), int64(len(report)))
require.NoError(t, err)
var foundEdit bool
for _, f := range zr.File {
if f.Name == "edit_history/"+editId+"/post.yaml" {
foundEdit = true
break
}
}
require.True(t, foundEdit, "edit history entry should be present in the report archive")
})
t.Run("Should not allow generating a report for a post in a DM channel", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := createDmPost(t, th, client)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{})
require.Error(t, err)
CheckBadRequestStatus(t, resp)
CheckErrorID(t, err, "api.data_spillage.error.invalid_channel_type")
require.Empty(t, report)
})
t.Run("Should not allow generating a report for a post in a GM channel", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := createGmPost(t, th, client)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{})
require.Error(t, err)
CheckBadRequestStatus(t, resp)
CheckErrorID(t, err, "api.data_spillage.error.invalid_channel_type")
require.Empty(t, report)
})
}
// parseExposureCSV skips the "#"-prefixed metadata preamble and returns the remaining records.
func parseExposureCSV(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
}
func TestGeneratePostExposureReport(t *testing.T) {
th := Setup(t).InitBasic(t)
client := th.Client
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense(t)
t.Run("Should return 501 when feature is disabled", func(t *testing.T) {
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(false)
config.ContentFlaggingSettings.SetDefaults()
})
post := th.CreatePost(t)
report, resp, err := client.GeneratePostExposureReport(context.Background(), post.Id)
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
require.Empty(t, report)
})
t.Run("Should return 400 when post ID is invalid", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
report, resp, err := client.GeneratePostExposureReport(context.Background(), "invalid")
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Empty(t, report)
})
t.Run("Should return 403 when user is not a reviewer", func(t *testing.T) {
appErr := setNonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
report, resp, err := client.GeneratePostExposureReport(context.Background(), post.Id)
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
require.Empty(t, report)
})
t.Run("Should return 404 when post is not flagged", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
report, resp, err := client.GeneratePostExposureReport(context.Background(), post.Id)
require.Error(t, err)
require.Equal(t, http.StatusNotFound, resp.StatusCode)
require.Empty(t, report)
})
t.Run("Should successfully generate report when the post has already been retained", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
flagPostViaAPI(t, client, post.Id)
resp, err := client.KeepFlaggedPost(context.Background(), post.Id, &model.FlagContentActionRequest{Comment: "looks fine"})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
report, resp, err := client.GeneratePostExposureReport(context.Background(), post.Id)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, report)
require.Contains(t, resp.Header.Get("Content-Type"), "text/csv")
require.Contains(t, resp.Header.Get("Content-Disposition"), "attachment; filename=\"post-exposure-"+post.Id)
})
t.Run("Should successfully generate report when the post has already been removed", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
flagPostViaAPI(t, client, post.Id)
resp, err := client.RemoveFlaggedPost(context.Background(), post.Id, &model.FlagContentActionRequest{Comment: "confirmed spillage"})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
// Removal scrubs the post's content but retains a stub row, and it never deletes
// the reporting_time property, so the exposure window is still computable.
report, resp, err := client.GeneratePostExposureReport(context.Background(), post.Id)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, report)
require.Contains(t, resp.Header.Get("Content-Type"), "text/csv")
require.Contains(t, resp.Header.Get("Content-Disposition"), "attachment; filename=\"post-exposure-"+post.Id)
})
t.Run("Should successfully generate report for a common reviewer", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
flagPostViaAPI(t, client, post.Id)
report, resp, err := client.GeneratePostExposureReport(context.Background(), post.Id)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, report)
require.Contains(t, resp.Header.Get("Content-Type"), "text/csv")
require.Contains(t, resp.Header.Get("Content-Disposition"), "attachment; filename=\"post-exposure-"+post.Id)
})
t.Run("Should successfully generate report when user is a team reviewer", func(t *testing.T) {
appErr := setBasicTeamReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
flagPostViaAPI(t, client, post.Id)
report, resp, err := client.GeneratePostExposureReport(context.Background(), post.Id)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, report)
})
t.Run("Should generate report for both the assignee and a non-assignee reviewer", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th, th.BasicUser2.Id)
require.Nil(t, appErr)
post := th.CreatePost(t)
flagPostViaAPI(t, client, post.Id)
resp, err := client.AssignContentFlaggingReviewer(context.Background(), post.Id, th.BasicUser2.Id)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
// BasicUser is a reviewer but not the assignee.
report, resp, err := client.GeneratePostExposureReport(context.Background(), post.Id)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, report)
// BasicUser2 is the assignee, and is therefore also a reviewer.
assigneeClient := th.CreateClient()
th.LoginBasic2WithClient(t, assigneeClient)
report, resp, err = assigneeClient.GeneratePostExposureReport(context.Background(), post.Id)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, report)
})
t.Run("Should return a parseable CSV listing the channel members", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
flagPostViaAPI(t, client, post.Id)
report, _, err := client.GeneratePostExposureReport(context.Background(), post.Id)
require.NoError(t, err)
body := string(report)
require.Contains(t, body, "# Post ID: "+post.Id)
require.Contains(t, body, "# Report version: "+model.PostExposureReportVersion)
records := parseExposureCSV(t, report)
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.Equal(t, th.BasicUser.Username, record[1])
}
}
require.True(t, found, "the post author is a channel member and must appear in the report")
})
t.Run("Should return 403 in team reviewer mode for a user not on the team's reviewer list", func(t *testing.T) {
appErr := setBasicTeamReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
flagPostViaAPI(t, client, post.Id)
otherClient := th.CreateClient()
th.LoginBasic2WithClient(t, otherClient)
report, resp, err := otherClient.GeneratePostExposureReport(context.Background(), post.Id)
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
require.Empty(t, report)
})
}
func readReportArchive(t *testing.T, report []byte) map[string][]byte {
t.Helper()
zr, err := zip.NewReader(bytes.NewReader(report), int64(len(report)))
require.NoError(t, err)
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 TestGenerateFlaggedPostReportFileDownloadPolicy(t *testing.T) {
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.PermissionPolicies = true
}).InitBasic(t)
client := th.Client
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = true
})
withDownloadDecision := func(t *testing.T, channelID string, allowed bool) {
t.Helper()
mockACS := &mocks.AccessControlServiceInterface{}
original := th.App.Srv().Channels().AccessControl
th.App.Srv().Channels().AccessControl = mockACS
t.Cleanup(func() { th.App.Srv().Channels().AccessControl = original })
mockACS.On("AccessEvaluation", mock.Anything, mock.MatchedBy(func(req model.AccessRequest) bool {
return req.Resource.ID == channelID && req.Action == model.AccessControlPolicyActionDownloadFileAttachment
})).Return(model.AccessDecision{Decision: allowed}, (*model.AppError)(nil))
// Only the decision under test is pinned; unrelated evaluations must not trip the mock.
mockACS.On("AccessEvaluation", mock.Anything, mock.Anything).
Return(model.AccessDecision{Decision: true}, (*model.AppError)(nil)).Maybe()
}
t.Run("Should include attachments when the download policy allows the reviewer", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post, fileInfo := uploadFileAndCreatePost(t, th, client)
flagPostViaAPI(t, client, post.Id)
withDownloadDecision(t, post.ChannelId, true)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
entries := readReportArchive(t, report)
require.Contains(t, entries, "post/attachments/"+fileInfo.Id+"_"+fileInfo.Name)
require.NotContains(t, entries, "ATTACHMENTS_OMITTED.txt")
})
t.Run("Should omit attachments without failing the request when the download policy denies the reviewer", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post, fileInfo := uploadFileAndCreatePost(t, th, client)
flagPostViaAPI(t, client, post.Id)
withDownloadDecision(t, post.ChannelId, false)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
entries := readReportArchive(t, report)
for name := range entries {
require.NotContains(t, name, fileInfo.Id, "no archive entry should be named after an omitted attachment")
}
require.Contains(t, entries, "ATTACHMENTS_OMITTED.txt")
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")
})
}
func TestGenerateFlaggedPostReportAttachmentsOmittedAudit(t *testing.T) {
logFile, err := os.CreateTemp(t.TempDir(), "audit.log")
require.NoError(t, err)
defer logFile.Close()
options := []app.Option{app.WithLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced, "advanced_logging"))}
th := SetupWithServerOptionsAndConfig(t, options, func(cfg *model.Config) {
cfg.FeatureFlags.PermissionPolicies = true
cfg.ExperimentalAuditSettings.FileEnabled = model.NewPointer(true)
cfg.ExperimentalAuditSettings.FileName = model.NewPointer(logFile.Name())
}).InitBasic(t)
client := th.Client
// WithLicense seeds the license early enough for the audit sink to come up; the
// test helper clears it again during setup, so re-apply it for content flagging.
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced, "advanced_logging"))
defer th.RemoveLicense(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = true
})
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
omittedPost, _ := uploadFileAndCreatePost(t, th, client)
flagPostViaAPI(t, client, omittedPost.Id)
completePost, _ := uploadFileAndCreatePost(t, th, client)
flagPostViaAPI(t, client, completePost.Id)
original := th.App.Srv().Channels().AccessControl
defer func() { th.App.Srv().Channels().AccessControl = original }()
setDownloadDecision := func(allowed bool) {
mockACS := &mocks.AccessControlServiceInterface{}
mockACS.On("AccessEvaluation", mock.Anything, mock.Anything).
Return(model.AccessDecision{Decision: allowed}, (*model.AppError)(nil))
th.App.Srv().Channels().AccessControl = mockACS
}
setDownloadDecision(false)
_, resp, err := client.GenerateFlaggedPostReport(context.Background(), omittedPost.Id, &model.FlagContentActionRequest{})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
setDownloadDecision(true)
_, resp, err = client.GenerateFlaggedPostReport(context.Background(), completePost.Id, &model.FlagContentActionRequest{})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NoError(t, th.Server.Audit.Flush())
require.NoError(t, logFile.Sync())
data, err := io.ReadAll(logFile)
require.NoError(t, err)
require.NotEmpty(t, data)
omittedEntry := FindAuditEntry(auditLinesForPost(string(data), omittedPost.Id), "generateFlaggedPostReport", th.BasicUser.Id)
require.NotNil(t, omittedEntry, "should find a generateFlaggedPostReport audit entry for the trimmed report")
require.Equal(t, "success", omittedEntry.Status)
require.EqualValues(t, 1, omittedEntry.Parameters["omitted_attachment_count"])
completeEntry := FindAuditEntry(auditLinesForPost(string(data), completePost.Id), "generateFlaggedPostReport", th.BasicUser.Id)
require.NotNil(t, completeEntry, "should find a generateFlaggedPostReport audit entry for the complete report")
require.Equal(t, "success", completeEntry.Status)
require.NotContains(t, completeEntry.Parameters, "omitted_attachment_count", "a complete report has no omission to record")
}
// auditLinesForPost narrows the audit log to the entries mentioning postID, so that
// FindAuditEntry - which returns the first match - can be pointed at a single request.
func auditLinesForPost(data, postID string) string {
var lines []string
for line := range strings.SplitSeq(data, "\n") {
if strings.Contains(line, postID) {
lines = append(lines, line)
}
}
return strings.Join(lines, "\n")
}
func TestGenerateFlaggedPostReportEvaluatesSessionAttributes(t *testing.T) {
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.PermissionPolicies = true
cfg.FeatureFlags.SessionAttributes = true
}).InitBasic(t)
client := th.Client
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = true
})
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
session, appErr := th.App.GetSession(client.AuthToken)
require.Nil(t, appErr)
setVPNActive := func(t *testing.T, value string) {
t.Helper()
require.NoError(t, th.App.Srv().Store().SessionAttribute().Refresh(session.Id, map[string]any{
model.SessionAttributesPropertyFieldVPNActive: value,
}, model.GetMillis()))
}
// Stands in for a policy that grants the download only while the reviewer's session
// reports an active VPN. Anything the PDP cannot match falls through to a denial, so
// a subject built without session attributes shows up as stripped attachments.
requireVPNActive := func(t *testing.T, channelID string) {
t.Helper()
mockACS := &mocks.AccessControlServiceInterface{}
original := th.App.Srv().Channels().AccessControl
th.App.Srv().Channels().AccessControl = mockACS
t.Cleanup(func() { th.App.Srv().Channels().AccessControl = original })
mockACS.On("AccessEvaluation", mock.Anything, mock.MatchedBy(func(req model.AccessRequest) bool {
return req.Action == model.AccessControlPolicyActionDownloadFileAttachment &&
req.Resource.ID == channelID &&
req.Subject.Session[model.SessionAttributesPropertyFieldVPNActive] == "true"
})).Return(model.AccessDecision{Decision: true}, (*model.AppError)(nil))
mockACS.On("AccessEvaluation", mock.Anything, mock.Anything).
Return(model.AccessDecision{Decision: false}, (*model.AppError)(nil)).Maybe()
}
t.Run("Should include attachments when the session attribute satisfies the policy", func(t *testing.T) {
post, fileInfo := uploadFileAndCreatePost(t, th, client)
flagPostViaAPI(t, client, post.Id)
setVPNActive(t, "true")
requireVPNActive(t, post.ChannelId)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
entries := readReportArchive(t, report)
require.Contains(t, entries, "post/attachments/"+fileInfo.Id+"_"+fileInfo.Name)
require.NotContains(t, entries, "ATTACHMENTS_OMITTED.txt")
})
t.Run("Should omit attachments when the session attribute fails the policy", func(t *testing.T) {
post, fileInfo := uploadFileAndCreatePost(t, th, client)
flagPostViaAPI(t, client, post.Id)
setVPNActive(t, "false")
requireVPNActive(t, post.ChannelId)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
entries := readReportArchive(t, report)
require.NotContains(t, entries, "post/attachments/"+fileInfo.Id+"_"+fileInfo.Name)
require.Contains(t, entries, "ATTACHMENTS_OMITTED.txt")
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,154 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"fmt"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
)
const (
acAllowOrigin = "Access-Control-Allow-Origin"
acExposeHeaders = "Access-Control-Expose-Headers"
acMaxAge = "Access-Control-Max-Age"
acAllowCredentials = "Access-Control-Allow-Credentials"
acAllowMethods = "Access-Control-Allow-Methods"
acAllowHeaders = "Access-Control-Allow-Headers"
)
func TestCORSRequestHandling(t *testing.T) {
mainHelper.Parallel(t)
for name, testcase := range map[string]struct {
AllowCorsFrom string
CorsExposedHeaders string
CorsAllowCredentials bool
ModifyRequest func(req *http.Request)
ExpectedAllowOrigin string
ExpectedExposeHeaders string
ExpectedAllowCredentials string
}{
"NoCORS": {
"",
"",
false,
func(req *http.Request) {
},
"",
"",
"",
},
"CORSEnabled": {
"http://somewhere.com",
"",
false,
func(req *http.Request) {
},
"",
"",
"",
},
"CORSEnabledStarOrigin": {
"*",
"",
false,
func(req *http.Request) {
req.Header.Set("Origin", "http://pre-release.mattermost.com")
},
"*",
"",
"",
},
"CORSEnabledStarNoOrigin": { // CORS spec requires this, not a bug.
"*",
"",
false,
func(req *http.Request) {
},
"",
"",
"",
},
"CORSEnabledMatching": {
"http://mattermost.com",
"",
false,
func(req *http.Request) {
req.Header.Set("Origin", "http://mattermost.com")
},
"http://mattermost.com",
"",
"",
},
"CORSEnabledMultiple": {
"http://spinmint.com http://mattermost.com",
"",
false,
func(req *http.Request) {
req.Header.Set("Origin", "http://mattermost.com")
},
"http://mattermost.com",
"",
"",
},
"CORSEnabledWithCredentials": {
"http://mattermost.com",
"",
true,
func(req *http.Request) {
req.Header.Set("Origin", "http://mattermost.com")
},
"http://mattermost.com",
"",
"true",
},
"CORSEnabledWithHeaders": {
"http://mattermost.com",
"x-my-special-header x-blueberry",
true,
func(req *http.Request) {
req.Header.Set("Origin", "http://mattermost.com")
},
"http://mattermost.com",
"X-My-Special-Header, X-Blueberry",
"true",
},
} {
t.Run(name, func(t *testing.T) {
th := SetupConfigWithStoreMock(t, func(cfg *model.Config) {
*cfg.ServiceSettings.AllowCorsFrom = testcase.AllowCorsFrom
*cfg.ServiceSettings.CorsExposedHeaders = testcase.CorsExposedHeaders
*cfg.ServiceSettings.CorsAllowCredentials = testcase.CorsAllowCredentials
})
licenseStore := mocks.LicenseStore{}
licenseStore.On("Get", "").Return(&model.LicenseRecord{}, nil)
th.App.Srv().Store().(*mocks.Store).On("License").Return(&licenseStore)
port := th.App.Srv().ListenAddr.Port
host := fmt.Sprintf("http://localhost:%v", port)
url := fmt.Sprintf("%v/api/v4/system/ping", host)
req, err := http.NewRequest("GET", url, nil)
require.NoError(t, err)
testcase.ModifyRequest(req)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, testcase.ExpectedAllowOrigin, resp.Header.Get(acAllowOrigin))
assert.Equal(t, testcase.ExpectedExposeHeaders, resp.Header.Get(acExposeHeaders))
assert.Equal(t, "", resp.Header.Get(acMaxAge))
assert.Equal(t, testcase.ExpectedAllowCredentials, resp.Header.Get(acAllowCredentials))
assert.Equal(t, "", resp.Header.Get(acAllowMethods))
assert.Equal(t, "", resp.Header.Get(acAllowHeaders))
})
}
}

View File

@@ -0,0 +1,512 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// This file implements the "User Attributes" API handlers (formerly "Custom
// Profile Attributes" / CPA). Internal identifiers and URL paths retain the
// old naming for backward compatibility. See MM-68235.
package api4
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/v8/channels/app"
)
func (api *API) InitCustomProfileAttributes() {
api.BaseRoutes.CustomProfileAttributesFields.Handle("", api.APISessionRequired(listCPAFields)).Methods(http.MethodGet)
api.BaseRoutes.CustomProfileAttributesFields.Handle("", api.APISessionRequired(createCPAField)).Methods(http.MethodPost)
api.BaseRoutes.CustomProfileAttributesField.Handle("", api.APISessionRequired(patchCPAField)).Methods(http.MethodPatch)
api.BaseRoutes.CustomProfileAttributesField.Handle("", api.APISessionRequired(deleteCPAField)).Methods(http.MethodDelete)
api.BaseRoutes.User.Handle("/custom_profile_attributes", api.APISessionRequired(listCPAValues)).Methods(http.MethodGet)
api.BaseRoutes.CustomProfileAttributesValues.Handle("", api.APISessionRequired(patchCPAValues)).Methods(http.MethodPatch)
api.BaseRoutes.CustomProfileAttributes.Handle("/group", api.APISessionRequired(getCPAGroup)).Methods(http.MethodGet)
api.BaseRoutes.User.Handle("/custom_profile_attributes", api.APISessionRequired(patchCPAValuesForUser)).Methods(http.MethodPatch)
}
func listCPAFields(c *Context, w http.ResponseWriter, r *http.Request) {
rctx := app.RequestContextWithCallerID(c.AppContext, sessionCallerID(c))
group, appErr := c.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
if appErr != nil {
c.Err = appErr
return
}
pfs, appErr := c.App.SearchPropertyFields(rctx, group.ID, model.PropertyFieldSearchOpts{
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeUser,
PerPage: model.AccessControlGroupFieldLimit + 5,
})
if appErr != nil {
c.Err = appErr
return
}
fields, convErr := model.CPAFieldsFromPropertyFields(pfs)
if convErr != nil {
c.Err = model.NewAppError("listCPAFields", "app.custom_profile_attributes.property_field_conversion.app_error", nil, "", http.StatusInternalServerError).Wrap(convErr)
return
}
if err := json.NewEncoder(w).Encode(fields); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func createCPAField(c *Context, w http.ResponseWriter, r *http.Request) {
var pf *model.CPAField
if err := json.NewDecoder(r.Body).Decode(&pf); err != nil || pf == nil {
c.SetInvalidParamWithErr("property_field", err)
return
}
pf.Name = strings.TrimSpace(pf.Name)
auditRec := c.MakeAuditRecord(model.AuditEventCreateCPAField, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "property_field", pf)
// CPA fields are system-scoped; only a system administrator may create
// them. This mirrors the scope-based permission check the shared generic
// handler enforces for system-typed fields.
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
// Translate to PropertyField and route through the generic property API.
// Server-controlled fields (group, type, target shape, creator) are
// stamped here; ID/TargetID/Protected are stripped so a caller can't
// inject them. Permissions and timestamps are filled in by lower layers.
field := pf.ToPropertyField()
group, appErr := c.App.GetPropertyGroup(c.AppContext, model.AccessControlPropertyGroupName)
if appErr != nil {
c.Err = appErr
return
}
field.ID = ""
field.GroupID = group.ID
field.ObjectType = model.PropertyFieldObjectTypeUser
field.TargetType = string(model.PropertyFieldTargetLevelSystem)
field.TargetID = ""
field.Protected = false
field.CreatedBy = c.AppContext.Session().UserId
field.UpdatedBy = c.AppContext.Session().UserId
rctx := app.RequestContextWithCallerID(c.AppContext, sessionCallerID(c))
connectionID := r.Header.Get(model.ConnectionId)
createdField, appErr := c.App.CreatePropertyField(rctx, field, false, connectionID)
if appErr != nil {
c.Err = appErr
return
}
cpaField, convErr := model.NewCPAFieldFromPropertyField(createdField)
if convErr != nil {
c.Err = model.NewAppError("createCPAField", "app.custom_profile_attributes.property_field_conversion.app_error", nil, "", http.StatusInternalServerError).Wrap(convErr)
return
}
// Send CPA-specific websocket event for backwards compatibility
message := model.NewWebSocketEvent(model.WebsocketEventCPAFieldCreated, "", "", "", nil, "")
message.Add("field", cpaField)
c.App.Publish(message)
auditRec.AddEventObjectType("property_field")
auditRec.AddEventResultState(cpaField)
auditRec.Success()
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(cpaField); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func patchCPAField(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireFieldId()
if c.Err != nil {
return
}
var patch *model.PropertyFieldPatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil || patch == nil {
c.SetInvalidParamWithErr("property_field_patch", err)
return
}
if patch.Name != nil {
*patch.Name = strings.TrimSpace(*patch.Name)
}
// Target fields are server-controlled; prevent the caller from patching them.
patch.TargetID = nil
patch.TargetType = nil
if err := patch.IsValid(); err != nil {
if appErr, ok := err.(*model.AppError); ok {
c.Err = appErr
} else {
c.Err = model.NewAppError("patchCPAField", "api.custom_profile_attributes.invalid_field_patch", nil, "", http.StatusBadRequest)
}
return
}
auditRec := c.MakeAuditRecord(model.AuditEventPatchCPAField, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "property_field_patch", patch)
group, appErr := c.App.GetPropertyGroup(c.AppContext, model.AccessControlPropertyGroupName)
if appErr != nil {
c.Err = appErr
return
}
rctx := app.RequestContextWithCallerID(c.AppContext, sessionCallerID(c))
existingField, appErr := c.App.GetPropertyField(rctx, group.ID, c.Params.FieldId)
if appErr != nil {
c.Err = appErr
return
}
if existingField.ObjectType != model.PropertyFieldObjectTypeUser {
c.Err = model.NewAppError("patchCPAField", "api.property_field.object_type_mismatch.app_error", nil, "", http.StatusNotFound)
return
}
// Permission branching (session-bound).
isOptionsOnly := isOptionsOnlyPatch(patch)
if isOptionsOnly && !existingField.Type.SupportsOptions() {
isOptionsOnly = false
}
if isOptionsOnly {
if !c.App.SessionHasPermissionToManagePropertyFieldOptions(rctx, *c.AppContext.Session(), existingField) {
c.Err = model.NewAppError("patchCPAField", "api.property_field.update.no_options_permission.app_error", nil, "", http.StatusForbidden)
return
}
} else {
if !c.App.SessionHasPermissionToEditPropertyField(rctx, *c.AppContext.Session(), existingField) {
c.Err = model.NewAppError("patchCPAField", "api.property_field.update.no_field_permission.app_error", nil, "", http.StatusForbidden)
return
}
}
// Capture original state for audit before in-place patch (Attrs is
// shallow-copied because Patch mutates it).
orig := *existingField
if existingField.Attrs != nil {
orig.Attrs = make(model.StringInterface, len(existingField.Attrs))
maps.Copy(orig.Attrs, existingField.Attrs)
}
auditRec.AddEventPriorState(&orig)
existingField.Patch(patch, true)
existingField.UpdatedBy = c.AppContext.Session().UserId
connectionID := r.Header.Get(model.ConnectionId)
updatedField, clearedIDs, updateErr := c.App.UpdatePropertyField(rctx, group.ID, existingField, false, connectionID)
if updateErr != nil {
c.Err = updateErr
return
}
cpaField, convErr := model.NewCPAFieldFromPropertyField(updatedField)
if convErr != nil {
c.Err = model.NewAppError("patchCPAField", "app.custom_profile_attributes.property_field_conversion.app_error", nil, "", http.StatusInternalServerError).Wrap(convErr)
return
}
// CPA-specific websocket event (backward compat). delete_values:true tells
// pre-PSAv2 webapp clients to clear cached values for this field; PSAv2
// clients receive the same signal via WebsocketEventPropertyValuesUpdated
// fired by App.UpdatePropertyField.
message := model.NewWebSocketEvent(model.WebsocketEventCPAFieldUpdated, "", "", "", nil, "")
message.Add("field", cpaField)
message.Add("delete_values", len(clearedIDs) > 0)
c.App.Publish(message)
auditRec.Success()
auditRec.AddEventResultState(cpaField)
auditRec.AddEventObjectType("property_field")
if err := json.NewEncoder(w).Encode(cpaField); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func deleteCPAField(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireFieldId()
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventDeleteCPAField, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "field_id", c.Params.FieldId)
group, appErr := c.App.GetPropertyGroup(c.AppContext, model.AccessControlPropertyGroupName)
if appErr != nil {
c.Err = appErr
return
}
rctx := app.RequestContextWithCallerID(c.AppContext, sessionCallerID(c))
existingField, appErr := c.App.GetPropertyField(rctx, group.ID, c.Params.FieldId)
if appErr != nil {
c.Err = appErr
return
}
if existingField.ObjectType != model.PropertyFieldObjectTypeUser {
c.Err = model.NewAppError("deleteCPAField", "api.property_field.object_type_mismatch.app_error", nil, "", http.StatusNotFound)
return
}
if !c.App.SessionHasPermissionToEditPropertyField(rctx, *c.AppContext.Session(), existingField) {
c.Err = model.NewAppError("deleteCPAField", "api.property_field.delete.no_permission.app_error", nil, "", http.StatusForbidden)
return
}
connectionID := r.Header.Get(model.ConnectionId)
if deleteErr := c.App.DeletePropertyField(rctx, group.ID, c.Params.FieldId, false, connectionID); deleteErr != nil {
c.Err = deleteErr
return
}
// CPA-specific websocket event (backward compat)
message := model.NewWebSocketEvent(model.WebsocketEventCPAFieldDeleted, "", "", "", nil, "")
message.Add("field_id", c.Params.FieldId)
c.App.Publish(message)
auditRec.AddEventPriorState(existingField)
auditRec.Success()
auditRec.AddEventResultState(existingField)
auditRec.AddEventObjectType("property_field")
ReturnStatusOK(w)
}
func getCPAGroup(c *Context, w http.ResponseWriter, r *http.Request) {
// Every other CPA endpoint enforces MinimumEnterpriseLicense via the
// LicenseCheckHook on field/value operations. GetPropertyGroup is not
// hooked, so we enforce the same contract here inline.
if !model.MinimumEnterpriseLicense(c.App.License()) {
c.Err = model.NewAppError("getCPAGroup", "app.property.license_error", nil, "an Enterprise license is required", http.StatusForbidden)
return
}
group, appErr := c.App.GetPropertyGroup(c.AppContext, model.AccessControlPropertyGroupName)
if appErr != nil {
c.Err = appErr
return
}
if err := json.NewEncoder(w).Encode(map[string]string{"id": group.ID}); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
// cpaPatchValues is the shared implementation for patchCPAValues and
// patchCPAValuesForUser. It translates the CPA request format to the generic
// property API, performs the same session-bound checks as the generic value
// patch handler (target access, batch caps, per-field permission), routes
// the upsert through App.UpsertPropertyValues, and emits the CPA-specific
// websocket event.
func cpaPatchValues(c *Context, w http.ResponseWriter, r *http.Request, userID string, updates map[string]json.RawMessage) {
rctx := app.RequestContextWithCallerID(c.AppContext, sessionCallerID(c))
group, appErr := c.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
if appErr != nil {
c.Err = appErr
return
}
if !hasTargetAccess(c, model.PropertyFieldObjectTypeUser, userID, true) {
return
}
// Translate CPA format → generic PropertyValuePatchItem list. Map
// iteration is unordered, but FieldID uniqueness is guaranteed by the
// JSON object key constraint, so we cannot hit duplicate-FieldID; still,
// we keep the same shape as the generic handler for parity.
items := make([]model.PropertyValuePatchItem, 0, len(updates))
for fieldID, value := range updates {
items = append(items, model.PropertyValuePatchItem{
FieldID: fieldID,
Value: value,
})
}
if len(items) == 0 {
c.Err = model.NewAppError("cpaPatchValues", "api.property_value.patch.empty_body.app_error", nil, "", http.StatusBadRequest)
return
}
if len(items) > maxPropertyValuePatchItems {
c.Err = model.NewAppError("cpaPatchValues", "api.property_value.patch.too_many_items.request_error", map[string]any{
"Max": maxPropertyValuePatchItems,
}, "", http.StatusBadRequest)
return
}
fieldIDs := make([]string, 0, len(items))
for _, item := range items {
if !model.IsValidId(item.FieldID) {
c.Err = model.NewAppError("cpaPatchValues", "api.property_value.patch.invalid_field_id.app_error", nil, "", http.StatusBadRequest)
return
}
fieldIDs = append(fieldIDs, item.FieldID)
}
fields, fieldsErr := c.App.GetPropertyFields(rctx, group.ID, fieldIDs)
if fieldsErr != nil {
c.Err = fieldsErr
return
}
fieldByID := make(map[string]*model.PropertyField, len(fields))
for _, f := range fields {
fieldByID[f.ID] = f
}
for _, item := range items {
f, ok := fieldByID[item.FieldID]
if !ok {
c.Err = model.NewAppError("cpaPatchValues", "api.property_value.patch.field_not_found.app_error",
map[string]any{"FieldID": item.FieldID}, "", http.StatusNotFound)
return
}
if f.ObjectType != model.PropertyFieldObjectTypeUser {
c.Err = model.NewAppError("cpaPatchValues", "api.property_field.object_type_mismatch.app_error", nil, "", http.StatusNotFound)
return
}
if !c.App.SessionHasPermissionToSetPropertyFieldValues(rctx, *c.AppContext.Session(), f, userID) {
c.Err = model.NewAppError("cpaPatchValues", "api.property_value.patch.no_values_permission.app_error", nil, "", http.StatusForbidden)
return
}
}
callerID := c.AppContext.Session().UserId
values := make([]*model.PropertyValue, len(items))
for i, item := range items {
values[i] = &model.PropertyValue{
TargetID: userID,
TargetType: model.PropertyFieldObjectTypeUser,
GroupID: group.ID,
FieldID: item.FieldID,
Value: item.Value,
CreatedBy: callerID,
UpdatedBy: callerID,
}
}
connectionID := r.Header.Get(model.ConnectionId)
upserted, upsertErr := c.App.UpsertPropertyValues(rctx, values, model.PropertyFieldObjectTypeUser, userID, connectionID)
if upsertErr != nil {
c.Err = upsertErr
return
}
// Translate response to CPA format: {fieldID: value}
results := make(map[string]json.RawMessage, len(upserted))
for _, value := range upserted {
results[value.FieldID] = value.Value
}
// CPA-specific websocket event (backward compat)
message := model.NewWebSocketEvent(model.WebsocketEventCPAValuesUpdated, "", "", "", nil, "")
message.Add("user_id", userID)
message.Add("values", results)
c.App.Publish(message)
if err := json.NewEncoder(w).Encode(results); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func patchCPAValues(c *Context, w http.ResponseWriter, r *http.Request) {
userID := c.AppContext.Session().UserId
var updates map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
c.SetInvalidParamWithErr("value", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventPatchCPAValues, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "user_id", userID)
cpaPatchValues(c, w, r, userID, updates)
if c.Err != nil {
return
}
auditRec.Success()
auditRec.AddEventObjectType("patchCPAValues")
}
func listCPAValues(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId()
if c.Err != nil {
return
}
if !hasTargetAccess(c, model.PropertyFieldObjectTypeUser, c.Params.UserId, false) {
return
}
rctx := app.RequestContextWithCallerID(c.AppContext, sessionCallerID(c))
group, appErr := c.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
if appErr != nil {
c.Err = appErr
return
}
values, appErr := c.App.SearchPropertyValues(rctx, group.ID, model.PropertyValueSearchOpts{
TargetIDs: []string{c.Params.UserId},
TargetType: model.PropertyValueTargetTypeUser,
// Single-target search: at most one value per (target, field), so the field cap bounds the page.
PerPage: model.AccessControlGroupFieldLimit + 5,
})
if appErr != nil {
c.Err = appErr
return
}
returnValue := make(map[string]json.RawMessage)
for _, value := range values {
returnValue[value.FieldID] = value.Value
}
if err := json.NewEncoder(w).Encode(returnValue); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func patchCPAValuesForUser(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId()
if c.Err != nil {
return
}
userID := c.Params.UserId
var updates map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
c.SetInvalidParamWithErr("value", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventPatchCPAValues, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "user_id", userID)
cpaPatchValues(c, w, r, userID, updates)
if c.Err != nil {
return
}
auditRec.Success()
auditRec.AddEventObjectType("patchCPAValues")
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import "net/http"
func (api *API) InitCustomProfileAttributesLocal() {
api.BaseRoutes.CustomProfileAttributesFields.Handle("", api.APILocal(listCPAFields)).Methods(http.MethodGet)
api.BaseRoutes.CustomProfileAttributesFields.Handle("", api.APILocal(createCPAField)).Methods(http.MethodPost)
api.BaseRoutes.CustomProfileAttributesField.Handle("", api.APILocal(patchCPAField)).Methods(http.MethodPatch)
api.BaseRoutes.CustomProfileAttributesField.Handle("", api.APILocal(deleteCPAField)).Methods(http.MethodDelete)
api.BaseRoutes.User.Handle("/custom_profile_attributes", api.APILocal(listCPAValues)).Methods(http.MethodGet)
api.BaseRoutes.CustomProfileAttributesValues.Handle("", api.APILocal(patchCPAValues)).Methods(http.MethodPatch)
api.BaseRoutes.User.Handle("/custom_profile_attributes", api.APILocal(patchCPAValuesForUser)).Methods(http.MethodPatch)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,515 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitDataRetention() {
api.BaseRoutes.DataRetention.Handle("/policy", api.APISessionRequired(getGlobalPolicy)).Methods(http.MethodGet)
api.BaseRoutes.DataRetention.Handle("/policies", api.APISessionRequired(getPolicies)).Methods(http.MethodGet)
api.BaseRoutes.DataRetention.Handle("/policies_count", api.APISessionRequired(getPoliciesCount)).Methods(http.MethodGet)
api.BaseRoutes.DataRetention.Handle("/policies", api.APISessionRequired(createPolicy)).Methods(http.MethodPost)
api.BaseRoutes.DataRetention.Handle("/policies/{policy_id:[A-Za-z0-9]+}", api.APISessionRequired(getPolicy)).Methods(http.MethodGet)
api.BaseRoutes.DataRetention.Handle("/policies/{policy_id:[A-Za-z0-9]+}", api.APISessionRequired(patchPolicy)).Methods(http.MethodPatch)
api.BaseRoutes.DataRetention.Handle("/policies/{policy_id:[A-Za-z0-9]+}", api.APISessionRequired(deletePolicy)).Methods(http.MethodDelete)
api.BaseRoutes.DataRetention.Handle("/policies/{policy_id:[A-Za-z0-9]+}/teams", api.APISessionRequired(getTeamsForPolicy)).Methods(http.MethodGet)
api.BaseRoutes.DataRetention.Handle("/policies/{policy_id:[A-Za-z0-9]+}/teams", api.APISessionRequired(addTeamsToPolicy)).Methods(http.MethodPost)
api.BaseRoutes.DataRetention.Handle("/policies/{policy_id:[A-Za-z0-9]+}/teams", api.APISessionRequired(removeTeamsFromPolicy)).Methods(http.MethodDelete)
api.BaseRoutes.DataRetention.Handle("/policies/{policy_id:[A-Za-z0-9]+}/teams/search", api.APISessionRequired(searchTeamsInPolicy)).Methods(http.MethodPost)
api.BaseRoutes.DataRetention.Handle("/policies/{policy_id:[A-Za-z0-9]+}/channels", api.APISessionRequired(getChannelsForPolicy)).Methods(http.MethodGet)
api.BaseRoutes.DataRetention.Handle("/policies/{policy_id:[A-Za-z0-9]+}/channels", api.APISessionRequired(addChannelsToPolicy)).Methods(http.MethodPost)
api.BaseRoutes.DataRetention.Handle("/policies/{policy_id:[A-Za-z0-9]+}/channels", api.APISessionRequired(removeChannelsFromPolicy)).Methods(http.MethodDelete)
api.BaseRoutes.DataRetention.Handle("/policies/{policy_id:[A-Za-z0-9]+}/channels/search", api.APISessionRequired(searchChannelsInPolicy)).Methods(http.MethodPost)
api.BaseRoutes.User.Handle("/data_retention/team_policies", api.APISessionRequired(getTeamPoliciesForUser)).Methods(http.MethodGet)
api.BaseRoutes.User.Handle("/data_retention/channel_policies", api.APISessionRequired(getChannelPoliciesForUser)).Methods(http.MethodGet)
}
func getGlobalPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
// No permission check required.
policy, appErr := c.App.GetGlobalRetentionPolicy()
if appErr != nil {
c.Err = appErr
return
}
js, err := json.Marshal(policy)
if err != nil {
c.Err = model.NewAppError("getGlobalPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getPolicies(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleReadComplianceDataRetentionPolicy)
return
}
limit := c.Params.PerPage
offset := c.Params.Page * limit
policies, appErr := c.App.GetRetentionPolicies(offset, limit)
if appErr != nil {
c.Err = appErr
return
}
js, err := json.Marshal(policies)
if err != nil {
c.Err = model.NewAppError("getPolicies", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getPoliciesCount(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleReadComplianceDataRetentionPolicy)
return
}
count, appErr := c.App.GetRetentionPoliciesCount()
if appErr != nil {
c.Err = appErr
return
}
body := struct {
TotalCount int64 `json:"total_count"`
}{count}
err := json.NewEncoder(w).Encode(body)
if err != nil {
c.Logger.Warn("Error writing response", mlog.Err(err))
}
}
func getPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleReadComplianceDataRetentionPolicy)
return
}
c.RequirePolicyId()
policy, appErr := c.App.GetRetentionPolicy(c.Params.PolicyId)
if appErr != nil {
c.Err = appErr
return
}
js, err := json.Marshal(policy)
if err != nil {
c.Err = model.NewAppError("getPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func createPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
var policy model.RetentionPolicyWithTeamAndChannelIDs
if jsonErr := json.NewDecoder(r.Body).Decode(&policy); jsonErr != nil {
c.SetInvalidParamWithErr("policy", jsonErr)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventCreatePolicy, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "policy", &policy)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleWriteComplianceDataRetentionPolicy)
return
}
newPolicy, appErr := c.App.CreateRetentionPolicy(&policy)
if appErr != nil {
c.Err = appErr
return
}
auditRec.AddEventResultState(newPolicy)
auditRec.AddEventObjectType("policy")
js, err := json.Marshal(newPolicy)
if err != nil {
c.Err = model.NewAppError("createPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.Success()
w.WriteHeader(http.StatusCreated)
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func patchPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
var patch model.RetentionPolicyWithTeamAndChannelIDs
if jsonErr := json.NewDecoder(r.Body).Decode(&patch); jsonErr != nil {
c.SetInvalidParamWithErr("policy", jsonErr)
return
}
c.RequirePolicyId()
patch.ID = c.Params.PolicyId
auditRec := c.MakeAuditRecord(model.AuditEventPatchPolicy, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "patch", &patch)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleWriteComplianceDataRetentionPolicy)
return
}
policy, appErr := c.App.PatchRetentionPolicy(&patch)
if appErr != nil {
c.Err = appErr
return
}
auditRec.AddEventResultState(policy)
auditRec.AddEventObjectType("retention_policy")
js, err := json.Marshal(policy)
if err != nil {
c.Err = model.NewAppError("patchPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.Success()
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func deletePolicy(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequirePolicyId()
policyId := c.Params.PolicyId
auditRec := c.MakeAuditRecord(model.AuditEventDeletePolicy, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "policy_id", policyId)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleWriteComplianceDataRetentionPolicy)
return
}
err := c.App.DeleteRetentionPolicy(policyId)
if err != nil {
c.Err = err
return
}
auditRec.Success()
ReturnStatusOK(w)
}
func getTeamsForPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleReadComplianceDataRetentionPolicy)
return
}
c.RequirePolicyId()
policyId := c.Params.PolicyId
limit := c.Params.PerPage
offset := c.Params.Page * limit
teams, appErr := c.App.GetTeamsForRetentionPolicy(policyId, offset, limit)
if appErr != nil {
c.Err = appErr
return
}
c.App.SanitizeTeams(*c.AppContext.Session(), teams.Teams)
b, err := json.Marshal(teams)
if err != nil {
c.Err = model.NewAppError("Api4.getTeamsForPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(b); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func searchTeamsInPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequirePolicyId()
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleReadComplianceDataRetentionPolicy)
return
}
var props model.TeamSearch
if err := json.NewDecoder(r.Body).Decode(&props); err != nil {
c.SetInvalidParamWithErr("team_search", err)
return
}
props.PolicyID = new(c.Params.PolicyId)
props.IncludePolicyID = new(true)
teams, _, appErr := c.App.SearchAllTeams(&props)
if appErr != nil {
c.Err = appErr
return
}
c.App.SanitizeTeams(*c.AppContext.Session(), teams)
js, err := json.Marshal(teams)
if err != nil {
c.Err = model.NewAppError("searchTeamsInPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func addTeamsToPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequirePolicyId()
policyId := c.Params.PolicyId
teamIDs, err := model.SortedArrayFromJSON(r.Body)
if err != nil {
c.Err = model.NewAppError("addTeamsToPolicy", model.PayloadParseError, nil, "", http.StatusBadRequest).Wrap(err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventAddTeamsToPolicy, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "policy_id", policyId)
model.AddEventParameterToAuditRec(auditRec, "team_ids", teamIDs)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleWriteComplianceDataRetentionPolicy)
return
}
appErr := c.App.AddTeamsToRetentionPolicy(policyId, teamIDs)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
ReturnStatusOK(w)
}
func removeTeamsFromPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequirePolicyId()
policyId := c.Params.PolicyId
teamIDs, err := model.SortedArrayFromJSON(r.Body)
if err != nil {
c.Err = model.NewAppError("removeTeamsFromPolicy", model.PayloadParseError, nil, "", http.StatusBadRequest).Wrap(err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventRemoveTeamsFromPolicy, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "policy_id", policyId)
model.AddEventParameterToAuditRec(auditRec, "team_ids", teamIDs)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleWriteComplianceDataRetentionPolicy)
return
}
appErr := c.App.RemoveTeamsFromRetentionPolicy(policyId, teamIDs)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
ReturnStatusOK(w)
}
func getChannelsForPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleReadComplianceDataRetentionPolicy)
return
}
c.RequirePolicyId()
policyId := c.Params.PolicyId
limit := c.Params.PerPage
offset := c.Params.Page * limit
channels, appErr := c.App.GetChannelsForRetentionPolicy(policyId, offset, limit)
if appErr != nil {
c.Err = appErr
return
}
b, err := json.Marshal(channels)
if err != nil {
c.Err = model.NewAppError("Api4.getChannelsForPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(b); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func searchChannelsInPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequirePolicyId()
var props *model.ChannelSearch
err := json.NewDecoder(r.Body).Decode(&props)
if err != nil || props == nil {
c.SetInvalidParamWithErr("channel_search", err)
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleReadComplianceDataRetentionPolicy)
return
}
opts := model.ChannelSearchOpts{
PolicyID: c.Params.PolicyId,
IncludePolicyID: true,
Deleted: props.Deleted,
IncludeDeleted: props.IncludeDeleted,
Public: props.Public,
Private: props.Private,
TeamIds: props.TeamIds,
}
channels, _, appErr := c.App.SearchAllChannels(c.AppContext, props.Term, opts)
if appErr != nil {
c.Err = appErr
return
}
channelsJSON, jsonErr := json.Marshal(channels)
if jsonErr != nil {
c.Err = model.NewAppError("searchChannelsInPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
return
}
if _, err := w.Write(channelsJSON); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func addChannelsToPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequirePolicyId()
policyId := c.Params.PolicyId
channelIDs, err := model.SortedArrayFromJSON(r.Body)
if err != nil {
c.Err = model.NewAppError("addChannelsToPolicy", model.PayloadParseError, nil, "", http.StatusBadRequest).Wrap(err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventAddChannelsToPolicy, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "policy_id", policyId)
model.AddEventParameterToAuditRec(auditRec, "channel_ids", channelIDs)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleWriteComplianceDataRetentionPolicy)
return
}
appErr := c.App.AddChannelsToRetentionPolicy(policyId, channelIDs)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
ReturnStatusOK(w)
}
func removeChannelsFromPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequirePolicyId()
policyId := c.Params.PolicyId
channelIDs, err := model.SortedArrayFromJSON(r.Body)
if err != nil {
c.Err = model.NewAppError("removeChannelsFromPolicy", model.PayloadParseError, nil, "", http.StatusBadRequest).Wrap(err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventRemoveChannelsFromPolicy, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "policy_id", policyId)
model.AddEventParameterToAuditRec(auditRec, "channel_ids", channelIDs)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleWriteComplianceDataRetentionPolicy)
return
}
appErr := c.App.RemoveChannelsFromRetentionPolicy(policyId, channelIDs)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
ReturnStatusOK(w)
}
func getTeamPoliciesForUser(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId()
if c.Err != nil {
return
}
userID := c.Params.UserId
limit := c.Params.PerPage
offset := c.Params.Page * limit
if userID != c.AppContext.Session().UserId && !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
policies, err := c.App.GetTeamPoliciesForUser(userID, offset, limit)
if err != nil {
c.Err = err
return
}
js, jsonErr := json.Marshal(policies)
if jsonErr != nil {
c.Err = model.NewAppError("getTeamPoliciesForUser", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getChannelPoliciesForUser(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId()
if c.Err != nil {
return
}
userID := c.Params.UserId
limit := c.Params.PerPage
offset := c.Params.Page * limit
if userID != c.AppContext.Session().UserId && !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
policies, err := c.App.GetChannelPoliciesForUser(userID, offset, limit)
if err != nil {
c.Err = err
return
}
js, jsonErr := json.Marshal(policies)
if jsonErr != nil {
c.Err = model.NewAppError("getChannelPoliciesForUser", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,100 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitDeliveryTracking() {
if !api.srv.Config().FeatureFlags.PostDeliveryTracking {
return
}
api.BaseRoutes.DeliveryTracking.Handle("/config", api.APISessionRequired(getDeliveryTrackingConfig)).Methods(http.MethodGet)
api.BaseRoutes.DeliveryTracking.Handle("/config", api.APISessionRequired(updateDeliveryTrackingConfig)).Methods(http.MethodPut)
}
func requireDeliveryTrackingAvailable(c *Context) {
if !model.MinimumEnterpriseAdvancedLicense(c.App.License()) {
c.Err = model.NewAppError("requireDeliveryTrackingAvailable", "api.delivery_tracking.error.license", nil, "", http.StatusNotImplemented)
return
}
if !c.App.Config().FeatureFlags.PostDeliveryTracking {
c.Err = model.NewAppError("requireDeliveryTrackingAvailable", "api.delivery_tracking.error.feature_flag", nil, "", http.StatusNotImplemented)
return
}
}
func getDeliveryTrackingConfig(c *Context, w http.ResponseWriter, r *http.Request) {
requireDeliveryTrackingAvailable(c)
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
config, appErr := c.App.GetDeliveryTrackingConfig(c.AppContext)
if appErr != nil {
c.Err = appErr
return
}
// The response is already committed by this point, so an encoding failure can only be
// logged, not turned into an error status.
if err := json.NewEncoder(w).Encode(config); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func updateDeliveryTrackingConfig(c *Context, w http.ResponseWriter, r *http.Request) {
requireDeliveryTrackingAvailable(c)
if c.Err != nil {
return
}
var config model.DeliveryTrackingConfig
if err := json.NewDecoder(r.Body).Decode(&config); err != nil {
c.SetInvalidParamWithErr("config", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventUpdateDeliveryTrackingConfig, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
config.SetDefaults()
// Recorded before validation so rejected attempts are auditable too. SetDefaults
// guarantees both pointers are set.
model.AddEventParameterToAuditRec(auditRec, "enable", *config.Enable)
model.AddEventParameterToAuditRec(auditRec, "enable_for_all_channels", *config.EnableForAllChannels)
// Record the count rather than the ids so the audit record stays bounded.
model.AddEventParameterToAuditRec(auditRec, "channel_id_count", len(config.ChannelIds))
if appErr := config.IsValid(); appErr != nil {
c.Err = appErr
return
}
if appErr := c.App.SaveDeliveryTrackingConfig(c.AppContext, &config); appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
writeOKResponse(w)
}

View File

@@ -0,0 +1,233 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
// deliveryEventCapture routes the test server's audit logger to a temp file bound to the
// audit-delivery level so the REST handlers' delivery records can be asserted end to end.
type deliveryEventCapture struct {
t *testing.T
th *TestHelper
path string
}
func startDeliveryEventCapture(t *testing.T, th *TestHelper) *deliveryEventCapture {
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 &deliveryEventCapture{t: t, th: th, path: path}
}
// postIDs returns the post IDs recorded so far for the given mechanism.
func (c *deliveryEventCapture) postIDs(mechanism string) []string {
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) {
return nil
}
require.NoError(c.t, err)
var out []string
for _, line := range splitJSONLines(data) {
var rec map[string]any
require.NoError(c.t, json.Unmarshal(line, &rec))
if rec[model.AuditKeyEventName] != model.AuditEventPostDelivered {
continue
}
meta, ok := rec[model.AuditKeyMeta].(map[string]any)
require.True(c.t, ok)
if meta[model.PostDeliveryKeyMechanism] != mechanism {
continue
}
postID, _ := meta[model.PostDeliveryKeyPostID].(string)
out = append(out, postID)
}
return out
}
func splitJSONLines(data []byte) [][]byte {
var out [][]byte
start := 0
for i := range data {
if data[i] != '\n' {
continue
}
if i > start {
out = append(out, data[start:i])
}
start = i + 1
}
if start < len(data) {
out = append(out, data[start:])
}
return out
}
// enableDeliveryTrackingToggle turns the admin toggle on for all eligible channels. The
// feature flag cannot be set this way — FeatureFlags writes through UpdateConfig are discarded
// by the config store — so callers that need the flag must use SetupConfig.
func enableDeliveryTrackingToggle(th *TestHelper) {
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.DeliveryTrackingSettings.Enable = model.NewPointer(true)
cfg.DeliveryTrackingSettings.EnableForAllChannels = model.NewPointer(true)
})
}
// setupDeliveryEvents returns a helper with the admin toggle on but the feature flag off, for
// asserting that the flag alone gates emission.
func setupDeliveryEvents(t *testing.T) *TestHelper {
t.Helper()
th := Setup(t).InitBasic(t)
enableDeliveryTrackingToggle(th)
return th
}
func TestPostDeliveryEventsForRESTReads(t *testing.T) {
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.PostDeliveryTracking = true
}).InitBasic(t)
enableDeliveryTrackingToggle(th)
// BasicUser2 reads posts authored by BasicUser, so the self-delivery skip never applies.
post, _, err := th.Client.CreatePost(context.Background(), &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "tracked message",
})
require.NoError(t, err)
th.LoginBasic2(t)
client := th.Client
t.Run("getPostsForChannel", func(t *testing.T) {
capture := startDeliveryEventCapture(t, th)
_, _, err := client.GetPostsForChannel(context.Background(), th.BasicChannel.Id, 0, 60, "", false, false)
require.NoError(t, err)
require.Contains(t, capture.postIDs(model.DeliveryMechanismProduct), post.Id)
})
t.Run("getPost", func(t *testing.T) {
capture := startDeliveryEventCapture(t, th)
_, _, err := client.GetPost(context.Background(), post.Id, "")
require.NoError(t, err)
require.Equal(t, []string{post.Id}, capture.postIDs(model.DeliveryMechanismProduct))
})
t.Run("getPost with a matching etag records nothing", func(t *testing.T) {
fetched, resp, err := client.GetPost(context.Background(), post.Id, "")
require.NoError(t, err)
require.NotNil(t, fetched)
etag := resp.Etag
require.NotEmpty(t, etag)
capture := startDeliveryEventCapture(t, th)
_, _, err = client.GetPost(context.Background(), post.Id, etag)
require.NoError(t, err)
require.Empty(t, capture.postIDs(model.DeliveryMechanismProduct))
})
t.Run("getPostThread", func(t *testing.T) {
capture := startDeliveryEventCapture(t, th)
_, _, err := client.GetPostThread(context.Background(), post.Id, "", false)
require.NoError(t, err)
require.Contains(t, capture.postIDs(model.DeliveryMechanismProduct), post.Id)
})
t.Run("getPostsByIds", func(t *testing.T) {
capture := startDeliveryEventCapture(t, th)
_, _, err := client.GetPostsByIds(context.Background(), []string{post.Id})
require.NoError(t, err)
require.Equal(t, []string{post.Id}, capture.postIDs(model.DeliveryMechanismProduct))
})
t.Run("getPinnedPosts", func(t *testing.T) {
th.LoginBasic(t)
_, err := th.Client.PinPost(context.Background(), post.Id)
require.NoError(t, err)
th.LoginBasic2(t)
capture := startDeliveryEventCapture(t, th)
_, _, err = client.GetPinnedPosts(context.Background(), th.BasicChannel.Id, "")
require.NoError(t, err)
require.Contains(t, capture.postIDs(model.DeliveryMechanismProduct), post.Id)
})
t.Run("records nothing for a DM", func(t *testing.T) {
dm, _, err := client.CreateDirectChannel(context.Background(), th.BasicUser.Id, th.BasicUser2.Id)
require.NoError(t, err)
th.LoginBasic(t)
dmPost, _, err := th.Client.CreatePost(context.Background(), &model.Post{
ChannelId: dm.Id,
Message: "dm message",
})
require.NoError(t, err)
th.LoginBasic2(t)
capture := startDeliveryEventCapture(t, th)
_, _, err = client.GetPost(context.Background(), dmPost.Id, "")
require.NoError(t, err)
require.Empty(t, capture.postIDs(model.DeliveryMechanismProduct))
})
}
func TestPostDeliveryEventsDisabled(t *testing.T) {
th := setupDeliveryEvents(t)
post, _, err := th.Client.CreatePost(context.Background(), &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "tracked message",
})
require.NoError(t, err)
th.LoginBasic2(t)
// The feature flag is off in this helper, so nothing should be recorded even with the
// admin toggle on.
require.False(t, th.App.Config().FeatureFlags.PostDeliveryTracking)
capture := startDeliveryEventCapture(t, th)
_, _, err = th.Client.GetPost(context.Background(), post.Id, "")
require.NoError(t, err)
require.Empty(t, capture.postIDs(model.DeliveryMechanismProduct))
}

View File

@@ -0,0 +1,220 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
// deliveryTrackingFlagOn turns the PostDeliveryTracking feature flag on. It must be passed
// to SetupConfig rather than applied later: InitDeliveryTracking only registers the routes
// when the flag is already on at boot.
func deliveryTrackingFlagOn(cfg *model.Config) {
cfg.FeatureFlags.PostDeliveryTracking = true
}
// licenseDeliveryTracking adds the Enterprise Advanced license the endpoints require.
func licenseDeliveryTracking(t *testing.T, th *TestHelper) {
t.Helper()
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
}
func TestDeliveryTrackingRoutesNotRegisteredWhenFlagOff(t *testing.T) {
// The flag defaults to false, so a plain Setup boots with the routes unregistered.
th := Setup(t).InitBasic(t)
licenseDeliveryTracking(t, th)
defer th.RemoveLicense(t)
_, resp, err := th.SystemAdminClient.GetDeliveryTrackingConfig(context.Background())
require.Error(t, err)
require.Equal(t, http.StatusNotFound, resp.StatusCode)
resp, err = th.SystemAdminClient.UpdateDeliveryTrackingConfig(context.Background(), &model.DeliveryTrackingConfig{
DeliveryTrackingSettings: model.DeliveryTrackingSettings{Enable: new(true), EnableForAllChannels: new(true)},
})
require.Error(t, err)
require.Equal(t, http.StatusNotFound, resp.StatusCode)
}
func TestGetDeliveryTrackingConfig(t *testing.T) {
th := SetupConfig(t, deliveryTrackingFlagOn).InitBasic(t)
t.Run("returns 501 without an Enterprise Advanced license", func(t *testing.T) {
th.RemoveLicense(t)
_, resp, err := th.SystemAdminClient.GetDeliveryTrackingConfig(context.Background())
require.Error(t, err)
CheckErrorID(t, err, "api.delivery_tracking.error.license")
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
})
t.Run("returns 501 when the feature flag is turned off at runtime", func(t *testing.T) {
licenseDeliveryTracking(t, th)
defer th.RemoveLicense(t)
// Routes stay registered until the next restart, so the handler must reject.
updateTestFeatureFlags(t, th, func(cfg *model.Config) {
cfg.FeatureFlags.PostDeliveryTracking = false
})
defer updateTestFeatureFlags(t, th, deliveryTrackingFlagOn)
_, resp, err := th.SystemAdminClient.GetDeliveryTrackingConfig(context.Background())
require.Error(t, err)
CheckErrorID(t, err, "api.delivery_tracking.error.feature_flag")
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
})
t.Run("returns 403 for a non-admin", func(t *testing.T) {
licenseDeliveryTracking(t, th)
defer th.RemoveLicense(t)
_, resp, err := th.Client.GetDeliveryTrackingConfig(context.Background())
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("returns the configuration for an admin", func(t *testing.T) {
licenseDeliveryTracking(t, th)
defer th.RemoveLicense(t)
config, _, err := th.SystemAdminClient.GetDeliveryTrackingConfig(context.Background())
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.Enable)
require.NotNil(t, config.EnableForAllChannels)
require.NotNil(t, config.ChannelIds)
})
}
func TestUpdateDeliveryTrackingConfig(t *testing.T) {
th := SetupConfig(t, deliveryTrackingFlagOn).InitBasic(t)
t.Run("returns 501 without an Enterprise Advanced license", func(t *testing.T) {
th.RemoveLicense(t)
resp, err := th.SystemAdminClient.UpdateDeliveryTrackingConfig(context.Background(), &model.DeliveryTrackingConfig{
DeliveryTrackingSettings: model.DeliveryTrackingSettings{Enable: new(true), EnableForAllChannels: new(true)},
})
require.Error(t, err)
CheckErrorID(t, err, "api.delivery_tracking.error.license")
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
})
t.Run("returns 501 when the feature flag is turned off at runtime", func(t *testing.T) {
licenseDeliveryTracking(t, th)
defer th.RemoveLicense(t)
updateTestFeatureFlags(t, th, func(cfg *model.Config) {
cfg.FeatureFlags.PostDeliveryTracking = false
})
defer updateTestFeatureFlags(t, th, deliveryTrackingFlagOn)
resp, err := th.SystemAdminClient.UpdateDeliveryTrackingConfig(context.Background(), &model.DeliveryTrackingConfig{
DeliveryTrackingSettings: model.DeliveryTrackingSettings{Enable: new(true), EnableForAllChannels: new(true)},
})
require.Error(t, err)
CheckErrorID(t, err, "api.delivery_tracking.error.feature_flag")
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
})
t.Run("returns 403 for a non-admin", func(t *testing.T) {
licenseDeliveryTracking(t, th)
defer th.RemoveLicense(t)
resp, err := th.Client.UpdateDeliveryTrackingConfig(context.Background(), &model.DeliveryTrackingConfig{
DeliveryTrackingSettings: model.DeliveryTrackingSettings{Enable: new(true), EnableForAllChannels: new(true)},
})
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("round trips a save and a read", func(t *testing.T) {
licenseDeliveryTracking(t, th)
defer th.RemoveLicense(t)
channel := th.CreatePublicChannel(t)
_, err := th.SystemAdminClient.UpdateDeliveryTrackingConfig(context.Background(), &model.DeliveryTrackingConfig{
DeliveryTrackingSettings: model.DeliveryTrackingSettings{Enable: new(true), EnableForAllChannels: new(false)},
ChannelIds: []string{channel.Id},
})
require.NoError(t, err)
config, _, err := th.SystemAdminClient.GetDeliveryTrackingConfig(context.Background())
require.NoError(t, err)
assert.True(t, *config.Enable)
assert.False(t, *config.EnableForAllChannels)
assert.Equal(t, []string{channel.Id}, config.ChannelIds)
})
t.Run("rejects selected channels with an empty list", func(t *testing.T) {
licenseDeliveryTracking(t, th)
defer th.RemoveLicense(t)
resp, err := th.SystemAdminClient.UpdateDeliveryTrackingConfig(context.Background(), &model.DeliveryTrackingConfig{
DeliveryTrackingSettings: model.DeliveryTrackingSettings{Enable: new(true), EnableForAllChannels: new(false)},
ChannelIds: []string{},
})
require.Error(t, err)
CheckErrorID(t, err, "model.delivery_tracking.is_valid.all_channels.app_error")
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("rejects a DM channel", func(t *testing.T) {
licenseDeliveryTracking(t, th)
defer th.RemoveLicense(t)
dmChannel, _, err := th.Client.CreateDirectChannel(context.Background(), th.BasicUser.Id, th.BasicUser2.Id)
require.NoError(t, err)
resp, err := th.SystemAdminClient.UpdateDeliveryTrackingConfig(context.Background(), &model.DeliveryTrackingConfig{
DeliveryTrackingSettings: model.DeliveryTrackingSettings{Enable: new(true), EnableForAllChannels: new(false)},
ChannelIds: []string{dmChannel.Id},
})
require.Error(t, err)
CheckErrorID(t, err, "app.delivery_tracking.dm_gm_not_allowed.app_error")
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("rejects a malformed channel id", func(t *testing.T) {
licenseDeliveryTracking(t, th)
defer th.RemoveLicense(t)
resp, err := th.SystemAdminClient.UpdateDeliveryTrackingConfig(context.Background(), &model.DeliveryTrackingConfig{
DeliveryTrackingSettings: model.DeliveryTrackingSettings{Enable: new(true), EnableForAllChannels: new(false)},
ChannelIds: []string{"not-a-valid-id"},
})
require.Error(t, err)
CheckErrorID(t, err, "model.delivery_tracking.is_valid.channel_id.app_error")
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("works with content flagging disabled", func(t *testing.T) {
licenseDeliveryTracking(t, th)
defer th.RemoveLicense(t)
// Post delivery audit logging is governed only by its own toggle and channel list,
// so it must not depend on the content flagging master switch.
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ContentFlaggingSettings.EnableContentFlagging = new(false)
})
_, err := th.SystemAdminClient.UpdateDeliveryTrackingConfig(context.Background(), &model.DeliveryTrackingConfig{
DeliveryTrackingSettings: model.DeliveryTrackingSettings{Enable: new(true), EnableForAllChannels: new(true)},
})
require.NoError(t, err)
config, _, err := th.SystemAdminClient.GetDeliveryTrackingConfig(context.Background())
require.NoError(t, err)
assert.True(t, *config.Enable)
})
}

View File

@@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/*
Package api4 implements the HTTP REST API layer for Mattermost server.
This package provides the primary interface between client applications
(web, mobile, desktop) and the Mattermost server backend. It exposes
HTTP endpoints that follow REST conventions for managing users, teams,
channels, posts, and other Mattermost resources.
# Architecture
The API is structured around resource-based endpoints under the /api/v4/ path.
Each endpoint is handled by specific handler functions that provide different
levels of authentication and authorization:
- APIHandler: Public endpoints requiring no authentication
- APISessionRequired: Endpoints requiring authenticated user sessions
- APISessionRequiredTrustRequester: Authenticated endpoints for trusted requests
- CloudAPIKeyRequired: Cloud installation webhook endpoints
- RemoteClusterTokenRequired: Remote cluster communication endpoints
- APILocal: Local mode access via UNIX socket
# Key Responsibilities
- Input validation: Validate request parameters and body content
- Permission checks: Verify user has required permissions for the operation
- HTTP handling: Parse requests, format responses, set appropriate status codes
- Error formatting: Convert app layer errors to appropriate HTTP responses
- Audit logging: Log security-relevant operations
# Error Handling
The API uses consistent error responses with appropriate HTTP status codes.
All handlers use the Context object for standardized error reporting and
audit logging. Errors are returned in a structured JSON format with
error codes, messages, and additional context when available.
# Security
Security is implemented through multiple layers:
- Authentication via sessions, tokens, or API keys
- Role-based access control and permission checking
- CSRF protection through request validation
- Rate limiting to prevent abuse
- Multi-factor authentication support
- Secure session management
The api4 package serves as the HTTP interface layer in Mattermost's
layered architecture, providing a stable, versioned API for client
applications while maintaining clear separation from business logic
and data persistence concerns.
*/
package api4

View File

@@ -0,0 +1,144 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitDrafts() {
api.BaseRoutes.Drafts.Handle("", api.APISessionRequired(upsertDraft)).Methods(http.MethodPost)
api.BaseRoutes.TeamForUser.Handle("/drafts", api.APISessionRequired(getDrafts)).Methods(http.MethodGet)
api.BaseRoutes.ChannelForUser.Handle("/drafts/{thread_id:[A-Za-z0-9]+}", api.APISessionRequired(deleteDraft)).Methods(http.MethodDelete)
api.BaseRoutes.ChannelForUser.Handle("/drafts", api.APISessionRequired(deleteDraft)).Methods(http.MethodDelete)
}
func upsertDraft(c *Context, w http.ResponseWriter, r *http.Request) {
if !*c.App.Config().ServiceSettings.AllowSyncedDrafts {
c.Err = model.NewAppError("upsertDraft", "api.drafts.disabled.app_error", nil, "", http.StatusNotImplemented)
return
}
var draft model.Draft
if jsonErr := json.NewDecoder(r.Body).Decode(&draft); jsonErr != nil {
c.SetInvalidParam("draft")
return
}
draft.DeleteAt = 0
draft.UserId = c.AppContext.Session().UserId
connectionID := r.Header.Get(model.ConnectionId)
hasPermission := false
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), draft.ChannelId, model.PermissionCreatePost); ok {
hasPermission = true
} else if channel, err := c.App.GetChannel(c.AppContext, draft.ChannelId); err == nil {
// Temporary permission check method until advanced permissions, please do not copy
if channel.Type == model.ChannelTypeOpen && c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionCreatePostPublic) {
hasPermission = true
}
}
if !hasPermission {
c.SetPermissionError(model.PermissionCreatePost)
return
}
dt, err := c.App.UpsertDraft(c.AppContext, &draft, connectionID)
if err != nil {
c.Err = err
return
}
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(dt); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getDrafts(c *Context, w http.ResponseWriter, r *http.Request) {
if c.Err != nil {
return
}
if !*c.App.Config().ServiceSettings.AllowSyncedDrafts {
c.Err = model.NewAppError("getDrafts", "api.drafts.disabled.app_error", nil, "", http.StatusNotImplemented)
return
}
hasPermission := false
if c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) {
hasPermission = true
}
if !hasPermission {
c.SetPermissionError(model.PermissionCreatePost)
return
}
drafts, err := c.App.GetDraftsForUser(c.AppContext, c.AppContext.Session().UserId, c.Params.TeamId)
if err != nil {
c.Err = err
return
}
if err := json.NewEncoder(w).Encode(drafts); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func deleteDraft(c *Context, w http.ResponseWriter, r *http.Request) {
if c.Err != nil {
return
}
if !*c.App.Config().ServiceSettings.AllowSyncedDrafts {
c.Err = model.NewAppError("deleteDraft", "api.drafts.disabled.app_error", nil, "", http.StatusNotImplemented)
return
}
rootID := ""
connectionID := r.Header.Get(model.ConnectionId)
if c.Params.ThreadId != "" {
rootID = c.Params.ThreadId
}
userID := c.AppContext.Session().UserId
channelID := c.Params.ChannelId
draft, err := c.App.GetDraft(userID, channelID, rootID)
if err != nil {
switch {
case err.StatusCode == http.StatusNotFound:
// If the draft doesn't exist in the server, we don't need to delete.
ReturnStatusOK(w)
default:
c.Err = err
}
return
}
if c.AppContext.Session().UserId != draft.UserId {
c.SetPermissionError(model.PermissionDeletePost)
return
}
if err := c.App.DeleteDraft(c.AppContext, draft, connectionID); err != nil {
c.Err = err
return
}
ReturnStatusOK(w)
}

View File

@@ -0,0 +1,211 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"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 TestUpsertDraft(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
// set config
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true })
client := th.Client
channel := th.BasicChannel
user := th.BasicUser
draft := &model.Draft{
CreateAt: 12345,
UpdateAt: 12345,
UserId: user.Id,
ChannelId: channel.Id,
Message: "original",
}
// try to upsert draft
draftResp, _, err := client.UpsertDraft(context.Background(), draft)
require.NoError(t, err)
assert.Equal(t, draft.UserId, draftResp.UserId)
assert.Equal(t, draft.Message, draftResp.Message)
assert.Equal(t, draft.ChannelId, draftResp.ChannelId)
// upload file
sent, err := testutils.ReadTestFile("test.png")
require.NoError(t, err)
fileResp, _, err := client.UploadFile(context.Background(), sent, channel.Id, "test.png")
require.NoError(t, err)
draftWithFiles := draft
draftWithFiles.FileIds = []string{fileResp.FileInfos[0].Id}
// try to upsert draft with file
draftResp, _, err = client.UpsertDraft(context.Background(), draftWithFiles)
require.NoError(t, err)
assert.Equal(t, draftWithFiles.UserId, draftResp.UserId)
assert.Equal(t, draftWithFiles.Message, draftResp.Message)
assert.Equal(t, draftWithFiles.ChannelId, draftResp.ChannelId)
assert.ElementsMatch(t, draftWithFiles.FileIds, draftResp.FileIds)
// try to upsert draft for invalid channel
draftInvalidChannel := draft
draftInvalidChannel.ChannelId = "12345"
_, resp, err := client.UpsertDraft(context.Background(), draft)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
// try to upsert draft without config setting set to true
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false })
_, resp, err = client.UpsertDraft(context.Background(), draft)
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
}
func TestGetDrafts(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true })
client := th.Client
channel1 := th.BasicChannel
channel2 := th.BasicChannel2
user := th.BasicUser
team := th.BasicTeam
draft1 := &model.Draft{
CreateAt: 00001,
UserId: user.Id,
ChannelId: channel1.Id,
Message: "draft1",
}
draft2 := &model.Draft{
CreateAt: 11111,
UserId: user.Id,
ChannelId: channel2.Id,
Message: "draft2",
}
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true })
// upsert draft1
_, _, err := client.UpsertDraft(context.Background(), draft1)
require.NoError(t, err)
// Wait a bit so the second draft gets a newer UpdateAt
time.Sleep(100 * time.Millisecond)
// upsert draft2
_, _, err = client.UpsertDraft(context.Background(), draft2)
require.NoError(t, err)
// try to get drafts
draftResp, _, err := client.GetDrafts(context.Background(), user.Id, team.Id)
require.NoError(t, err)
assert.Equal(t, draft2.UserId, draftResp[0].UserId)
assert.Equal(t, draft2.Message, draftResp[0].Message)
assert.Equal(t, draft2.ChannelId, draftResp[0].ChannelId)
assert.Equal(t, draft1.UserId, draftResp[1].UserId)
assert.Equal(t, draft1.Message, draftResp[1].Message)
assert.Equal(t, draft1.ChannelId, draftResp[1].ChannelId)
assert.Len(t, draftResp, 2)
// try to get drafts on invalid team
_, resp, err := client.GetDrafts(context.Background(), user.Id, "12345")
require.Error(t, err)
CheckForbiddenStatus(t, resp)
// try to get drafts when config is turned off
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false })
_, resp, err = client.GetDrafts(context.Background(), user.Id, team.Id)
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
}
func TestDeleteDraft(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true })
client := th.Client
channel1 := th.BasicChannel
channel2 := th.BasicChannel2
user := th.BasicUser
team := th.BasicTeam
draft1 := &model.Draft{
CreateAt: 00001,
UserId: user.Id,
ChannelId: channel1.Id,
Message: "draft1",
RootId: "",
}
draft2 := &model.Draft{
CreateAt: 11111,
UserId: user.Id,
ChannelId: channel2.Id,
Message: "draft2",
RootId: model.NewId(),
}
// upsert draft1
_, _, err := client.UpsertDraft(context.Background(), draft1)
require.NoError(t, err)
// Wait a bit so the second draft gets a newer UpdateAt
time.Sleep(100 * time.Millisecond)
// upsert draft2
_, _, err = client.UpsertDraft(context.Background(), draft2)
require.NoError(t, err)
// get drafts
draftResp, _, err := client.GetDrafts(context.Background(), user.Id, team.Id)
require.NoError(t, err)
assert.Equal(t, draft2.UserId, draftResp[0].UserId)
assert.Equal(t, draft2.Message, draftResp[0].Message)
assert.Equal(t, draft2.ChannelId, draftResp[0].ChannelId)
assert.Equal(t, draft1.UserId, draftResp[1].UserId)
assert.Equal(t, draft1.Message, draftResp[1].Message)
assert.Equal(t, draft1.ChannelId, draftResp[1].ChannelId)
// try to delete draft1
_, _, err = client.DeleteDraft(context.Background(), user.Id, channel1.Id, draft1.RootId)
require.NoError(t, err)
// get drafts
draftResp, _, err = client.GetDrafts(context.Background(), user.Id, team.Id)
require.NoError(t, err)
assert.Equal(t, draft2.UserId, draftResp[0].UserId)
assert.Equal(t, draft2.Message, draftResp[0].Message)
assert.Equal(t, draft2.ChannelId, draftResp[0].ChannelId)
assert.Len(t, draftResp, 1)
}

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitElasticsearch() {
api.BaseRoutes.Elasticsearch.Handle("/test", api.APISessionRequired(testElasticsearch)).Methods(http.MethodPost)
api.BaseRoutes.Elasticsearch.Handle("/purge_indexes", api.APISessionRequired(purgeElasticsearchIndexes)).Methods(http.MethodPost)
}
func testElasticsearch(c *Context, w http.ResponseWriter, r *http.Request) {
var cfg *model.Config
err := json.NewDecoder(r.Body).Decode(&cfg)
if err != nil {
c.Logger.Warn("Error decoding config.", mlog.Err(err))
}
if cfg == nil {
cfg = c.App.Config()
}
if checkHasNilFields(&cfg.ElasticsearchSettings) {
c.Err = model.NewAppError("testElasticsearch", "api.elasticsearch.test_elasticsearch_settings_nil.app_error", nil, "", http.StatusBadRequest)
return
}
// PERMISSION_TEST_ELASTICSEARCH is an ancillary permission of PERMISSION_SYSCONSOLE_WRITE_ENVIRONMENT_ELASTICSEARCH,
// which should prevent read-only managers from password sniffing
if !c.App.SessionHasPermissionToAndNotRestrictedAdmin(*c.AppContext.Session(), model.PermissionTestElasticsearch) {
c.SetPermissionError(model.PermissionTestElasticsearch)
return
}
if err := c.App.TestElasticsearch(c.AppContext, cfg); err != nil {
c.Err = err
return
}
ReturnStatusOK(w)
}
func purgeElasticsearchIndexes(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord(model.AuditEventPurgeElasticsearchIndexes, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
if !c.App.SessionHasPermissionToAndNotRestrictedAdmin(*c.AppContext.Session(), model.PermissionPurgeElasticsearchIndexes) {
c.SetPermissionError(model.PermissionPurgeElasticsearchIndexes)
return
}
specifiedIndexesQuery := r.URL.Query()["index"]
if err := c.App.PurgeElasticsearchIndexes(c.AppContext, specifiedIndexesQuery); err != nil {
c.Err = err
return
}
auditRec.Success()
ReturnStatusOK(w)
}

View File

@@ -0,0 +1,81 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestElasticsearchTest(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
t.Run("as system user", func(t *testing.T) {
resp, err := th.Client.TestElasticsearch(context.Background())
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("as system admin", func(t *testing.T) {
resp, err := th.SystemAdminClient.TestElasticsearch(context.Background())
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
})
t.Run("invalid config", func(t *testing.T) {
cfg := &model.Config{}
cfg.SetDefaults()
cfg.ElasticsearchSettings.Password = nil
data, err := json.Marshal(cfg)
require.NoError(t, err)
resp, err := th.SystemAdminClient.DoAPIPost(context.Background(), "/elasticsearch/test", string(data))
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("as restricted system admin", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ElasticsearchSettings.SetDefaults()
*cfg.ExperimentalSettings.RestrictSystemAdmin = true
})
resp, err := th.SystemAdminClient.TestElasticsearch(context.Background())
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
}
func TestElasticsearchPurgeIndexes(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
t.Run("as system user", func(t *testing.T) {
resp, err := th.Client.PurgeElasticsearchIndexes(context.Background())
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("as system admin", func(t *testing.T) {
resp, err := th.SystemAdminClient.PurgeElasticsearchIndexes(context.Background())
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
})
t.Run("as restricted system admin", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = true })
resp, err := th.SystemAdminClient.PurgeElasticsearchIndexes(context.Background())
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
}

View File

@@ -0,0 +1,348 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"io"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/web"
)
const (
EmojiMaxAutocompleteItems = 100
GetEmojisByNamesMax = 200
)
func (api *API) InitEmoji() {
api.BaseRoutes.Emojis.Handle("", api.APISessionRequired(createEmoji, handlerParamFileAPI)).Methods(http.MethodPost)
api.BaseRoutes.Emojis.Handle("", api.APISessionRequired(getEmojiList)).Methods(http.MethodGet)
api.BaseRoutes.Emojis.Handle("/names", api.APISessionRequired(getEmojisByNames)).Methods(http.MethodPost)
api.BaseRoutes.Emojis.Handle("/search", api.APISessionRequired(searchEmojis)).Methods(http.MethodPost)
api.BaseRoutes.Emojis.Handle("/autocomplete", api.APISessionRequired(autocompleteEmojis)).Methods(http.MethodGet)
api.BaseRoutes.Emoji.Handle("", api.APISessionRequired(deleteEmoji)).Methods(http.MethodDelete)
api.BaseRoutes.Emoji.Handle("", api.APISessionRequired(getEmoji)).Methods(http.MethodGet)
api.BaseRoutes.EmojiByName.Handle("", api.APISessionRequired(getEmojiByName)).Methods(http.MethodGet)
api.BaseRoutes.Emoji.Handle("/image", api.APISessionRequiredTrustRequester(getEmojiImage)).Methods(http.MethodGet)
}
func createEmoji(c *Context, w http.ResponseWriter, r *http.Request) {
defer func() {
if _, err := io.Copy(io.Discard, r.Body); err != nil {
c.Logger.Warn("Error while discarding request body", mlog.Err(err))
}
}()
if !*c.App.Config().ServiceSettings.EnableCustomEmoji {
c.Err = model.NewAppError("createEmoji", "api.emoji.disabled.app_error", nil, "", http.StatusNotImplemented)
return
}
if r.ContentLength > app.MaxEmojiFileSize {
c.Err = model.NewAppError("createEmoji", "api.emoji.create.too_large.app_error", nil, "", http.StatusRequestEntityTooLarge)
return
}
r.Body = http.MaxBytesReader(w, r.Body, app.MaxEmojiFileSize)
if err := r.ParseMultipartForm(app.MaxEmojiFileSize); err != nil {
c.Err = model.NewAppError("createEmoji", "api.emoji.create.parse.app_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
if imageFiles := r.MultipartForm.File["image"]; len(imageFiles) > 0 && imageFiles[0].Size > app.MaxEmojiFileSize {
c.Err = model.NewAppError("createEmoji", "api.emoji.create.too_large.app_error", nil, "", http.StatusRequestEntityTooLarge)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventCreateEmoji, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
// Allow any user with CREATE_EMOJIS permission at Team level to create emojis at system level
memberships, err := c.App.GetTeamMembersForUser(c.AppContext, c.AppContext.Session().UserId, "", true)
if err != nil {
c.Err = err
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionCreateEmojis) {
hasPermission := false
for _, membership := range memberships {
if c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), membership.TeamId, model.PermissionCreateEmojis) {
hasPermission = true
break
}
}
if !hasPermission {
c.SetPermissionError(model.PermissionCreateEmojis)
return
}
}
m := r.MultipartForm
props := m.Value
if len(props["emoji"]) == 0 {
c.SetInvalidParam("emoji")
return
}
var emoji model.Emoji
if jsonErr := json.Unmarshal([]byte(props["emoji"][0]), &emoji); jsonErr != nil {
c.SetInvalidParam("emoji")
return
}
auditRec.AddEventResultState(&emoji)
auditRec.AddEventObjectType("emoji")
newEmoji, err := c.App.CreateEmoji(c.AppContext, c.AppContext.Session().UserId, &emoji, m)
if err != nil {
c.Err = err
return
}
auditRec.Success()
if err := json.NewEncoder(w).Encode(newEmoji); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getEmojiList(c *Context, w http.ResponseWriter, r *http.Request) {
if !*c.App.Config().ServiceSettings.EnableCustomEmoji {
c.Err = model.NewAppError("getEmoji", "api.emoji.disabled.app_error", nil, "", http.StatusNotImplemented)
return
}
sort := r.URL.Query().Get("sort")
if sort != "" && sort != model.EmojiSortByName {
c.SetInvalidURLParam("sort")
return
}
listEmoji, err := c.App.GetEmojiList(c.AppContext, c.Params.Page, c.Params.PerPage, sort)
if err != nil {
c.Err = err
return
}
if err := json.NewEncoder(w).Encode(listEmoji); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func deleteEmoji(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireEmojiId()
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventDeleteEmoji, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
emoji, err := c.App.GetEmoji(c.AppContext, c.Params.EmojiId)
if err != nil {
model.AddEventParameterToAuditRec(auditRec, "emoji_id", c.Params.EmojiId)
c.Err = err
return
}
auditRec.AddEventPriorState(emoji)
auditRec.AddEventObjectType("emoji")
// Allow any user with DELETE_EMOJIS permission at Team level to delete emojis at system level
memberships, err := c.App.GetTeamMembersForUser(c.AppContext, c.AppContext.Session().UserId, "", true)
if err != nil {
c.Err = err
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionDeleteEmojis) {
hasPermission := false
for _, membership := range memberships {
if c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), membership.TeamId, model.PermissionDeleteEmojis) {
hasPermission = true
break
}
}
if !hasPermission {
c.SetPermissionError(model.PermissionDeleteEmojis)
return
}
}
if c.AppContext.Session().UserId != emoji.CreatorId {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionDeleteOthersEmojis) {
hasPermission := false
for _, membership := range memberships {
if c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), membership.TeamId, model.PermissionDeleteOthersEmojis) {
hasPermission = true
break
}
}
if !hasPermission {
c.SetPermissionError(model.PermissionDeleteOthersEmojis)
return
}
}
}
err = c.App.DeleteEmoji(c.AppContext, emoji)
if err != nil {
c.Err = err
return
}
auditRec.Success()
ReturnStatusOK(w)
}
func getEmoji(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireEmojiId()
if c.Err != nil {
return
}
if !*c.App.Config().ServiceSettings.EnableCustomEmoji {
c.Err = model.NewAppError("getEmoji", "api.emoji.disabled.app_error", nil, "", http.StatusNotImplemented)
return
}
emoji, err := c.App.GetEmoji(c.AppContext, c.Params.EmojiId)
if err != nil {
c.Err = err
return
}
if err := json.NewEncoder(w).Encode(emoji); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getEmojiByName(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireEmojiName()
if c.Err != nil {
return
}
if !*c.App.Config().ServiceSettings.EnableCustomEmoji {
c.Err = model.NewAppError("getEmojiByName", "api.emoji.disabled.app_error", nil, "", http.StatusNotImplemented)
return
}
emoji, err := c.App.GetEmojiByName(c.AppContext, c.Params.EmojiName)
if err != nil {
c.Err = err
return
}
if err := json.NewEncoder(w).Encode(emoji); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getEmojisByNames(c *Context, w http.ResponseWriter, r *http.Request) {
names, err := model.SortedArrayFromJSON(r.Body)
if err != nil {
c.Err = model.NewAppError("getEmojisByNames", model.PayloadParseError, nil, "", http.StatusBadRequest).Wrap(err)
return
} else if len(names) == 0 {
c.SetInvalidParam("names")
return
}
if !*c.App.Config().ServiceSettings.EnableCustomEmoji {
c.Err = model.NewAppError("getEmojisByNames", "api.emoji.disabled.app_error", nil, "", http.StatusNotImplemented)
return
}
if len(names) > GetEmojisByNamesMax {
c.Err = model.NewAppError("getEmojisByNames", "api.emoji.get_multiple_by_name_too_many.request_error", map[string]any{
"MaxNames": GetEmojisByNamesMax,
}, "", http.StatusBadRequest)
return
}
emojis, appErr := c.App.GetMultipleEmojiByName(c.AppContext, names)
if appErr != nil {
c.Err = appErr
return
}
if err := json.NewEncoder(w).Encode(emojis); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getEmojiImage(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireEmojiId()
if c.Err != nil {
return
}
if !*c.App.Config().ServiceSettings.EnableCustomEmoji {
c.Err = model.NewAppError("getEmojiImage", "api.emoji.disabled.app_error", nil, "", http.StatusNotImplemented)
return
}
image, imageType, err := c.App.GetEmojiImage(c.AppContext, c.Params.EmojiId)
if err != nil {
c.Err = err
return
}
w.Header().Set("Content-Type", "image/"+imageType)
w.Header().Set("Cache-Control", "max-age=2592000, private")
if _, err := w.Write(image); err != nil {
c.Logger.Warn("Error while writing image response", mlog.Err(err))
}
}
func searchEmojis(c *Context, w http.ResponseWriter, r *http.Request) {
var emojiSearch model.EmojiSearch
if jsonErr := json.NewDecoder(r.Body).Decode(&emojiSearch); jsonErr != nil {
c.SetInvalidParamWithErr("term", jsonErr)
return
}
if emojiSearch.Term == "" {
c.SetInvalidParam("term")
return
}
emojis, err := c.App.SearchEmoji(c.AppContext, emojiSearch.Term, emojiSearch.PrefixOnly, web.PerPageMaximum)
if err != nil {
c.Err = err
return
}
if err := json.NewEncoder(w).Encode(emojis); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func autocompleteEmojis(c *Context, w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
c.SetInvalidURLParam("name")
return
}
emojis, err := c.App.SearchEmoji(c.AppContext, name, true, EmojiMaxAutocompleteItems)
if err != nil {
c.Err = err
return
}
if err := json.NewEncoder(w).Encode(emojis); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

View File

@@ -0,0 +1,915 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"bytes"
"context"
"image"
_ "image/gif"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
)
func TestCreateEmoji(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
client := th.Client
EnableCustomEmoji := *th.App.Config().ServiceSettings.EnableCustomEmoji
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = EnableCustomEmoji })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = false })
defaultRolePermissions := th.SaveDefaultRolePermissions(t)
defer func() {
th.RestoreDefaultRolePermissions(t, defaultRolePermissions)
}()
// constants to be used along with checkEmojiFile
emojiWidth := app.MaxEmojiWidth
emojiHeight := app.MaxEmojiHeight * 2
// check that emoji gets resized correctly, respecting proportions, and is of expected type
checkEmojiFile := func(id, expectedImageType string) {
path := *th.App.Config().FileSettings.Directory
file, fileErr := os.Open(filepath.Join(path, "/emoji/"+id+"/image"))
require.NoError(t, fileErr)
defer file.Close()
config, imageType, err := image.DecodeConfig(file)
require.NoError(t, err)
require.Equal(t, expectedImageType, imageType)
require.Equal(t, emojiWidth/2, config.Width)
require.Equal(t, emojiHeight/2, config.Height)
}
emoji := &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
// try to create an emoji when they're disabled
_, resp, err := client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
// enable emoji creation for next cases
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = true })
// try to create a valid gif emoji when they're enabled
newEmoji, _, err := client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, emojiWidth, emojiHeight), "image.gif")
require.NoError(t, err)
require.Equal(t, newEmoji.Name, emoji.Name, "create with wrong name")
checkEmojiFile(newEmoji.Id, "gif")
// try to create an emoji with a duplicate name
emoji2 := &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: newEmoji.Name,
}
_, resp, err = client.CreateEmoji(context.Background(), emoji2, utils.CreateTestGif(t, 10, 10), "image.gif")
require.Error(t, err)
CheckBadRequestStatus(t, resp)
CheckErrorID(t, err, "api.emoji.create.duplicate.app_error")
// try to create a valid animated gif emoji
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
newEmoji, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestAnimatedGif(t, emojiWidth, emojiHeight, 10), "image.gif")
require.NoError(t, err)
require.Equal(t, newEmoji.Name, emoji.Name, "create with wrong name")
checkEmojiFile(newEmoji.Id, "gif")
// try to create a valid webp emoji
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
path, _ := fileutils.FindDir("tests")
bytes, err := os.ReadFile(filepath.Join(path, "testwebp.webp"))
require.NoError(t, err)
newEmoji, _, err = client.CreateEmoji(context.Background(), emoji, bytes, "image.webp")
require.NoError(t, err)
require.Equal(t, newEmoji.Name, emoji.Name, "create with wrong name")
checkEmojiFile(newEmoji.Id, "png") // emoji must be converted from webp to png
// try to create a valid jpeg emoji
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
newEmoji, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestJpeg(t, emojiWidth, emojiHeight), "image.jpeg")
require.NoError(t, err)
require.Equal(t, newEmoji.Name, emoji.Name, "create with wrong name")
checkEmojiFile(newEmoji.Id, "png") // emoji must be converted from jpeg to png
// try to create a valid png emoji
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
newEmoji, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestPng(t, emojiWidth, emojiHeight), "image.png")
require.NoError(t, err)
require.Equal(t, newEmoji.Name, emoji.Name, "create with wrong name")
checkEmojiFile(newEmoji.Id, "png")
// try to create an emoji that's too wide
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
newEmoji, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 1000, 10), "image.gif")
require.NoError(t, err)
require.Equal(t, newEmoji.Name, emoji.Name, "create with wrong name")
// try to create an emoji that's too wide
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
_, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, app.MaxEmojiOriginalWidth+1), "image.gif")
require.Error(t, err, "should fail - emoji is too wide")
// try to create an emoji that's too tall
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
_, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, app.MaxEmojiOriginalHeight+1, 10), "image.gif")
require.Error(t, err, "should fail - emoji is too tall")
// try to create an emoji that's too large
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
_, resp, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestAnimatedGif(t, 100, 100, 10000), "image.gif")
require.Error(t, err, "should fail - emoji is too big")
CheckRequestEntityTooLargeStatus(t, resp)
CheckErrorID(t, err, "api.emoji.create.too_large.app_error")
// try to create an animated gif with too many frames
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
_, resp, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestAnimatedGif(t, 200, 200, app.MaxEmojiGIFFrames+1), "image.gif")
require.Error(t, err, "should fail - gif has too many frames")
CheckBadRequestStatus(t, resp)
CheckErrorID(t, err, "api.emoji.upload.too_many_frames.app_error")
// try to create an animated gif with too many frames that does not need resizing
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
_, resp, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestAnimatedGif(t, app.MaxEmojiWidth, app.MaxEmojiHeight, app.MaxEmojiGIFFrames+1), "image.gif")
require.Error(t, err, "should fail - gif has too many frames")
CheckBadRequestStatus(t, resp)
CheckErrorID(t, err, "api.emoji.upload.too_many_frames.app_error")
// try to create an animated gif with exactly the maximum allowed frames
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
_, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestAnimatedGif(t, 200, 200, app.MaxEmojiGIFFrames), "image.gif")
require.NoError(t, err, "should succeed - gif has exactly the maximum allowed frames")
// try to create an emoji with data that isn't an image
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
_, resp, err = client.CreateEmoji(context.Background(), emoji, make([]byte, 100), "image.gif")
require.Error(t, err)
CheckBadRequestStatus(t, resp)
CheckErrorID(t, err, "api.emoji.upload.image.app_error")
// try to create an emoji as another user
emoji = &model.Emoji{
CreatorId: th.BasicUser2.Id,
Name: model.NewId(),
}
_, resp, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.Error(t, err)
CheckForbiddenStatus(t, resp)
// try to create an emoji without permissions
th.RemovePermissionFromRole(t, model.PermissionCreateEmojis.Id, model.SystemUserRoleId)
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
_, resp, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.Error(t, err)
CheckForbiddenStatus(t, resp)
// create an emoji with permissions in one team
th.AddPermissionToRole(t, model.PermissionCreateEmojis.Id, model.TeamUserRoleId)
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
_, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
}
func TestGetEmojiList(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
client := th.Client
EnableCustomEmoji := *th.App.Config().ServiceSettings.EnableCustomEmoji
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = EnableCustomEmoji })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = true })
t.Run("should return an empty array when there are no custom emoijs", func(t *testing.T) {
listEmoji, _, err := client.GetEmojiList(context.Background(), 0, 100)
require.NoError(t, err)
require.NotEqual(t, nil, listEmoji)
require.Equal(t, []*model.Emoji{}, listEmoji)
})
emojis := []*model.Emoji{
{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
},
{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
},
{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
},
}
t.Run("should return an array of emojis", func(t *testing.T) {
for idx, emoji := range emojis {
newEmoji, _, err := client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
emojis[idx] = newEmoji
}
listEmoji, _, err := client.GetEmojiList(context.Background(), 0, 100)
require.NoError(t, err)
for _, emoji := range emojis {
found := false
for _, savedEmoji := range listEmoji {
if emoji.Id == savedEmoji.Id {
found = true
break
}
}
require.Truef(t, found, "failed to get emoji with id %v, %v", emoji.Id, len(listEmoji))
}
})
t.Run("should return an empty array when past the maximum page count", func(t *testing.T) {
listEmoji, _, err := client.GetEmojiList(context.Background(), 1, 100)
require.NoError(t, err)
require.NotEqual(t, nil, listEmoji)
require.Equal(t, []*model.Emoji{}, listEmoji)
})
t.Run("should not return a deleted emoji", func(t *testing.T) {
_, err := client.DeleteEmoji(context.Background(), emojis[0].Id)
require.NoError(t, err)
listEmoji, _, err := client.GetEmojiList(context.Background(), 0, 100)
require.NoError(t, err)
found := false
for _, savedEmoji := range listEmoji {
if savedEmoji.Id == emojis[0].Id {
found = true
break
}
}
require.Falsef(t, found, "should not get a deleted emoji %v", emojis[0].Id)
})
t.Run("should return fewer results based on the provided page size", func(t *testing.T) {
listEmoji, _, err := client.GetEmojiList(context.Background(), 0, 1)
require.NoError(t, err)
require.Len(t, listEmoji, 1, "should only return 1")
})
t.Run("should return a sorted emoji list", func(t *testing.T) {
listEmoji, _, err := client.GetSortedEmojiList(context.Background(), 0, 100, model.EmojiSortByName)
require.NoError(t, err)
require.Greater(t, len(listEmoji), 0, "should return more than 0")
})
}
func TestGetEmojisByNames(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
// Set up some custom emojis
adminClient := th.SystemAdminClient
imageBytes := utils.CreateTestJpeg(t, 10, 10)
emoji1 := &model.Emoji{
CreatorId: th.SystemAdminUser.Id,
Name: model.NewId(),
}
emoji1, _, err := adminClient.CreateEmoji(context.Background(), emoji1, imageBytes, "emoji.jpg")
require.NoError(t, err)
emoji2 := &model.Emoji{
CreatorId: th.SystemAdminUser.Id,
Name: model.NewId(),
}
emoji2, _, err = adminClient.CreateEmoji(context.Background(), emoji2, imageBytes, "emoji.jpg")
require.NoError(t, err)
client := th.Client
t.Run("should return a single emoji", func(t *testing.T) {
emojis, _, err := client.GetEmojisByNames(context.Background(), []string{emoji1.Name})
require.NoError(t, err)
require.Len(t, emojis, 1)
assert.Equal(t, emoji1.Id, emojis[0].Id)
})
t.Run("should return multiple emojis", func(t *testing.T) {
emojis, _, err := client.GetEmojisByNames(context.Background(), []string{emoji1.Name, emoji2.Name})
var emojiIds []string
for _, emoji := range emojis {
emojiIds = append(emojiIds, emoji.Id)
}
require.NoError(t, err)
require.Len(t, emojis, 2)
assert.Contains(t, emojiIds, emoji1.Id)
assert.Contains(t, emojiIds, emoji2.Id)
})
t.Run("should ignore non-existent emojis", func(t *testing.T) {
emojis, _, err := client.GetEmojisByNames(context.Background(), []string{emoji1.Name, emoji2.Name, model.NewId()})
var emojiIds []string
for _, emoji := range emojis {
emojiIds = append(emojiIds, emoji.Id)
}
require.NoError(t, err)
require.Len(t, emojis, 2)
assert.Contains(t, emojiIds, emoji1.Id)
assert.Contains(t, emojiIds, emoji2.Id)
})
t.Run("should return an empty array when no emojis are found", func(t *testing.T) {
emojis, _, err := client.GetEmojisByNames(context.Background(), []string{model.NewId(), model.NewId()})
require.NoError(t, err)
assert.NotNil(t, emojis)
assert.Equal(t, []*model.Emoji{}, emojis)
})
t.Run("should return an error when too many emojis are requested", func(t *testing.T) {
names := make([]string, GetEmojisByNamesMax+1)
for i := range names {
names[i] = model.NewId()
}
_, _, err := client.GetEmojisByNames(context.Background(), names)
require.Error(t, err)
})
}
func TestDeleteEmoji(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
client := th.Client
EnableCustomEmoji := *th.App.Config().ServiceSettings.EnableCustomEmoji
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = EnableCustomEmoji })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = true })
defaultRolePermissions := th.SaveDefaultRolePermissions(t)
defer func() {
th.RestoreDefaultRolePermissions(t, defaultRolePermissions)
}()
emoji := &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
newEmoji, _, err := client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
_, err = client.DeleteEmoji(context.Background(), newEmoji.Id)
require.NoError(t, err)
_, _, err = client.GetEmoji(context.Background(), newEmoji.Id)
require.Error(t, err, "expected error fetching deleted emoji")
// Admin can delete other users emoji
newEmoji, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
_, err = th.SystemAdminClient.DeleteEmoji(context.Background(), newEmoji.Id)
require.NoError(t, err)
_, _, err = th.SystemAdminClient.GetEmoji(context.Background(), newEmoji.Id)
require.Error(t, err, "expected error fetching deleted emoji")
// Try to delete just deleted emoji
resp, err := client.DeleteEmoji(context.Background(), newEmoji.Id)
require.Error(t, err)
CheckNotFoundStatus(t, resp)
// Try to delete non-existing emoji
resp, err = client.DeleteEmoji(context.Background(), model.NewId())
require.Error(t, err)
CheckNotFoundStatus(t, resp)
// Try to delete without Id
resp, err = client.DeleteEmoji(context.Background(), "")
require.Error(t, err)
CheckNotFoundStatus(t, resp)
// Try to delete my custom emoji without permissions
newEmoji, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
th.RemovePermissionFromRole(t, model.PermissionDeleteEmojis.Id, model.SystemUserRoleId)
resp, err = client.DeleteEmoji(context.Background(), newEmoji.Id)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
th.AddPermissionToRole(t, model.PermissionDeleteEmojis.Id, model.SystemUserRoleId)
// Try to delete other user's custom emoji without DELETE_EMOJIS permissions
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
newEmoji, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
th.RemovePermissionFromRole(t, model.PermissionDeleteEmojis.Id, model.SystemUserRoleId)
th.AddPermissionToRole(t, model.PermissionDeleteOthersEmojis.Id, model.SystemUserRoleId)
_, err = client.Logout(context.Background())
require.NoError(t, err)
th.LoginBasic2(t)
resp, err = client.DeleteEmoji(context.Background(), newEmoji.Id)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
th.RemovePermissionFromRole(t, model.PermissionDeleteOthersEmojis.Id, model.SystemUserRoleId)
th.AddPermissionToRole(t, model.PermissionDeleteEmojis.Id, model.SystemUserRoleId)
_, err = client.Logout(context.Background())
require.NoError(t, err)
th.LoginBasic(t)
// Try to delete other user's custom emoji without DELETE_OTHERS_EMOJIS permissions
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
newEmoji, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
_, err = client.Logout(context.Background())
require.NoError(t, err)
th.LoginBasic2(t)
resp, err = client.DeleteEmoji(context.Background(), newEmoji.Id)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
_, err = client.Logout(context.Background())
require.NoError(t, err)
th.LoginBasic(t)
// Try to delete other user's custom emoji with permissions
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
newEmoji, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
th.AddPermissionToRole(t, model.PermissionDeleteEmojis.Id, model.SystemUserRoleId)
th.AddPermissionToRole(t, model.PermissionDeleteOthersEmojis.Id, model.SystemUserRoleId)
_, err = client.Logout(context.Background())
require.NoError(t, err)
th.LoginBasic2(t)
_, err = client.DeleteEmoji(context.Background(), newEmoji.Id)
require.NoError(t, err)
_, err = client.Logout(context.Background())
require.NoError(t, err)
th.LoginBasic(t)
// Try to delete my custom emoji with permissions at team level
newEmoji, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
th.RemovePermissionFromRole(t, model.PermissionDeleteEmojis.Id, model.SystemUserRoleId)
th.AddPermissionToRole(t, model.PermissionDeleteEmojis.Id, model.TeamUserRoleId)
_, err = client.DeleteEmoji(context.Background(), newEmoji.Id)
require.NoError(t, err)
th.AddPermissionToRole(t, model.PermissionDeleteEmojis.Id, model.SystemUserRoleId)
th.RemovePermissionFromRole(t, model.PermissionDeleteEmojis.Id, model.TeamUserRoleId)
// Try to delete other user's custom emoji with permissions at team level
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
newEmoji, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
th.RemovePermissionFromRole(t, model.PermissionDeleteEmojis.Id, model.SystemUserRoleId)
th.RemovePermissionFromRole(t, model.PermissionDeleteOthersEmojis.Id, model.SystemUserRoleId)
th.AddPermissionToRole(t, model.PermissionDeleteEmojis.Id, model.TeamUserRoleId)
th.AddPermissionToRole(t, model.PermissionDeleteOthersEmojis.Id, model.TeamUserRoleId)
_, err = client.Logout(context.Background())
require.NoError(t, err)
th.LoginBasic2(t)
_, err = client.DeleteEmoji(context.Background(), newEmoji.Id)
require.NoError(t, err)
}
func TestGetEmoji(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
client := th.Client
EnableCustomEmoji := *th.App.Config().ServiceSettings.EnableCustomEmoji
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = EnableCustomEmoji })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = true })
emoji := &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
newEmoji, _, err := client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
emoji, _, err = client.GetEmoji(context.Background(), newEmoji.Id)
require.NoError(t, err)
require.Equal(t, newEmoji.Id, emoji.Id, "wrong emoji was returned")
_, resp, err := client.GetEmoji(context.Background(), model.NewId())
require.Error(t, err)
CheckNotFoundStatus(t, resp)
}
func TestGetEmojiByName(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
client := th.Client
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = true })
emoji := &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
newEmoji, _, err := client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
emoji, _, err = client.GetEmojiByName(context.Background(), newEmoji.Name)
require.NoError(t, err)
assert.Equal(t, newEmoji.Name, emoji.Name)
_, resp, err := client.GetEmojiByName(context.Background(), model.NewId())
require.Error(t, err)
CheckNotFoundStatus(t, resp)
_, err = client.Logout(context.Background())
require.NoError(t, err)
_, resp, err = client.GetEmojiByName(context.Background(), newEmoji.Name)
require.Error(t, err)
CheckUnauthorizedStatus(t, resp)
}
func TestGetEmojiImage(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
client := th.Client
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = true })
emoji1 := &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
emoji1, _, err := client.CreateEmoji(context.Background(), emoji1, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = false })
_, resp, err := client.GetEmojiImage(context.Background(), emoji1.Id)
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
CheckErrorID(t, err, "api.emoji.disabled.app_error")
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = true })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.DriverName = "local" })
emojiImage, _, err := client.GetEmojiImage(context.Background(), emoji1.Id)
require.NoError(t, err)
require.Greater(t, len(emojiImage), 0, "should return the image")
_, imageType, err := image.DecodeConfig(bytes.NewReader(emojiImage))
require.NoError(t, err)
require.Equal(t, imageType, "gif", "expected gif")
emoji2 := &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
emoji2, _, err = client.CreateEmoji(context.Background(), emoji2, utils.CreateTestAnimatedGif(t, 10, 10, 10), "image.gif")
require.NoError(t, err)
emojiImage, _, err = client.GetEmojiImage(context.Background(), emoji2.Id)
require.NoError(t, err)
require.Greater(t, len(emojiImage), 0, "no image returned")
_, imageType, err = image.DecodeConfig(bytes.NewReader(emojiImage))
require.NoError(t, err, "unable to identify received image")
require.Equal(t, imageType, "gif", "expected gif")
emoji3 := &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
emoji3, _, err = client.CreateEmoji(context.Background(), emoji3, utils.CreateTestJpeg(t, 10, 10), "image.jpg")
require.NoError(t, err)
emojiImage, _, err = client.GetEmojiImage(context.Background(), emoji3.Id)
require.NoError(t, err)
require.Greater(t, len(emojiImage), 0, "no image returned")
_, imageType, err = image.DecodeConfig(bytes.NewReader(emojiImage))
require.NoError(t, err, "unable to identify received image")
require.Equal(t, imageType, "jpeg", "expected jpeg")
emoji4 := &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
emoji4, _, err = client.CreateEmoji(context.Background(), emoji4, utils.CreateTestPng(t, 10, 10), "image.png")
require.NoError(t, err)
emojiImage, _, err = client.GetEmojiImage(context.Background(), emoji4.Id)
require.NoError(t, err)
require.Greater(t, len(emojiImage), 0, "no image returned")
_, imageType, err = image.DecodeConfig(bytes.NewReader(emojiImage))
require.NoError(t, err, "unable to identify received image")
require.Equal(t, imageType, "png", "expected png")
_, err = client.DeleteEmoji(context.Background(), emoji4.Id)
require.NoError(t, err)
_, resp, err = client.GetEmojiImage(context.Background(), emoji4.Id)
require.Error(t, err)
CheckNotFoundStatus(t, resp)
_, resp, err = client.GetEmojiImage(context.Background(), model.NewId())
require.Error(t, err)
CheckNotFoundStatus(t, resp)
_, resp, err = client.GetEmojiImage(context.Background(), "")
require.Error(t, err)
CheckBadRequestStatus(t, resp)
}
func TestSearchEmoji(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
client := th.Client
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = true })
searchTerm1 := model.NewId()
searchTerm2 := model.NewId()
emojis := []*model.Emoji{
{
CreatorId: th.BasicUser.Id,
Name: searchTerm1,
},
{
CreatorId: th.BasicUser.Id,
Name: "blargh_" + searchTerm2,
},
}
for idx, emoji := range emojis {
newEmoji, _, err := client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
emojis[idx] = newEmoji
}
t.Run("should return emojis based on the query", func(t *testing.T) {
search := &model.EmojiSearch{Term: searchTerm1}
remojis, resp, err := client.SearchEmoji(context.Background(), search)
require.NoError(t, err)
CheckOKStatus(t, resp)
found := false
for _, e := range remojis {
if e.Name == emojis[0].Name {
found = true
}
}
assert.True(t, found)
search.Term = searchTerm2
search.PrefixOnly = true
remojis, resp, err = client.SearchEmoji(context.Background(), search)
require.NoError(t, err)
CheckOKStatus(t, resp)
found = false
for _, e := range remojis {
if e.Name == emojis[1].Name {
found = true
}
}
assert.False(t, found)
search.PrefixOnly = false
remojis, resp, err = client.SearchEmoji(context.Background(), search)
require.NoError(t, err)
CheckOKStatus(t, resp)
found = false
for _, e := range remojis {
if e.Name == emojis[1].Name {
found = true
}
}
assert.True(t, found)
})
t.Run("should return an empty array when no emojis match the query", func(t *testing.T) {
search := &model.EmojiSearch{Term: model.NewId()}
remojis, _, err := client.SearchEmoji(context.Background(), search)
require.NoError(t, err)
require.NotEqual(t, nil, remojis)
require.Equal(t, []*model.Emoji{}, remojis)
})
t.Run("should return a 400 error when an empty term is passed", func(t *testing.T) {
search := &model.EmojiSearch{Term: ""}
_, resp, err := client.SearchEmoji(context.Background(), search)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("should return a 401 error when logged out", func(t *testing.T) {
search := &model.EmojiSearch{Term: searchTerm1}
_, err := client.Logout(context.Background())
require.NoError(t, err)
_, resp, err := client.SearchEmoji(context.Background(), search)
require.Error(t, err)
CheckUnauthorizedStatus(t, resp)
})
}
func TestAutocompleteEmoji(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
client := th.Client
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = true })
searchTerm1 := model.NewId()
emojis := []*model.Emoji{
{
CreatorId: th.BasicUser.Id,
Name: searchTerm1,
},
{
CreatorId: th.BasicUser.Id,
Name: "blargh_" + searchTerm1,
},
}
for idx, emoji := range emojis {
newEmoji, _, err := client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
emojis[idx] = newEmoji
}
t.Run("should return autocompleted emojis based on the search term", func(t *testing.T) {
remojis, resp, err := client.AutocompleteEmoji(context.Background(), searchTerm1, "")
require.NoErrorf(t, err, "AutocompleteEmoji failed with search term: %s", searchTerm1)
CheckOKStatus(t, resp)
found1 := false
found2 := false
for _, e := range remojis {
if e.Name == emojis[0].Name {
found1 = true
}
if e.Name == emojis[1].Name {
found2 = true
}
}
assert.True(t, found1)
assert.False(t, found2)
})
t.Run("should return an empty array when no emojis match the search term", func(t *testing.T) {
remojis, resp, err := client.AutocompleteEmoji(context.Background(), model.NewId(), "")
require.NoErrorf(t, err, "AutocompleteEmoji failed with search term: %s", searchTerm1)
CheckOKStatus(t, resp)
require.NotEqual(t, nil, remojis)
require.Equal(t, []*model.Emoji{}, remojis)
})
t.Run("should return a 400 error when an empty term is passed", func(t *testing.T) {
_, resp, err := client.AutocompleteEmoji(context.Background(), "", "")
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("should return a 401 error when logged out", func(t *testing.T) {
_, err := client.Logout(context.Background())
require.NoError(t, err)
_, resp, err := client.AutocompleteEmoji(context.Background(), searchTerm1, "")
require.Error(t, err)
CheckUnauthorizedStatus(t, resp)
})
}

View File

@@ -0,0 +1,161 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
)
// errorReasonMaxRunes matches the bound postLog applies to client-supplied log messages.
const errorReasonMaxRunes = 400
// truncateErrorReason marks a cut reason so it stays distinguishable from a complete one.
func truncateErrorReason(reason string) string {
const ellipsis = "..."
runes := []rune(reason)
if len(runes) <= errorReasonMaxRunes {
return reason
}
return string(runes[:errorReasonMaxRunes-len(ellipsis)]) + ellipsis
}
func (api *API) InitEphemeralMode() {
api.BaseRoutes.EphemeralMode.Handle("/cleanup", api.APISessionRequired(logCleanup)).Methods(http.MethodPost)
api.BaseRoutes.EphemeralMode.Handle("/purge", api.APISessionRequired(logOfflinePurge)).Methods(http.MethodPost)
api.BaseRoutes.EphemeralMode.Handle("/wipe", api.APIHandler(logSessionWipe)).Methods(http.MethodPost)
}
func logCleanup(c *Context, w http.ResponseWriter, r *http.Request) {
if !model.MinimumEnterpriseAdvancedLicense(c.App.License()) {
c.Err = model.NewAppError("logCleanup", "license_error.feature_unavailable.specific", map[string]any{"Feature": "Ephemeral Mode"}, "", http.StatusNotImplemented)
return
}
var report model.CleanupReport
if err := json.NewDecoder(r.Body).Decode(&report); err != nil {
c.SetInvalidParamWithErr("cleanup_report", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventAutoCacheCleanupRun, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
// if no deleted count reported and it is not an error, the request is considered invalid
if report.ErrorReason == nil {
if report.PostsDeleted == nil {
c.SetInvalidParam("posts_deleted")
return
}
if report.PlaybookRunsDeleted == nil {
c.SetInvalidParam("playbook_runs_deleted")
return
}
}
if report.PostsDeleted != nil {
model.AddEventParameterToAuditRec(auditRec, "posts_deleted", *report.PostsDeleted)
}
if report.PlaybookRunsDeleted != nil {
model.AddEventParameterToAuditRec(auditRec, "playbook_runs_deleted", *report.PlaybookRunsDeleted)
}
if report.CleanupAt != nil {
model.AddEventParameterToAuditRec(auditRec, "cleanup_at", *report.CleanupAt)
}
model.AddEventParameterToAuditRec(auditRec, "server_ts", model.GetMillis())
if report.ErrorReason != nil {
auditRec.AddErrorDesc(truncateErrorReason(*report.ErrorReason))
auditRec.Fail()
} else {
auditRec.Success()
}
ReturnStatusOK(w)
}
func logOfflinePurge(c *Context, w http.ResponseWriter, r *http.Request) {
if !model.MinimumEnterpriseAdvancedLicense(c.App.License()) {
c.Err = model.NewAppError("logOfflinePurge", "license_error.feature_unavailable.specific", map[string]any{"Feature": "Ephemeral Mode"}, "", http.StatusNotImplemented)
return
}
var report model.OfflinePurgeReport
if err := json.NewDecoder(r.Body).Decode(&report); err != nil {
c.SetInvalidParamWithErr("offline_purge_report", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventOfflinePurge, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
// a successful purge with no offline time records no evidence of why it ran
if report.ErrorReason == nil && report.OfflineTimeMinutes == nil {
c.SetInvalidParam("offline_time_minutes")
return
}
if report.OfflineTimeMinutes != nil {
model.AddEventParameterToAuditRec(auditRec, "offline_time_minutes", *report.OfflineTimeMinutes)
}
if report.PurgeAt != nil {
model.AddEventParameterToAuditRec(auditRec, "purge_at", *report.PurgeAt)
}
model.AddEventParameterToAuditRec(auditRec, "server_ts", model.GetMillis())
if report.ErrorReason != nil {
auditRec.AddErrorDesc(truncateErrorReason(*report.ErrorReason))
auditRec.Fail()
} else {
auditRec.Success()
}
ReturnStatusOK(w)
}
// logSessionWipe is called by a client confirming a local wipe after its session was revoked, so
// it runs without a session and the actor is taken from the signature the wipe push carried.
func logSessionWipe(c *Context, w http.ResponseWriter, r *http.Request) {
if !model.MinimumEnterpriseAdvancedLicense(c.App.License()) {
c.Err = model.NewAppError("logSessionWipe", "license_error.feature_unavailable.specific", map[string]any{"Feature": "Ephemeral Mode"}, "", http.StatusNotImplemented)
return
}
var report model.SessionWipeReport
if err := json.NewDecoder(r.Body).Decode(&report); err != nil {
c.SetInvalidParamWithErr("session_wipe_report", err)
return
}
// verified before the audit record exists so an unverified caller cannot append one at all
claims, err := c.App.VerifyWipeSignature(report.Signature)
if err != nil {
c.SetInvalidParamWithErr("signature", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventSessionWipe, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
auditRec.Actor.UserId = claims.UserId
model.AddEventParameterToAuditRec(auditRec, "device_id", model.RedactDeviceId(claims.DeviceId))
model.AddEventParameterToAuditRec(auditRec, "ack_id", claims.AckId)
if report.WipeAt != nil {
model.AddEventParameterToAuditRec(auditRec, "wipe_at", *report.WipeAt)
}
model.AddEventParameterToAuditRec(auditRec, "server_ts", model.GetMillis())
if report.ErrorReason != nil {
auditRec.AddErrorDesc(truncateErrorReason(*report.ErrorReason))
auditRec.Fail()
} else {
auditRec.Success()
}
ReturnStatusOK(w)
}

View File

@@ -0,0 +1,486 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"io"
"net/http"
"os"
"strings"
"testing"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
const (
wipeSignatureAckId = "ackid"
wipeSignatureDeviceId = "testdevice"
)
// signWipePush mints the signature a session wipe push carries for the given user, which the
// client echoes back when confirming its wipe.
func signWipePush(t *testing.T, th *TestHelper, userID string) string {
t.Helper()
signature, err := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
"ack_id": wipeSignatureAckId,
"device_id": wipeSignatureDeviceId,
"user_id": userID,
}).SignedString(th.App.AsymmetricSigningKey())
require.NoError(t, err)
return signature
}
// setupEphemeralModeAuditTest starts a TestHelper with a file-based audit target so tests can
// read back the audit entries the ephemeral mode endpoints log.
func setupEphemeralModeAuditTest(t *testing.T) (*TestHelper, *os.File) {
logFile, err := os.CreateTemp("", "ephemeral_mode_audit.log")
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, logFile.Close())
require.NoError(t, os.Remove(logFile.Name()))
})
th := SetupWithServerOptionsAndConfig(t, nil, func(cfg *model.Config) {
cfg.ExperimentalAuditSettings.FileEnabled = new(true)
cfg.ExperimentalAuditSettings.FileName = new(logFile.Name())
}).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
t.Cleanup(func() { th.RemoveLicense(t) })
return th, logFile
}
func readAuditEntry(t *testing.T, th *TestHelper, logFile *os.File, eventName, userID string) *AuditEntry {
require.NoError(t, th.Server.Audit.Flush())
require.NoError(t, logFile.Sync())
data, err := io.ReadAll(logFile)
require.NoError(t, err)
entry := FindAuditEntry(string(data), eventName, userID)
require.NotNil(t, entry, "should find a %s audit entry for user %s", eventName, userID)
return entry
}
func TestLogCleanup(t *testing.T) {
mainHelper.Parallel(t)
t.Run("logs success when deleted record counts and cleanup date are all provided", func(t *testing.T) {
th, logFile := setupEphemeralModeAuditTest(t)
body := mustMarshal(t, model.CleanupReport{
PostsDeleted: model.NewPointer(int64(10)),
PlaybookRunsDeleted: model.NewPointer(int64(2)),
CleanupAt: model.NewPointer(model.GetMillis()),
})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/cleanup", string(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
entry := readAuditEntry(t, th, logFile, model.AuditEventAutoCacheCleanupRun, th.BasicUser.Id)
require.Equal(t, model.AuditStatusSuccess, entry.Status)
require.EqualValues(t, 10, entry.Parameters["posts_deleted"])
require.EqualValues(t, 2, entry.Parameters["playbook_runs_deleted"])
require.Contains(t, entry.Parameters, "cleanup_at")
require.Contains(t, entry.Parameters, "server_ts")
})
t.Run("succeeds when cleanup date is omitted but deleted record counts are provided", func(t *testing.T) {
th, logFile := setupEphemeralModeAuditTest(t)
body := mustMarshal(t, model.CleanupReport{
PostsDeleted: model.NewPointer(int64(5)),
PlaybookRunsDeleted: model.NewPointer(int64(1)),
})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/cleanup", string(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
entry := readAuditEntry(t, th, logFile, model.AuditEventAutoCacheCleanupRun, th.BasicUser.Id)
require.Equal(t, model.AuditStatusSuccess, entry.Status)
require.EqualValues(t, 5, entry.Parameters["posts_deleted"])
require.EqualValues(t, 1, entry.Parameters["playbook_runs_deleted"])
require.NotContains(t, entry.Parameters, "cleanup_at")
})
t.Run("accepts a failure report with no deleted record counts and records no counts for it", func(t *testing.T) {
th, logFile := setupEphemeralModeAuditTest(t)
body := mustMarshal(t, model.CleanupReport{
CleanupAt: model.NewPointer(model.GetMillis()),
ErrorReason: model.NewPointer("disk full during cleanup"),
})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/cleanup", string(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
entry := readAuditEntry(t, th, logFile, model.AuditEventAutoCacheCleanupRun, th.BasicUser.Id)
require.Equal(t, model.AuditStatusFail, entry.Status)
require.NotContains(t, entry.Parameters, "posts_deleted")
require.NotContains(t, entry.Parameters, "playbook_runs_deleted")
errData, ok := entry.Raw["error"].(map[string]any)
require.True(t, ok)
require.Equal(t, "disk full during cleanup", errData["description"])
})
t.Run("logs failure without a description when the client reports an empty error reason", func(t *testing.T) {
th, logFile := setupEphemeralModeAuditTest(t)
body := mustMarshal(t, model.CleanupReport{
ErrorReason: model.NewPointer(""),
})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/cleanup", string(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
entry := readAuditEntry(t, th, logFile, model.AuditEventAutoCacheCleanupRun, th.BasicUser.Id)
require.Equal(t, model.AuditStatusFail, entry.Status)
errData, ok := entry.Raw["error"].(map[string]any)
require.True(t, ok)
require.NotContains(t, errData, "description")
})
t.Run("rejects a success report that provides only the cleanup date", func(t *testing.T) {
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense(t)
body := mustMarshal(t, model.CleanupReport{
CleanupAt: model.NewPointer(model.GetMillis()),
})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/cleanup", string(body))
require.Error(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("rejects a success report that provides only the deleted post count", func(t *testing.T) {
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense(t)
body := mustMarshal(t, model.CleanupReport{
PostsDeleted: model.NewPointer(int64(3)),
})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/cleanup", string(body))
require.Error(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("rejects request when license does not support ephemeral mode", func(t *testing.T) {
th := Setup(t).InitBasic(t)
body := mustMarshal(t, model.CleanupReport{
CleanupAt: model.NewPointer(model.GetMillis()),
})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/cleanup", string(body))
require.Error(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
})
}
func TestLogOfflinePurge(t *testing.T) {
mainHelper.Parallel(t)
t.Run("logs success when offline time and purge date are both provided", func(t *testing.T) {
th, logFile := setupEphemeralModeAuditTest(t)
body := mustMarshal(t, model.OfflinePurgeReport{
OfflineTimeMinutes: model.NewPointer(int64(90)),
PurgeAt: model.NewPointer(model.GetMillis()),
})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/purge", string(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
entry := readAuditEntry(t, th, logFile, model.AuditEventOfflinePurge, th.BasicUser.Id)
require.Equal(t, model.AuditStatusSuccess, entry.Status)
require.EqualValues(t, 90, entry.Parameters["offline_time_minutes"])
require.Contains(t, entry.Parameters, "purge_at")
require.Contains(t, entry.Parameters, "server_ts")
})
t.Run("succeeds when purge date is omitted but offline time is provided", func(t *testing.T) {
th, logFile := setupEphemeralModeAuditTest(t)
body := mustMarshal(t, model.OfflinePurgeReport{
OfflineTimeMinutes: model.NewPointer(int64(45)),
})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/purge", string(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
entry := readAuditEntry(t, th, logFile, model.AuditEventOfflinePurge, th.BasicUser.Id)
require.Equal(t, model.AuditStatusSuccess, entry.Status)
require.EqualValues(t, 45, entry.Parameters["offline_time_minutes"])
require.NotContains(t, entry.Parameters, "purge_at")
})
t.Run("logs failure with the reported reason when the client reports the offline purge failed", func(t *testing.T) {
th, logFile := setupEphemeralModeAuditTest(t)
body := mustMarshal(t, model.OfflinePurgeReport{
OfflineTimeMinutes: model.NewPointer(int64(90)),
ErrorReason: model.NewPointer("could not reach storage"),
})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/purge", string(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
entry := readAuditEntry(t, th, logFile, model.AuditEventOfflinePurge, th.BasicUser.Id)
require.Equal(t, model.AuditStatusFail, entry.Status)
errData, ok := entry.Raw["error"].(map[string]any)
require.True(t, ok)
require.Equal(t, "could not reach storage", errData["description"])
})
t.Run("accepts a failure report with no offline time and records no offline time for it", func(t *testing.T) {
th, logFile := setupEphemeralModeAuditTest(t)
body := mustMarshal(t, model.OfflinePurgeReport{
ErrorReason: model.NewPointer("could not read the last connected timestamp"),
})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/purge", string(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
entry := readAuditEntry(t, th, logFile, model.AuditEventOfflinePurge, th.BasicUser.Id)
require.Equal(t, model.AuditStatusFail, entry.Status)
require.NotContains(t, entry.Parameters, "offline_time_minutes")
errData, ok := entry.Raw["error"].(map[string]any)
require.True(t, ok)
require.Equal(t, "could not read the last connected timestamp", errData["description"])
})
t.Run("rejects request missing offline time even when purge date is provided", func(t *testing.T) {
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense(t)
body := mustMarshal(t, model.OfflinePurgeReport{
PurgeAt: model.NewPointer(model.GetMillis()),
})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/purge", string(body))
require.Error(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("rejects request missing offline time and purge date", func(t *testing.T) {
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense(t)
body := mustMarshal(t, model.OfflinePurgeReport{})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/purge", string(body))
require.Error(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("rejects request when license does not support ephemeral mode", func(t *testing.T) {
th := Setup(t).InitBasic(t)
body := mustMarshal(t, model.OfflinePurgeReport{
OfflineTimeMinutes: model.NewPointer(int64(45)),
})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/purge", string(body))
require.Error(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
})
}
func TestLogSessionWipe(t *testing.T) {
mainHelper.Parallel(t)
// The wipe confirmation is sent by a client whose session was already revoked server-side,
// so it must succeed without any authentication. The signature the wipe push carried is
// what attributes the record.
t.Run("logs success without a session and takes the actor from the signature", func(t *testing.T) {
th, logFile := setupEphemeralModeAuditTest(t)
client := th.CreateClient()
body := mustMarshal(t, model.SessionWipeReport{
Signature: signWipePush(t, th, th.BasicUser.Id),
WipeAt: model.NewPointer(model.GetMillis()),
})
resp, err := client.DoAPIPost(context.Background(), "/ephemeral_mode/wipe", string(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
entry := readAuditEntry(t, th, logFile, model.AuditEventSessionWipe, th.BasicUser.Id)
require.Equal(t, model.AuditStatusSuccess, entry.Status)
require.Equal(t, model.RedactDeviceId(wipeSignatureDeviceId), entry.Parameters["device_id"])
require.Equal(t, wipeSignatureAckId, entry.Parameters["ack_id"])
require.Contains(t, entry.Parameters, "wipe_at")
require.Contains(t, entry.Parameters, "server_ts")
})
t.Run("succeeds when the wipe date is omitted", func(t *testing.T) {
th, logFile := setupEphemeralModeAuditTest(t)
client := th.CreateClient()
body := mustMarshal(t, model.SessionWipeReport{
Signature: signWipePush(t, th, th.BasicUser.Id),
})
resp, err := client.DoAPIPost(context.Background(), "/ephemeral_mode/wipe", string(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
entry := readAuditEntry(t, th, logFile, model.AuditEventSessionWipe, th.BasicUser.Id)
require.Equal(t, model.AuditStatusSuccess, entry.Status)
require.NotContains(t, entry.Parameters, "wipe_at")
})
t.Run("logs failure with the reported reason when the client reports the session wipe failed", func(t *testing.T) {
th, logFile := setupEphemeralModeAuditTest(t)
client := th.CreateClient()
body := mustMarshal(t, model.SessionWipeReport{
Signature: signWipePush(t, th, th.BasicUser.Id),
ErrorReason: model.NewPointer("local storage wipe threw an exception"),
})
resp, err := client.DoAPIPost(context.Background(), "/ephemeral_mode/wipe", string(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
entry := readAuditEntry(t, th, logFile, model.AuditEventSessionWipe, th.BasicUser.Id)
require.Equal(t, model.AuditStatusFail, entry.Status)
errData, ok := entry.Raw["error"].(map[string]any)
require.True(t, ok)
require.Equal(t, "local storage wipe threw an exception", errData["description"])
})
// An error reason must not waive the signature requirement: this endpoint is
// unauthenticated, so that would let anyone append unattributable audit records.
t.Run("rejects a failure report that omits the signature", func(t *testing.T) {
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense(t)
client := th.CreateClient()
body := mustMarshal(t, model.SessionWipeReport{
ErrorReason: model.NewPointer("local storage was already unreadable"),
})
resp, err := client.DoAPIPost(context.Background(), "/ephemeral_mode/wipe", string(body))
require.Error(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
// Rejecting the request is not enough on an unauthenticated endpoint: without a verified
// signature there is no actor, so the attempt must leave no sessionWipe record behind at all.
t.Run("rejects a report whose signature does not verify and records nothing for it", func(t *testing.T) {
th, logFile := setupEphemeralModeAuditTest(t)
client := th.CreateClient()
body := mustMarshal(t, model.SessionWipeReport{
Signature: signWipePush(t, th, th.BasicUser.Id) + "tampered",
})
resp, err := client.DoAPIPost(context.Background(), "/ephemeral_mode/wipe", string(body))
require.Error(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.NoError(t, th.Server.Audit.Flush())
data, err := io.ReadAll(logFile)
require.NoError(t, err)
require.Nil(t, FindAuditEntry(string(data), model.AuditEventSessionWipe, ""))
})
t.Run("rejects request when license does not support ephemeral mode", func(t *testing.T) {
th := Setup(t).InitBasic(t)
client := th.CreateClient()
body := mustMarshal(t, model.SessionWipeReport{
Signature: signWipePush(t, th, th.BasicUser.Id),
})
resp, err := client.DoAPIPost(context.Background(), "/ephemeral_mode/wipe", string(body))
require.Error(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
})
}
// An over-long reason is truncated rather than rejected, so a client that reports a failure
// verbosely still gets its audit record. The ellipsis keeps a cut reason distinguishable from
// a complete one when the record is read back as evidence.
func TestEphemeralModeTruncatesLongErrorReasons(t *testing.T) {
mainHelper.Parallel(t)
longReason := strings.Repeat("test", errorReasonMaxRunes)
testCases := []struct {
name string
path string
event string
client func(th *TestHelper) *model.Client4
body func(t *testing.T, th *TestHelper) any
}{
{
name: "cleanup",
path: "/ephemeral_mode/cleanup",
event: model.AuditEventAutoCacheCleanupRun,
client: func(th *TestHelper) *model.Client4 { return th.Client },
body: func(_ *testing.T, _ *TestHelper) any {
return model.CleanupReport{ErrorReason: model.NewPointer(longReason)}
},
},
{
name: "offline purge",
path: "/ephemeral_mode/purge",
event: model.AuditEventOfflinePurge,
client: func(th *TestHelper) *model.Client4 { return th.Client },
body: func(_ *testing.T, _ *TestHelper) any {
return model.OfflinePurgeReport{ErrorReason: model.NewPointer(longReason)}
},
},
{
name: "session wipe",
path: "/ephemeral_mode/wipe",
event: model.AuditEventSessionWipe,
client: func(th *TestHelper) *model.Client4 { return th.CreateClient() },
body: func(t *testing.T, th *TestHelper) any {
return model.SessionWipeReport{Signature: signWipePush(t, th, th.BasicUser.Id), ErrorReason: model.NewPointer(longReason)}
},
},
}
for _, tc := range testCases {
t.Run(tc.name+" records the error reason cut to the maximum length and marked as cut", func(t *testing.T) {
th, logFile := setupEphemeralModeAuditTest(t)
body := mustMarshal(t, tc.body(t, th))
resp, err := tc.client(th).DoAPIPost(context.Background(), tc.path, string(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
entry := readAuditEntry(t, th, logFile, tc.event, th.BasicUser.Id)
errData, ok := entry.Raw["error"].(map[string]any)
require.True(t, ok)
require.Equal(t, longReason[:errorReasonMaxRunes-3]+"...", errData["description"])
})
}
}

View File

@@ -0,0 +1,118 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"path/filepath"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitExport() {
api.BaseRoutes.Exports.Handle("", api.APISessionRequired(listExports)).Methods(http.MethodGet)
api.BaseRoutes.Export.Handle("", api.APISessionRequired(deleteExport)).Methods(http.MethodDelete)
api.BaseRoutes.Export.Handle("", api.APISessionRequired(downloadExport)).Methods(http.MethodGet)
api.BaseRoutes.Export.Handle("/presign-url", api.APISessionRequired(generatePresignURLExport)).Methods(http.MethodPost)
}
func listExports(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.IsSystemAdmin() {
c.SetPermissionError(model.PermissionManageSystem)
return
}
exports, appErr := c.App.ListExports()
if appErr != nil {
c.Err = appErr
return
}
data, err := json.Marshal(exports)
if err != nil {
c.Err = model.NewAppError("listImports", "app.export.marshal.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(data); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func deleteExport(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord(model.AuditEventDeleteExport, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "export_name", c.Params.ExportName)
if !c.IsSystemAdmin() {
c.SetPermissionError(model.PermissionManageSystem)
return
}
if err := c.App.DeleteExport(c.Params.ExportName); err != nil {
c.Err = err
return
}
auditRec.Success()
ReturnStatusOK(w)
}
func downloadExport(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.IsSystemAdmin() {
c.SetPermissionError(model.PermissionManageSystem)
return
}
filePath := filepath.Join(*c.App.Config().ExportSettings.Directory, c.Params.ExportName)
if ok, err := c.App.ExportFileExists(filePath); err != nil {
c.Err = err
return
} else if !ok {
c.Err = model.NewAppError("downloadExport", "api.export.export_not_found.app_error", nil, "", http.StatusNotFound)
return
}
file, err := c.App.ExportFileReader(filePath)
if err != nil {
c.Err = err
return
}
defer file.Close()
w.Header().Set("Content-Type", "application/zip")
http.ServeContent(w, r, c.Params.ExportName, time.Time{}, file)
}
func generatePresignURLExport(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord(model.AuditEventGeneratePresignURLExport, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "export_name", c.Params.ExportName)
if !c.IsSystemAdmin() {
c.SetPermissionError(model.PermissionManageSystem)
return
}
res, appErr := c.App.GeneratePresignURLForExport(c.Params.ExportName)
if appErr != nil {
c.Err = appErr
return
}
data, err := json.Marshal(res)
if err != nil {
c.Err = model.NewAppError("generatePresignURLExport", "app.export.marshal.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.Success()
if _, err := w.Write(data); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import "net/http"
func (api *API) InitExportLocal() {
api.BaseRoutes.Exports.Handle("", api.APILocal(listExports)).Methods(http.MethodGet)
api.BaseRoutes.Export.Handle("", api.APILocal(deleteExport)).Methods(http.MethodDelete)
api.BaseRoutes.Export.Handle("", api.APILocal(downloadExport)).Methods(http.MethodGet)
api.BaseRoutes.Export.Handle("/presign-url", api.APILocal(generatePresignURLExport)).Methods(http.MethodPost)
}

View File

@@ -0,0 +1,340 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"bytes"
"context"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
"github.com/stretchr/testify/require"
)
func TestListExports(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
t.Run("no permissions", func(t *testing.T) {
exports, _, err := th.Client.ListExports(context.Background())
require.Error(t, err)
CheckErrorID(t, err, "api.context.permissions.app_error")
require.Nil(t, exports)
})
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
exports, _, err := c.ListExports(context.Background())
require.NoError(t, err)
require.Empty(t, exports)
}, "no exports")
dataDir := *th.App.Config().FileSettings.Directory
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
exportDir := filepath.Join(dataDir, *th.App.Config().ExportSettings.Directory)
err := os.Mkdir(exportDir, 0700)
require.NoError(t, err)
defer func() {
err = os.RemoveAll(exportDir)
require.NoError(t, err)
}()
f, err := os.Create(filepath.Join(exportDir, "export.zip"))
require.NoError(t, err)
err = f.Close()
require.NoError(t, err)
exports, _, err := c.ListExports(context.Background())
require.NoError(t, err)
require.Len(t, exports, 1)
require.Equal(t, exports[0], "export.zip")
}, "expected exports")
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
originalExportDir := *th.App.Config().ExportSettings.Directory
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExportSettings.Directory = "new" })
defer th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ExportSettings.Directory = originalExportDir
})
exportDir := filepath.Join(dataDir, *th.App.Config().ExportSettings.Directory)
err := os.Mkdir(exportDir, 0700)
require.NoError(t, err)
defer func() {
err = os.RemoveAll(exportDir)
require.NoError(t, err)
}()
exports, _, err := c.ListExports(context.Background())
require.NoError(t, err)
require.Empty(t, exports)
f, err := os.Create(filepath.Join(exportDir, "export.zip"))
require.NoError(t, err)
err = f.Close()
require.NoError(t, err)
exports, _, err = c.ListExports(context.Background())
require.NoError(t, err)
require.Len(t, exports, 1)
require.Equal(t, "export.zip", exports[0])
}, "change export directory")
}
func TestDeleteExport(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
t.Run("no permissions", func(t *testing.T) {
_, err := th.Client.DeleteExport(context.Background(), "export.zip")
require.Error(t, err)
CheckErrorID(t, err, "api.context.permissions.app_error")
})
dataDir := *th.App.Config().FileSettings.Directory
exportDir := filepath.Join(dataDir, *th.App.Config().ExportSettings.Directory)
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
err := os.Mkdir(exportDir, 0700)
require.NoError(t, err)
defer func() {
err = os.RemoveAll(exportDir)
require.NoError(t, err)
}()
exportName := "export.zip"
f, err := os.Create(filepath.Join(exportDir, exportName))
require.NoError(t, err)
err = f.Close()
require.NoError(t, err)
exports, _, err := c.ListExports(context.Background())
require.NoError(t, err)
require.Len(t, exports, 1)
require.Equal(t, exports[0], exportName)
_, err = c.DeleteExport(context.Background(), exportName)
require.NoError(t, err)
exports, _, err = c.ListExports(context.Background())
require.NoError(t, err)
require.Empty(t, exports)
// verify idempotence
_, err = c.DeleteExport(context.Background(), exportName)
require.NoError(t, err)
}, "successfully delete export")
}
func TestDownloadExport(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
t.Run("no permissions", func(t *testing.T) {
var buf bytes.Buffer
n, _, err := th.Client.DownloadExport(context.Background(), "export.zip", &buf, 0)
require.Error(t, err)
CheckErrorID(t, err, "api.context.permissions.app_error")
require.Zero(t, n)
})
dataDir := *th.App.Config().FileSettings.Directory
exportDir := filepath.Join(dataDir, *th.App.Config().ExportSettings.Directory)
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
var buf bytes.Buffer
n, _, err := c.DownloadExport(context.Background(), "export.zip", &buf, 0)
require.Error(t, err)
CheckErrorID(t, err, "api.export.export_not_found.app_error")
require.Zero(t, n)
}, "not found")
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
err := os.Mkdir(exportDir, 0700)
require.NoError(t, err)
defer func() {
err = os.RemoveAll(exportDir)
require.NoError(t, err)
}()
data := randomBytes(t, 1024*1024)
var buf bytes.Buffer
exportName := "export.zip"
err = os.WriteFile(filepath.Join(exportDir, exportName), data, 0600)
require.NoError(t, err)
n, _, err := c.DownloadExport(context.Background(), exportName, &buf, 0)
require.NoError(t, err)
require.Equal(t, len(data), int(n))
require.Equal(t, data, buf.Bytes())
}, "full download")
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
err := os.Mkdir(exportDir, 0700)
require.NoError(t, err)
defer func() {
err = os.RemoveAll(exportDir)
require.NoError(t, err)
}()
data := randomBytes(t, 1024*1024)
var buf bytes.Buffer
exportName := "export.zip"
err = os.WriteFile(filepath.Join(exportDir, exportName), data, 0600)
require.NoError(t, err)
offset := 1024 * 512
n, _, err := c.DownloadExport(context.Background(), exportName, &buf, int64(offset))
require.NoError(t, err)
require.Equal(t, len(data)-offset, int(n))
require.Equal(t, data[offset:], buf.Bytes())
}, "download with offset")
}
func BenchmarkDownloadExport(b *testing.B) {
th := Setup(b)
dataDir := *th.App.Config().FileSettings.Directory
exportDir := filepath.Join(dataDir, *th.App.Config().ExportSettings.Directory)
err := os.Mkdir(exportDir, 0700)
require.NoError(b, err)
defer func() {
err = os.RemoveAll(exportDir)
require.NoError(b, err)
}()
exportName := "export.zip"
f, err := os.Create(filepath.Join(exportDir, exportName))
require.NoError(b, err)
err = f.Close()
require.NoError(b, err)
err = os.Truncate(filepath.Join(exportDir, exportName), 1024*1024*1024)
require.NoError(b, err)
for i := 0; b.Loop(); i++ {
outFilePath := filepath.Join(dataDir, fmt.Sprintf("export%d.zip", i))
outFile, err := os.Create(outFilePath)
require.NoError(b, err)
_, _, err = th.SystemAdminClient.DownloadExport(context.Background(), exportName, outFile, 0)
require.NoError(b, err)
err = outFile.Close()
require.NoError(b, err)
err = os.Remove(outFilePath)
require.NoError(b, err)
}
}
func TestGeneratePresignedURL(t *testing.T) {
mainHelper.Parallel(t)
t.Run("no permissions", func(t *testing.T) {
th := Setup(t)
_, _, err := th.Client.GeneratePresignedURL(context.Background(), "export.zip")
require.Error(t, err)
CheckErrorID(t, err, "api.context.permissions.app_error")
})
t.Run("blocked when not running in Cloud", func(t *testing.T) {
th := Setup(t)
th.App.Srv().SetLicense(model.NewTestLicense())
_, resp, err := th.SystemAdminClient.GeneratePresignedURL(context.Background(), "export.zip")
require.Error(t, err)
CheckForbiddenStatus(t, resp)
CheckErrorID(t, err, "app.export.generate_presigned_url.direct_download.app_error")
})
t.Run("blocked without a license", func(t *testing.T) {
th := Setup(t)
th.App.Srv().SetLicense(nil)
_, resp, err := th.SystemAdminClient.GeneratePresignedURL(context.Background(), "export.zip")
require.Error(t, err)
CheckForbiddenStatus(t, resp)
CheckErrorID(t, err, "app.export.generate_presigned_url.direct_download.app_error")
})
t.Run("passes gate when Cloud, then requires a dedicated export store", func(t *testing.T) {
th := Setup(t)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.FileSettings.DedicatedExportStore = false
})
_, _, err := th.SystemAdminClient.GeneratePresignedURL(context.Background(), "export.zip")
require.Error(t, err)
CheckErrorID(t, err, "app.export.generate_presigned_url.config.app_error")
})
// The full happy path against a real presign-capable (S3/minio) export store: a
// Cloud server with a dedicated export store returns a working presigned URL over
// the API. Skipped when minio isn't reachable.
t.Run("succeeds against a presign-capable export store", func(t *testing.T) {
s3Host := os.Getenv("CI_MINIO_HOST")
if s3Host == "" {
s3Host = "localhost"
}
s3Port := os.Getenv("CI_MINIO_PORT")
if s3Port == "" {
s3Port = "9000"
}
s3Endpoint := net.JoinHostPort(s3Host, s3Port)
conn, err := net.DialTimeout("tcp", s3Endpoint, 2*time.Second)
if err != nil {
t.Skipf("minio not available at %s: %v", s3Endpoint, err)
}
conn.Close()
// Use a fresh bucket per run so MakeBucket is unambiguous.
bucket := model.NewId()
// The dedicated export filestore is built once at startup, so the export-store
// configuration must be applied before the server starts, not via UpdateConfig.
th := SetupConfig(t, func(cfg *model.Config) {
*cfg.FileSettings.DedicatedExportStore = true
*cfg.FileSettings.ExportDriverName = model.ImageDriverS3
*cfg.FileSettings.ExportAmazonS3AccessKeyId = model.MinioAccessKey
*cfg.FileSettings.ExportAmazonS3SecretAccessKey = model.MinioSecretKey
*cfg.FileSettings.ExportAmazonS3Bucket = bucket
*cfg.FileSettings.ExportAmazonS3Endpoint = s3Endpoint
*cfg.FileSettings.ExportAmazonS3Region = ""
*cfg.FileSettings.ExportAmazonS3SSL = false
})
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
backend, ok := th.App.ExportFileBackend().(*filestore.S3FileBackend)
require.True(t, ok, "expected a dedicated S3 export backend")
require.NoError(t, backend.MakeBucket())
exportName := "job_export.zip"
payload := []byte("export-payload")
_, appErr := th.App.WriteExportFile(bytes.NewReader(payload), filepath.Join(*th.App.Config().ExportSettings.Directory, exportName))
require.Nil(t, appErr)
resp, _, err := th.SystemAdminClient.GeneratePresignedURL(context.Background(), exportName)
require.NoError(t, err)
require.NotNil(t, resp)
require.NotEmpty(t, resp.URL)
// The presigned URL should serve the exported file directly.
httpResp, err := (&http.Client{Timeout: 30 * time.Second}).Get(resp.URL)
require.NoError(t, err)
defer httpResp.Body.Close()
require.Equal(t, http.StatusOK, httpResp.StatusCode)
body, err := io.ReadAll(httpResp.Body)
require.NoError(t, err)
require.Equal(t, payload, body)
})
}

1037
server/channels/api4/file.go Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"net/http"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitGroupLocal() {
api.BaseRoutes.Channels.Handle("/{channel_id:[A-Za-z0-9]+}/groups", api.APILocal(getGroupsByChannelLocal)).Methods(http.MethodGet)
api.BaseRoutes.Teams.Handle("/{team_id:[A-Za-z0-9]+}/groups", api.APILocal(getGroupsByTeamLocal)).Methods(http.MethodGet)
}
func getGroupsByChannelLocal(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId()
if c.Err != nil {
return
}
b, appErr := getGroupsByChannelCommon(c, r)
if appErr != nil {
c.Err = appErr
return
}
if _, err := w.Write(b); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getGroupsByTeamLocal(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireTeamId()
if c.Err != nil {
return
}
b, appError := getGroupsByTeamCommon(c, r)
if appError != nil {
c.Err = appError
return
}
if _, err := w.Write(b); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

View File

@@ -0,0 +1,256 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"net/http"
"github.com/klauspost/compress/gzhttp"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/web"
)
type Context = web.Context
type handlerFunc func(*Context, http.ResponseWriter, *http.Request)
type APIHandlerOption string
const (
handlerParamFileAPI = APIHandlerOption("fileAPI")
)
// APIHandler provides a handler for API endpoints which do not require the user to be logged in order for access to be
// granted.
func (api *API) APIHandler(h handlerFunc, opts ...APIHandlerOption) http.Handler {
handler := &web.Handler{
Srv: api.srv,
HandleFunc: h,
HandlerName: web.GetHandlerName(h),
RequireSession: false,
TrustRequester: false,
RequireMfa: false,
IsStatic: false,
IsLocal: false,
}
setHandlerOpts(handler, opts...)
if *api.srv.Config().ServiceSettings.WebserverMode == "gzip" {
return gzhttp.GzipHandler(handler)
}
return handler
}
// APISessionRequired provides a handler for API endpoints which require the user to be logged in in order for access to
// be granted.
func (api *API) APISessionRequired(h handlerFunc, opts ...APIHandlerOption) http.Handler {
handler := &web.Handler{
Srv: api.srv,
HandleFunc: h,
HandlerName: web.GetHandlerName(h),
RequireSession: true,
TrustRequester: false,
RequireMfa: true,
IsStatic: false,
IsLocal: false,
}
setHandlerOpts(handler, opts...)
if *api.srv.Config().ServiceSettings.WebserverMode == "gzip" {
return gzhttp.GzipHandler(handler)
}
return handler
}
// CloudAPIKeyRequired provides a handler for webhook endpoints to access Cloud installations from CWS
func (api *API) CloudAPIKeyRequired(h handlerFunc, opts ...APIHandlerOption) http.Handler {
handler := &web.Handler{
Srv: api.srv,
HandleFunc: h,
HandlerName: web.GetHandlerName(h),
RequireSession: false,
RequireCloudKey: true,
TrustRequester: false,
RequireMfa: false,
IsStatic: false,
IsLocal: false,
}
setHandlerOpts(handler, opts...)
if *api.srv.Config().ServiceSettings.WebserverMode == "gzip" {
return gzhttp.GzipHandler(handler)
}
return handler
}
// RemoteClusterTokenRequired provides a handler for remote cluster requests to /remotecluster endpoints.
func (api *API) RemoteClusterTokenRequired(h handlerFunc, opts ...APIHandlerOption) http.Handler {
handler := &web.Handler{
Srv: api.srv,
HandleFunc: h,
HandlerName: web.GetHandlerName(h),
RequireSession: false,
RequireCloudKey: false,
RequireRemoteClusterToken: true,
TrustRequester: false,
RequireMfa: false,
IsStatic: false,
IsLocal: false,
}
setHandlerOpts(handler, opts...)
if *api.srv.Config().ServiceSettings.WebserverMode == "gzip" {
return gzhttp.GzipHandler(handler)
}
return handler
}
// APISessionRequiredMfa provides a handler for API endpoints which require a logged-in user session but when accessed,
// if MFA is enabled, the MFA process is not yet complete, and therefore the requirement to have completed the MFA
// authentication must be waived.
func (api *API) APISessionRequiredMfa(h handlerFunc, opts ...APIHandlerOption) http.Handler {
handler := &web.Handler{
Srv: api.srv,
HandleFunc: h,
HandlerName: web.GetHandlerName(h),
RequireSession: true,
TrustRequester: false,
RequireMfa: false,
IsStatic: false,
IsLocal: false,
}
setHandlerOpts(handler, opts...)
if *api.srv.Config().ServiceSettings.WebserverMode == "gzip" {
return gzhttp.GzipHandler(handler)
}
return handler
}
// APIHandlerTrustRequester provides a handler for API endpoints which do not require the user to be logged in and are
// allowed to be requested directly rather than via javascript/XMLHttpRequest, such as site branding images or the
// websocket.
func (api *API) APIHandlerTrustRequester(h handlerFunc, opts ...APIHandlerOption) http.Handler {
handler := &web.Handler{
Srv: api.srv,
HandleFunc: h,
HandlerName: web.GetHandlerName(h),
RequireSession: false,
TrustRequester: true,
RequireMfa: false,
IsStatic: false,
IsLocal: false,
}
setHandlerOpts(handler, opts...)
if *api.srv.Config().ServiceSettings.WebserverMode == "gzip" {
return gzhttp.GzipHandler(handler)
}
return handler
}
// APISessionRequiredTrustRequester provides a handler for API endpoints which do require the user to be logged in and
// are allowed to be requested directly rather than via javascript/XMLHttpRequest, such as emoji or file uploads.
func (api *API) APISessionRequiredTrustRequester(h handlerFunc, opts ...APIHandlerOption) http.Handler {
handler := &web.Handler{
Srv: api.srv,
HandleFunc: h,
HandlerName: web.GetHandlerName(h),
RequireSession: true,
TrustRequester: true,
RequireMfa: true,
IsStatic: false,
IsLocal: false,
}
setHandlerOpts(handler, opts...)
if *api.srv.Config().ServiceSettings.WebserverMode == "gzip" {
return gzhttp.GzipHandler(handler)
}
return handler
}
// DisableWhenBusy provides a handler for API endpoints which should be disabled when the server is under load,
// responding with HTTP 503 (Service Unavailable).
func (api *API) APISessionRequiredDisableWhenBusy(h handlerFunc, opts ...APIHandlerOption) http.Handler {
handler := &web.Handler{
Srv: api.srv,
HandleFunc: h,
HandlerName: web.GetHandlerName(h),
RequireSession: true,
TrustRequester: false,
RequireMfa: true,
IsStatic: false,
IsLocal: false,
DisableWhenBusy: true,
}
setHandlerOpts(handler, opts...)
if *api.srv.Config().ServiceSettings.WebserverMode == "gzip" {
return gzhttp.GzipHandler(handler)
}
return handler
}
// APILocal provides a handler for API endpoints to be used in local
// mode, this is, through a UNIX socket and without an authenticated
// session, but with one that has no user set and no permission
// restrictions
func (api *API) APILocal(h handlerFunc, opts ...APIHandlerOption) http.Handler {
handler := &web.Handler{
Srv: api.srv,
HandleFunc: h,
HandlerName: web.GetHandlerName(h),
RequireSession: false,
TrustRequester: false,
RequireMfa: false,
IsStatic: false,
IsLocal: true,
}
setHandlerOpts(handler, opts...)
if *api.srv.Config().ServiceSettings.WebserverMode == "gzip" {
return gzhttp.GzipHandler(handler)
}
return handler
}
func (api *API) RateLimitedHandler(apiHandler http.Handler, settings model.RateLimitSettings) http.Handler {
if !*api.srv.Config().RateLimitSettings.Enable {
return apiHandler
}
settings.SetDefaults()
rateLimiter, err := app.NewRateLimiter(&settings, []string{})
if err != nil {
api.srv.Log().Error("getRateLimitedHandler", mlog.Err(err))
return nil
}
return rateLimiter.RateLimitHandler(apiHandler)
}
func requireLicense(c *Context) *model.AppError {
if c.App.Channels().License() == nil {
err := model.NewAppError("", "api.license_error", nil, "", http.StatusNotImplemented)
return err
}
return nil
}
func setHandlerOpts(handler *web.Handler, opts ...APIHandlerOption) {
if len(opts) == 0 {
return
}
for _, option := range opts {
switch option {
case handlerParamFileAPI:
handler.FileAPI = true
}
}
}

View File

@@ -0,0 +1,98 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func handlerForGzip(t *testing.T) func(*Context, http.ResponseWriter, *http.Request) {
return func(_ *Context, w http.ResponseWriter, _ *http.Request) {
// gziphandler default requires body size greater than 1400 bytes
var body [1400]byte
_, err := w.Write(body[:])
require.NoError(t, err)
}
}
func testAPIHandlerGzipMode(t *testing.T, name string, h http.Handler, token string) {
t.Run("Handler: "+name+" No Accept-Encoding", func(t *testing.T) {
resp := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/v4/test", nil)
req.Header.Set(model.HeaderAuth, "Bearer "+token)
h.ServeHTTP(resp, req)
assert.Equal(t, http.StatusOK, resp.Code)
assert.Equal(t, "", resp.Header().Get("Content-Encoding"))
})
t.Run("Handler: "+name+" With Accept-Encoding", func(t *testing.T) {
resp := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/v4/test", nil)
req.Header.Set("Accept-Encoding", "gzip")
req.Header.Set(model.HeaderAuth, "Bearer "+token)
h.ServeHTTP(resp, req)
assert.Equal(t, http.StatusOK, resp.Code)
assert.Equal(t, "gzip", resp.Header().Get("Content-Encoding"))
})
}
func testAPIHandlerNoGzipMode(t *testing.T, name string, h http.Handler, token string) {
t.Run("Handler: "+name+" No Accept-Encoding", func(t *testing.T) {
resp := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/v4/test", nil)
req.Header.Set(model.HeaderAuth, "Bearer "+token)
h.ServeHTTP(resp, req)
assert.Equal(t, http.StatusOK, resp.Code)
assert.Equal(t, "", resp.Header().Get("Content-Encoding"))
})
t.Run("Handler: "+name+" With Accept-Encoding", func(t *testing.T) {
resp := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/v4/test", nil)
req.Header.Set("Accept-Encoding", "gzip")
req.Header.Set(model.HeaderAuth, "Bearer "+token)
h.ServeHTTP(resp, req)
assert.Equal(t, http.StatusOK, resp.Code)
assert.Equal(t, "", resp.Header().Get("Content-Encoding"))
})
}
func TestAPIHandlersWithGzip(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
api, err := Init(th.Server)
require.NoError(t, err)
session, _ := th.App.GetSession(th.Client.AuthToken)
t.Run("with WebserverMode == \"gzip\"", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.WebserverMode = "gzip" })
testAPIHandlerGzipMode(t, "ApiHandler", api.APIHandler(handlerForGzip(t)), "")
testAPIHandlerGzipMode(t, "ApiSessionRequired", api.APISessionRequired(handlerForGzip(t)), session.Token)
testAPIHandlerGzipMode(t, "ApiSessionRequiredMfa", api.APISessionRequiredMfa(handlerForGzip(t)), session.Token)
testAPIHandlerGzipMode(t, "ApiHandlerTrustRequester", api.APIHandlerTrustRequester(handlerForGzip(t)), "")
testAPIHandlerGzipMode(t, "ApiSessionRequiredTrustRequester", api.APISessionRequiredTrustRequester(handlerForGzip(t)), session.Token)
})
t.Run("with WebserverMode == \"nogzip\"", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.WebserverMode = "nogzip" })
testAPIHandlerNoGzipMode(t, "ApiHandler", api.APIHandler(handlerForGzip(t)), "")
testAPIHandlerNoGzipMode(t, "ApiSessionRequired", api.APISessionRequired(handlerForGzip(t)), session.Token)
testAPIHandlerNoGzipMode(t, "ApiSessionRequiredMfa", api.APISessionRequiredMfa(handlerForGzip(t)), session.Token)
testAPIHandlerNoGzipMode(t, "ApiHandlerTrustRequester", api.APIHandlerTrustRequester(handlerForGzip(t)), "")
testAPIHandlerNoGzipMode(t, "ApiSessionRequiredTrustRequester", api.APISessionRequiredTrustRequester(handlerForGzip(t)), session.Token)
})
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"net/url"
"strconv"
"github.com/pkg/errors"
)
// pluckIDs maps a slice to the IDs produced by idFn, preserving order.
func pluckIDs[T any](items []T, idFn func(T) string) []string {
ids := make([]string, 0, len(items))
for _, item := range items {
ids = append(ids, idFn(item))
}
return ids
}
// containsByID reports whether any element of items yields id via idFn.
func containsByID[T any](items []T, id string, idFn func(T) string) bool {
for _, item := range items {
if idFn(item) == id {
return true
}
}
return false
}
func parseInt(u *url.URL, name string, defaultValue int) (int, error) {
valueStr := u.Query().Get(name)
if valueStr == "" {
return defaultValue, nil
}
value, err := strconv.Atoi(valueStr)
if err != nil {
return 0, errors.Wrapf(err, "failed to parse %s as integer", name)
}
return value, nil
}

View File

@@ -0,0 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"net/http"
"github.com/mattermost/mattermost/server/public/model"
)
// APIs for self-hosted workspaces to communicate with the backing customer & payments system.
// Endpoints for cloud installations should not go in this file.
func (api *API) InitHostedCustomer() {
// POST /api/v4/hosted_customer/available
api.BaseRoutes.HostedCustomer.Handle("/signup_available", api.APISessionRequired(handleSignupAvailable)).Methods(http.MethodGet)
}
func handleSignupAvailable(c *Context, w http.ResponseWriter, r *http.Request) {
const where = "Api4.handleSignupAvailable"
c.Err = model.NewAppError(where, "api.server.hosted_signup_unavailable.error", nil, "", http.StatusNotImplemented)
}

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"net/http"
"net/url"
"github.com/mattermost/mattermost/server/public/model"
)
func (api *API) InitImage() {
api.BaseRoutes.Image.Handle("", api.APISessionRequiredTrustRequester(getImage)).Methods(http.MethodGet)
}
func getImage(c *Context, w http.ResponseWriter, r *http.Request) {
actualURL := r.URL.Query().Get("url")
parsedURL, err := url.Parse(actualURL)
if err != nil {
c.Err = model.NewAppError("getImage", "api.image.get.app_error", nil, "", http.StatusBadRequest).Wrap(err)
return
} else if parsedURL.Opaque != "" {
c.Err = model.NewAppError("getImage", "api.image.get.app_error", nil, "", http.StatusBadRequest)
return
}
siteURL, err := url.Parse(*c.App.Config().ServiceSettings.SiteURL)
if err != nil {
c.Err = model.NewAppError("getImage", "model.config.is_valid.site_url.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if parsedURL.Scheme == "" {
parsedURL.Scheme = siteURL.Scheme
}
if parsedURL.Host == "" {
parsedURL.Host = siteURL.Host
}
if *c.App.Config().ImageProxySettings.Enable {
// in case image proxy is enabled and we are fetching a remote image (NOT static or served by plugins), pass request to proxy
if parsedURL.Host != siteURL.Host {
c.App.ImageProxy().GetImage(w, r, parsedURL.String())
} else {
// Otherwise we redirect.
http.Redirect(w, r, parsedURL.String(), http.StatusFound)
}
return
}
// When proxy disabled this endpoint should fail as we don't support redirecting to external images any longer (MM-54477).
c.Err = model.NewAppError("getImage", "api.image.get.app_error", nil, "", http.StatusBadRequest)
}

View File

@@ -0,0 +1,106 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestGetImage(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
// Prevent the test client from following a redirect
th.Client.HTTPClient.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
t.Run("proxy disabled", func(t *testing.T) {
imageURL := "http://foo.bar/baz.gif"
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ImageProxySettings.Enable = new(false)
})
r, err := http.NewRequest("GET", th.Client.APIURL+"/image?url="+url.QueryEscape(imageURL), nil)
require.NoError(t, err)
r.Header.Set(model.HeaderAuth, th.Client.AuthType+" "+th.Client.AuthToken)
// External images should not be allowed through this endpoint when proxy is disabled.
resp, err := th.Client.HTTPClient.Do(r)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("local", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ImageProxySettings.Enable = new(true)
cfg.ImageProxySettings.ImageProxyType = new("local")
// Allow requests to the "remote" image
cfg.ServiceSettings.AllowedUntrustedInternalConnections = new("127.0.0.1")
})
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, err := w.Write([]byte("success"))
require.NoError(t, err)
})
imageServer := httptest.NewServer(handler)
defer imageServer.Close()
r, err := http.NewRequest("GET", th.Client.APIURL+"/image?url="+url.QueryEscape(imageServer.URL+"/image.png"), nil)
require.NoError(t, err)
r.Header.Set(model.HeaderAuth, th.Client.AuthType+" "+th.Client.AuthToken)
resp, err := th.Client.HTTPClient.Do(r)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
respBody, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, "success", string(respBody))
// local images should not be proxied, but forwarded
r, err = http.NewRequest("GET", th.Client.APIURL+"/image?url=/plugins/test/image.png", nil)
require.NoError(t, err)
r.Header.Set(model.HeaderAuth, th.Client.AuthType+" "+th.Client.AuthToken)
resp, err = th.Client.HTTPClient.Do(r)
require.NoError(t, err)
assert.Equal(t, http.StatusFound, resp.StatusCode)
// protocol relative URLs should be handled by proxy
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ServiceSettings.SiteURL = new("http://foo.com")
})
r, err = http.NewRequest("GET", th.Client.APIURL+"/image?url="+strings.TrimPrefix(imageServer.URL, "http:")+"/image.png", nil)
require.NoError(t, err)
r.Header.Set(model.HeaderAuth, th.Client.AuthType+" "+th.Client.AuthToken)
resp, err = th.Client.HTTPClient.Do(r)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
// opaque URLs are not supported, should return an error
r, err = http.NewRequest("GET", th.Client.APIURL+"/image?url=mailto:test@example.com", nil)
require.NoError(t, err)
r.Header.Set(model.HeaderAuth, th.Client.AuthType+" "+th.Client.AuthToken)
resp, err = th.Client.HTTPClient.Do(r)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitImport() {
api.BaseRoutes.Imports.Handle("", api.APISessionRequired(listImports)).Methods(http.MethodGet)
api.BaseRoutes.Import.Handle("", api.APISessionRequired(deleteImport)).Methods(http.MethodDelete)
}
func listImports(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.IsSystemAdmin() {
c.SetPermissionError(model.PermissionManageSystem)
return
}
imports, appErr := c.App.ListImports()
if appErr != nil {
c.Err = appErr
return
}
if err := json.NewEncoder(w).Encode(imports); err != nil {
c.Logger.Warn("Error writing imports", mlog.Err(err))
}
}
func deleteImport(c *Context, w http.ResponseWriter, r *http.Request) {
importName := c.Params.ImportName
auditRec := c.MakeAuditRecord(model.AuditEventDeleteImport, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
auditRec.AddMeta("import_name", importName)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
if err := c.App.DeleteImport(importName); err != nil {
c.Err = err
return
}
auditRec.Success()
ReturnStatusOK(w)
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import "net/http"
func (api *API) InitImportLocal() {
api.BaseRoutes.Imports.Handle("", api.APILocal(listImports)).Methods(http.MethodGet)
api.BaseRoutes.Import.Handle("", api.APILocal(deleteImport)).Methods(http.MethodDelete)
}

View File

@@ -0,0 +1,184 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"os"
"path"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
)
// Helper function to upload a new import file
func uploadNewImport(th *TestHelper, c *model.Client4, t *testing.T) string {
testsDir, _ := fileutils.FindDir("tests")
require.NotEmpty(t, testsDir)
file, err := os.Open(testsDir + "/import_test.zip")
require.NoError(t, err)
info, err := file.Stat()
require.NoError(t, err)
us := &model.UploadSession{
Filename: info.Name(),
FileSize: info.Size(),
Type: model.UploadTypeImport,
}
if c == th.LocalClient {
us.UserId = model.UploadNoUserID
}
u, _, err := c.CreateUpload(context.Background(), us)
require.NoError(t, err)
require.NotNil(t, u)
finfo, _, err := c.UploadData(context.Background(), u.Id, file)
require.NoError(t, err)
require.NotNil(t, finfo)
return u.Id
}
func TestListImports(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
t.Run("no permissions", func(t *testing.T) {
imports, _, err := th.Client.ListImports(context.Background())
require.Error(t, err)
CheckErrorID(t, err, "api.context.permissions.app_error")
require.Nil(t, imports)
})
dataDir := *th.App.Config().FileSettings.Directory
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
imports, _, err := c.ListImports(context.Background())
require.NoError(t, err)
require.Empty(t, imports)
}, "no imports")
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
id := uploadNewImport(th, c, t)
id2 := uploadNewImport(th, c, t)
importDir := filepath.Join(dataDir, "import")
f, err := os.Create(filepath.Join(importDir, "import.zip.tmp"))
require.NoError(t, err)
f.Close()
imports, _, err := c.ListImports(context.Background())
require.NoError(t, err)
require.NotEmpty(t, imports)
require.Len(t, imports, 2)
require.Contains(t, imports, id+"_import_test.zip")
require.Contains(t, imports, id2+"_import_test.zip")
require.NoError(t, os.RemoveAll(importDir))
}, "expected imports")
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ImportSettings.Directory = "import_new" })
defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ImportSettings.Directory = "import" })
importDir := filepath.Join(dataDir, "import_new")
imports, _, err := c.ListImports(context.Background())
require.NoError(t, err)
require.Empty(t, imports)
id := uploadNewImport(th, c, t)
imports, _, err = c.ListImports(context.Background())
require.NoError(t, err)
require.NotEmpty(t, imports)
require.Len(t, imports, 1)
require.Equal(t, id+"_import_test.zip", imports[0])
require.NoError(t, os.RemoveAll(importDir))
}, "change import directory")
}
func TestImportInLocalMode(t *testing.T) {
mainHelper.Parallel(t)
th := SetupWithServerOptions(t, []app.Option{app.RunEssentialJobs})
testsDir, _ := fileutils.FindDir("tests")
require.NotEmpty(t, testsDir)
job := &model.Job{
Type: model.JobTypeImportProcess,
Data: map[string]string{
"import_file": path.Join(testsDir, "import_test.zip"),
"local_mode": "true",
},
}
received, _, err := th.SystemAdminClient.CreateJob(context.Background(), job)
require.NoError(t, err)
defer func() {
_, err = th.App.Srv().Store().Job().Delete(received.Id)
require.NoError(t, err, "Failed to delete job")
}()
cnt1, err := th.App.Srv().Store().Post().AnalyticsPostCount(&model.PostCountOptions{UsersPostsOnly: true})
require.NoError(t, err)
var appErr *model.AppError
for !(received.Status == model.JobStatusSuccess || received.Status == model.JobStatusError) {
received, appErr = th.App.GetJob(th.Context, received.Id)
require.Nil(t, appErr)
time.Sleep(5 * time.Second)
th.Context.Logger().Debug("Job status", mlog.String("status", received.Status))
}
require.Equal(t, model.JobStatusSuccess, received.Status)
cnt2, err := th.App.Srv().Store().Post().AnalyticsPostCount(&model.PostCountOptions{UsersPostsOnly: true})
require.NoError(t, err)
// Just a sanity check to ensure new posts are actually added in the system.
require.Greater(t, cnt2, cnt1)
}
func TestDeleteImport(t *testing.T) {
th := Setup(t)
t.Run("no delete permissions", func(t *testing.T) {
response, err := th.Client.DeleteImport(context.Background(), "import_test.zip")
require.Error(t, err)
CheckErrorID(t, err, "api.context.permissions.app_error")
require.Equal(t, 403, response.StatusCode)
})
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
id := uploadNewImport(th, th.SystemAdminClient, t)
id2 := uploadNewImport(th, th.SystemAdminClient, t)
response, delErr := th.SystemAdminClient.DeleteImport(context.Background(), id+"_import_test.zip")
require.Equal(t, 200, response.StatusCode)
require.NoError(t, delErr)
imports, _, err := th.SystemAdminClient.ListImports(context.Background())
require.NoError(t, err)
require.NotEmpty(t, imports)
require.Len(t, imports, 1)
require.Contains(t, imports, id2+"_import_test.zip")
require.NotContains(t, imports, id+"_import_test.zip")
_, err = th.SystemAdminClient.DeleteImport(context.Background(), id2+"_import_test.zip")
require.NoError(t, err)
//idempotency check
_, err = th.SystemAdminClient.DeleteImport(context.Background(), id2+"_import_test.zip")
require.NoError(t, err)
}, "successful deletion")
}

View File

@@ -0,0 +1,322 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"errors"
"io"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitAction() {
api.BaseRoutes.Post.Handle("/actions/{action_id:[A-Za-z0-9_-]+}", api.APISessionRequired(doPostAction)).Methods(http.MethodPost)
api.BaseRoutes.APIRoot.Handle("/actions/dialogs/open", api.APIHandler(openDialog)).Methods(http.MethodPost)
api.BaseRoutes.APIRoot.Handle("/actions/dialogs/submit", api.APISessionRequired(submitDialog)).Methods(http.MethodPost)
api.BaseRoutes.APIRoot.Handle("/actions/dialogs/lookup", api.APISessionRequired(lookupDialog)).Methods(http.MethodPost)
api.BaseRoutes.APIRoot.Handle("/actions/dialogs/execute", api.APISessionRequired(executeDialogAction)).Methods(http.MethodPost)
}
func doPostAction(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequirePostId()
if c.Err != nil {
return
}
var actionRequest model.DoPostActionRequest
dec := json.NewDecoder(r.Body)
err := dec.Decode(&actionRequest)
if err != nil && !errors.Is(err, io.EOF) {
// Empty body is allowed for backward-compatibility with older clients.
// Any other decode failure means the request cannot be trusted — in
// particular, a wrong-type query would otherwise fall through as nil
// and silently execute the action without the caller's params.
c.SetInvalidParamWithErr("action_request", err)
return
}
if err == nil {
// Reject trailing JSON values after the first object (e.g.
// `{"query":{"k":"v"}}{"cookie":"x"}`). json.Decoder.Decode
// stops at the first complete value and would otherwise silently
// ignore the rest, leaving the caller's intent ambiguous.
var trailing any
if extraErr := dec.Decode(&trailing); !errors.Is(extraErr, io.EOF) {
c.SetInvalidParamWithErr("action_request", extraErr)
return
}
}
var legacyCookie *model.PostActionCookie
var mmBlocksCookie *model.MmBlocksActionCookie
if actionRequest.Cookie != "" {
cookieStr, decErr := model.DecryptPostActionCookie(actionRequest.Cookie, c.App.PostActionCookieSecret())
if decErr != nil {
c.Err = model.NewAppError("DoPostAction", "api.post.do_action.action_integration.app_error", nil, "", http.StatusBadRequest).Wrap(decErr)
return
}
var parseErr error
legacyCookie, mmBlocksCookie, parseErr = model.ParseDecryptedActionCookiePayload(cookieStr)
if parseErr != nil {
c.Err = model.NewAppError("DoPostAction", "api.post.do_action.action_integration.app_error", nil, "", http.StatusBadRequest).Wrap(parseErr)
return
}
if !c.App.Config().FeatureFlags.MmBlocksEnabled && mmBlocksCookie != nil {
c.Err = model.NewAppError("DoPostAction", "api.post.do_action.action_integration.app_error", nil, "", http.StatusBadRequest).Wrap(errors.New("mm_blocks are not enabled"))
return
}
var cookiePostId string
var channelID string
if legacyCookie != nil {
cookiePostId = legacyCookie.PostId
channelID = legacyCookie.ChannelId
} else if mmBlocksCookie != nil {
cookiePostId = mmBlocksCookie.PostId
channelID = mmBlocksCookie.ChannelId
}
if cookiePostId != c.Params.PostId {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
channel, appErr := c.App.GetChannel(c.AppContext, channelID)
if appErr != nil {
c.Err = appErr
return
}
if ok, _ := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel); !ok {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
} else {
if ok, _ := c.App.SessionHasPermissionToReadPost(c.AppContext, *c.AppContext.Session(), c.Params.PostId); !ok {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
}
var appErr *model.AppError
resp := &model.PostActionAPIResponse{Status: "OK"}
resp.TriggerId, resp.GotoLocation, appErr = c.App.DoPostActionWithCookie(c.AppContext, c.Params.PostId, c.Params.ActionId, c.AppContext.Session().UserId,
actionRequest.SelectedOption, legacyCookie, mmBlocksCookie, actionRequest.Query, actionRequest.IntegrationFormat)
if appErr != nil {
c.Err = appErr
return
}
err = json.NewEncoder(w).Encode(resp)
if err != nil {
c.Logger.Warn("Error writing response", mlog.Err(err))
}
}
func openDialog(c *Context, w http.ResponseWriter, r *http.Request) {
var dialog model.OpenDialogRequest
err := json.NewDecoder(r.Body).Decode(&dialog)
if err != nil {
c.SetInvalidParamWithErr("dialog", err)
return
}
if dialog.URL == "" {
c.SetInvalidParam("url")
return
}
if appErr := c.App.OpenInteractiveDialog(c.AppContext, dialog); appErr != nil {
c.Err = appErr
return
}
ReturnStatusOK(w)
}
func submitDialog(c *Context, w http.ResponseWriter, r *http.Request) {
var submit model.SubmitDialogRequest
jsonErr := json.NewDecoder(r.Body).Decode(&submit)
if jsonErr != nil {
c.SetInvalidParamWithErr("dialog", jsonErr)
return
}
if submit.URL == "" {
c.SetInvalidParam("url")
return
}
submit.UserId = c.AppContext.Session().UserId
channel, err := c.App.GetChannel(c.AppContext, submit.ChannelId)
if err != nil {
c.Err = err
return
}
if ok, _ := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel); !ok {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
// Derive the team from the server-loaded channel, not the client-supplied
// TeamId — otherwise a client could omit TeamId on a team channel and skip
// the PermissionViewTeam check. channel.TeamId is empty for DM/GM channels.
if channel.TeamId != "" {
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
}
// The client-supplied TeamId is not trusted for the permission check above, so
// don't forward it to the integration either — use the channel's authoritative
// team (empty for DM/GM).
submit.TeamId = channel.TeamId
resp, err := c.App.SubmitInteractiveDialog(c.AppContext, submit)
if err != nil {
c.Err = err
return
}
b, _ := json.Marshal(resp)
if _, err := w.Write(b); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func executeDialogAction(c *Context, w http.ResponseWriter, r *http.Request) {
var request model.ExecuteDialogActionRequest
jsonErr := json.NewDecoder(r.Body).Decode(&request)
if jsonErr != nil {
c.SetInvalidParamWithErr("dialog_action", jsonErr)
return
}
if request.URL == "" {
c.SetInvalidParam("url")
return
}
if !model.IsValidLookupURL(request.URL) {
c.SetInvalidParam("url")
return
}
channel, err := c.App.GetChannel(c.AppContext, request.ChannelId)
if err != nil {
c.Err = err
return
}
if ok, _ := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel); !ok {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
// Derive the team from the server-loaded channel, not the client-supplied
// TeamId — otherwise a client could omit TeamId on a team channel and skip
// the PermissionViewTeam check. channel.TeamId is empty for DM/GM channels.
if channel.TeamId != "" {
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
}
// The client-supplied TeamId is not trusted for the permission check above,
// so don't trust it for the downstream team lookup / integration payload either.
// Use the channel's authoritative team (empty for DM/GM, which skips the lookup).
request.TeamId = channel.TeamId
triggerId, err := c.App.ExecuteDialogAction(c.AppContext, c.AppContext.Session().UserId, request)
if err != nil {
c.Err = err
return
}
resp := &model.ExecuteDialogActionResponse{TriggerId: triggerId}
if encErr := json.NewEncoder(w).Encode(resp); encErr != nil {
c.Logger.Warn("Error writing response", mlog.Err(encErr))
}
}
// lookupDialog handles API requests for dynamic dialog element lookups.
// It validates the request URL for security, checks user permissions, and
// delegates to the app layer for the actual lookup operation.
func lookupDialog(c *Context, w http.ResponseWriter, r *http.Request) {
var lookup model.SubmitDialogRequest
jsonErr := json.NewDecoder(r.Body).Decode(&lookup)
if jsonErr != nil {
c.SetInvalidParamWithErr("dialog", jsonErr)
return
}
if lookup.URL == "" {
c.SetInvalidParam("url")
return
}
// Validate URL for security
if !model.IsValidLookupURL(lookup.URL) {
c.SetInvalidParam("url")
return
}
lookup.UserId = c.AppContext.Session().UserId
channel, err := c.App.GetChannel(c.AppContext, lookup.ChannelId)
if err != nil {
c.Err = err
return
}
if ok, _ := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel); !ok {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
// Derive the team from the server-loaded channel, not the client-supplied
// TeamId — otherwise a client could omit TeamId on a team channel and skip
// the PermissionViewTeam check. channel.TeamId is empty for DM/GM channels.
if channel.TeamId != "" {
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
}
// The client-supplied TeamId is not trusted for the permission check above, so
// don't forward it to the integration either — use the channel's authoritative
// team (empty for DM/GM). Set before logging so the trace reflects the real team.
lookup.TeamId = channel.TeamId
c.Logger.Debug("Performing lookup dialog request",
mlog.String("url", lookup.URL),
mlog.String("user_id", lookup.UserId),
mlog.String("channel_id", lookup.ChannelId),
mlog.String("team_id", lookup.TeamId),
mlog.Any("selected_field", lookup.Submission["selected_field"]),
mlog.Any("query", lookup.Submission["query"]),
)
resp, err := c.App.LookupInteractiveDialog(c.AppContext, lookup)
if err != nil {
c.Logger.Error("Error performing lookup dialog", mlog.Err(err))
c.Err = err
return
}
b, _ := json.Marshal(resp)
if _, err := w.Write(b); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/einterfaces"
)
func (api *API) InitIPFiltering() {
api.BaseRoutes.IPFiltering.Handle("", api.APISessionRequired(getIPFilters)).Methods(http.MethodGet)
api.BaseRoutes.IPFiltering.Handle("", api.APISessionRequired(applyIPFilters)).Methods(http.MethodPost)
api.BaseRoutes.IPFiltering.Handle("/my_ip", api.APISessionRequired(myIP)).Methods(http.MethodGet)
}
func ensureIPFilteringInterface(c *Context, where string) (einterfaces.IPFilteringInterface, bool) {
license := c.App.License()
if c.App.IPFiltering() == nil || license == nil || !license.IsCloud() || !model.MinimumEnterpriseLicense(license) {
c.Err = model.NewAppError(where, "api.context.ip_filtering.not_available.app_error", nil, "", http.StatusNotImplemented)
return nil, false
}
return c.App.IPFiltering(), true
}
func getIPFilters(c *Context, w http.ResponseWriter, r *http.Request) {
ipFiltering, ok := ensureIPFilteringInterface(c, "getIPFilters")
if !ok {
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadIPFilters) {
c.SetPermissionError(model.PermissionSysconsoleReadIPFilters)
return
}
allowedRanges, err := ipFiltering.GetIPFilters()
if err != nil {
c.Err = model.NewAppError("getIPFilters", "api.context.ip_filtering.get_ip_filters.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if err := json.NewEncoder(w).Encode(allowedRanges); err != nil {
c.Err = model.NewAppError("getIPFilters", "api.context.ip_filtering.get_ip_filters.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
}
func applyIPFilters(c *Context, w http.ResponseWriter, r *http.Request) {
ipFiltering, ok := ensureIPFilteringInterface(c, "applyIPFilters")
if !ok {
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteIPFilters) {
c.SetPermissionError(model.PermissionSysconsoleWriteIPFilters)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventApplyIPFilters, model.AuditStatusFail)
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
allowedRanges := &model.AllowedIPRanges{} // Initialize the allowedRanges variable
if err := json.NewDecoder(r.Body).Decode(allowedRanges); err != nil {
c.Err = model.NewAppError("applyIPFilters", "api.context.ip_filtering.apply_ip_filters.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
model.AddEventParameterAuditableToAuditRec(auditRec, "IPFilter", allowedRanges)
updatedAllowedRanges, err := ipFiltering.ApplyIPFilters(allowedRanges)
if err != nil {
c.Err = model.NewAppError("applyIPFilters", "api.context.ip_filtering.apply_ip_filters.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.Success()
go func() {
if err := c.App.SendIPFiltersChangedEmail(c.AppContext, c.AppContext.Session().UserId); err != nil {
c.Logger.Warn("Failed to send IP filters changed email", mlog.Err(err))
}
}()
if err := json.NewEncoder(w).Encode(updatedAllowedRanges); err != nil {
c.Err = model.NewAppError("getIPFilters", "api.context.ip_filtering.get_ip_filters.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
}
func myIP(c *Context, w http.ResponseWriter, r *http.Request) {
_, ok := ensureIPFilteringInterface(c, "myIP")
if !ok {
return
}
response := &model.GetIPAddressResponse{
IP: c.AppContext.IPAddress(),
}
json, err := json.Marshal(response)
if err != nil {
c.Err = model.NewAppError("myIP", "api.context.ip_filtering.get_my_ip.failed", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(json); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

View File

@@ -0,0 +1,272 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
"github.com/stretchr/testify/require"
)
func Test_getIPFilters(t *testing.T) {
lic := &model.License{
Features: &model.Features{
CustomPermissionsSchemes: new(false),
Cloud: new(true),
},
Customer: &model.Customer{
Name: "TestName",
Email: "test@example.com",
},
SkuName: "SKU NAME",
SkuShortName: model.LicenseShortSkuEnterprise,
StartsAt: model.GetMillis() - 1000,
ExpiresAt: model.GetMillis() + 100000,
}
t.Run("No license returns 501", func(t *testing.T) {
th := Setup(t).InitBasic(t)
ipFiltering := &mocks.IPFilteringInterface{}
th.App.Srv().IPFiltering = ipFiltering
appErr := th.App.Srv().RemoveLicense()
require.Nil(t, appErr)
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
require.NoError(t, err)
ipFilters, r, err := th.Client.GetIPFilters(context.Background())
require.Error(t, err)
require.Nil(t, ipFilters)
require.Equal(t, 501, r.StatusCode)
})
t.Run("License but no permission", func(t *testing.T) {
th := Setup(t).InitBasic(t)
ipFiltering := &mocks.IPFilteringInterface{}
th.App.Srv().IPFiltering = ipFiltering
th.App.Srv().SetLicense(lic)
_, _, err := th.Client.Login(context.Background(), th.BasicUser2.Email, th.BasicUser2.Password)
require.NoError(t, err)
ipFilters, r, err := th.Client.GetIPFilters(context.Background())
require.Error(t, err)
require.Nil(t, ipFilters)
require.Equal(t, 403, r.StatusCode)
})
t.Run("License and permission", func(t *testing.T) {
th := Setup(t).InitBasic(t)
ipFiltering := &mocks.IPFilteringInterface{}
ipFiltering.Mock.On("GetIPFilters").Return(&model.AllowedIPRanges{
model.AllowedIPRange{
CIDRBlock: "127.0.0.1/32",
Description: "test",
},
}, nil)
th.App.Srv().IPFiltering = ipFiltering
th.App.Srv().SetLicense(lic)
_, _, err := th.Client.Login(context.Background(), th.SystemAdminUser.Email, th.SystemAdminUser.Password)
require.NoError(t, err)
ipFilters, r, err := th.Client.GetIPFilters(context.Background())
require.NoError(t, err)
require.NotNil(t, ipFilters)
require.Equal(t, 200, r.StatusCode)
})
t.Run("License and permission but not cloud returns 501", func(t *testing.T) {
th := Setup(t).InitBasic(t)
ipFiltering := &mocks.IPFilteringInterface{}
ipFiltering.Mock.On("GetIPFilters").Return(&model.AllowedIPRanges{
model.AllowedIPRange{
CIDRBlock: "127.0.0.1/32",
Description: "test",
},
}, nil)
th.App.Srv().IPFiltering = ipFiltering
lic.Features.Cloud = new(false)
th.App.Srv().SetLicense(lic)
_, _, err := th.Client.Login(context.Background(), th.SystemAdminUser.Email, th.SystemAdminUser.Password)
require.NoError(t, err)
ipFilters, r, err := th.Client.GetIPFilters(context.Background())
require.Error(t, err)
require.Nil(t, ipFilters)
require.Equal(t, 501, r.StatusCode)
})
}
func Test_applyIPFilters(t *testing.T) {
allowedRanges := &model.AllowedIPRanges{
model.AllowedIPRange{
CIDRBlock: "127.0.0.1/32",
Description: "test",
},
}
lic := model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise, "cloud")
lic.Id = "testlicenseid"
// Initialize the allowedRanges variable
t.Run("No license returns 501", func(t *testing.T) {
th := Setup(t).InitBasic(t)
ipFiltering := &mocks.IPFilteringInterface{}
th.App.Srv().IPFiltering = ipFiltering
appErr := th.App.Srv().RemoveLicense()
require.Nil(t, appErr)
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
require.NoError(t, err)
ipFilters, r, err := th.Client.ApplyIPFilters(context.Background(), allowedRanges)
require.Error(t, err)
require.Nil(t, ipFilters)
require.Equal(t, 501, r.StatusCode)
})
t.Run("License and permission but not cloud returns 501", func(t *testing.T) {
th := Setup(t).InitBasic(t)
ipFiltering := &mocks.IPFilteringInterface{}
th.App.Srv().IPFiltering = ipFiltering
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
_, _, err := th.Client.Login(context.Background(), th.SystemAdminUser.Email, th.SystemAdminUser.Password)
require.NoError(t, err)
ipFilters, r, err := th.Client.ApplyIPFilters(context.Background(), allowedRanges)
require.Error(t, err)
require.Nil(t, ipFilters)
require.Equal(t, 501, r.StatusCode)
})
t.Run("License but no permission", func(t *testing.T) {
th := Setup(t).InitBasic(t)
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
require.NoError(t, err)
ipFiltering := &mocks.IPFilteringInterface{}
th.App.Srv().IPFiltering = ipFiltering
th.App.Srv().SetLicense(lic)
ipFilters, r, err := th.Client.ApplyIPFilters(context.Background(), allowedRanges)
require.Error(t, err)
require.Nil(t, ipFilters)
require.Equal(t, 403, r.StatusCode)
})
t.Run("License and permission", func(t *testing.T) {
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(lic)
ipFiltering := &mocks.IPFilteringInterface{}
ipFiltering.Mock.On("ApplyIPFilters", mock.Anything).Return(&model.AllowedIPRanges{
model.AllowedIPRange{
CIDRBlock: "127.0.0.1/32",
Description: "test",
},
}, nil)
th.App.Srv().IPFiltering = ipFiltering
cloud := &mocks.CloudInterface{}
cloud.Mock.On("GetCloudCustomer", mock.Anything).Return(&model.CloudCustomer{
CloudCustomerInfo: model.CloudCustomerInfo{Email: "test@localhost"},
}, nil)
th.App.Srv().Cloud = cloud
_, _, err := th.Client.Login(context.Background(), th.SystemAdminUser.Email, th.SystemAdminUser.Password)
require.NoError(t, err)
ipFilters, r, err := th.Client.ApplyIPFilters(context.Background(), allowedRanges)
require.NoError(t, err)
require.NotNil(t, ipFilters)
require.Equal(t, 200, r.StatusCode)
})
}
func Test_getMyIP(t *testing.T) {
lic := &model.License{
Features: &model.Features{
CustomPermissionsSchemes: new(false),
Cloud: new(true),
},
Customer: &model.Customer{
Name: "TestName",
Email: "test@example.com",
},
SkuName: "SKU NAME",
SkuShortName: model.LicenseShortSkuEnterprise,
StartsAt: model.GetMillis() - 1000,
ExpiresAt: model.GetMillis() + 100000,
}
t.Run("No license returns 501", func(t *testing.T) {
th := Setup(t).InitBasic(t)
ipFiltering := &mocks.IPFilteringInterface{}
th.App.Srv().IPFiltering = ipFiltering
appErr := th.App.Srv().RemoveLicense()
require.Nil(t, appErr)
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
require.NoError(t, err)
myIP, r, err := th.Client.GetMyIP(context.Background())
require.Error(t, err)
require.Nil(t, myIP)
require.Equal(t, 501, r.StatusCode)
})
t.Run("Licensed but not cloud returns 501", func(t *testing.T) {
th := Setup(t).InitBasic(t)
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
require.NoError(t, err)
ipFiltering := &mocks.IPFilteringInterface{}
th.App.Srv().IPFiltering = ipFiltering
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
myIP, r, err := th.Client.GetMyIP(context.Background())
require.Error(t, err)
require.Nil(t, myIP)
require.Equal(t, 501, r.StatusCode)
})
t.Run("Licensed and cloud returns 200", func(t *testing.T) {
th := Setup(t).InitBasic(t)
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
require.NoError(t, err)
ipFiltering := &mocks.IPFilteringInterface{}
th.App.Srv().IPFiltering = ipFiltering
th.App.Srv().SetLicense(lic)
myIP, r, err := th.Client.GetMyIP(context.Background())
require.NoError(t, err)
require.NotNil(t, myIP)
require.Equal(t, 200, r.StatusCode)
})
}

472
server/channels/api4/job.go Normal file
View File

@@ -0,0 +1,472 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"cmp"
"encoding/json"
"net/http"
"path"
"path/filepath"
"slices"
"strconv"
"time"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/platform/shared/web"
)
func (api *API) InitJob() {
api.BaseRoutes.Jobs.Handle("", api.APISessionRequired(getJobs)).Methods(http.MethodGet)
api.BaseRoutes.Jobs.Handle("", api.APISessionRequired(createJob)).Methods(http.MethodPost)
api.BaseRoutes.Jobs.Handle("/{job_id:[A-Za-z0-9]+}", api.APISessionRequired(getJob)).Methods(http.MethodGet)
api.BaseRoutes.Jobs.Handle("/{job_id:[A-Za-z0-9]+}/download", api.APISessionRequiredTrustRequester(downloadJob)).Methods(http.MethodGet)
api.BaseRoutes.Jobs.Handle("/{job_id:[A-Za-z0-9]+}/cancel", api.APISessionRequired(cancelJob)).Methods(http.MethodPost)
api.BaseRoutes.Jobs.Handle("/type/{job_type:[A-Za-z0-9_-]+}", api.APISessionRequired(getJobsByType)).Methods(http.MethodGet)
api.BaseRoutes.Jobs.Handle("/{job_id:[A-Za-z0-9]+}/status", api.APISessionRequired(updateJobStatus)).Methods(http.MethodPatch)
}
func getJob(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireJobId()
if c.Err != nil {
return
}
job, err := c.App.GetJob(c.AppContext, c.Params.JobId)
if err != nil {
c.Err = err
return
}
hasPermission, permissionRequired := c.App.SessionHasPermissionToReadJob(*c.AppContext.Session(), job.Type)
if permissionRequired == nil {
c.Err = model.NewAppError("getJob", "api.job.retrieve.nopermissions", nil, "", http.StatusBadRequest)
return
}
if !hasPermission {
c.SetPermissionError(permissionRequired)
return
}
if err := json.NewEncoder(w).Encode(job); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func downloadJob(c *Context, w http.ResponseWriter, r *http.Request) {
config := c.App.Config()
const oldFilePath = "export"
const FileMime = "application/zip"
c.RequireJobId()
if c.Err != nil {
return
}
if !*config.MessageExportSettings.DownloadExportResults {
c.Err = model.NewAppError("downloadExportResultsNotEnabled", "app.job.download_export_results_not_enabled", nil, "", http.StatusNotImplemented)
return
}
job, err := c.App.GetJob(c.AppContext, c.Params.JobId)
if err != nil {
c.Err = err
return
}
// Currently, this endpoint only supports downloading the compliance report.
// If you need to download another job type, you will need to alter this section of the code to accommodate it.
if job.Type == model.JobTypeMessageExport && !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionDownloadComplianceExportResult) {
c.SetPermissionError(model.PermissionDownloadComplianceExportResult)
return
} else if job.Type != model.JobTypeMessageExport {
c.Err = model.NewAppError("unableToDownloadJob", "api.job.unable_to_download_job.incorrect_job_type", nil, "", http.StatusBadRequest)
return
}
isDownloadable, _ := strconv.ParseBool(job.Data["is_downloadable"])
if !isDownloadable {
c.Err = model.NewAppError("unableToDownloadJob", "api.job.unable_to_download_job", nil, "", http.StatusBadRequest)
return
}
exportDir, ok := job.Data["export_dir"]
fileName := path.Base(exportDir)
if !ok || exportDir == "" || fileName == "/" || fileName == "." {
// Could be a pre-overhaul job. Try the old method:
fileName = job.Id + ".zip"
filePath := filepath.Join(oldFilePath, fileName)
var fileReader filestore.ReadCloseSeeker
fileReader, err = c.App.ExportFileReader(filePath)
if err != nil {
c.Err = model.NewAppError("unableToDownloadJob", "api.job.unable_to_download_job", nil,
"job.Data did not include export_dir, export_dir was malformed, or jobId.zip wasn't found",
http.StatusNotFound).Wrap(err)
return
}
defer fileReader.Close()
// We are able to pass 0 for content size due to the fact that Golang's serveContent (https://golang.org/src/net/http/fs.go)
// already sets that for us
web.WriteFileResponse(fileName, FileMime, 0, time.UnixMilli(job.LastActivityAt), *c.App.Config().ServiceSettings.WebserverMode, fileReader, true, w, r)
return
}
// We have a base directory, we're using that as the exported filename:
fileName += ".zip"
cleanedExportDir := filepath.Clean(exportDir)
if !filepath.IsLocal(cleanedExportDir) {
c.Err = model.NewAppError("unableToDownloadJob", "api.job.unable_to_download_job", nil,
"job.Data did not include export_dir, export_dir was malformed, or jobId.zip wasn't found",
http.StatusNotFound)
return
}
zipReader, err := c.App.ExportZipReader(cleanedExportDir, false)
if err != nil {
c.Err = model.NewAppError("unableToDownloadJob", "api.job.unable_to_download_job", nil,
"error creating zip reader", http.StatusNotFound).Wrap(err)
return
}
defer zipReader.Close()
if err := web.WriteStreamResponse(w, zipReader, fileName, FileMime, true); err != nil {
c.Err = model.NewAppError("unableToDownloadJob", "api.job.unable_to_download_job", nil,
"failure to WriteStreamResponse", http.StatusInternalServerError).
Wrap(err)
return
}
}
func createJob(c *Context, w http.ResponseWriter, r *http.Request) {
var job model.Job
if jsonErr := json.NewDecoder(r.Body).Decode(&job); jsonErr != nil {
c.SetInvalidParamWithErr("job", jsonErr)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventCreateJob, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "job", &job)
hasPermission, permissionRequired := c.App.SessionHasPermissionToCreateJob(*c.AppContext.Session(), &job)
if permissionRequired == nil {
c.Err = model.NewAppError("unableToCreateJob", "api.job.unable_to_create_job.incorrect_job_type", nil, "", http.StatusBadRequest)
return
}
if !hasPermission {
c.SetPermissionError(permissionRequired)
return
}
// Inject requester context for self-inclusion resolution.
if job.Type == model.JobTypeAccessControlSync {
if job.Data == nil {
job.Data = make(model.StringMap)
}
job.Data["requester_id"] = c.AppContext.Session().UserId
if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
job.Data["requester_is_admin"] = "true"
} else {
delete(job.Data, "requester_is_admin")
}
}
rjob, err := c.App.CreateJob(c.AppContext, &job)
if err != nil {
c.Err = err
return
}
auditRec.Success()
auditRec.AddEventResultState(rjob)
auditRec.AddEventObjectType("job")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(rjob); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getJobs(c *Context, w http.ResponseWriter, r *http.Request) {
if c.Err != nil {
return
}
jobType := r.URL.Query().Get("job_type")
var validJobTypes []string
if jobType != "" {
isValidJobType := model.IsValidJobType(jobType)
if !isValidJobType {
c.SetInvalidURLParam("job_type")
return
}
hasPermission, permissionRequired := c.App.SessionHasPermissionToReadJob(*c.AppContext.Session(), jobType)
if permissionRequired == nil {
c.Err = model.NewAppError("getJobsByType", "api.job.retrieve.nopermissions", nil, "", http.StatusBadRequest)
return
}
if !hasPermission {
c.SetPermissionError(permissionRequired)
return
}
validJobTypes = append(validJobTypes, jobType)
} else {
for _, jType := range model.AllJobTypes {
hasPermission, permissionRequired := c.App.SessionHasPermissionToReadJob(*c.AppContext.Session(), jType)
if permissionRequired == nil {
c.Logger.Warn("The job types of a job you are trying to retrieve does not contain permissions", mlog.String("job_type", jType))
continue
}
if hasPermission {
validJobTypes = append(validJobTypes, jType)
}
}
}
if len(validJobTypes) == 0 {
c.SetPermissionError()
return
}
status := r.URL.Query().Get("status")
isValidStatus := model.IsValidJobStatus(status)
if status != "" && !isValidStatus {
c.Err = model.NewAppError("getJobs", "api.job.status.invalid", nil, "", http.StatusBadRequest)
return
}
var jobs []*model.Job
var appErr *model.AppError
if status == "" {
jobs, appErr = c.App.GetJobsByTypesPage(c.AppContext, validJobTypes, c.Params.Page, c.Params.PerPage)
} else {
jobs, appErr = c.App.GetJobsByTypesAndStatuses(c.AppContext, validJobTypes, []string{status}, c.Params.Page, c.Params.PerPage)
}
if appErr != nil {
c.Err = appErr
return
}
js, err := json.Marshal(jobs)
if err != nil {
c.Err = model.NewAppError("getJobs", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getJobsByType(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireJobType()
if c.Err != nil {
return
}
hasPermission, permissionRequired := c.App.SessionHasPermissionToReadJob(*c.AppContext.Session(), c.Params.JobType)
if permissionRequired == nil {
c.Err = model.NewAppError("getJobsByType", "api.job.retrieve.nopermissions", nil, "", http.StatusBadRequest)
return
}
// Team admin path: allow reading access_control_sync jobs scoped to their team.
teamID := r.URL.Query().Get("team_id")
hasTeamFilter := false
if teamID != "" {
if !model.IsValidId(teamID) {
c.SetInvalidURLParam("team_id")
return
}
hasTeamFilter = true
}
isTeamScopedSyncRequest := !hasPermission &&
c.Params.JobType == model.JobTypeAccessControlSync &&
hasTeamFilter &&
c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamID, model.PermissionManageTeamAccessRules)
// A team-type policy's ID is its team ID, so filtering team sync jobs by
// policy_id keeps a team admin scoped to their own team. Requires the absence
// of team_id so this grant can never authorize an unvetted team filter.
policyID := r.URL.Query().Get("policy_id")
isTeamPolicyScopedSyncRequest := !hasPermission &&
c.Params.JobType == model.JobTypeAccessControlTeamSync &&
!hasTeamFilter &&
policyID != "" && model.IsValidId(policyID) &&
c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), policyID, model.PermissionManageTeamAccessRules)
if !hasPermission && !isTeamScopedSyncRequest && !isTeamPolicyScopedSyncRequest {
c.SetPermissionError(permissionRequired)
return
}
var jobs []*model.Job
if hasTeamFilter {
// When team_id is provided, return only jobs scoped to that team.
// Sorted by CreateAt DESC; limited to the requested page size.
teamJobs, appErr := c.App.GetJobsByTypeAndData(c.AppContext, c.Params.JobType, map[string]string{"team_id": teamID})
if appErr != nil {
c.Err = appErr
return
}
slices.SortFunc(teamJobs, func(a, b *model.Job) int {
return cmp.Compare(b.CreateAt, a.CreateAt)
})
start := c.Params.Page * c.Params.PerPage
if start >= len(teamJobs) {
jobs = []*model.Job{}
} else {
end := min(start+c.Params.PerPage, len(teamJobs))
jobs = teamJobs[start:end]
}
} else if policyID != "" && (c.Params.JobType == model.JobTypeAccessControlSync || c.Params.JobType == model.JobTypeAccessControlTeamSync) {
// Otherwise only system admins may filter by policy_id, to prevent job
// enumeration across policies.
if !isTeamPolicyScopedSyncRequest && !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
policyJobs, appErr := c.App.GetJobsByTypeAndData(c.AppContext, c.Params.JobType,
map[string]string{"policy_id": policyID})
if appErr != nil {
c.Err = appErr
return
}
slices.SortFunc(policyJobs, func(a, b *model.Job) int {
return cmp.Compare(b.CreateAt, a.CreateAt)
})
start := c.Params.Page * c.Params.PerPage
if start >= len(policyJobs) {
jobs = []*model.Job{}
} else {
end := min(start+c.Params.PerPage, len(policyJobs))
jobs = policyJobs[start:end]
}
} else {
// Store returns jobs ordered by CreateAt DESC; pagination applied at the store level.
var appErr *model.AppError
jobs, appErr = c.App.GetJobsByTypePage(c.AppContext, c.Params.JobType, c.Params.Page, c.Params.PerPage)
if appErr != nil {
c.Err = appErr
return
}
}
js, err := json.Marshal(jobs)
if err != nil {
c.Err = model.NewAppError("getJobsByType", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func cancelJob(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireJobId()
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventCancelJob, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "job_id", c.Params.JobId)
job, err := c.App.GetJob(c.AppContext, c.Params.JobId)
if err != nil {
c.Err = err
return
}
auditRec.AddEventPriorState(job)
auditRec.AddEventObjectType("job")
// if permission to create, permission to cancel, same permission
hasPermission, permissionRequired := c.App.SessionHasPermissionToCreateJob(*c.AppContext.Session(), job)
if permissionRequired == nil {
c.Err = model.NewAppError("unableToCancelJob", "api.job.unable_to_create_job.incorrect_job_type", nil, "", http.StatusBadRequest)
return
}
if !hasPermission {
c.SetPermissionError(permissionRequired)
return
}
if err := c.App.CancelJob(c.AppContext, c.Params.JobId); err != nil {
c.Err = err
return
}
auditRec.Success()
ReturnStatusOK(w)
}
func updateJobStatus(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireJobId()
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventUpdateJobStatus, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "job_id", c.Params.JobId)
props := model.StringInterfaceFromJSON(r.Body)
status, ok := props["status"].(string)
if !ok {
c.SetInvalidParam("status")
return
}
force, ok := props["force"].(bool)
if !ok {
force = false
}
job, err := c.App.GetJob(c.AppContext, c.Params.JobId)
if err != nil {
c.Err = err
return
}
auditRec.AddEventPriorState(job)
auditRec.AddEventObjectType("job")
hasPermission, permissionRequired := c.App.SessionHasPermissionToManageJob(*c.AppContext.Session(), job)
if permissionRequired == nil {
c.Err = model.NewAppError("updateJobStatus", "api.job.unable_to_manage_job.incorrect_job_type", nil, "", http.StatusBadRequest)
return
}
if !hasPermission {
c.SetPermissionError(permissionRequired)
return
}
if !force && !job.IsValidStatusChange(status) {
c.Err = model.NewAppError("updateJobStatus", "api.job.status.invalid", nil, "", http.StatusBadRequest)
return
}
if err := c.App.UpdateJobStatus(c.AppContext, job, status); err != nil {
c.Err = err
return
}
auditRec.Success()
ReturnStatusOK(w)
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import "net/http"
func (api *API) InitJobLocal() {
api.BaseRoutes.Jobs.Handle("", api.APILocal(getJobs)).Methods(http.MethodGet)
api.BaseRoutes.Jobs.Handle("", api.APILocal(createJob)).Methods(http.MethodPost)
api.BaseRoutes.Jobs.Handle("/{job_id:[A-Za-z0-9]+}", api.APILocal(getJob)).Methods(http.MethodGet)
api.BaseRoutes.Jobs.Handle("/{job_id:[A-Za-z0-9]+}/download", api.APILocal(downloadJob)).Methods(http.MethodGet)
api.BaseRoutes.Jobs.Handle("/{job_id:[A-Za-z0-9]+}/cancel", api.APILocal(cancelJob)).Methods(http.MethodPost)
api.BaseRoutes.Jobs.Handle("/type/{job_type:[A-Za-z0-9_-]+}", api.APILocal(getJobsByType)).Methods(http.MethodGet)
api.BaseRoutes.Jobs.Handle("/{job_id:[A-Za-z0-9]+}/status", api.APILocal(updateJobStatus)).Methods(http.MethodPatch)
}

View File

@@ -0,0 +1,957 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestCreateJob(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
th.LoginSystemManager(t)
job := &model.Job{
Type: model.JobTypeActiveUsers,
Data: map[string]string{
"thing": "stuff",
},
}
t.Run("valid job as user without permissions", func(t *testing.T) {
_, resp, err := th.SystemManagerClient.CreateJob(context.Background(), job)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("valid job as user with permissions", func(t *testing.T) {
received, _, err := th.SystemAdminClient.CreateJob(context.Background(), job)
require.NoError(t, err)
defer func() {
result, appErr := th.App.Srv().Store().Job().Delete(received.Id)
require.NoErrorf(t, appErr, "Failed to delete job (result: %v): %v", result, appErr)
}()
})
t.Run("invalid job type as user without permissions", func(t *testing.T) {
_, resp, err := th.SystemAdminClient.CreateJob(context.Background(), &model.Job{Type: model.NewId()})
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
}
func TestCreateNotifyExpiringAccessTokensJob(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
th.LoginSystemManager(t)
job := &model.Job{Type: model.JobTypeNotifyExpiringAccessTokens}
t.Run("forbidden without manage_jobs permission", func(t *testing.T) {
_, resp, err := th.SystemManagerClient.CreateJob(context.Background(), job)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("allowed as system admin", func(t *testing.T) {
received, _, err := th.SystemAdminClient.CreateJob(context.Background(), job)
require.NoError(t, err)
defer func() {
result, appErr := th.App.Srv().Store().Job().Delete(received.Id)
require.NoErrorf(t, appErr, "Failed to delete job (result: %v): %v", result, appErr)
}()
require.Equal(t, model.JobTypeNotifyExpiringAccessTokens, received.Type)
})
}
func TestGetJob(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
job := &model.Job{
Id: model.NewId(),
Status: model.JobStatusPending,
Type: model.JobTypeMessageExport,
}
_, err := th.App.Srv().Store().Job().Save(job)
require.NoError(t, err)
defer func() {
result, appErr := th.App.Srv().Store().Job().Delete(job.Id)
require.NoError(t, appErr, "Failed to delete job (result: %v)", result)
}()
received, _, err := th.SystemAdminClient.GetJob(context.Background(), job.Id)
require.NoError(t, err)
require.Equal(t, job.Id, received.Id, "incorrect job received")
require.Equal(t, job.Status, received.Status, "incorrect job received")
_, resp, err := th.SystemAdminClient.GetJob(context.Background(), "1234")
require.Error(t, err)
CheckBadRequestStatus(t, resp)
_, resp, err = th.Client.GetJob(context.Background(), job.Id)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
_, resp, err = th.SystemAdminClient.GetJob(context.Background(), model.NewId())
require.Error(t, err)
CheckNotFoundStatus(t, resp)
}
func TestGetJobs(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
jobType := model.JobTypeDataRetention
t0 := model.GetMillis()
jobs := []*model.Job{
{
Id: model.NewId(),
Type: jobType,
CreateAt: t0 + 1,
},
{
Id: model.NewId(),
Type: jobType,
CreateAt: t0,
},
{
Id: model.NewId(),
Type: jobType,
CreateAt: t0 + 2,
},
{
Id: model.NewId(),
Type: model.JobTypeLdapSync,
CreateAt: t0 + 3,
Status: model.JobStatusPending,
},
}
for _, job := range jobs {
_, err := th.App.Srv().Store().Job().Save(job)
require.NoError(t, err)
defer func(jobId string) {
result, appErr := th.App.Srv().Store().Job().Delete(jobId)
require.NoError(t, appErr, "Failed to delete job (result: %v)", result)
}(job.Id)
}
t.Run("Get 2 jobs", func(t *testing.T) {
received, _, err := th.SystemAdminClient.GetJobs(context.Background(), "", "", 0, 2)
require.NoError(t, err)
require.Len(t, received, 2, "received wrong number of jobs")
require.Equal(t, jobs[3].Id, received[0].Id, "should've received newest job first")
require.Equal(t, jobs[2].Id, received[1].Id, "should've received second newest job second")
})
t.Run("Get oldest job using paging", func(t *testing.T) {
received, _, err := th.SystemAdminClient.GetJobs(context.Background(), "", "", 1, 3)
require.NoError(t, err)
require.Equal(t, jobs[1].Id, received[0].Id, "should've received oldest job last")
})
t.Run("Return error fetching job without permissions", func(t *testing.T) {
_, resp, err := th.Client.GetJobs(context.Background(), "", "", 0, 60)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("Get job by type", func(t *testing.T) {
received, _, err := th.SystemAdminClient.GetJobs(context.Background(), model.JobTypeLdapSync, "", 0, 3)
require.NoError(t, err)
require.Len(t, received, 1, "received wrong number of jobs")
require.Equal(t, jobs[3].Id, received[0].Id, "should've received the ldap sync job")
})
t.Run("Get job by status", func(t *testing.T) {
received, _, err := th.SystemAdminClient.GetJobs(context.Background(), "", model.JobStatusPending, 0, 3)
require.NoError(t, err)
require.Len(t, received, 1, "received wrong number of jobs")
require.Equal(t, jobs[3].Id, received[0].Id, "should've received the ldap sync job")
})
t.Run("Get job by type and status", func(t *testing.T) {
received, _, err := th.SystemAdminClient.GetJobs(context.Background(), model.JobTypeLdapSync, model.JobStatusPending, 0, 3)
require.NoError(t, err)
require.Len(t, received, 1, "received wrong number of jobs")
require.Equal(t, jobs[3].Id, received[0].Id, "should've received the ldap sync job")
})
t.Run("Return 400 for invalid status", func(t *testing.T) {
_, resp, err := th.SystemAdminClient.GetJobs(context.Background(), "", "not_a_valid_status", 0, 60)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
}
func TestGetJobsByType(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
th.LoginSystemManager(t)
jobType := model.JobTypeDataRetention
jobs := []*model.Job{
{
Id: model.NewId(),
Type: jobType,
CreateAt: 1000,
},
{
Id: model.NewId(),
Type: jobType,
CreateAt: 999,
},
{
Id: model.NewId(),
Type: jobType,
CreateAt: 1001,
},
{
Id: model.NewId(),
Type: model.NewId(),
CreateAt: 1002,
},
}
for _, job := range jobs {
_, err := th.App.Srv().Store().Job().Save(job)
require.NoError(t, err)
defer func(jobId string) {
result, appErr := th.App.Srv().Store().Job().Delete(jobId)
require.NoError(t, appErr, "Failed to delete job (result: %v)", result)
}(job.Id)
}
received, _, err := th.SystemAdminClient.GetJobsByType(context.Background(), jobType, 0, 2)
require.NoError(t, err)
require.Len(t, received, 2, "received wrong number of jobs")
require.Equal(t, jobs[2].Id, received[0].Id, "should've received newest job first")
require.Equal(t, jobs[0].Id, received[1].Id, "should've received second newest job second")
received, _, err = th.SystemAdminClient.GetJobsByType(context.Background(), jobType, 1, 2)
require.NoError(t, err)
require.Len(t, received, 1, "received wrong number of jobs")
require.Equal(t, jobs[1].Id, received[0].Id, "should've received oldest job last")
_, resp, err := th.SystemAdminClient.GetJobsByType(context.Background(), "", 0, 60)
require.Error(t, err)
CheckNotFoundStatus(t, resp)
_, resp, err = th.SystemAdminClient.GetJobsByType(context.Background(), strings.Repeat("a", 33), 0, 60)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
_, resp, err = th.Client.GetJobsByType(context.Background(), jobType, 0, 60)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
_, _, err = th.SystemManagerClient.GetJobsByType(context.Background(), model.JobTypeElasticsearchPostIndexing, 0, 60)
require.NoError(t, err)
_, _, err = th.SystemAdminClient.GetJobsByType(context.Background(), model.JobTypePostPersistentNotifications, 0, 60)
require.NoError(t, err)
_, _, err = th.SystemAdminClient.GetJobsByType(context.Background(), model.JobTypeDeleteExpiredPosts, 0, 60)
require.NoError(t, err)
}
func TestGetJobsByTypeWithPolicyIDFilter(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
th.LoginSystemManager(t)
policyID := model.NewId()
otherPolicyID := model.NewId()
t0 := model.GetMillis()
jobs := []*model.Job{
{
Id: model.NewId(),
Type: model.JobTypeAccessControlSync,
CreateAt: t0,
Data: map[string]string{"policy_id": policyID},
},
{
Id: model.NewId(),
Type: model.JobTypeAccessControlSync,
CreateAt: t0 + 1,
Data: map[string]string{"policy_id": policyID},
},
{
Id: model.NewId(),
Type: model.JobTypeAccessControlSync,
CreateAt: t0 + 2,
Data: map[string]string{"policy_id": otherPolicyID},
},
}
for _, job := range jobs {
_, err := th.App.Srv().Store().Job().Save(job)
require.NoError(t, err)
defer func(jobID string) {
_, appErr := th.App.Srv().Store().Job().Delete(jobID)
require.NoError(t, appErr, "Failed to delete job %s", jobID)
}(job.Id)
}
t.Run("policy_id filter returns only matching jobs", func(t *testing.T) {
resp, err := th.SystemAdminClient.DoAPIGet(
context.Background(),
"/jobs/type/"+model.JobTypeAccessControlSync+"?page=0&per_page=60&policy_id="+policyID,
"",
)
require.NoError(t, err)
defer resp.Body.Close()
var received []*model.Job
require.NoError(t, json.NewDecoder(resp.Body).Decode(&received))
require.Len(t, received, 2)
// Newest first
require.Equal(t, jobs[1].Id, received[0].Id)
require.Equal(t, jobs[0].Id, received[1].Id)
})
t.Run("policy_id filter excludes other policies", func(t *testing.T) {
resp, err := th.SystemAdminClient.DoAPIGet(
context.Background(),
"/jobs/type/"+model.JobTypeAccessControlSync+"?page=0&per_page=60&policy_id="+otherPolicyID,
"",
)
require.NoError(t, err)
defer resp.Body.Close()
var received []*model.Job
require.NoError(t, json.NewDecoder(resp.Body).Decode(&received))
require.Len(t, received, 1)
require.Equal(t, jobs[2].Id, received[0].Id)
})
t.Run("policy_id filter on non-access_control_sync type is ignored", func(t *testing.T) {
// Save a data-retention job with a policy_id field (unusual, but proves the filter is ignored)
drJob := &model.Job{
Id: model.NewId(),
Type: model.JobTypeDataRetention,
CreateAt: t0 + 3,
Data: map[string]string{"policy_id": policyID},
}
_, err := th.App.Srv().Store().Job().Save(drJob)
require.NoError(t, err)
defer func() {
_, appErr := th.App.Srv().Store().Job().Delete(drJob.Id)
require.NoError(t, appErr)
}()
resp, err := th.SystemAdminClient.DoAPIGet(
context.Background(),
"/jobs/type/"+model.JobTypeDataRetention+"?page=0&per_page=60&policy_id="+policyID,
"",
)
require.NoError(t, err)
defer resp.Body.Close()
var received []*model.Job
require.NoError(t, json.NewDecoder(resp.Body).Decode(&received))
// policy_id is ignored for non-access_control_sync; all data-retention jobs are returned
ids := make([]string, len(received))
for i, j := range received {
ids[i] = j.Id
}
require.Contains(t, ids, drJob.Id)
})
t.Run("policy_id filter requires system admin permission", func(t *testing.T) {
// SessionHasPermissionToReadJob for JobTypeAccessControlSync already requires
// PermissionManageSystem (see app/job.go), so the policyID guard in getJobsByType
// acts as defence-in-depth. Use SystemManagerClient — a role that has many admin
// privileges but intentionally lacks PermissionManageSystem — to verify that any
// caller without manage_system is denied (403) at the read-job gate before the
// policyID branch is even reached.
resp, err := th.SystemManagerClient.DoAPIGet(
context.Background(),
"/jobs/type/"+model.JobTypeAccessControlSync+"?page=0&per_page=60&policy_id="+policyID,
"",
)
require.Error(t, err)
require.Equal(t, 403, resp.StatusCode)
resp.Body.Close()
})
t.Run("without policy_id returns all access_control_sync jobs", func(t *testing.T) {
resp, err := th.SystemAdminClient.DoAPIGet(
context.Background(),
"/jobs/type/"+model.JobTypeAccessControlSync+"?page=0&per_page=60",
"",
)
require.NoError(t, err)
defer resp.Body.Close()
var received []*model.Job
require.NoError(t, json.NewDecoder(resp.Body).Decode(&received))
ids := make([]string, len(received))
for i, j := range received {
ids[i] = j.Id
}
require.Contains(t, ids, jobs[0].Id)
require.Contains(t, ids, jobs[1].Id)
require.Contains(t, ids, jobs[2].Id)
})
t.Run("policy_id with no matching jobs returns empty list not error", func(t *testing.T) {
unknownPolicyID := model.NewId()
resp, err := th.SystemAdminClient.DoAPIGet(
context.Background(),
"/jobs/type/"+model.JobTypeAccessControlSync+"?page=0&per_page=60&policy_id="+unknownPolicyID,
"",
)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
var received []*model.Job
require.NoError(t, json.NewDecoder(resp.Body).Decode(&received))
require.Empty(t, received)
})
t.Run("policy_id filter respects page and per_page pagination", func(t *testing.T) {
// Two jobs match policyID (jobs[0] at t0, jobs[1] at t0+1). Sorted newest-first,
// so page=0,per_page=1 → jobs[1]; page=1,per_page=1 → jobs[0]; page=2 → empty.
resp0, err := th.SystemAdminClient.DoAPIGet(
context.Background(),
"/jobs/type/"+model.JobTypeAccessControlSync+"?page=0&per_page=1&policy_id="+policyID,
"",
)
require.NoError(t, err)
defer resp0.Body.Close()
var page0 []*model.Job
require.NoError(t, json.NewDecoder(resp0.Body).Decode(&page0))
require.Len(t, page0, 1)
require.Equal(t, jobs[1].Id, page0[0].Id, "page 0 should be the newest job")
resp1, err := th.SystemAdminClient.DoAPIGet(
context.Background(),
"/jobs/type/"+model.JobTypeAccessControlSync+"?page=1&per_page=1&policy_id="+policyID,
"",
)
require.NoError(t, err)
defer resp1.Body.Close()
var page1 []*model.Job
require.NoError(t, json.NewDecoder(resp1.Body).Decode(&page1))
require.Len(t, page1, 1)
require.Equal(t, jobs[0].Id, page1[0].Id, "page 1 should be the older job")
resp2, err := th.SystemAdminClient.DoAPIGet(
context.Background(),
"/jobs/type/"+model.JobTypeAccessControlSync+"?page=2&per_page=1&policy_id="+policyID,
"",
)
require.NoError(t, err)
defer resp2.Body.Close()
var page2 []*model.Job
require.NoError(t, json.NewDecoder(resp2.Body).Decode(&page2))
require.Empty(t, page2, "page beyond last should be empty")
})
}
func TestGetJobsByType_TeamAdminAccessControlSync(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.AddPermissionToRole(t, model.PermissionManageTeamAccessRules.Id, model.TeamAdminRoleId)
teamJob := &model.Job{
Id: model.NewId(),
Type: model.JobTypeAccessControlSync,
Status: model.JobStatusSuccess,
CreateAt: model.GetMillis(),
Data: map[string]string{"team_id": th.BasicTeam.Id, "policy_id": "p1"},
}
otherTeamJob := &model.Job{
Id: model.NewId(),
Type: model.JobTypeAccessControlSync,
Status: model.JobStatusSuccess,
CreateAt: model.GetMillis(),
Data: map[string]string{"team_id": model.NewId(), "policy_id": "p2"},
}
systemJob := &model.Job{
Id: model.NewId(),
Type: model.JobTypeAccessControlSync,
Status: model.JobStatusSuccess,
CreateAt: model.GetMillis(),
Data: map[string]string{"policy_id": "p3"},
}
for _, job := range []*model.Job{teamJob, otherTeamJob, systemJob} {
_, err := th.App.Srv().Store().Job().Save(job)
require.NoError(t, err)
defer func(id string) {
_, _ = th.App.Srv().Store().Job().Delete(id)
}(job.Id)
}
t.Run("team admin can see only their team's sync jobs", func(t *testing.T) {
th.LoginTeamAdmin(t)
defer th.LoginBasic(t)
jobs, _, err := th.Client.GetJobsByTypeForTeam(context.Background(), model.JobTypeAccessControlSync, 0, 60, th.BasicTeam.Id)
require.NoError(t, err)
require.Len(t, jobs, 1)
require.Equal(t, teamJob.Id, jobs[0].Id)
})
t.Run("team admin cannot see sync jobs without team_id", func(t *testing.T) {
th.LoginTeamAdmin(t)
defer th.LoginBasic(t)
_, resp, err := th.Client.GetJobsByType(context.Background(), model.JobTypeAccessControlSync, 0, 60)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("team admin cannot read non-sync job types with team_id", func(t *testing.T) {
th.LoginTeamAdmin(t)
defer th.LoginBasic(t)
_, resp, err := th.Client.GetJobsByTypeForTeam(context.Background(), model.JobTypeDataRetention, 0, 60, th.BasicTeam.Id)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("system admin sees all sync jobs without team_id filter", func(t *testing.T) {
jobs, _, err := th.SystemAdminClient.GetJobsByType(context.Background(), model.JobTypeAccessControlSync, 0, 60)
require.NoError(t, err)
require.GreaterOrEqual(t, len(jobs), 3)
})
t.Run("system admin with team_id filter sees only that team's jobs", func(t *testing.T) {
jobs, _, err := th.SystemAdminClient.GetJobsByTypeForTeam(context.Background(), model.JobTypeAccessControlSync, 0, 60, th.BasicTeam.Id)
require.NoError(t, err)
require.Len(t, jobs, 1)
require.Equal(t, teamJob.Id, jobs[0].Id)
})
t.Run("returns empty when no team-scoped jobs exist for that team", func(t *testing.T) {
jobs, _, err := th.SystemAdminClient.GetJobsByTypeForTeam(context.Background(), model.JobTypeAccessControlSync, 0, 60, model.NewId())
require.NoError(t, err)
require.Empty(t, jobs)
})
t.Run("pagination works correctly for team-scoped jobs", func(t *testing.T) {
// page=0 returns the job, page=1 returns empty (not the same page again)
jobs, _, err := th.SystemAdminClient.GetJobsByTypeForTeam(context.Background(), model.JobTypeAccessControlSync, 0, 1, th.BasicTeam.Id)
require.NoError(t, err)
require.Len(t, jobs, 1)
jobs, _, err = th.SystemAdminClient.GetJobsByTypeForTeam(context.Background(), model.JobTypeAccessControlSync, 1, 1, th.BasicTeam.Id)
require.NoError(t, err)
require.Empty(t, jobs)
})
t.Run("team admin cannot query jobs for a team they are not admin of", func(t *testing.T) {
th.LoginTeamAdmin(t)
defer th.LoginBasic(t)
otherTeamID := model.NewId()
_, resp, err := th.Client.GetJobsByTypeForTeam(context.Background(), model.JobTypeAccessControlSync, 0, 60, otherTeamID)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("malformed team_id returns 400 instead of silently dropping the filter", func(t *testing.T) {
// "not-a-valid-id" is not a 26-character alphanum Mattermost ID.
_, resp, err := th.SystemAdminClient.GetJobsByTypeForTeam(context.Background(), model.JobTypeAccessControlSync, 0, 60, "not-a-valid-id")
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
}
func TestGetJobsByType_TeamAdminAccessControlTeamSync(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.AddPermissionToRole(t, model.PermissionManageTeamAccessRules.Id, model.TeamAdminRoleId)
otherTeamID := model.NewId()
parentPolicyID := model.NewId()
// A team-type policy's ID is its team ID, so policy_id is the team scope here.
ownTeamJob := &model.Job{
Id: model.NewId(),
Type: model.JobTypeAccessControlTeamSync,
Status: model.JobStatusSuccess,
CreateAt: model.GetMillis(),
Data: map[string]string{"policy_id": th.BasicTeam.Id},
}
otherTeamJob := &model.Job{
Id: model.NewId(),
Type: model.JobTypeAccessControlTeamSync,
Status: model.JobStatusSuccess,
CreateAt: model.GetMillis(),
Data: map[string]string{"policy_id": otherTeamID},
}
channelSyncJob := &model.Job{
Id: model.NewId(),
Type: model.JobTypeAccessControlSync,
Status: model.JobStatusSuccess,
CreateAt: model.GetMillis(),
Data: map[string]string{"policy_id": parentPolicyID},
}
for _, job := range []*model.Job{ownTeamJob, otherTeamJob, channelSyncJob} {
_, err := th.App.Srv().Store().Job().Save(job)
require.NoError(t, err)
defer func(id string) {
_, _ = th.App.Srv().Store().Job().Delete(id)
}(job.Id)
}
getJobs := func(t *testing.T, client *model.Client4, jobType, extraQuery string) *http.Response {
t.Helper()
resp, _ := client.DoAPIGet(context.Background(), "/jobs/type/"+jobType+"?page=0&per_page=60"+extraQuery, "")
require.NotNil(t, resp)
return resp
}
t.Run("team admin reads team sync jobs for their own team", func(t *testing.T) {
th.LoginTeamAdmin(t)
defer th.LoginBasic(t)
resp := getJobs(t, th.Client, model.JobTypeAccessControlTeamSync, "&policy_id="+th.BasicTeam.Id)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var received []*model.Job
require.NoError(t, json.NewDecoder(resp.Body).Decode(&received))
require.Len(t, received, 1)
require.Equal(t, ownTeamJob.Id, received[0].Id)
})
t.Run("team admin cannot read another team's team sync jobs", func(t *testing.T) {
th.LoginTeamAdmin(t)
defer th.LoginBasic(t)
resp := getJobs(t, th.Client, model.JobTypeAccessControlTeamSync, "&policy_id="+otherTeamID)
defer resp.Body.Close()
require.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("team admin still cannot enumerate channel sync jobs by policy_id", func(t *testing.T) {
th.LoginTeamAdmin(t)
defer th.LoginBasic(t)
resp := getJobs(t, th.Client, model.JobTypeAccessControlSync, "&policy_id="+parentPolicyID)
defer resp.Body.Close()
require.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("team admin cannot read team sync jobs without a policy_id", func(t *testing.T) {
th.LoginTeamAdmin(t)
defer th.LoginBasic(t)
resp := getJobs(t, th.Client, model.JobTypeAccessControlTeamSync, "")
defer resp.Body.Close()
require.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("team admin cannot pair an unvetted team_id with their own policy_id", func(t *testing.T) {
th.LoginTeamAdmin(t)
defer th.LoginBasic(t)
resp := getJobs(t, th.Client, model.JobTypeAccessControlTeamSync,
"&policy_id="+th.BasicTeam.Id+"&team_id="+otherTeamID)
defer resp.Body.Close()
require.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("system admin reads team sync jobs by policy_id", func(t *testing.T) {
resp := getJobs(t, th.SystemAdminClient, model.JobTypeAccessControlTeamSync, "&policy_id="+otherTeamID)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var received []*model.Job
require.NoError(t, json.NewDecoder(resp.Body).Decode(&received))
require.Len(t, received, 1)
require.Equal(t, otherTeamJob.Id, received[0].Id)
})
}
func TestDownloadJob(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.LoginSystemManager(t)
jobName := model.NewId()
job := &model.Job{
Id: jobName,
Type: model.JobTypeMessageExport,
Data: map[string]string{
"export_type": "csv",
},
Status: model.JobStatusSuccess,
}
// DownloadExportResults is not set to true so we should get a not implemented error status
_, resp, err := th.Client.DownloadJob(context.Background(), job.Id)
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.MessageExportSettings.DownloadExportResults = true
})
// Normal user cannot download the results of these job (non-existent job)
_, resp, err = th.Client.DownloadJob(context.Background(), job.Id)
require.Error(t, err)
CheckNotFoundStatus(t, resp)
// System admin trying to download the results of a non-existent job
_, resp, err = th.SystemAdminClient.DownloadJob(context.Background(), job.Id)
require.Error(t, err)
CheckNotFoundStatus(t, resp)
// Here we have a job that exist in our database but the results do not exist therefore when we try to download the results
// as a system admin, we should get a not found status.
_, err = th.App.Srv().Store().Job().Save(job)
require.NoError(t, err)
defer func() {
_, delErr := th.App.Srv().Store().Job().Delete(job.Id)
require.NoError(t, delErr, "Failed to delete job %s", job.Id)
}()
filePath := filepath.Join(*th.App.Config().FileSettings.Directory, "export/"+job.Id+"/testdat.txt")
err = os.MkdirAll(filepath.Dir(filePath), 0770)
require.NoError(t, err)
_, createErr := os.Create(filePath)
require.NoError(t, createErr)
// Normal user cannot download the results of these job (not the right permission)
_, resp, err = th.Client.DownloadJob(context.Background(), job.Id)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
_, resp, err = th.SystemManagerClient.DownloadJob(context.Background(), job.Id)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
// System manager with default permissions cannot download the results of these job (Doesn't have correct permissions)
_, resp, err = th.SystemManagerClient.DownloadJob(context.Background(), job.Id)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
_, resp, err = th.SystemAdminClient.DownloadJob(context.Background(), job.Id)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
job.Data["is_downloadable"] = "true"
updatedJob, err := th.App.Srv().Store().Job().UpdateOptimistically(job, model.JobStatusSuccess)
require.NotNil(t, updatedJob)
require.NoError(t, err)
_, resp, err = th.SystemAdminClient.DownloadJob(context.Background(), job.Id)
require.Error(t, err)
CheckNotFoundStatus(t, resp)
// Now we stub the results of the job into the same directory and try to download it again
// This time we should successfully retrieve the results without any error
filePath = filepath.Join(*th.App.Config().FileSettings.Directory, "export/"+job.Id+".zip")
err = os.MkdirAll(filepath.Dir(filePath), 0770)
require.NoError(t, err)
_, createErr = os.Create(filePath)
require.NoError(t, createErr)
_, _, err = th.SystemAdminClient.DownloadJob(context.Background(), job.Id)
require.NoError(t, err)
// Here we are creating a new job which doesn't have type of message export
jobName = model.NewId()
job = &model.Job{
Id: jobName,
Type: model.JobTypeCloud,
Data: map[string]string{
"export_type": "csv",
},
Status: model.JobStatusSuccess,
}
_, err = th.App.Srv().Store().Job().Save(job)
require.NoError(t, err)
defer func() {
_, delErr := th.App.Srv().Store().Job().Delete(job.Id)
require.NoError(t, delErr, "Failed to delete job %s", job.Id)
}()
// System admin shouldn't be able to download since the job type is not message export
_, resp, err = th.SystemAdminClient.DownloadJob(context.Background(), job.Id)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
// Test the case where export_dir is not valid
jobName = model.NewId()
job = &model.Job{
Id: jobName,
Type: model.JobTypeMessageExport,
Data: map[string]string{
"export_type": "csv",
"is_downloadable": "true",
"export_dir": "/bad/absolute/path",
},
Status: model.JobStatusSuccess,
}
_, err = th.App.Srv().Store().Job().Save(job)
require.NoError(t, err)
defer func() {
_, delErr := th.App.Srv().Store().Job().Delete(job.Id)
require.NoError(t, delErr, "Failed to delete job %s", job.Id)
}()
_, resp, err = th.SystemAdminClient.DownloadJob(context.Background(), job.Id)
require.Error(t, err)
require.EqualError(t, err, "Unable to download this job")
CheckNotFoundStatus(t, resp)
}
func TestCancelJob(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
jobType := model.JobTypeMessageExport
jobs := []*model.Job{
{
Id: model.NewId(),
Type: jobType,
Status: model.JobStatusPending,
},
{
Id: model.NewId(),
Type: jobType,
Status: model.JobStatusInProgress,
},
{
Id: model.NewId(),
Type: jobType,
Status: model.JobStatusSuccess,
},
}
for _, job := range jobs {
_, err := th.App.Srv().Store().Job().Save(job)
require.NoError(t, err)
defer func(jobId string) {
_, delErr := th.App.Srv().Store().Job().Delete(jobId)
require.NoError(t, delErr, "Failed to delete job %s", jobId)
}(job.Id)
}
resp, err := th.Client.CancelJob(context.Background(), jobs[0].Id)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
_, err = th.SystemAdminClient.CancelJob(context.Background(), jobs[0].Id)
require.NoError(t, err)
_, err = th.SystemAdminClient.CancelJob(context.Background(), jobs[1].Id)
require.NoError(t, err)
resp, err = th.SystemAdminClient.CancelJob(context.Background(), jobs[2].Id)
require.Error(t, err)
CheckInternalErrorStatus(t, resp)
resp, err = th.SystemAdminClient.CancelJob(context.Background(), model.NewId())
require.Error(t, err)
CheckNotFoundStatus(t, resp)
}
func TestUpdateJobStatus(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
jobType := model.JobTypeDataRetention
jobs := []*model.Job{
{
Id: model.NewId(),
Type: jobType,
Status: model.JobStatusPending,
},
{
Id: model.NewId(),
Type: jobType,
Status: model.JobStatusInProgress,
},
{
Id: model.NewId(),
Type: jobType,
Status: model.JobStatusSuccess,
},
{
Id: model.NewId(),
Type: jobType,
Status: model.JobStatusPending,
},
}
for _, job := range jobs {
_, err := th.App.Srv().Store().Job().Save(job)
require.NoError(t, err)
defer func(jobID string) {
_, delErr := th.App.Srv().Store().Job().Delete(jobID)
require.NoError(t, delErr, "Failed to delete job %s", jobID)
}(job.Id)
}
t.Run("Fail to update job status without permission", func(t *testing.T) {
resp, err := th.Client.UpdateJobStatus(context.Background(), jobs[0].Id, model.JobStatusCancelRequested, false)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("Change a pending job to cancel requested without force with sysadmin client", func(t *testing.T) {
_, err := th.SystemAdminClient.UpdateJobStatus(context.Background(), jobs[0].Id, model.JobStatusCancelRequested, false)
require.NoError(t, err)
})
t.Run("Change a pending job to cancel requested without force with local client", func(t *testing.T) {
_, err := th.LocalClient.UpdateJobStatus(context.Background(), jobs[3].Id, model.JobStatusCancelRequested, false)
require.NoError(t, err)
})
t.Run("Fail to change a pending job to canceled without force", func(t *testing.T) {
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
resp, err := client.UpdateJobStatus(context.Background(), jobs[0].Id, model.JobStatusCanceled, false)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
})
t.Run("Change a pending job to canceled with force", func(t *testing.T) {
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
_, err := client.UpdateJobStatus(context.Background(), jobs[0].Id, model.JobStatusCanceled, true)
require.NoError(t, err)
})
})
}

View File

@@ -0,0 +1,515 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"mime/multipart"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
type mixedUnlinkedGroup struct {
ID *string `json:"mattermost_group_id"`
DisplayName string `json:"name"`
RemoteID string `json:"primary_key"`
HasSyncables *bool `json:"has_syncables"`
}
func (api *API) InitLdap() {
api.BaseRoutes.LDAP.Handle("/sync", api.APISessionRequired(syncLdap)).Methods(http.MethodPost)
api.BaseRoutes.LDAP.Handle("/test", api.APISessionRequired(testLdap)).Methods(http.MethodPost)
api.BaseRoutes.LDAP.Handle("/test_connection", api.APISessionRequired(testLdapConnection)).Methods(http.MethodPost)
api.BaseRoutes.LDAP.Handle("/test_diagnostics", api.APISessionRequired(testLdapDiagnostics)).Methods(http.MethodPost)
api.BaseRoutes.LDAP.Handle("/migrateid", api.APISessionRequired(migrateIDLdap)).Methods(http.MethodPost)
// GET /api/v4/ldap/groups?page=0&per_page=1000
api.BaseRoutes.LDAP.Handle("/groups", api.APISessionRequired(getLdapGroups)).Methods(http.MethodGet)
// POST /api/v4/ldap/groups/:remote_id/link
api.BaseRoutes.LDAP.Handle(`/groups/{remote_id}/link`, api.APISessionRequired(linkLdapGroup)).Methods(http.MethodPost)
// DELETE /api/v4/ldap/groups/:remote_id/link
api.BaseRoutes.LDAP.Handle(`/groups/{remote_id}/link`, api.APISessionRequired(unlinkLdapGroup)).Methods(http.MethodDelete)
api.BaseRoutes.LDAP.Handle("/certificate/public", api.APISessionRequired(addLdapPublicCertificate, handlerParamFileAPI)).Methods(http.MethodPost)
api.BaseRoutes.LDAP.Handle("/certificate/private", api.APISessionRequired(addLdapPrivateCertificate, handlerParamFileAPI)).Methods(http.MethodPost)
api.BaseRoutes.LDAP.Handle("/certificate/public", api.APISessionRequired(removeLdapPublicCertificate)).Methods(http.MethodDelete)
api.BaseRoutes.LDAP.Handle("/certificate/private", api.APISessionRequired(removeLdapPrivateCertificate)).Methods(http.MethodDelete)
api.BaseRoutes.LDAP.Handle("/users/{user_id}/group_sync_memberships", api.APISessionRequired(addUserToGroupSyncables)).Methods(http.MethodPost)
}
func syncLdap(c *Context, w http.ResponseWriter, r *http.Request) {
if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.LDAP {
c.Err = model.NewAppError("api4.syncLdap", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented)
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionCreateLdapSyncJob) {
c.SetPermissionError(model.PermissionCreateLdapSyncJob)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventSyncLdap, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
c.App.SyncLdap(c.AppContext)
auditRec.Success()
ReturnStatusOK(w)
}
func testLdap(c *Context, w http.ResponseWriter, r *http.Request) {
if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.LDAP {
c.Err = model.NewAppError("api4.testLdap", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented)
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionTestLdap) {
c.SetPermissionError(model.PermissionTestLdap)
return
}
if err := c.App.TestLdap(c.AppContext); err != nil {
c.Err = err
return
}
ReturnStatusOK(w)
}
func testLdapConnection(c *Context, w http.ResponseWriter, r *http.Request) {
if c.App.Channels().License() == nil || !model.SafeDereference(c.App.Channels().License().Features.LDAP) {
c.Err = model.NewAppError("api4.testLdapConnection", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented)
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionTestLdap) {
c.SetPermissionError(model.PermissionTestLdap)
return
}
var settings model.LdapSettings
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
c.SetInvalidParamWithErr("ldap_settings", err)
return
}
if err := c.App.TestLdapConnection(c.AppContext, settings); err != nil {
c.Err = err
return
}
ReturnStatusOK(w)
}
func testLdapDiagnostics(c *Context, w http.ResponseWriter, r *http.Request) {
if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.LDAP {
c.Err = model.NewAppError("Api4.testLdapDiagnostics", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented)
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionTestLdap) {
c.SetPermissionError(model.PermissionTestLdap)
return
}
testTypeStr := r.URL.Query().Get("test")
if testTypeStr == "" {
c.SetInvalidParam("test")
return
}
testType := model.LdapDiagnosticTestType(testTypeStr)
if !testType.IsValid() {
c.SetInvalidParam("test")
return
}
var settings model.LdapSettings
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
c.SetInvalidParamWithErr("ldap_settings", err)
return
}
res, appErr := c.App.TestLdapDiagnostics(c.AppContext, testType, settings)
if appErr != nil {
c.Err = appErr
return
}
if err := json.NewEncoder(w).Encode(res); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getLdapGroups(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadUserManagementGroups) {
c.SetPermissionError(model.PermissionSysconsoleReadUserManagementGroups)
return
}
if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.LDAPGroups {
c.Err = model.NewAppError("api4.getLdapGroups", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented)
return
}
opts := model.LdapGroupSearchOpts{
Q: c.Params.Q,
}
if c.Params.IsLinked != nil {
opts.IsLinked = c.Params.IsLinked
}
if c.Params.IsConfigured != nil {
opts.IsConfigured = c.Params.IsConfigured
}
groups, total, appErr := c.App.GetAllLdapGroupsPage(c.AppContext, c.Params.Page, c.Params.PerPage, opts)
if appErr != nil {
c.Err = appErr
return
}
mugs := []*mixedUnlinkedGroup{}
for _, group := range groups {
mug := &mixedUnlinkedGroup{
DisplayName: group.DisplayName,
RemoteID: group.GetRemoteId(),
}
if len(group.Id) == 26 {
mug.ID = &group.Id
mug.HasSyncables = &group.HasSyncables
}
mugs = append(mugs, mug)
}
b, err := json.Marshal(struct {
Count int `json:"count"`
Groups []*mixedUnlinkedGroup `json:"groups"`
}{Count: total, Groups: mugs})
if err != nil {
c.Err = model.NewAppError("api4.getLdapGroups", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(b); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func linkLdapGroup(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireRemoteId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteUserManagementGroups) {
c.SetPermissionError(model.PermissionSysconsoleWriteUserManagementGroups)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventLinkLdapGroup, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "remote_id", c.Params.RemoteId)
if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.LDAPGroups {
c.Err = model.NewAppError("api4.linkLdapGroup", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented)
return
}
ldapGroup, appErr := c.App.GetLdapGroup(c.AppContext, c.Params.RemoteId)
if appErr != nil {
c.Err = appErr
return
}
if ldapGroup == nil {
c.Err = model.NewAppError("api4.linkLdapGroup", "api.ldap_group.not_found", nil, "", http.StatusNotFound)
return
}
group, appErr := c.App.GetGroupByRemoteID(ldapGroup.GetRemoteId(), model.GroupSourceLdap)
if appErr != nil && appErr.Id != "app.group.no_rows" {
c.Err = appErr
return
}
if group != nil {
model.AddEventParameterAuditableToAuditRec(auditRec, "group", group)
}
var status int
var newOrUpdatedGroup *model.Group
// Truncate display name if necessary
var displayName string
if len(ldapGroup.DisplayName) > model.GroupDisplayNameMaxLength {
displayName = ldapGroup.DisplayName[:model.GroupDisplayNameMaxLength]
} else {
displayName = ldapGroup.DisplayName
}
// Group has been previously linked
if group != nil {
if group.DeleteAt == 0 {
newOrUpdatedGroup = group
} else {
group.DeleteAt = 0
group.DisplayName = displayName
group.RemoteId = ldapGroup.RemoteId
newOrUpdatedGroup, appErr = c.App.UpdateGroup(group)
if appErr != nil {
c.Err = appErr
return
}
auditRec.AddEventResultState(newOrUpdatedGroup)
auditRec.AddEventObjectType("group")
}
status = http.StatusOK
} else {
// Group has never been linked
//
// For group mentions implementation, the Name column will no longer be set by default.
// Instead it will be set and saved in the web app when Group Mentions is enabled.
newGroup := &model.Group{
DisplayName: displayName,
RemoteId: ldapGroup.RemoteId,
Source: model.GroupSourceLdap,
}
newOrUpdatedGroup, appErr = c.App.CreateGroup(newGroup)
if appErr != nil {
c.Err = appErr
return
}
auditRec.AddEventResultState(newOrUpdatedGroup)
auditRec.AddEventObjectType("group")
status = http.StatusCreated
}
b, err := json.Marshal(newOrUpdatedGroup)
if err != nil {
c.Err = model.NewAppError("api4.linkLdapGroup", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.Success()
w.WriteHeader(status)
if _, err := w.Write(b); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func unlinkLdapGroup(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireRemoteId()
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventUnlinkLdapGroup, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "remote_id", c.Params.RemoteId)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteUserManagementGroups) {
c.SetPermissionError(model.PermissionSysconsoleWriteUserManagementGroups)
return
}
if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.LDAPGroups {
c.Err = model.NewAppError("api4.unlinkLdapGroup", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented)
return
}
group, err := c.App.GetGroupByRemoteID(c.Params.RemoteId, model.GroupSourceLdap)
if err != nil {
c.Err = err
return
}
auditRec.AddEventPriorState(group)
auditRec.AddEventObjectType("group")
if group.DeleteAt == 0 {
deletedGroup, err := c.App.DeleteGroup(group.Id)
if err != nil {
c.Err = err
return
}
auditRec.AddEventResultState(deletedGroup)
}
auditRec.Success()
ReturnStatusOK(w)
}
func migrateIDLdap(c *Context, w http.ResponseWriter, r *http.Request) {
props := model.StringInterfaceFromJSON(r.Body)
toAttribute, ok := props["toAttribute"].(string)
if !ok || toAttribute == "" {
c.SetInvalidParam("toAttribute")
return
}
auditRec := c.MakeAuditRecord(model.AuditEventIdMigrateLdap, model.AuditStatusFail)
model.AddEventParameterToAuditRec(auditRec, "to_attribute", toAttribute)
defer c.LogAuditRec(auditRec)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.LDAP {
c.Err = model.NewAppError("api4.idMigrateLdap", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented)
return
}
if err := c.App.MigrateIdLDAP(c.AppContext, toAttribute); err != nil {
c.Err = err
return
}
auditRec.Success()
ReturnStatusOK(w)
}
func parseLdapCertificateRequest(r *http.Request, maxFileSize int64) (*multipart.FileHeader, *model.AppError) {
err := r.ParseMultipartForm(maxFileSize)
if err != nil {
return nil, model.NewAppError("addLdapCertificate", "api.admin.add_certificate.parseform.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
m := r.MultipartForm
fileArray, ok := m.File["certificate"]
if !ok {
return nil, model.NewAppError("addLdapCertificate", "api.admin.add_certificate.no_file.app_error", nil, "", http.StatusBadRequest)
}
if len(fileArray) <= 0 {
return nil, model.NewAppError("addLdapCertificate", "api.admin.add_certificate.array.app_error", nil, "", http.StatusBadRequest)
}
return fileArray[0], nil
}
func addLdapPublicCertificate(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionAddLdapPublicCert) {
c.SetPermissionError(model.PermissionAddLdapPublicCert)
return
}
fileData, err := parseLdapCertificateRequest(r, *c.App.Config().FileSettings.MaxFileSize)
if err != nil {
c.Err = err
return
}
auditRec := c.MakeAuditRecord(model.AuditEventAddLdapPublicCertificate, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "filename", fileData.Filename)
if err := c.App.AddLdapPublicCertificate(fileData); err != nil {
c.Err = err
return
}
auditRec.Success()
ReturnStatusOK(w)
}
func addLdapPrivateCertificate(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionAddLdapPrivateCert) {
c.SetPermissionError(model.PermissionAddLdapPrivateCert)
return
}
fileData, err := parseLdapCertificateRequest(r, *c.App.Config().FileSettings.MaxFileSize)
if err != nil {
c.Err = err
return
}
auditRec := c.MakeAuditRecord(model.AuditEventAddLdapPrivateCertificate, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "filename", fileData.Filename)
if err := c.App.AddLdapPrivateCertificate(fileData); err != nil {
c.Err = err
return
}
auditRec.Success()
ReturnStatusOK(w)
}
func removeLdapPublicCertificate(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionRemoveLdapPublicCert) {
c.SetPermissionError(model.PermissionRemoveLdapPublicCert)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventRemoveLdapPublicCertificate, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
if err := c.App.RemoveLdapPublicCertificate(); err != nil {
c.Err = err
return
}
auditRec.Success()
ReturnStatusOK(w)
}
func removeLdapPrivateCertificate(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionRemoveLdapPrivateCert) {
c.SetPermissionError(model.PermissionRemoveLdapPrivateCert)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventRemoveLdapPrivateCertificate, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
if err := c.App.RemoveLdapPrivateCertificate(); err != nil {
c.Err = err
return
}
auditRec.Success()
ReturnStatusOK(w)
}
// addUserToGroupSyncables creates memberships—for the given user—to all of their group syncables (i.e. channels or teams).
// For each group the user is a member of, for each channel and/or team that group is associated with, the user will be added.
func addUserToGroupSyncables(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteUserManagementGroups) {
c.SetPermissionError(model.PermissionSysconsoleWriteUserManagementGroups)
return
}
user, appErr := c.App.GetUser(c.AppContext, c.Params.UserId)
if appErr != nil {
c.Err = appErr
return
}
if user.AuthService != model.UserAuthServiceLdap && (user.AuthService != model.UserAuthServiceSaml || !*c.App.Config().SamlSettings.EnableSyncWithLdap) {
c.Err = model.NewAppError("addUserToGroupSyncables", "api.user.add_user_to_group_syncables.not_ldap_user.app_error", nil, "", http.StatusBadRequest)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventAddUserToGroupSyncables, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
params := model.CreateDefaultMembershipParams{Since: 0, ReAddRemovedMembers: true, ScopedUserID: &user.Id}
err := c.App.CreateDefaultMemberships(c.AppContext, params)
if err != nil {
c.Err = model.NewAppError("addUserToGroupSyncables", "api.admin.syncables_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
auditRec.Success()
ReturnStatusOK(w)
}

View File

@@ -0,0 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import "net/http"
func (api *API) InitLdapLocal() {
api.BaseRoutes.LDAP.Handle("/migrateid", api.APILocal(migrateIDLdap)).Methods(http.MethodPost)
api.BaseRoutes.LDAP.Handle("/sync", api.APILocal(syncLdap)).Methods(http.MethodPost)
api.BaseRoutes.LDAP.Handle("/test", api.APILocal(testLdap)).Methods(http.MethodPost)
api.BaseRoutes.LDAP.Handle("/groups", api.APILocal(getLdapGroups)).Methods(http.MethodGet)
api.BaseRoutes.LDAP.Handle("/certificate/public", api.APILocal(addLdapPublicCertificate)).Methods(http.MethodPost)
api.BaseRoutes.LDAP.Handle("/certificate/private", api.APILocal(addLdapPrivateCertificate)).Methods(http.MethodPost)
api.BaseRoutes.LDAP.Handle("/certificate/public", api.APILocal(removeLdapPublicCertificate)).Methods(http.MethodDelete)
api.BaseRoutes.LDAP.Handle("/certificate/private", api.APILocal(removeLdapPrivateCertificate)).Methods(http.MethodDelete)
}

View File

@@ -0,0 +1,322 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
)
var spPrivateKey = `-----BEGIN PRIVATE KEY-----
MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQDbVbUfO8gFDgqx
w3Z7gX5layTKKXQT623h0eUHXo95jIdApMyCdhRYoYz9OUvo01aQ0UyErcyWKUJE
3E0YEP/MjvBGTIemmkj/NQWtLqIxZZFnl8uVcm5gPWTJgEhzy9i4/D49qolYakJO
VkK+fnAWUzIiO5GIM6It8zuDIK9a8lnLK6CGWhWUDR8s6nlxOmiG32LRKPAOJrlx
NPbDJO5SV/Wkte/1UdVCR9cW5FroJ5ae/cUEpMeNpiFMCc49gDPEOLOTAroYs1bO
hS4mGArlO0WZUz37cyZSo/MtWJo2Y7bkVejAt6pdMcmvYNy5yddrslA+0OiteZS4
dN01tHa4QiEaNVZ+DdKWfpJFYqqVNNq/YMveUjk7IbnnJpz+ylOc8zNneoiwE5CI
+mmFp0X0+Zt1IJD7BXZEw37Jhk+YeBdQUnkHPWKHj4dkKPpfjPX/K1r2G1CY7iDG
3V1fPsIFAfCUvLbWH994haezz9U+hXu89LmhnKq638fDduGYKQOyYz8/BsQ1MQP5
kCrDg5HhnqUx/dECElFlHCnq3Z/gHoQOicxA8f1GeCDiIE2VFYZRQLDL21lb9ozQ
BFbLZZfGaLGmUPhecQ0RrQ/W4YPNhBvyXELOjCsDfu6ltnob6E8Lux7sNohFuLaY
g0AzDRfezhU0RqWXURKlpiqG0qaoWwIDAQABAoICAQDXt4vTlDA9CHpsKxm0jr+J
b79XNT38+Wew2YavoMjretLrOSoKhaetI/ZOdrO54WEaPT9MnsLATQPoReNs8Asl
XM/j1BD2QnfYyIU0ttC+VG6VvC12Zn04GimuJIUdnjcgeLWeYMOEOb3M3fn28NO8
oUaFdKDFnEK9fqPha5wLjp/Ruq6+dIsUeXNX8aRPQGrde4bsv56ZzGxGcxjfBMuA
IRJvVKEUXc+oyI867IycF5OD+4Jx9r5tCh9lcZ9tzVEcg8fZpqzw7jFKHKIuxSay
HYFuMvia/b2LOcRJrQK+y4NtPzETmY/s6LK70kBEWceNHGrf3Qd61kD2yblmwH6h
F47M/tY8OAXoSmxS259HzJc7DT1WvaDiCZzfVntoJPv6x7CaP6XfLySAq3MTP77x
jGIVZYMg9lGQBTQE6SHCuoM/szUT6PYRtbrcpqjh/MOHvALzgjgtAXWrDf8zLRpD
RAAOKjBILIgNC92h3Oe9bFFfRMEkWvDYWeUs2tmEVJtZm7lDB02vVcRyvRk1sFy3
BkDNB+INbZX/aDblFl8Z7W60jOa7Wr+Hn68dds56PYzsl5NxNTL3fFlx8Yaztd6b
3j654bXGiYSKLPn2PGatWdNcmIsFXN5UIKEDHrn/YeiagFoNvPL1AzpyVvzbkKp0
g+HWAssgI7TTQ3fRMtolgQKCAQEA+B7cdp2k41mKmKrDdmj4iS4ES/SR133ED0SJ
F3fVcJPyKv7iW2zTl8vwTE817HBavPX01bBah51ZSI0RZv+VL11hsGFZfKKYIX5t
60v5zKk5Z+WKlAyM/BHs43gej4KKrd5SMxma/cXpCNdgRJjz8YJpEuoI14Tq7qXC
Bi1v1GLrGXOLng8Mklh7rgs0pwF7BZIzur1xtAKDztebhofrLTXLmLZS/DkHI5qY
qeMonrm5MI/B66FiQEsVt+guz4fMAeNp/sLUPk2iL/qGFyDjvXOosHChffNDv2+l
A17X/oKGpd3jahXRrP/UeuuVyVt5B5xA+SCbzJHF87A0pnKTWQKCAQEA4kzT2lou
vToJxJZWM92TN+1kOfN3VIq5yWpOcesd2NOnVf9SwmSYf/KKsyvzcrMXWSIL8Gp3
h5eBK69N0bHkWfSkGTFa9WwrXx1yR3IOir1L+iFhd6Z8ASvwK93QIBYTSyE3eK9d
RU3ahXIQJFifx1tNoU8RbhlgLukaovnfQjt9xI67cgvXrb9RA0d8hZ81r8Lg/uz4
PN5htNCe6YWC01c2ufIGOqwO6QoYYW3yR00L1ANkE1ohHSrz7JGKthS8vdK/Ogfh
UwR/JaA3kZ6DdoWAfzZd1BbT3WgMG36Il6Hk2EtOCYuD0AuURWcQjJGkN4+xWqtS
U+bfB11bUBgm0wKCAQBnStm226vwJa+oHLbgjZSh7zFEuZ0ZW7cKMBruVSnbAww2
0ANF0klIEVOJQRSOyLtNnQr/Brq5aEzqAigze8UMgdCQUAaj90Bj+TEjWm60v+Ix
GYMWXR84NPIsRC5cyhiXh00rDsbSTNjVoGvoQtCTQxohEKL7rc7r6L+cOMAsZ729
y7dc5qDyL7nVW77go6ImUJYOcJ1sNfvPWTzaxaynFpUajxR/AfKx5MMXPoUDhwfM
apxtTrMLVvbEp/kM1liclKLktxEKmuEhHidCa6PDk+mvAkSInYQfpwfIHmzG/Gm3
lWb+G/U9EwfO4FJsEBOTkn4N+IBDqpABAeL5RAuJAoIBAHFi9z9Psl2DqANFJFoG
ak46dt6Ge8LzY1VlG3r+yEys+AohzRCzoKlzGEXf/rH4w/kYEw1Z+xwIMGN4CbDI
xlbAOjyZOy7/DNgyg+ECaADiCiCA+zodQ8K+hi8ki7SX+wDI2udwTnZ8JMJ6PVZI
xX345HOvj1cwBb5bc8o3EsM31bNXpNnmzyEyW+AdwGmfNSIkreFtUJAHCMO1R/pP
uBY2e6g9eRuKvEnNkhu3IA7TrtqC/HCp1y+rJt7gqbTDvTILV183NZIIDcEHfvBK
kSogiBq1Xdv3uB4WlQJtqvj22Bf721Ty/4+NTbRciLE2BCcGq2F3t99sLVGeWDNQ
dpsCggEAcuxrYqR659hvqAhjFjfiOY3X5VMzaWJO7ERDCgVjtVsopBOaM23Gg9zl
4TISwG3MXBjDwOqhpP7T6ytxWZphyN51zXgwGghhcze8f+HstGo0dpjnFSM5ml+Y
q0o8LMYlM6NrtYwocMTm4fzh9gXa6aDGadb/dW8DsWmYmBHXH5ViZB7uzbcbtQRI
7EuwV+DYLualVpJ99pjbb7a8PPPvQrGLb2Lhlk7P2NT25Nal26vwUTPHTZVV4s7W
0HY6fD+opKhBHQami5XbSUVznTWus6Zgc3bi4k9NsSNUQNfBKz79zM/EvIPXEklP
kSU80FrXITorOgZogkDk0FVpJA3qvQ==
-----END PRIVATE KEY-----`
var spPublicCertificate = `-----BEGIN CERTIFICATE-----
MIIFijCCA3KgAwIBAgIJAIRQ3EwrvOprMA0GCSqGSIb3DQEBCwUAMFwxCzAJBgNV
BAYTAlVTMRIwEAYDVQQHDAlQYWxvIEFsdG8xEzARBgNVBAoMCk1hdHRlcm1vc3Qx
DzANBgNVBAsMBkRldk9wczETMBEGA1UEAwwKY2xpZW50LmNvbTAeFw0xOTA5MTIx
NzM1MzdaFw0yOTA5MDkxNzM1MzdaMFwxCzAJBgNVBAYTAlVTMRIwEAYDVQQHDAlQ
YWxvIEFsdG8xEzARBgNVBAoMCk1hdHRlcm1vc3QxDzANBgNVBAsMBkRldk9wczET
MBEGA1UEAwwKY2xpZW50LmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC
ggIBANtVtR87yAUOCrHDdnuBfmVrJMopdBPrbeHR5Qdej3mMh0CkzIJ2FFihjP05
S+jTVpDRTIStzJYpQkTcTRgQ/8yO8EZMh6aaSP81Ba0uojFlkWeXy5VybmA9ZMmA
SHPL2Lj8Pj2qiVhqQk5WQr5+cBZTMiI7kYgzoi3zO4Mgr1ryWcsroIZaFZQNHyzq
eXE6aIbfYtEo8A4muXE09sMk7lJX9aS17/VR1UJH1xbkWugnlp79xQSkx42mIUwJ
zj2AM8Q4s5MCuhizVs6FLiYYCuU7RZlTPftzJlKj8y1YmjZjtuRV6MC3ql0xya9g
3LnJ12uyUD7Q6K15lLh03TW0drhCIRo1Vn4N0pZ+kkViqpU02r9gy95SOTshuecm
nP7KU5zzM2d6iLATkIj6aYWnRfT5m3UgkPsFdkTDfsmGT5h4F1BSeQc9YoePh2Qo
+l+M9f8rWvYbUJjuIMbdXV8+wgUB8JS8ttYf33iFp7PP1T6Fe7z0uaGcqrrfx8N2
4ZgpA7JjPz8GxDUxA/mQKsODkeGepTH90QISUWUcKerdn+AehA6JzEDx/UZ4IOIg
TZUVhlFAsMvbWVv2jNAEVstll8ZosaZQ+F5xDRGtD9bhg82EG/JcQs6MKwN+7qW2
ehvoTwu7Huw2iEW4tpiDQDMNF97OFTRGpZdREqWmKobSpqhbAgMBAAGjTzBNMBIG
A1UdEwEB/wQIMAYBAf8CAQAwNwYDVR0RBDAwLoIOd3d3LmNsaWVudC5jb22CEGFk
bWluLmNsaWVudC5jb22HBMCoAQqHBAoAAOowDQYJKoZIhvcNAQELBQADggIBAFEI
D1ySRS+lQYVm24PPIUH5OmBEJUsVKI/zUXEQ4hdqEqN4UA3NGKkujajTz2fStaOj
LfGDup1ZQRYG6VVvNwbZHX9G9mb8TyZ12XFLVjPTbxoG+NZb3ipue9S6qZcT9WEF
sjaXhkVNhhVc1GOMnv/FNiclLPWLMnR8WST+Y+WSsT59wP40kJynaT7wQt2TmImg
kQfM69jQNgAkyrFwO8y1YcnH7Avrw9YvzhUWG2FfNCTTVNb+StxNtqGwvDV33iZ2
bBUWIy2fsNUA4tUYK31Ye6thJiKmvy/LqVJ415gPsI3zHzTCLU/GBUCNCNnEDnhU
KO2K3mk1wK3sshMGcda/Xz2a9TfkIxs0pkenS57bZ8xT7mxBzXsZGm7Mnb2fujmX
fBEyxQ2ot0Nl9Lp26WrBjQZojJ10Ic2IRxU3spC/FYK7BenQEAdnNHkyQ3lowAto
NpOL+j+1ooksPQbp4DeIBbrZDNKvFot+ja2aDJ738sgXf8ht7kGXA5DPNtPLsmUr
wpZrhxKD6pXVPhA6EeG2efdUP1ODslmehl4t2yX+FqHChnl7E012W8Cf0Ugybp1t
15IXg8GxCRENSNAwpOvTMkoonHqNvBkaCDZHtxeyJMJWQW1B0Xek1JY3CNHvnY7I
MCOV5SHi05kD42JSSbmw190VAa4QRGikaeWRhDsj
-----END CERTIFICATE-----`
func TestTestLdap(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
resp, err := client.TestLdap(context.Background())
CheckNotImplementedStatus(t, resp)
require.Error(t, err)
CheckErrorID(t, err, "api.ldap_groups.license_error")
})
th.App.Srv().SetLicense(model.NewTestLicense("ldap_groups"))
resp, err := th.Client.TestLdap(context.Background())
CheckForbiddenStatus(t, resp)
require.Error(t, err)
CheckErrorID(t, err, "api.context.permissions.app_error")
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
resp, err = client.TestLdap(context.Background())
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
require.Error(t, err)
CheckErrorID(t, err, "ent.ldap.disabled.app_error")
})
}
func TestSyncLdap(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
resp, err := client.TestLdap(context.Background())
CheckNotImplementedStatus(t, resp)
require.Error(t, err)
CheckErrorID(t, err, "api.ldap_groups.license_error")
})
th.App.Srv().SetLicense(model.NewTestLicense("ldap_groups"))
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.LdapSettings.EnableSync = true
})
ldapMock := &mocks.LdapInterface{}
ldapMock.On(
"StartSynchronizeJob",
mock.AnythingOfType("*request.Context"),
false,
).Return(nil, nil)
th.App.Channels().Ldap = ldapMock
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
_, err := client.SyncLdap(context.Background())
require.NoError(t, err)
})
resp, err := th.Client.SyncLdap(context.Background())
require.Error(t, err)
CheckForbiddenStatus(t, resp)
}
func TestGetLdapGroups(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
_, resp, err := th.Client.GetLdapGroups(context.Background())
require.Error(t, err)
CheckForbiddenStatus(t, resp)
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
_, resp, err := client.GetLdapGroups(context.Background())
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
})
}
func TestLinkLdapGroup(t *testing.T) {
mainHelper.Parallel(t)
const entryUUID string = "foo"
th := Setup(t)
_, resp, err := th.Client.LinkLdapGroup(context.Background(), entryUUID)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
_, resp, err = th.SystemAdminClient.LinkLdapGroup(context.Background(), entryUUID)
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
}
func TestUnlinkLdapGroup(t *testing.T) {
mainHelper.Parallel(t)
const entryUUID string = "foo"
th := Setup(t)
_, resp, err := th.Client.UnlinkLdapGroup(context.Background(), entryUUID)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
_, resp, err = th.SystemAdminClient.UnlinkLdapGroup(context.Background(), entryUUID)
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
}
func TestMigrateIdLdap(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
resp, err := th.Client.MigrateIdLdap(context.Background(), "objectGUID")
require.Error(t, err)
CheckForbiddenStatus(t, resp)
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
resp, err = client.MigrateIdLdap(context.Background(), "")
require.Error(t, err)
CheckBadRequestStatus(t, resp)
resp, err = client.MigrateIdLdap(context.Background(), "objectGUID")
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
})
}
func TestUploadPublicCertificate(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
_, err := th.Client.UploadLdapPublicCertificate(context.Background(), []byte(spPublicCertificate))
require.Error(t, err, "Should have failed. No System Admin privileges")
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
_, err = client.UploadLdapPublicCertificate(context.Background(), []byte(spPrivateKey))
require.NoErrorf(t, err, "Should have passed. System Admin privileges %v", err)
})
_, err = th.Client.DeleteLdapPublicCertificate(context.Background())
require.Error(t, err, "Should have failed. No System Admin privileges")
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
_, err := client.DeleteLdapPublicCertificate(context.Background())
require.NoError(t, err, "Should have passed. System Admin privileges")
})
}
func TestUploadPrivateCertificate(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
_, err := th.Client.UploadLdapPrivateCertificate(context.Background(), []byte(spPrivateKey))
require.Error(t, err, "Should have failed. No System Admin privileges")
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
_, err = client.UploadLdapPrivateCertificate(context.Background(), []byte(spPrivateKey))
require.NoErrorf(t, err, "Should have passed. System Admin privileges %v", err)
})
_, err = th.Client.DeleteLdapPrivateCertificate(context.Background())
require.Error(t, err, "Should have failed. No System Admin privileges")
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
_, err := client.DeleteLdapPrivateCertificate(context.Background())
require.NoErrorf(t, err, "Should have passed. System Admin privileges %v", err)
})
}
func TestAddUserToGroupSyncables(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
resp, err := th.Client.AddUserToGroupSyncables(context.Background(), th.BasicUser.Id)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
resp, err = th.SystemAdminClient.AddUserToGroupSyncables(context.Background(), "invalid-user-id")
require.Error(t, err)
CheckNotFoundStatus(t, resp)
resp, err = th.SystemAdminClient.AddUserToGroupSyncables(context.Background(), th.BasicUser.Id)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
id := model.NewId()
user := &model.User{
Email: "test@localhost",
Username: model.NewUsername(),
AuthData: &id,
AuthService: model.UserAuthServiceLdap,
}
user, err = th.App.Srv().Store().User().Save(th.Context, user)
require.NoError(t, err)
resp, err = th.SystemAdminClient.AddUserToGroupSyncables(context.Background(), user.Id)
require.NoError(t, err)
CheckOKStatus(t, resp)
t.Run("should sync SAML users when SamlSettings.EnableSyncWithLdap is true", func(t *testing.T) {
id = model.NewId()
user = &model.User{
Email: "test123@localhost",
Username: model.NewUsername(),
AuthData: &id,
AuthService: model.UserAuthServiceSaml,
}
user, err = th.App.Srv().Store().User().Save(th.Context, user)
require.NoError(t, err)
resp, err = th.Client.AddUserToGroupSyncables(context.Background(), user.Id)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.SamlSettings.EnableSyncWithLdap = true
})
resp, err = th.SystemAdminClient.AddUserToGroupSyncables(context.Background(), user.Id)
require.NoError(t, err)
CheckOKStatus(t, resp)
})
}

View File

@@ -0,0 +1,316 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"bytes"
"encoding/json"
"io"
"math"
"net/http"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
func (api *API) InitLicense() {
api.BaseRoutes.APIRoot.Handle("/trial-license", api.APISessionRequired(requestTrialLicense)).Methods(http.MethodPost)
api.BaseRoutes.APIRoot.Handle("/trial-license/prev", api.APISessionRequired(getPrevTrialLicense)).Methods(http.MethodGet)
api.BaseRoutes.APIRoot.Handle("/license", api.APISessionRequired(addLicense, handlerParamFileAPI)).Methods(http.MethodPost)
api.BaseRoutes.APIRoot.Handle("/license", api.APISessionRequired(removeLicense)).Methods(http.MethodDelete)
api.BaseRoutes.APIRoot.Handle("/license/client", api.APIHandler(getClientLicense)).Methods(http.MethodGet)
api.BaseRoutes.APIRoot.Handle("/license/load_metric", api.APISessionRequired(getLicenseLoadMetric)).Methods(http.MethodGet)
api.BaseRoutes.APIRoot.Handle("/license/preview", api.APISessionRequired(previewLicense, handlerParamFileAPI)).Methods(http.MethodPost)
}
func getClientLicense(c *Context, w http.ResponseWriter, r *http.Request) {
var clientLicense map[string]string
if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionReadLicenseInformation) {
clientLicense = c.App.Srv().ClientLicense()
} else {
clientLicense = c.App.Srv().GetSanitizedClientLicense()
}
if _, err := w.Write([]byte(model.MapToJSON(clientLicense))); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
// parseLicenseFileFromRequest extracts license bytes from a multipart form request.
// Returns the license bytes, the filename, and any error that occurred.
func parseLicenseFileFromRequest(c *Context, r *http.Request) ([]byte, string, *model.AppError) {
err := r.ParseMultipartForm(*c.App.Config().FileSettings.MaxFileSize)
if err != nil {
return nil, "", model.NewAppError("parseLicenseFileFromRequest", "api.license.parse_license.parse_form.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
defer func() {
if r.MultipartForm != nil {
if err = r.MultipartForm.RemoveAll(); err != nil {
c.Logger.Warn("Failed to remove temporary multipart files", mlog.Err(err))
}
}
}()
fileArray, ok := r.MultipartForm.File["license"]
if !ok {
return nil, "", model.NewAppError("parseLicenseFileFromRequest", "api.license.add_license.no_file.app_error", nil, "", http.StatusBadRequest)
}
if len(fileArray) <= 0 {
return nil, "", model.NewAppError("parseLicenseFileFromRequest", "api.license.add_license.array.app_error", nil, "", http.StatusBadRequest)
}
fileData := fileArray[0]
file, err := fileData.Open()
if err != nil {
return nil, "", model.NewAppError("parseLicenseFileFromRequest", "api.license.add_license.open.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
defer file.Close()
buf := bytes.NewBuffer(nil)
if _, err := io.Copy(buf, file); err != nil {
return nil, "", model.NewAppError("parseLicenseFileFromRequest", "api.license.add_license.copy.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return buf.Bytes(), fileData.Filename, nil
}
func addLicense(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord(model.AuditEventAddLicense, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
c.LogAudit("attempt")
if !c.App.SessionHasPermissionToAndNotRestrictedAdmin(*c.AppContext.Session(), model.PermissionManageLicenseInformation) {
c.SetPermissionError(model.PermissionManageLicenseInformation)
return
}
licenseBytes, filename, appErr := parseLicenseFileFromRequest(c, r)
if appErr != nil {
c.Err = appErr
return
}
model.AddEventParameterToAuditRec(auditRec, "filename", filename)
license, appErr := utils.LicenseValidator.LicenseFromBytes(licenseBytes)
if appErr != nil {
c.Err = appErr
return
}
// skip the restrictions if license is a sanctioned trial
if !license.IsSanctionedTrial() && license.IsTrialLicense() {
lm := c.App.Srv().Platform().LicenseManager()
if lm == nil {
c.Err = model.NewAppError("addLicense", "api.license.upgrade_needed.app_error", nil, "", http.StatusInternalServerError)
return
}
canStartTrialLicense, err := lm.CanStartTrial()
if err != nil {
c.Err = model.NewAppError("addLicense", "api.license.add_license.open.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if !canStartTrialLicense {
c.Err = model.NewAppError("addLicense", "api.license.request-trial.can-start-trial.not-allowed", nil, "", http.StatusBadRequest)
return
}
}
license, appErr = c.App.Srv().SaveLicense(licenseBytes)
if appErr != nil {
if appErr.Id == model.ExpiredLicenseError {
c.LogAudit("failed - expired or non-started license")
} else if appErr.Id == model.InvalidLicenseError {
c.LogAudit("failed - invalid license")
} else {
c.LogAudit("failed - unable to save license")
}
c.Err = appErr
return
}
if c.App.Channels().License().IsCloud() {
// If cloud, invalidate the caches when a new license is loaded
defer func() {
if err := c.App.Srv().Cloud.HandleLicenseChange(); err != nil {
c.Logger.Warn("Error while handling license change", mlog.Err(err))
}
}()
}
auditRec.Success()
c.LogAudit("success")
if err := json.NewEncoder(w).Encode(license); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func previewLicense(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionToAndNotRestrictedAdmin(*c.AppContext.Session(), model.PermissionManageLicenseInformation) {
c.SetPermissionError(model.PermissionManageLicenseInformation)
return
}
licenseBytes, _, appErr := parseLicenseFileFromRequest(c, r)
if appErr != nil {
c.Err = appErr
return
}
license, appErr := utils.LicenseValidator.LicenseFromBytes(licenseBytes)
if appErr != nil {
c.Err = appErr
return
}
// Return the parsed license without saving it
if err := json.NewEncoder(w).Encode(license); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func removeLicense(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord(model.AuditEventRemoveLicense, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
c.LogAudit("attempt")
if !c.App.SessionHasPermissionToAndNotRestrictedAdmin(*c.AppContext.Session(), model.PermissionManageLicenseInformation) {
c.SetPermissionError(model.PermissionManageLicenseInformation)
return
}
if err := c.App.Srv().RemoveLicense(); err != nil {
c.Err = err
return
}
auditRec.Success()
c.LogAudit("success")
ReturnStatusOK(w)
}
func requestTrialLicense(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord(model.AuditEventRequestTrialLicense, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
c.LogAudit("attempt")
if !c.App.SessionHasPermissionToAndNotRestrictedAdmin(*c.AppContext.Session(), model.PermissionManageLicenseInformation) {
c.SetPermissionError(model.PermissionManageLicenseInformation)
return
}
if c.App.Srv().Platform().LicenseManager() == nil {
c.Err = model.NewAppError("requestTrialLicense", "api.license.upgrade_needed.app_error", nil, "", http.StatusForbidden)
return
}
canStartTrialLicense, err := c.App.Srv().Platform().LicenseManager().CanStartTrial()
if err != nil {
c.Err = model.NewAppError("requestTrialLicense", "api.license.request-trial.can-start-trial.error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if !canStartTrialLicense {
c.Err = model.NewAppError("requestTrialLicense", "api.license.request-trial.can-start-trial.not-allowed", nil, "", http.StatusBadRequest)
return
}
var trialRequest *model.TrialLicenseRequest
b, readErr := io.ReadAll(r.Body)
if readErr != nil {
c.Err = model.NewAppError("requestTrialLicense", "api.license.request-trial.bad-request", nil, "", http.StatusBadRequest)
return
}
err = json.Unmarshal(b, &trialRequest)
if err != nil {
c.Err = model.NewAppError("requestTrialLicense", "api.license.request-trial.bad-request", nil, "", http.StatusBadRequest).Wrap(err)
return
}
var appErr *model.AppError
// If any of the newly supported trial request fields are set (ie, not a legacy request), process this as a new trial request (requiring the new fields) otherwise fall back on the old method.
if !trialRequest.IsLegacy() {
appErr = c.App.Channels().RequestTrialLicenseWithExtraFields(c.AppContext, c.AppContext.Session().UserId, trialRequest)
} else {
appErr = c.App.Channels().RequestTrialLicense(c.AppContext, c.AppContext.Session().UserId, trialRequest.Users, trialRequest.TermsAccepted, trialRequest.ReceiveEmailsAccepted)
}
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
c.LogAudit("success")
ReturnStatusOK(w)
}
func getPrevTrialLicense(c *Context, w http.ResponseWriter, r *http.Request) {
if c.App.Srv().Platform().LicenseManager() == nil {
c.Err = model.NewAppError("getPrevTrialLicense", "api.license.upgrade_needed.app_error", nil, "", http.StatusForbidden)
return
}
license, err := c.App.Srv().Platform().LicenseManager().GetPrevTrial()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var clientLicense map[string]string
if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionReadLicenseInformation) {
clientLicense = utils.GetClientLicense(license)
} else {
clientLicense = utils.GetSanitizedClientLicense(utils.GetClientLicense(license))
}
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write([]byte(model.MapToJSON(clientLicense))); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
// getLicenseLoadMetric returns a load metric computed as (mau / licensed) * 1000.
func getLicenseLoadMetric(c *Context, w http.ResponseWriter, r *http.Request) {
var loadMetric int
var licenseUsers int
license := c.App.Srv().License()
if license != nil && license.Features != nil {
licenseUsers = *license.Features.Users
}
if licenseUsers > 0 {
monthlyActiveUsers, err := c.App.Srv().Store().User().AnalyticsActiveCount(app.MonthMilliseconds, model.UserCountOptions{IncludeBotAccounts: false, IncludeDeleted: false})
if err != nil {
c.Err = model.NewAppError("getLicenseLoad", "api.license.load_metric.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
loadMetric = int(math.Round((float64(monthlyActiveUsers) / float64(licenseUsers) * float64(1000))))
}
// Create response object
data := map[string]int{
"load": loadMetric,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(data); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

View File

@@ -0,0 +1,111 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"bytes"
"encoding/json"
"io"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitLicenseLocal() {
api.BaseRoutes.APIRoot.Handle("/license", api.APILocal(localAddLicense, handlerParamFileAPI)).Methods(http.MethodPost)
api.BaseRoutes.APIRoot.Handle("/license", api.APILocal(localRemoveLicense)).Methods(http.MethodDelete)
api.BaseRoutes.APIRoot.Handle("/license/client", api.APILocal(localGetClientLicense)).Methods(http.MethodGet)
}
func localAddLicense(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord(model.AuditEventLocalAddLicense, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
c.LogAudit("attempt")
err := r.ParseMultipartForm(*c.App.Config().FileSettings.MaxFileSize)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer func() {
if r.MultipartForm != nil {
if err = r.MultipartForm.RemoveAll(); err != nil {
c.Logger.Warn("Failed to remove temporary multipart files", mlog.Err(err))
}
}
}()
fileArray, ok := r.MultipartForm.File["license"]
if !ok {
c.Err = model.NewAppError("addLicense", "api.license.add_license.no_file.app_error", nil, "", http.StatusBadRequest)
return
}
if len(fileArray) <= 0 {
c.Err = model.NewAppError("addLicense", "api.license.add_license.array.app_error", nil, "", http.StatusBadRequest)
return
}
fileData := fileArray[0]
model.AddEventParameterToAuditRec(auditRec, "filename", fileData.Filename)
file, err := fileData.Open()
if err != nil {
c.Err = model.NewAppError("addLicense", "api.license.add_license.open.app_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
defer file.Close()
buf := bytes.NewBuffer(nil)
if _, err := io.Copy(buf, file); err != nil {
c.Err = model.NewAppError("addLicense", "api.license.add_license.copy.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
license, appErr := c.App.Srv().SaveLicense(buf.Bytes())
if appErr != nil {
if appErr.Id == model.ExpiredLicenseError {
c.LogAudit("failed - expired or non-started license")
} else if appErr.Id == model.InvalidLicenseError {
c.LogAudit("failed - invalid license")
} else {
c.LogAudit("failed - unable to save license")
}
c.Err = appErr
return
}
auditRec.Success()
c.LogAudit("success")
if err := json.NewEncoder(w).Encode(license); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func localRemoveLicense(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord(model.AuditEventLocalRemoveLicense, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
c.LogAudit("attempt")
if err := c.App.Srv().RemoveLicense(); err != nil {
c.Err = err
return
}
auditRec.Success()
c.LogAudit("success")
ReturnStatusOK(w)
}
func localGetClientLicense(c *Context, w http.ResponseWriter, r *http.Request) {
clientLicense := c.App.Srv().ClientLicense()
if _, err := w.Write([]byte(model.MapToJSON(clientLicense))); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

View File

@@ -0,0 +1,739 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/utils"
mocks2 "github.com/mattermost/mattermost/server/v8/channels/utils/mocks"
"github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestGetOldClientLicense(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
client := th.Client
license, _, err := client.GetOldClientLicense(context.Background(), "")
require.NoError(t, err)
require.NotEqual(t, license["IsLicensed"], "", "license not returned correctly")
_, err = client.Logout(context.Background())
require.NoError(t, err)
_, _, err = client.GetOldClientLicense(context.Background(), "")
require.NoError(t, err)
resp, err := client.DoAPIGet(context.Background(), "/license/client", "")
require.NoError(t, err, "get /license/client should not return an error")
require.Equal(t, http.StatusOK, resp.StatusCode, "expected 200 OK")
resp, err = client.DoAPIGet(context.Background(), "/license/client?format=junk", "")
require.NoError(t, err, "get /license/client?format=junk should not return an error")
require.Equal(t, http.StatusOK, resp.StatusCode, "expected 200 OK")
license, _, err = th.SystemAdminClient.GetOldClientLicense(context.Background(), "")
require.NoError(t, err)
require.NotEmpty(t, license["IsLicensed"], "license not returned correctly")
}
func TestUploadLicenseFile(t *testing.T) {
th := Setup(t)
client := th.Client
LocalClient := th.LocalClient
t.Run("as system user", func(t *testing.T) {
resp, err := client.UploadLicenseFile(context.Background(), []byte{})
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
resp, err := c.UploadLicenseFile(context.Background(), []byte{})
require.Error(t, err)
CheckBadRequestStatus(t, resp)
}, "as system admin user")
t.Run("as restricted system admin user", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = true })
resp, err := th.SystemAdminClient.UploadLicenseFile(context.Background(), []byte{})
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("restricted admin setting not honoured through local client", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = true })
resp, err := LocalClient.UploadLicenseFile(context.Background(), []byte{})
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("server has already gone through trial", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = false })
mockLicenseValidator := mocks2.LicenseValidatorIface{}
defer testutils.ResetLicenseValidator()
userCount := 100
mills := model.GetMillis()
license := model.License{
Id: "AAAAAAAAAAAAAAAAAAAAAAAAAA",
Features: &model.Features{
Users: &userCount,
},
Customer: &model.Customer{
Name: "Test",
},
StartsAt: mills + 100,
ExpiresAt: mills + 100 + (30*(time.Hour*24) + (time.Hour * 8)).Milliseconds(),
}
mockLicenseValidator.On("LicenseFromBytes", mock.Anything).Return(&license, nil).Once()
licenseBytes, _ := json.Marshal(license)
licenseStr := string(licenseBytes)
mockLicenseValidator.On("ValidateLicense", mock.Anything).Return(licenseStr, nil)
utils.LicenseValidator = &mockLicenseValidator
licenseManagerMock := &mocks.LicenseInterface{}
licenseManagerMock.On("CanStartTrial").Return(false, nil).Once()
th.App.Srv().Platform().SetLicenseManager(licenseManagerMock)
resp, err := th.SystemAdminClient.UploadLicenseFile(context.Background(), []byte("sadasdasdasdasdasdsa"))
CheckErrorID(t, err, "api.license.request-trial.can-start-trial.not-allowed")
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("try to get one through trial, with TE build", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = false })
th.App.Srv().Platform().SetLicenseManager(nil)
mockLicenseValidator := mocks2.LicenseValidatorIface{}
defer testutils.ResetLicenseValidator()
license := model.License{
Id: model.NewId(),
Features: &model.Features{
Users: new(100),
},
Customer: &model.Customer{
Name: "Test",
},
StartsAt: model.GetMillis() + 100,
ExpiresAt: model.GetMillis() + 100 + (30*(time.Hour*24) + (time.Hour * 8)).Milliseconds(),
}
mockLicenseValidator.On("LicenseFromBytes", mock.Anything).Return(&license, nil).Once()
licenseBytes, err := json.Marshal(license)
require.NoError(t, err)
mockLicenseValidator.On("ValidateLicense", mock.Anything).Return(string(licenseBytes), nil)
utils.LicenseValidator = &mockLicenseValidator
resp, err := th.SystemAdminClient.UploadLicenseFile(context.Background(), []byte(""))
CheckErrorID(t, err, "api.license.upgrade_needed.app_error")
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
})
t.Run("allow uploading sanctioned trials even if server already gone through trial", func(t *testing.T) {
mockLicenseValidator := mocks2.LicenseValidatorIface{}
defer testutils.ResetLicenseValidator()
userCount := 100
mills := model.GetMillis()
license := model.License{
Id: "PPPPPPPPPPPPPPPPPPPPPPPPPP",
Features: &model.Features{
Users: &userCount,
},
Customer: &model.Customer{
Name: "Test",
},
IsTrial: true,
StartsAt: mills + 100,
ExpiresAt: mills + 100 + (29*(time.Hour*24) + (time.Hour * 8)).Milliseconds(),
}
mockLicenseValidator.On("LicenseFromBytes", mock.Anything).Return(&license, nil).Once()
licenseBytes, _ := json.Marshal(license)
licenseStr := string(licenseBytes)
mockLicenseValidator.On("ValidateLicense", mock.Anything).Return(licenseStr, nil)
utils.LicenseValidator = &mockLicenseValidator
licenseManagerMock := &mocks.LicenseInterface{}
licenseManagerMock.On("CanStartTrial").Return(false, nil).Once()
th.App.Srv().Platform().SetLicenseManager(licenseManagerMock)
resp, err := th.SystemAdminClient.UploadLicenseFile(context.Background(), []byte("sadasdasdasdasdasdsa"))
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
})
}
func TestPreviewLicenseFile(t *testing.T) {
th := Setup(t)
client := th.Client
t.Run("as system user", func(t *testing.T) {
_, resp, err := client.PreviewLicenseFile(context.Background(), []byte{})
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("as system admin with empty file", func(t *testing.T) {
_, resp, err := th.SystemAdminClient.PreviewLicenseFile(context.Background(), []byte{})
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("as restricted system admin user", func(t *testing.T) {
originalRestrictSystemAdmin := *th.App.Config().ExperimentalSettings.RestrictSystemAdmin
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = true })
t.Cleanup(func() {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ExperimentalSettings.RestrictSystemAdmin = originalRestrictSystemAdmin
})
})
_, resp, err := th.SystemAdminClient.PreviewLicenseFile(context.Background(), []byte{})
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("preview valid license", func(t *testing.T) {
mockLicenseValidator := mocks2.LicenseValidatorIface{}
defer testutils.ResetLicenseValidator()
userCount := 100
mills := model.GetMillis()
license := model.License{
Id: model.NewId(),
Features: &model.Features{
Users: &userCount,
},
Customer: &model.Customer{
Name: "Test Customer",
Company: "Test Company",
},
SkuName: "Enterprise",
SkuShortName: "enterprise",
StartsAt: mills,
ExpiresAt: mills + (365 * 24 * time.Hour).Milliseconds(),
}
mockLicenseValidator.On("LicenseFromBytes", mock.Anything).Return(&license, nil).Once()
utils.LicenseValidator = &mockLicenseValidator
previewedLicense, resp, err := th.SystemAdminClient.PreviewLicenseFile(context.Background(), []byte("test-license-data"))
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotNil(t, previewedLicense)
require.Equal(t, license.Id, previewedLicense.Id)
require.Equal(t, "Test Customer", previewedLicense.Customer.Name)
require.Equal(t, "Test Company", previewedLicense.Customer.Company)
require.Equal(t, "Enterprise", previewedLicense.SkuName)
require.Equal(t, "enterprise", previewedLicense.SkuShortName)
require.Equal(t, userCount, *previewedLicense.Features.Users)
})
t.Run("preview invalid license", func(t *testing.T) {
mockLicenseValidator := mocks2.LicenseValidatorIface{}
defer testutils.ResetLicenseValidator()
mockLicenseValidator.On("LicenseFromBytes", mock.Anything).Return(nil, model.NewAppError("LicenseFromBytes", "model.license.is_valid.app_error", nil, "", http.StatusBadRequest)).Once()
utils.LicenseValidator = &mockLicenseValidator
_, resp, err := th.SystemAdminClient.PreviewLicenseFile(context.Background(), []byte("invalid-license-data"))
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("preview does not save license", func(t *testing.T) {
mockLicenseValidator := mocks2.LicenseValidatorIface{}
defer testutils.ResetLicenseValidator()
userCount := 50
mills := model.GetMillis()
license := model.License{
Id: model.NewId(),
Features: &model.Features{
Users: &userCount,
},
Customer: &model.Customer{
Name: "Preview Only",
},
SkuName: "Professional",
SkuShortName: "professional",
StartsAt: mills,
ExpiresAt: mills + (365 * 24 * time.Hour).Milliseconds(),
}
mockLicenseValidator.On("LicenseFromBytes", mock.Anything).Return(&license, nil).Once()
utils.LicenseValidator = &mockLicenseValidator
// Get current license before preview
currentLicense := th.App.Srv().License()
// Preview the license
_, resp, err := th.SystemAdminClient.PreviewLicenseFile(context.Background(), []byte("test-license-data"))
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
// Verify the license was not saved
licenseAfterPreview := th.App.Srv().License()
if currentLicense == nil {
require.Nil(t, licenseAfterPreview)
} else {
require.Equal(t, currentLicense.Id, licenseAfterPreview.Id)
}
})
}
func TestRemoveLicenseFile(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
client := th.Client
LocalClient := th.LocalClient
t.Run("as system user", func(t *testing.T) {
resp, err := client.RemoveLicenseFile(context.Background())
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
_, err := c.RemoveLicenseFile(context.Background())
require.NoError(t, err)
}, "as system admin user")
t.Run("as restricted system admin user", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = true })
resp, err := th.SystemAdminClient.RemoveLicenseFile(context.Background())
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("restricted admin setting not honoured through local client", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = true })
_, err := LocalClient.RemoveLicenseFile(context.Background())
require.NoError(t, err)
})
}
func TestRequestTrialLicenseWithExtraFields(t *testing.T) {
th := Setup(t)
licenseManagerMock := &mocks.LicenseInterface{}
licenseManagerMock.On("CanStartTrial").Return(true, nil)
th.App.Srv().Platform().SetLicenseManager(licenseManagerMock)
cloud := mocks.CloudInterface{}
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "http://localhost:8065/" })
nUsers := 1
validTrialRequest := &model.TrialLicenseRequest{
Email: "test@mattermost.com",
Users: nUsers,
TermsAccepted: true,
CompanyCountry: "US",
CompanyName: "mattermost",
CompanySize: "1-10",
ContactName: "Matter Most",
}
t.Run("permission denied", func(t *testing.T) {
resp, err := th.Client.RequestTrialLicenseWithExtraFields(context.Background(), &model.TrialLicenseRequest{})
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("trial license user count less than current users", func(t *testing.T) {
license := model.NewTestLicense()
license.Features.Users = new(nUsers)
licenseJSON, jsonErr := json.Marshal(license)
require.NoError(t, jsonErr)
testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
res.WriteHeader(http.StatusOK)
response := map[string]string{
"license": string(licenseJSON),
}
err := json.NewEncoder(res).Encode(response)
require.NoError(t, err)
}))
defer testServer.Close()
mockLicenseValidator := mocks2.LicenseValidatorIface{}
defer testutils.ResetLicenseValidator()
mockLicenseValidator.On("ValidateLicense", mock.Anything).Return(string(licenseJSON), nil)
utils.LicenseValidator = &mockLicenseValidator
licenseManagerMock := &mocks.LicenseInterface{}
licenseManagerMock.On("CanStartTrial").Return(true, nil).Once()
th.App.Srv().Platform().SetLicenseManager(licenseManagerMock)
originalCwsUrl := *th.App.Srv().Config().CloudSettings.CWSURL
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.CloudSettings.CWSURL = testServer.URL })
defer func(requestTrialURL string) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.CloudSettings.CWSURL = requestTrialURL })
}(originalCwsUrl)
cloud.On("ValidateBusinessEmail", mock.Anything, mock.Anything).Return(nil)
resp, err := th.SystemAdminClient.RequestTrialLicenseWithExtraFields(context.Background(), validTrialRequest)
CheckErrorID(t, err, "api.license.add_license.unique_users.app_error")
CheckBadRequestStatus(t, resp)
})
t.Run("returns status 451 when it receives status 451", func(t *testing.T) {
license := model.NewTestLicense()
license.Features.Users = new(nUsers)
licenseJSON, jsonErr := json.Marshal(license)
require.NoError(t, jsonErr)
testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
res.WriteHeader(http.StatusUnavailableForLegalReasons)
}))
defer testServer.Close()
mockLicenseValidator := mocks2.LicenseValidatorIface{}
defer testutils.ResetLicenseValidator()
mockLicenseValidator.On("ValidateLicense", mock.Anything).Return(string(licenseJSON), nil)
utils.LicenseValidator = &mockLicenseValidator
licenseManagerMock := &mocks.LicenseInterface{}
licenseManagerMock.On("CanStartTrial").Return(true, nil).Once()
th.App.Srv().Platform().SetLicenseManager(licenseManagerMock)
originalCwsUrl := *th.App.Srv().Config().CloudSettings.CWSURL
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.CloudSettings.CWSURL = testServer.URL })
defer func(requestTrialURL string) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.CloudSettings.CWSURL = requestTrialURL })
}(originalCwsUrl)
resp, err := th.SystemAdminClient.RequestTrialLicenseWithExtraFields(context.Background(), validTrialRequest)
require.Error(t, err)
require.Equal(t, resp.StatusCode, 451)
})
t.Run("returns status 400 if request is a mix of legacy and new fields", func(t *testing.T) {
validTrialRequest.CompanyCountry = ""
validTrialRequest.Users = 100
defer func() { validTrialRequest.CompanyCountry = "US" }()
license := model.NewTestLicense()
license.Features.Users = new(nUsers)
licenseJSON, jsonErr := json.Marshal(license)
require.NoError(t, jsonErr)
testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
res.WriteHeader(http.StatusOK)
response := map[string]string{
"license": string(licenseJSON),
}
err := json.NewEncoder(res).Encode(response)
require.NoError(t, err)
}))
defer testServer.Close()
mockLicenseValidator := mocks2.LicenseValidatorIface{}
defer testutils.ResetLicenseValidator()
mockLicenseValidator.On("ValidateLicense", mock.Anything).Return(string(licenseJSON), nil)
utils.LicenseValidator = &mockLicenseValidator
licenseManagerMock := &mocks.LicenseInterface{}
licenseManagerMock.On("CanStartTrial").Return(true, nil).Once()
th.App.Srv().Platform().SetLicenseManager(licenseManagerMock)
originalCwsUrl := *th.App.Srv().Config().CloudSettings.CWSURL
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.CloudSettings.CWSURL = testServer.URL })
defer func(requestTrialURL string) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.CloudSettings.CWSURL = requestTrialURL })
}(originalCwsUrl)
cloud.On("ValidateBusinessEmail", mock.Anything, mock.Anything).Return(nil)
resp, err := th.SystemAdminClient.RequestTrialLicenseWithExtraFields(context.Background(), validTrialRequest)
CheckErrorID(t, err, "api.license.request-trial.bad-request")
CheckBadRequestStatus(t, resp)
})
th.App.Srv().Platform().SetLicenseManager(nil)
t.Run("trial license should fail if LicenseManager is nil", func(t *testing.T) {
resp, err := th.SystemAdminClient.RequestTrialLicenseWithExtraFields(context.Background(), validTrialRequest)
CheckErrorID(t, err, "api.license.upgrade_needed.app_error")
CheckForbiddenStatus(t, resp)
})
}
func TestRequestTrialLicense(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
licenseManagerMock := &mocks.LicenseInterface{}
licenseManagerMock.On("CanStartTrial").Return(true, nil)
th.App.Srv().Platform().SetLicenseManager(licenseManagerMock)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "http://localhost:8065/" })
t.Run("permission denied", func(t *testing.T) {
resp, err := th.Client.RequestTrialLicense(context.Background(), 1000)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("trial license invalid JSON", func(t *testing.T) {
// the JSON is invalid because it is missing a closing brace
licenseManagerMock := &mocks.LicenseInterface{}
licenseManagerMock.On("CanStartTrial").Return(true, nil).Once()
th.App.Srv().Platform().SetLicenseManager(licenseManagerMock)
resp, err := th.SystemAdminClient.DoAPIPost(context.Background(), "/trial-license", `{"users": 5`)
CheckErrorID(t, err, "api.license.request-trial.bad-request")
CheckBadRequestStatus(t, model.BuildResponse(resp))
})
t.Run("trial license user count less than current users", func(t *testing.T) {
nUsers := 1
license := model.NewTestLicense()
license.Features.Users = new(nUsers)
licenseJSON, jsonErr := json.Marshal(license)
require.NoError(t, jsonErr)
testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
res.WriteHeader(http.StatusOK)
response := map[string]string{
"license": string(licenseJSON),
}
err := json.NewEncoder(res).Encode(response)
require.NoError(t, err)
}))
defer testServer.Close()
mockLicenseValidator := mocks2.LicenseValidatorIface{}
defer testutils.ResetLicenseValidator()
mockLicenseValidator.On("ValidateLicense", mock.Anything).Return(string(licenseJSON), nil)
utils.LicenseValidator = &mockLicenseValidator
licenseManagerMock := &mocks.LicenseInterface{}
licenseManagerMock.On("CanStartTrial").Return(true, nil).Once()
th.App.Srv().Platform().SetLicenseManager(licenseManagerMock)
originalCwsUrl := *th.App.Srv().Config().CloudSettings.CWSURL
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.CloudSettings.CWSURL = testServer.URL })
defer func(requestTrialURL string) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.CloudSettings.CWSURL = requestTrialURL })
}(originalCwsUrl)
resp, err := th.SystemAdminClient.RequestTrialLicense(context.Background(), nUsers)
CheckErrorID(t, err, "api.license.add_license.unique_users.app_error")
CheckBadRequestStatus(t, resp)
})
t.Run("returns status 451 when it receives status 451", func(t *testing.T) {
nUsers := 1
license := model.NewTestLicense()
license.Features.Users = new(nUsers)
licenseJSON, jsonErr := json.Marshal(license)
require.NoError(t, jsonErr)
testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
res.WriteHeader(http.StatusUnavailableForLegalReasons)
}))
defer testServer.Close()
mockLicenseValidator := mocks2.LicenseValidatorIface{}
defer testutils.ResetLicenseValidator()
mockLicenseValidator.On("ValidateLicense", mock.Anything).Return(string(licenseJSON), nil)
utils.LicenseValidator = &mockLicenseValidator
licenseManagerMock := &mocks.LicenseInterface{}
licenseManagerMock.On("CanStartTrial").Return(true, nil).Once()
th.App.Srv().Platform().SetLicenseManager(licenseManagerMock)
originalCwsUrl := *th.App.Srv().Config().CloudSettings.CWSURL
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.CloudSettings.CWSURL = testServer.URL })
defer func(requestTrialURL string) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.CloudSettings.CWSURL = requestTrialURL })
}(originalCwsUrl)
resp, err := th.SystemAdminClient.RequestTrialLicense(context.Background(), nUsers)
require.Error(t, err)
require.Equal(t, resp.StatusCode, 451)
})
th.App.Srv().Platform().SetLicenseManager(nil)
t.Run("trial license should fail if LicenseManager is nil", func(t *testing.T) {
resp, err := th.SystemAdminClient.RequestTrialLicense(context.Background(), 1)
CheckErrorID(t, err, "api.license.upgrade_needed.app_error")
CheckForbiddenStatus(t, resp)
})
}
func TestGetLicenseLoadMetric(t *testing.T) {
t.Run("when user is logged out", func(t *testing.T) {
th := Setup(t)
client := th.CreateClient()
_, resp, err := client.GetLicenseLoadMetric(context.Background())
require.Error(t, err)
CheckUnauthorizedStatus(t, resp)
})
t.Run("when no license is loaded", func(t *testing.T) {
th := Setup(t)
th.App.Srv().Platform().SetLicense(nil)
data, resp, err := th.Client.GetLicenseLoadMetric(context.Background())
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, 0, data["load"])
})
t.Run("with 50 users on a license count of 1000", func(t *testing.T) {
th := Setup(t)
// Create a license with 1000 users
license := model.NewTestLicense()
license.Features.Users = new(1000) // Set license for 1000 users
th.App.Srv().Platform().SetLicense(license)
// Make user active by setting their status
status := &model.Status{
UserId: th.BasicUser.Id,
Status: model.StatusAway,
Manual: true,
LastActivityAt: model.GetMillis(),
}
initialErr := th.App.Srv().Store().Status().SaveOrUpdate(status)
require.NoError(t, initialErr)
// Add 50 active users (50/1000 * 1000 = 50)
for range 49 { // 49 + 1 basic user = 50 active users
user := th.CreateUser(t)
// Make user active
status := &model.Status{
UserId: user.Id,
Status: model.StatusAway,
Manual: true,
LastActivityAt: model.GetMillis(),
}
statusErr := th.App.Srv().Store().Status().SaveOrUpdate(status)
require.NoError(t, statusErr)
}
// Check load metric - should be exactly 50
data, resp, err := th.Client.GetLicenseLoadMetric(context.Background())
require.NoError(t, err)
require.NotNil(t, resp)
loadValue := data["load"]
require.Equal(t, 50, loadValue)
})
t.Run("with 19 users on a license count of 20", func(t *testing.T) {
th := Setup(t)
// Create a license with 20 users
license := model.NewTestLicense()
license.Features.Users = new(20) // Set license for 20 users
th.App.Srv().Platform().SetLicense(license)
// Make user active by setting their status
status := &model.Status{
UserId: th.BasicUser.Id,
Status: model.StatusAway,
Manual: true,
LastActivityAt: model.GetMillis(),
}
initialErr := th.App.Srv().Store().Status().SaveOrUpdate(status)
require.NoError(t, initialErr)
// Add 19 active users (19/20 * 1000 = 950)
for range 18 { // 18 + 1 basic user = 19 active users
user := th.CreateUser(t)
// Make user active
status := &model.Status{
UserId: user.Id,
Status: model.StatusAway,
Manual: true,
LastActivityAt: model.GetMillis(),
}
statusErr := th.App.Srv().Store().Status().SaveOrUpdate(status)
require.NoError(t, statusErr)
}
// Check load metric - should be around
data, resp, err := th.Client.GetLicenseLoadMetric(context.Background())
require.NoError(t, err)
require.NotNil(t, resp)
loadValue := data["load"]
require.Equal(t, 950, loadValue)
})
t.Run("with 30 users on a license count of 20", func(t *testing.T) {
th := Setup(t)
// Create a license with 20 users
license := model.NewTestLicense()
license.Features.Users = new(20) // Set license for 20 users
th.App.Srv().Platform().SetLicense(license)
// Make user active by setting their status
status := &model.Status{
UserId: th.BasicUser.Id,
Status: model.StatusAway,
Manual: true,
LastActivityAt: model.GetMillis(),
}
initialErr := th.App.Srv().Store().Status().SaveOrUpdate(status)
require.NoError(t, initialErr)
// Add 30 active users (30/20 * 1000 = 1500)
for range 29 { // 29 + 1 basic user = 30 active users
user := th.CreateUser(t)
// Make user active
status := &model.Status{
UserId: user.Id,
Status: model.StatusAway,
Manual: true,
LastActivityAt: model.GetMillis(),
}
statusErr := th.App.Srv().Store().Status().SaveOrUpdate(status)
require.NoError(t, statusErr)
}
// Check load metric - should be exactly 1500
data, resp, err := th.Client.GetLicenseLoadMetric(context.Background())
require.NoError(t, err)
require.NotNil(t, resp)
loadValue := data["load"]
require.Equal(t, 1500, loadValue)
})
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitLimits() {
api.BaseRoutes.Limits.Handle("/server", api.APISessionRequired(getServerLimits)).Methods(http.MethodGet)
}
func getServerLimits(c *Context, w http.ResponseWriter, r *http.Request) {
isAdmin := c.IsSystemAdmin() && c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadUserManagementUsers)
// Only admins receive (and need) the user/guest counts, so only compute them for
// admins. This keeps the expensive count queries off the per-login/per-refresh hot
// path that non-admin clients hit via loadMe()/loadConfigAndMe().
serverLimits, err := c.App.GetServerLimits(isAdmin)
if err != nil {
c.Err = err
return
}
// Non-admin users only get message history limit information, no user count data
if !isAdmin {
limitedData := &model.ServerLimits{
MaxUsersLimit: 0,
MaxUsersHardLimit: 0,
ActiveUserCount: 0,
SingleChannelGuestCount: 0,
SingleChannelGuestLimit: 0,
LastAccessiblePostTime: serverLimits.LastAccessiblePostTime,
PostHistoryLimit: serverLimits.PostHistoryLimit,
}
serverLimits = limitedData
}
if err := json.NewEncoder(w).Encode(serverLimits); err != nil {
c.Logger.Warn("Error writing server limits response", mlog.Err(err))
}
}

View File

@@ -0,0 +1,215 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestGetServerLimits(t *testing.T) {
mainHelper.Parallel(t)
t.Run("admin users can get full server limits", func(t *testing.T) {
th := Setup(t).InitBasic(t)
// Set up unlicensed server
th.App.Srv().SetLicense(nil)
// Test with system admin
serverLimits, resp, err := th.SystemAdminClient.GetServerLimits(context.Background())
require.NoError(t, err)
CheckOKStatus(t, resp)
// Should have full access to all limits data
require.Greater(t, serverLimits.ActiveUserCount, int64(0))
require.Equal(t, int64(200), serverLimits.MaxUsersLimit)
require.Equal(t, int64(250), serverLimits.MaxUsersHardLimit)
require.Equal(t, int64(0), serverLimits.PostHistoryLimit)
require.Equal(t, int64(0), serverLimits.LastAccessiblePostTime)
})
t.Run("non-admin users get limited data with licensed server", func(t *testing.T) {
th := Setup(t).InitBasic(t)
// Set up licensed server with user limits
userLimit := 100
extraUsers := 10
postHistoryLimit := int64(10000)
license := model.NewTestLicense("")
license.IsSeatCountEnforced = true
license.Features.Users = &userLimit
license.ExtraUsers = &extraUsers
license.Limits = &model.LicenseLimits{
PostHistory: postHistoryLimit,
}
th.App.Srv().SetLicense(license)
// Test with regular user
serverLimits, resp, err := th.Client.GetServerLimits(context.Background())
require.NoError(t, err)
CheckOKStatus(t, resp)
// Non-admin users should get zero for user count data (privacy)
require.Equal(t, int64(0), serverLimits.ActiveUserCount)
require.Equal(t, int64(0), serverLimits.MaxUsersLimit)
require.Equal(t, int64(0), serverLimits.MaxUsersHardLimit)
// But should get message history limits (needed for UI)
require.Equal(t, postHistoryLimit, serverLimits.PostHistoryLimit)
})
t.Run("admin users get full limts", func(t *testing.T) {
th := Setup(t).InitBasic(t)
// Set up licensed server with post history limits
userLimit := 100
postHistoryLimit := int64(10000)
license := model.NewTestLicense("")
license.IsSeatCountEnforced = true
license.Features.Users = &userLimit
license.Limits = &model.LicenseLimits{
PostHistory: postHistoryLimit,
}
th.App.Srv().SetLicense(license)
// Test with system admin
serverLimits, resp, err := th.SystemAdminClient.GetServerLimits(context.Background())
require.NoError(t, err)
CheckOKStatus(t, resp)
// Should have full access to all limits data
require.Greater(t, serverLimits.ActiveUserCount, int64(0))
require.Equal(t, int64(100), serverLimits.MaxUsersLimit)
require.Equal(t, int64(100), serverLimits.MaxUsersHardLimit)
// Should have post history limits
require.Equal(t, postHistoryLimit, serverLimits.PostHistoryLimit)
// LastAccessiblePostTime may be 0 if no posts exist in test database, which is expected
require.GreaterOrEqual(t, serverLimits.LastAccessiblePostTime, int64(0))
})
t.Run("non-admin users get post history limits when configured", func(t *testing.T) {
th := Setup(t).InitBasic(t)
// Set up licensed server with post history limits
userLimit := 100
postHistoryLimit := int64(10000)
license := model.NewTestLicense("")
license.IsSeatCountEnforced = true
license.Features.Users = &userLimit
license.Limits = &model.LicenseLimits{
PostHistory: postHistoryLimit,
}
th.App.Srv().SetLicense(license)
// Test with regular user
serverLimits, resp, err := th.Client.GetServerLimits(context.Background())
require.NoError(t, err)
CheckOKStatus(t, resp)
// Non-admin users should get zero for user count data (privacy)
require.Equal(t, int64(0), serverLimits.ActiveUserCount)
require.Equal(t, int64(0), serverLimits.MaxUsersLimit)
require.Equal(t, int64(0), serverLimits.MaxUsersHardLimit)
// But should get post history limits (needed for UI)
require.Equal(t, postHistoryLimit, serverLimits.PostHistoryLimit)
// LastAccessiblePostTime may be 0 if no posts exist in test database, which is expected
require.GreaterOrEqual(t, serverLimits.LastAccessiblePostTime, int64(0))
})
t.Run("zero post history limit shows no limits", func(t *testing.T) {
th := Setup(t).InitBasic(t)
// Set up licensed server with zero post history limit
userLimit := 100
postHistoryLimit := int64(0)
license := model.NewTestLicense("")
license.IsSeatCountEnforced = true
license.Features.Users = &userLimit
license.Limits = &model.LicenseLimits{
PostHistory: postHistoryLimit,
}
th.App.Srv().SetLicense(license)
// Test with both admin and regular user
clients := []*model.Client4{th.SystemAdminClient, th.Client}
for i, client := range clients {
serverLimits, resp, err := client.GetServerLimits(context.Background())
require.NoError(t, err, "Failed for client %d", i)
CheckOKStatus(t, resp)
// Should have no post history limits
require.Equal(t, int64(0), serverLimits.PostHistoryLimit)
require.Equal(t, int64(0), serverLimits.LastAccessiblePostTime)
}
})
t.Run("license with nil Limits shows no post history limits", func(t *testing.T) {
th := Setup(t).InitBasic(t)
// Set up licensed server with nil Limits
userLimit := 100
license := model.NewTestLicense("")
license.IsSeatCountEnforced = true
license.Features.Users = &userLimit
license.Limits = nil // Explicitly set to nil
th.App.Srv().SetLicense(license)
// Test with both admin and regular user
clients := []*model.Client4{th.SystemAdminClient, th.Client}
for i, client := range clients {
serverLimits, resp, err := client.GetServerLimits(context.Background())
require.NoError(t, err, "Failed for client %d", i)
CheckOKStatus(t, resp)
// Should have no post history limits
require.Equal(t, int64(0), serverLimits.PostHistoryLimit)
require.Equal(t, int64(0), serverLimits.LastAccessiblePostTime)
}
})
t.Run("non-admin users get zero single channel guest data", func(t *testing.T) {
th := Setup(t).InitBasic(t)
userLimit := 100
license := model.NewTestLicense("")
license.Features.Users = &userLimit
th.App.Srv().SetLicense(license)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GuestAccountsSettings.Enable = true })
serverLimits, resp, err := th.Client.GetServerLimits(context.Background())
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Equal(t, int64(0), serverLimits.SingleChannelGuestCount)
require.Equal(t, int64(0), serverLimits.SingleChannelGuestLimit)
})
t.Run("admin users get single channel guest data with eligible license", func(t *testing.T) {
th := Setup(t).InitBasic(t)
userLimit := 100
license := model.NewTestLicense("")
license.Features.Users = &userLimit
th.App.Srv().SetLicense(license)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GuestAccountsSettings.Enable = true })
serverLimits, resp, err := th.SystemAdminClient.GetServerLimits(context.Background())
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Equal(t, int64(userLimit), serverLimits.SingleChannelGuestLimit)
})
}

View File

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

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
)
func (api *API) InitClientPerformanceMetrics() {
api.BaseRoutes.APIRoot.Handle("/client_perf", api.APISessionRequiredTrustRequester(submitPerformanceReport)).Methods(http.MethodPost)
}
func submitPerformanceReport(c *Context, w http.ResponseWriter, r *http.Request) {
// we return early if server does not have any metrics infra available
if c.App.Metrics() == nil || !*c.App.Config().MetricsSettings.EnableClientMetrics {
return
}
var report model.PerformanceReport
if jsonErr := json.NewDecoder(r.Body).Decode(&report); jsonErr != nil {
c.SetInvalidParamWithErr("submitPerformanceReport", jsonErr)
return
}
if err := report.IsValid(); err != nil {
c.SetInvalidParamWithErr("submitPerformanceReport", err)
return
}
if appErr := c.App.RegisterPerformanceReport(c.AppContext, &report); appErr != nil {
c.Err = appErr
return
}
ReturnStatusOK(w)
}

View File

@@ -0,0 +1,227 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"net/http"
"testing"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/app/platform"
"github.com/mattermost/mattermost/server/v8/einterfaces"
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
"github.com/stretchr/testify/require"
)
func setupMetricsMock() *mocks.MetricsInterface {
metricsMock := &mocks.MetricsInterface{}
metricsMock.On("IncrementWebsocketEvent", mock.AnythingOfType("model.WebsocketEventType")).Return()
metricsMock.On("IncrementWebSocketBroadcastBufferSize", mock.AnythingOfType("string"), mock.AnythingOfType("float64")).Return()
metricsMock.On("DecrementWebSocketBroadcastBufferSize", mock.AnythingOfType("string"), mock.AnythingOfType("float64")).Return()
metricsMock.On("IncrementMemCacheInvalidationCounter", mock.AnythingOfType("string")).Return()
metricsMock.On("IncrementMemCacheMissCounter", mock.AnythingOfType("string")).Return()
metricsMock.On("IncrementMemCacheHitCounter", mock.AnythingOfType("string")).Return()
metricsMock.On("GetLoggerMetricsCollector").Return(nil)
metricsMock.On("IncrementMemCacheHitCounterSession").Return()
metricsMock.On("IncrementHTTPError").Return()
metricsMock.On("IncrementHTTPRequest").Return()
metricsMock.On("ObserveAPIEndpointDuration", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("float64")).Return()
metricsMock.On("ObserveRedisEndpointDuration", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("float64")).Return()
metricsMock.On("Register").Return()
return metricsMock
}
func TestSubmitMetrics(t *testing.T) {
t.Run("unauthenticated user should not submit metrics", func(t *testing.T) {
th := Setup(t)
_, err := th.Client.Logout(th.Context.Context())
require.NoError(t, err)
resp, err := th.Client.SubmitClientMetrics(th.Context.Context(), nil)
require.Error(t, err)
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
// if the metrics is not enabled on server, we don't want to return
// an error code.
t.Run("metrics not enabled", func(t *testing.T) {
th := Setup(t)
resp, err := th.Client.SubmitClientMetrics(th.Context.Context(), nil)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("metrics enabled but invalid version", func(t *testing.T) {
metricsMock := setupMetricsMock()
platform.RegisterMetricsInterface(func(_ *platform.PlatformService, _, _ string) einterfaces.MetricsInterface {
return metricsMock
})
t.Cleanup(func() {
platform.RegisterMetricsInterface(func(_ *platform.PlatformService, _, _ string) einterfaces.MetricsInterface {
return nil
})
})
th := SetupEnterpriseWithServerOptions(t, []app.Option{app.StartMetrics})
// enable metrics and add the license
th.App.Srv().SetLicense(model.NewTestLicense())
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.MetricsSettings.Enable = true })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.MetricsSettings.ListenAddress = ":0" })
resp, err := th.Client.SubmitClientMetrics(th.Context.Context(), &model.PerformanceReport{
Version: "0.1",
})
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("metrics enabled and valid", func(t *testing.T) {
metricsMock := setupMetricsMock()
metricsMock.On("IncrementClientLongTasks",
mock.AnythingOfType("string"),
mock.AnythingOfType("string"),
mock.AnythingOfType("string"),
mock.AnythingOfType("float64")).Return()
platform.RegisterMetricsInterface(func(_ *platform.PlatformService, _, _ string) einterfaces.MetricsInterface {
return metricsMock
})
t.Cleanup(func() {
platform.RegisterMetricsInterface(func(_ *platform.PlatformService, _, _ string) einterfaces.MetricsInterface {
return nil
})
})
th := SetupEnterpriseWithServerOptions(t, []app.Option{app.StartMetrics})
// enable metrics and add the license
th.App.Srv().SetLicense(model.NewTestLicense())
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.MetricsSettings.Enable = true })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.MetricsSettings.ListenAddress = ":0" })
resp, err := th.Client.SubmitClientMetrics(th.Context.Context(), &model.PerformanceReport{
Version: "0.1",
Start: float64(time.Now().Add(-1 * time.Minute).UnixMilli()),
End: float64(time.Now().UnixMilli()),
Counters: []*model.MetricSample{
{Metric: model.ClientLongTasks, Value: 1},
},
})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("metrics enabled but client metrics are disabled", func(t *testing.T) {
metricsMock := setupMetricsMock()
platform.RegisterMetricsInterface(func(_ *platform.PlatformService, _, _ string) einterfaces.MetricsInterface {
return metricsMock
})
t.Cleanup(func() {
platform.RegisterMetricsInterface(func(_ *platform.PlatformService, _, _ string) einterfaces.MetricsInterface {
return nil
})
})
th := SetupEnterpriseWithServerOptions(t, []app.Option{app.StartMetrics})
// enable metrics and add the license
th.App.Srv().SetLicense(model.NewTestLicense())
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.MetricsSettings.Enable = true })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.MetricsSettings.EnableClientMetrics = false })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.MetricsSettings.ListenAddress = ":0" })
resp, err := th.Client.SubmitClientMetrics(th.Context.Context(), &model.PerformanceReport{
Version: "0.1",
Start: float64(time.Now().Add(-1 * time.Minute).UnixMilli()),
End: float64(time.Now().UnixMilli()),
Counters: []*model.MetricSample{
{Metric: model.ClientLongTasks, Value: 1},
},
})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("metrics recorded for API errors", func(t *testing.T) {
metricsMock := setupMetricsMock()
metricsMock.On("IncrementClientLongTasks",
mock.AnythingOfType("string"),
mock.AnythingOfType("string"),
mock.AnythingOfType("string"),
mock.AnythingOfType("float64")).Return()
platform.RegisterMetricsInterface(func(_ *platform.PlatformService, _, _ string) einterfaces.MetricsInterface {
return metricsMock
})
t.Cleanup(func() {
platform.RegisterMetricsInterface(func(_ *platform.PlatformService, _, _ string) einterfaces.MetricsInterface {
return nil
})
})
th := SetupEnterpriseWithServerOptions(t, []app.Option{app.StartMetrics})
// enable metrics and add the license
th.App.Srv().SetLicense(model.NewTestLicense())
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.MetricsSettings.Enable = true })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.MetricsSettings.ListenAddress = ":0" })
_, resp, err := th.Client.CreatePost(th.Context.Context(), &model.Post{
ChannelId: model.NewId(),
})
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
metricsMock.AssertCalled(t, "IncrementHTTPRequest")
metricsMock.AssertCalled(t, "IncrementHTTPError")
})
t.Run("metrics recorded for URL length limit errors", func(t *testing.T) {
metricsMock := setupMetricsMock()
metricsMock.On("IncrementClientLongTasks",
mock.AnythingOfType("string"),
mock.AnythingOfType("string"),
mock.AnythingOfType("string"),
mock.AnythingOfType("float64")).Return()
platform.RegisterMetricsInterface(func(_ *platform.PlatformService, _, _ string) einterfaces.MetricsInterface {
return metricsMock
})
t.Cleanup(func() {
platform.RegisterMetricsInterface(func(_ *platform.PlatformService, _, _ string) einterfaces.MetricsInterface {
return nil
})
})
th := SetupEnterpriseWithServerOptions(t, []app.Option{app.StartMetrics})
// enable metrics and add the license
th.App.Srv().SetLicense(model.NewTestLicense())
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.MetricsSettings.Enable = true })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.MetricsSettings.ListenAddress = ":0" })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.MaximumURLLength = 1 })
_, resp, err := th.Client.CreatePost(th.Context.Context(), &model.Post{
ChannelId: model.NewId(),
})
require.Error(t, err)
require.Equal(t, http.StatusRequestURITooLong, resp.StatusCode)
metricsMock.AssertCalled(t, "IncrementHTTPRequest")
metricsMock.AssertCalled(t, "IncrementHTTPError")
})
}

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
)
func handleNotifyAdmin(c *Context, w http.ResponseWriter, r *http.Request) {
var notifyAdminRequest *model.NotifyAdminToUpgradeRequest
err := json.NewDecoder(r.Body).Decode(&notifyAdminRequest)
if err != nil || notifyAdminRequest == nil {
c.SetInvalidParamWithErr("notifyAdminRequest", err)
return
}
userId := c.AppContext.Session().UserId
appErr := c.App.SaveAdminNotification(userId, notifyAdminRequest)
if appErr != nil {
c.Err = appErr
return
}
ReturnStatusOK(w)
}
func handleTriggerNotifyAdminPosts(c *Context, w http.ResponseWriter, r *http.Request) {
if !*c.App.Config().ServiceSettings.EnableAPITriggerAdminNotifications {
c.Err = model.NewAppError("Api4.handleTriggerNotifyAdminPosts", "api.cloud.app_error", nil, "Manual triggering of notifications not allowed", http.StatusForbidden)
return
}
var notifyAdminRequest *model.NotifyAdminToUpgradeRequest
err := json.NewDecoder(r.Body).Decode(&notifyAdminRequest)
if err != nil || notifyAdminRequest == nil {
c.SetInvalidParamWithErr("notifyAdminRequest", err)
return
}
// only system admins can manually trigger these notifications
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
appErr := c.App.SendNotifyAdminPosts(c.AppContext, "", "", notifyAdminRequest.TrialNotification)
if appErr != nil {
c.Err = appErr
return
}
ReturnStatusOK(w)
}

View File

@@ -0,0 +1,166 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestNotifyAdmin(t *testing.T) {
mainHelper.Parallel(t)
t.Run("error when notifying with empty data", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
statusCode, err := th.Client.NotifyAdmin(context.Background(), nil)
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, statusCode)
})
t.Run("error when plan is unknown when notifying on upgrade", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
statusCode, err := th.Client.NotifyAdmin(context.Background(), &model.NotifyAdminToUpgradeRequest{
RequiredPlan: "Unknown plan",
RequiredFeature: model.PaidFeatureAllProfessionalfeatures,
})
require.Error(t, err)
require.Equal(t, "Unable to save notify data.", err.Error())
require.Equal(t, http.StatusInternalServerError, statusCode)
})
t.Run("error when plan is unknown when notifying to trial", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
statusCode, err := th.Client.NotifyAdmin(context.Background(), &model.NotifyAdminToUpgradeRequest{
RequiredPlan: "Unknown plan",
RequiredFeature: model.PaidFeatureAllProfessionalfeatures,
TrialNotification: true,
})
require.Error(t, err)
require.Equal(t, "Unable to save notify data.", err.Error())
require.Equal(t, http.StatusInternalServerError, statusCode)
})
t.Run("error when feature is unknown when notifying on upgrade", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
statusCode, err := th.Client.NotifyAdmin(context.Background(), &model.NotifyAdminToUpgradeRequest{
RequiredPlan: model.LicenseShortSkuProfessional,
RequiredFeature: "Unknown feature",
})
require.Error(t, err)
require.Equal(t, "Unable to save notify data.", err.Error())
require.Equal(t, http.StatusInternalServerError, statusCode)
})
t.Run("error when feature is unknown when notifying to trial", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
statusCode, err := th.Client.NotifyAdmin(context.Background(), &model.NotifyAdminToUpgradeRequest{
RequiredPlan: model.LicenseShortSkuProfessional,
RequiredFeature: "Unknown feature",
TrialNotification: true,
})
require.Error(t, err)
require.Equal(t, "Unable to save notify data.", err.Error())
require.Equal(t, http.StatusInternalServerError, statusCode)
})
t.Run("error when user tries to notify again on same feature within the cool off period", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
statusCode, err := th.Client.NotifyAdmin(context.Background(), &model.NotifyAdminToUpgradeRequest{
RequiredPlan: model.LicenseShortSkuProfessional,
RequiredFeature: model.PaidFeatureAllProfessionalfeatures,
})
require.NoError(t, err)
require.Equal(t, http.StatusOK, statusCode)
// second attempt to notify for all professional features
statusCode, err = th.Client.NotifyAdmin(context.Background(), &model.NotifyAdminToUpgradeRequest{
RequiredPlan: model.LicenseShortSkuProfessional,
RequiredFeature: model.PaidFeatureAllProfessionalfeatures,
})
require.Error(t, err)
require.Equal(t, "Already notified admin", err.Error())
require.Equal(t, http.StatusForbidden, statusCode)
})
t.Run("successfully save upgrade notification", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
statusCode, err := th.Client.NotifyAdmin(context.Background(), &model.NotifyAdminToUpgradeRequest{
RequiredPlan: model.LicenseShortSkuProfessional,
RequiredFeature: model.PaidFeatureAllProfessionalfeatures,
})
require.NoError(t, err)
require.Equal(t, http.StatusOK, statusCode)
})
}
func TestTriggerNotifyAdmin(t *testing.T) {
mainHelper.Parallel(t)
t.Run("error when EnableAPITriggerAdminNotifications is not true", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableAPITriggerAdminNotifications = false })
statusCode, err := th.SystemAdminClient.TriggerNotifyAdmin(context.Background(), &model.NotifyAdminToUpgradeRequest{})
require.Error(t, err)
require.Equal(t, "Internal error during cloud api request.", err.Error())
require.Equal(t, http.StatusForbidden, statusCode)
})
t.Run("error when non admins try to trigger notifications", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableAPITriggerAdminNotifications = true })
statusCode, err := th.Client.TriggerNotifyAdmin(context.Background(), &model.NotifyAdminToUpgradeRequest{})
require.Error(t, err)
require.Equal(t, "You do not have the appropriate permissions.", err.Error())
require.Equal(t, http.StatusForbidden, statusCode)
})
t.Run("happy path", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableAPITriggerAdminNotifications = true })
statusCode, err := th.Client.NotifyAdmin(context.Background(), &model.NotifyAdminToUpgradeRequest{
RequiredPlan: model.LicenseShortSkuProfessional,
RequiredFeature: model.PaidFeatureAllProfessionalfeatures,
})
require.NoError(t, err)
require.Equal(t, http.StatusOK, statusCode)
statusCode, err = th.SystemAdminClient.TriggerNotifyAdmin(context.Background(), &model.NotifyAdminToUpgradeRequest{})
require.NoError(t, err)
require.Equal(t, http.StatusOK, statusCode)
})
}

View File

@@ -0,0 +1,446 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitOAuth() {
api.BaseRoutes.OAuthApps.Handle("", api.APISessionRequired(createOAuthApp)).Methods(http.MethodPost)
api.BaseRoutes.OAuthApp.Handle("", api.APISessionRequired(updateOAuthApp)).Methods(http.MethodPut)
api.BaseRoutes.OAuthApps.Handle("", api.APISessionRequired(getOAuthApps)).Methods(http.MethodGet)
api.BaseRoutes.OAuthApp.Handle("", api.APISessionRequired(getOAuthApp)).Methods(http.MethodGet)
api.BaseRoutes.OAuthApp.Handle("/info", api.APISessionRequired(getOAuthAppInfo)).Methods(http.MethodGet)
api.BaseRoutes.OAuthApp.Handle("", api.APISessionRequired(deleteOAuthApp)).Methods(http.MethodDelete)
api.BaseRoutes.OAuthApp.Handle("/regen_secret", api.APISessionRequired(regenerateOAuthAppSecret)).Methods(http.MethodPost)
// DCR (Dynamic Client Registration) endpoints as per RFC 7591
api.BaseRoutes.OAuthApps.Handle("/register", api.RateLimitedHandler(api.APIHandler(registerOAuthClient), model.RateLimitSettings{PerSec: new(2), MaxBurst: new(1)})).Methods(http.MethodPost)
api.BaseRoutes.User.Handle("/oauth/apps/authorized", api.APISessionRequired(getAuthorizedOAuthApps)).Methods(http.MethodGet)
}
func createOAuthApp(c *Context, w http.ResponseWriter, r *http.Request) {
var appRequest model.OAuthAppRequest
if jsonErr := json.NewDecoder(r.Body).Decode(&appRequest); jsonErr != nil {
c.SetInvalidParamWithErr("oauth_app", jsonErr)
return
}
// Build OAuthApp from request
oauthApp := model.OAuthApp{
Name: appRequest.Name,
Description: appRequest.Description,
IconURL: appRequest.IconURL,
CallbackUrls: appRequest.CallbackUrls,
Homepage: appRequest.Homepage,
IsTrusted: appRequest.IsTrusted,
}
auditRec := c.MakeAuditRecord(model.AuditEventCreateOAuthApp, model.AuditStatusFail)
model.AddEventParameterAuditableToAuditRec(auditRec, "oauth_app", &oauthApp)
defer c.LogAuditRec(auditRec)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageOAuth) {
c.SetPermissionError(model.PermissionManageOAuth)
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
oauthApp.IsTrusted = false
}
oauthApp.CreatorId = c.AppContext.Session().UserId
oauthApp.IsDynamicallyRegistered = false
// Use internal method to control secret generation
// Public clients: generateSecret = false (keeps empty secret)
// Confidential clients: generateSecret = true (auto-generates secret)
generateSecret := !appRequest.IsPublic
rapp, err := c.App.CreateOAuthAppInternal(&oauthApp, generateSecret)
if err != nil {
c.Err = err
return
}
auditRec.Success()
auditRec.AddEventResultState(rapp)
auditRec.AddEventObjectType("oauth_app")
c.LogAudit("client_id=" + rapp.Id)
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(rapp); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func updateOAuthApp(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireAppId()
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventUpdateOAuthApp, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "oauth_app_id", c.Params.AppId)
c.LogAudit("attempt")
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageOAuth) {
c.SetPermissionError(model.PermissionManageOAuth)
return
}
var oauthApp model.OAuthApp
if jsonErr := json.NewDecoder(r.Body).Decode(&oauthApp); jsonErr != nil {
c.SetInvalidParamWithErr("oauth_app", jsonErr)
return
}
model.AddEventParameterAuditableToAuditRec(auditRec, "oauth_app", &oauthApp)
// The app being updated in the payload must be the same one as indicated in the URL.
if oauthApp.Id != c.Params.AppId {
c.SetInvalidParam("app_id")
return
}
oldOAuthApp, err := c.App.GetOAuthApp(c.Params.AppId)
if err != nil {
c.Err = err
return
}
auditRec.AddEventPriorState(oldOAuthApp)
if c.AppContext.Session().UserId != oldOAuthApp.CreatorId && !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystemWideOAuth) {
c.SetPermissionError(model.PermissionManageSystemWideOAuth)
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
oauthApp.IsTrusted = oldOAuthApp.IsTrusted
}
updatedOAuthApp, err := c.App.UpdateOAuthApp(oldOAuthApp, &oauthApp)
if err != nil {
c.Err = err
return
}
auditRec.AddEventResultState(updatedOAuthApp)
auditRec.AddEventObjectType("oauth_app")
auditRec.Success()
c.LogAudit("success")
if err := json.NewEncoder(w).Encode(updatedOAuthApp); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getOAuthApps(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageOAuth) {
c.Err = model.NewAppError("getOAuthApps", "api.command.admin_only.app_error", nil, "", http.StatusForbidden)
return
}
var apps []*model.OAuthApp
var appErr *model.AppError
if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystemWideOAuth) {
apps, appErr = c.App.GetOAuthApps(c.Params.Page, c.Params.PerPage)
} else if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageOAuth) {
apps, appErr = c.App.GetOAuthAppsByCreator(c.AppContext.Session().UserId, c.Params.Page, c.Params.PerPage)
} else {
c.SetPermissionError(model.PermissionManageOAuth)
return
}
if appErr != nil {
c.Err = appErr
return
}
js, err := json.Marshal(apps)
if err != nil {
c.Err = model.NewAppError("getOAuthApps", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getOAuthApp(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireAppId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageOAuth) {
c.SetPermissionError(model.PermissionManageOAuth)
return
}
oauthApp, err := c.App.GetOAuthApp(c.Params.AppId)
if err != nil {
c.Err = err
return
}
if oauthApp.CreatorId != c.AppContext.Session().UserId && !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystemWideOAuth) {
c.SetPermissionError(model.PermissionManageSystemWideOAuth)
return
}
if err := json.NewEncoder(w).Encode(oauthApp); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getOAuthAppInfo(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireAppId()
if c.Err != nil {
return
}
oauthApp, err := c.App.GetOAuthApp(c.Params.AppId)
if err != nil {
c.Err = err
return
}
oauthApp.Sanitize()
if err := json.NewEncoder(w).Encode(oauthApp); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func deleteOAuthApp(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireAppId()
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventDeleteOAuthApp, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "oauth_app_id", c.Params.AppId)
c.LogAudit("attempt")
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageOAuth) {
c.SetPermissionError(model.PermissionManageOAuth)
return
}
oauthApp, err := c.App.GetOAuthApp(c.Params.AppId)
if err != nil {
c.Err = err
return
}
auditRec.AddEventPriorState(oauthApp)
auditRec.AddEventObjectType("oauth_app")
if c.AppContext.Session().UserId != oauthApp.CreatorId && !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystemWideOAuth) {
c.SetPermissionError(model.PermissionManageSystemWideOAuth)
return
}
err = c.App.DeleteOAuthApp(c.AppContext, oauthApp.Id)
if err != nil {
c.Err = err
return
}
auditRec.Success()
c.LogAudit("success")
ReturnStatusOK(w)
}
func regenerateOAuthAppSecret(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireAppId()
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventRegenerateOAuthAppSecret, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "oauth_app_id", c.Params.AppId)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageOAuth) {
c.SetPermissionError(model.PermissionManageOAuth)
return
}
oauthApp, err := c.App.GetOAuthApp(c.Params.AppId)
if err != nil {
c.Err = err
return
}
auditRec.AddEventPriorState(oauthApp)
auditRec.AddEventObjectType("oauth_app")
if oauthApp.CreatorId != c.AppContext.Session().UserId && !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystemWideOAuth) {
c.SetPermissionError(model.PermissionManageSystemWideOAuth)
return
}
// Prevent regenerating secrets for public clients
if oauthApp.IsPublicClient() {
c.Err = model.NewAppError("regenerateOAuthAppSecret", "api.oauth.regenerate_secret.public_client.app_error", nil, "app_id="+oauthApp.Id, http.StatusBadRequest)
return
}
oauthApp, err = c.App.RegenerateOAuthAppSecret(oauthApp)
if err != nil {
c.Err = err
return
}
auditRec.AddEventResultState(oauthApp)
auditRec.Success()
c.LogAudit("success")
if err := json.NewEncoder(w).Encode(oauthApp); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getAuthorizedOAuthApps(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionToUser(c.AppContext, *c.AppContext.Session(), c.Params.UserId) {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
apps, appErr := c.App.GetAuthorizedAppsForUser(c.Params.UserId, c.Params.Page, c.Params.PerPage)
if appErr != nil {
c.Err = appErr
return
}
js, err := json.Marshal(apps)
if err != nil {
c.Err = model.NewAppError("getAuthorizedOAuthApps", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
// DCR (Dynamic Client Registration) endpoint handlers as per RFC 7591
func registerOAuthClient(c *Context, w http.ResponseWriter, r *http.Request) {
// Session and permission checks removed for DCR endpoint to allow external client registration
auditRec := c.MakeAuditRecord(model.AuditEventRegisterOAuthClient, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
var clientRequest model.ClientRegistrationRequest
if jsonErr := json.NewDecoder(r.Body).Decode(&clientRequest); jsonErr != nil {
dcrError := model.NewDCRError(model.DCRErrorInvalidClientMetadata, "Invalid JSON in request body")
w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(dcrError); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
return
}
// Add DCR request parameters to audit record
model.AddEventParameterToAuditRec(auditRec, "redirect_uris", clientRequest.RedirectURIs)
if clientRequest.ClientName != nil {
model.AddEventParameterToAuditRec(auditRec, "client_name", *clientRequest.ClientName)
}
if clientRequest.TokenEndpointAuthMethod != nil {
model.AddEventParameterToAuditRec(auditRec, "token_endpoint_auth_method", *clientRequest.TokenEndpointAuthMethod)
}
if clientRequest.ClientURI != nil {
model.AddEventParameterToAuditRec(auditRec, "client_uri", *clientRequest.ClientURI)
}
// Check if OAuth service provider is enabled
if !*c.App.Config().ServiceSettings.EnableOAuthServiceProvider {
dcrError := model.NewDCRError(model.DCRErrorUnsupportedOperation, "OAuth service provider is disabled")
w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(dcrError); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
return
}
// Check if DCR is enabled
if c.App.Config().ServiceSettings.EnableDynamicClientRegistration == nil || !*c.App.Config().ServiceSettings.EnableDynamicClientRegistration {
dcrError := model.NewDCRError(model.DCRErrorUnsupportedOperation, "Dynamic client registration is disabled")
w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(dcrError); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
return
}
// Validate the request
if err := clientRequest.IsValid(); err != nil {
dcrError := model.NewDCRError(model.DCRErrorInvalidClientMetadata, err.Message)
w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(dcrError); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
return
}
// Enforce DCR redirect URI allowlist if configured
allowlist := c.App.Config().ServiceSettings.DCRRedirectURIAllowlist
if len(allowlist) > 0 {
for _, uri := range clientRequest.RedirectURIs {
if !model.RedirectURIMatchesAllowlist(uri, allowlist) {
dcrError := model.NewDCRError(model.DCRErrorInvalidRedirectURI, "One or more redirect URIs do not match the allowlist")
w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(dcrError); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
return
}
}
}
// No user ID for DCR
userID := ""
app, appErr := c.App.RegisterOAuthClient(c.AppContext, &clientRequest, userID)
if appErr != nil {
dcrError := model.NewDCRError(model.DCRErrorInvalidClientMetadata, appErr.Message)
w.WriteHeader(appErr.StatusCode)
if err := json.NewEncoder(w).Encode(dcrError); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
return
}
auditRec.Success()
auditRec.AddEventResultState(app)
auditRec.AddEventObjectType("oauth_app")
c.LogAudit("client_id=" + app.Id)
siteURL := *c.App.Config().ServiceSettings.SiteURL
response := app.ToClientRegistrationResponse(siteURL)
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(response); err != nil {
c.Logger.Warn("Error writing DCR response", mlog.Err(err))
}
}

File diff suppressed because it is too large Load Diff

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