snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
This commit is contained in:
322
server/channels/api4/abac_file_metadata_test.go
Normal file
322
server/channels/api4/abac_file_metadata_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
1480
server/channels/api4/access_control.go
Normal file
1480
server/channels/api4/access_control.go
Normal file
File diff suppressed because it is too large
Load Diff
175
server/channels/api4/access_control_decision_test.go
Normal file
175
server/channels/api4/access_control_decision_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
25
server/channels/api4/access_control_local.go
Normal file
25
server/channels/api4/access_control_local.go
Normal 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)
|
||||
}
|
||||
76
server/channels/api4/agents.go
Normal file
76
server/channels/api4/agents.go
Normal 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))
|
||||
}
|
||||
}
|
||||
73
server/channels/api4/ai_bridge_test_helper.go
Normal file
73
server/channels/api4/ai_bridge_test_helper.go
Normal 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)
|
||||
}
|
||||
218
server/channels/api4/ai_bridge_test_helper_test.go
Normal file
218
server/channels/api4/ai_bridge_test_helper_test.go
Normal 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
548
server/channels/api4/api.go
Normal 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
|
||||
1499
server/channels/api4/apitestlib.go
Normal file
1499
server/channels/api4/apitestlib.go
Normal file
File diff suppressed because it is too large
Load Diff
67
server/channels/api4/apitestlib_test.go
Normal file
67
server/channels/api4/apitestlib_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
83
server/channels/api4/audit_logging.go
Normal file
83
server/channels/api4/audit_logging.go
Normal 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)
|
||||
}
|
||||
69
server/channels/api4/board.go
Normal file
69
server/channels/api4/board.go
Normal 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))
|
||||
}
|
||||
}
|
||||
146
server/channels/api4/board_test.go
Normal file
146
server/channels/api4/board_test.go
Normal 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
319
server/channels/api4/bot.go
Normal 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))
|
||||
}
|
||||
}
|
||||
17
server/channels/api4/bot_local.go
Normal file
17
server/channels/api4/bot_local.go
Normal 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)
|
||||
}
|
||||
1587
server/channels/api4/bot_test.go
Normal file
1587
server/channels/api4/bot_test.go
Normal file
File diff suppressed because it is too large
Load Diff
105
server/channels/api4/brand.go
Normal file
105
server/channels/api4/brand.go
Normal 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)
|
||||
}
|
||||
166
server/channels/api4/brand_test.go
Normal file
166
server/channels/api4/brand_test.go
Normal 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)
|
||||
}
|
||||
3493
server/channels/api4/channel.go
Normal file
3493
server/channels/api4/channel.go
Normal file
File diff suppressed because it is too large
Load Diff
485
server/channels/api4/channel_bookmark.go
Normal file
485
server/channels/api4/channel_bookmark.go
Normal 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))
|
||||
}
|
||||
}
|
||||
1788
server/channels/api4/channel_bookmark_test.go
Normal file
1788
server/channels/api4/channel_bookmark_test.go
Normal file
File diff suppressed because it is too large
Load Diff
395
server/channels/api4/channel_category.go
Normal file
395
server/channels/api4/channel_category.go
Normal 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)
|
||||
}
|
||||
1632
server/channels/api4/channel_category_test.go
Normal file
1632
server/channels/api4/channel_category_test.go
Normal file
File diff suppressed because it is too large
Load Diff
180
server/channels/api4/channel_common_teams_test.go
Normal file
180
server/channels/api4/channel_common_teams_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
293
server/channels/api4/channel_join_request.go
Normal file
293
server/channels/api4/channel_join_request.go
Normal 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))
|
||||
}
|
||||
}
|
||||
154
server/channels/api4/channel_join_request_test.go
Normal file
154
server/channels/api4/channel_join_request_test.go
Normal 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()
|
||||
}
|
||||
445
server/channels/api4/channel_local.go
Normal file
445
server/channels/api4/channel_local.go
Normal 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)
|
||||
}
|
||||
634
server/channels/api4/cloud.go
Normal file
634
server/channels/api4/cloud.go
Normal 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))
|
||||
}
|
||||
}
|
||||
488
server/channels/api4/cloud_test.go
Normal file
488
server/channels/api4/cloud_test.go
Normal 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"])
|
||||
})
|
||||
}
|
||||
38
server/channels/api4/cluster.go
Normal file
38
server/channels/api4/cluster.go
Normal 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))
|
||||
}
|
||||
}
|
||||
39
server/channels/api4/cluster_test.go
Normal file
39
server/channels/api4/cluster_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
554
server/channels/api4/command.go
Normal file
554
server/channels/api4/command.go
Normal 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))
|
||||
}
|
||||
}
|
||||
39
server/channels/api4/command_help_test.go
Normal file
39
server/channels/api4/command_help_test.go
Normal 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")
|
||||
}
|
||||
51
server/channels/api4/command_local.go
Normal file
51
server/channels/api4/command_local.go
Normal 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))
|
||||
}
|
||||
}
|
||||
2063
server/channels/api4/command_test.go
Normal file
2063
server/channels/api4/command_test.go
Normal file
File diff suppressed because it is too large
Load Diff
477
server/channels/api4/commands_test.go
Normal file
477
server/channels/api4/commands_test.go
Normal 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")
|
||||
}
|
||||
163
server/channels/api4/compliance.go
Normal file
163
server/channels/api4/compliance.go
Normal 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))
|
||||
}
|
||||
}
|
||||
469
server/channels/api4/config.go
Normal file
469
server/channels/api4/config.go
Normal 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)
|
||||
}
|
||||
}
|
||||
205
server/channels/api4/config_local.go
Normal file
205
server/channels/api4/config_local.go
Normal 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))
|
||||
}
|
||||
}
|
||||
1119
server/channels/api4/config_test.go
Normal file
1119
server/channels/api4/config_test.go
Normal file
File diff suppressed because it is too large
Load Diff
657
server/channels/api4/content_flagging.go
Normal file
657
server/channels/api4/content_flagging.go
Normal 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)
|
||||
}
|
||||
}
|
||||
183
server/channels/api4/content_flagging_report.go
Normal file
183
server/channels/api4/content_flagging_report.go
Normal 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()
|
||||
}
|
||||
695
server/channels/api4/content_flagging_report_test.go
Normal file
695
server/channels/api4/content_flagging_report_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
1556
server/channels/api4/content_flagging_test.go
Normal file
1556
server/channels/api4/content_flagging_test.go
Normal file
File diff suppressed because it is too large
Load Diff
154
server/channels/api4/cors_test.go
Normal file
154
server/channels/api4/cors_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
512
server/channels/api4/custom_profile_attributes.go
Normal file
512
server/channels/api4/custom_profile_attributes.go
Normal 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")
|
||||
}
|
||||
16
server/channels/api4/custom_profile_attributes_local.go
Normal file
16
server/channels/api4/custom_profile_attributes_local.go
Normal 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)
|
||||
}
|
||||
2077
server/channels/api4/custom_profile_attributes_test.go
Normal file
2077
server/channels/api4/custom_profile_attributes_test.go
Normal file
File diff suppressed because it is too large
Load Diff
515
server/channels/api4/data_retention.go
Normal file
515
server/channels/api4/data_retention.go
Normal 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))
|
||||
}
|
||||
}
|
||||
1247
server/channels/api4/data_retention_test.go
Normal file
1247
server/channels/api4/data_retention_test.go
Normal file
File diff suppressed because it is too large
Load Diff
100
server/channels/api4/delivery_tracking.go
Normal file
100
server/channels/api4/delivery_tracking.go
Normal 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)
|
||||
}
|
||||
233
server/channels/api4/delivery_tracking_events_test.go
Normal file
233
server/channels/api4/delivery_tracking_events_test.go
Normal 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))
|
||||
}
|
||||
220
server/channels/api4/delivery_tracking_test.go
Normal file
220
server/channels/api4/delivery_tracking_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
56
server/channels/api4/doc.go
Normal file
56
server/channels/api4/doc.go
Normal 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
|
||||
144
server/channels/api4/drafts.go
Normal file
144
server/channels/api4/drafts.go
Normal 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)
|
||||
}
|
||||
211
server/channels/api4/drafts_test.go
Normal file
211
server/channels/api4/drafts_test.go
Normal 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)
|
||||
}
|
||||
67
server/channels/api4/elasticsearch.go
Normal file
67
server/channels/api4/elasticsearch.go
Normal 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)
|
||||
}
|
||||
81
server/channels/api4/elasticsearch_test.go
Normal file
81
server/channels/api4/elasticsearch_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
348
server/channels/api4/emoji.go
Normal file
348
server/channels/api4/emoji.go
Normal 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))
|
||||
}
|
||||
}
|
||||
915
server/channels/api4/emoji_test.go
Normal file
915
server/channels/api4/emoji_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
161
server/channels/api4/ephemeral_mode.go
Normal file
161
server/channels/api4/ephemeral_mode.go
Normal 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)
|
||||
}
|
||||
486
server/channels/api4/ephemeral_mode_test.go
Normal file
486
server/channels/api4/ephemeral_mode_test.go
Normal 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"])
|
||||
})
|
||||
}
|
||||
}
|
||||
118
server/channels/api4/export.go
Normal file
118
server/channels/api4/export.go
Normal 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))
|
||||
}
|
||||
}
|
||||
13
server/channels/api4/export_local.go
Normal file
13
server/channels/api4/export_local.go
Normal 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)
|
||||
}
|
||||
340
server/channels/api4/export_test.go
Normal file
340
server/channels/api4/export_test.go
Normal 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
1037
server/channels/api4/file.go
Normal file
File diff suppressed because it is too large
Load Diff
1957
server/channels/api4/file_test.go
Normal file
1957
server/channels/api4/file_test.go
Normal file
File diff suppressed because it is too large
Load Diff
1590
server/channels/api4/group.go
Normal file
1590
server/channels/api4/group.go
Normal file
File diff suppressed because it is too large
Load Diff
47
server/channels/api4/group_local.go
Normal file
47
server/channels/api4/group_local.go
Normal 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))
|
||||
}
|
||||
}
|
||||
256
server/channels/api4/handlers.go
Normal file
256
server/channels/api4/handlers.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
98
server/channels/api4/handlers_test.go
Normal file
98
server/channels/api4/handlers_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
44
server/channels/api4/helpers.go
Normal file
44
server/channels/api4/helpers.go
Normal 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
|
||||
}
|
||||
22
server/channels/api4/hosted_customer.go
Normal file
22
server/channels/api4/hosted_customer.go
Normal 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)
|
||||
}
|
||||
53
server/channels/api4/image.go
Normal file
53
server/channels/api4/image.go
Normal 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)
|
||||
}
|
||||
106
server/channels/api4/image_test.go
Normal file
106
server/channels/api4/image_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
54
server/channels/api4/import.go
Normal file
54
server/channels/api4/import.go
Normal 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)
|
||||
}
|
||||
11
server/channels/api4/import_local.go
Normal file
11
server/channels/api4/import_local.go
Normal 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)
|
||||
}
|
||||
184
server/channels/api4/import_test.go
Normal file
184
server/channels/api4/import_test.go
Normal 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")
|
||||
}
|
||||
322
server/channels/api4/integration_action.go
Normal file
322
server/channels/api4/integration_action.go
Normal 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))
|
||||
}
|
||||
}
|
||||
1561
server/channels/api4/integration_action_test.go
Normal file
1561
server/channels/api4/integration_action_test.go
Normal file
File diff suppressed because it is too large
Load Diff
117
server/channels/api4/ip_filtering.go
Normal file
117
server/channels/api4/ip_filtering.go
Normal 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))
|
||||
}
|
||||
}
|
||||
272
server/channels/api4/ip_filtering_test.go
Normal file
272
server/channels/api4/ip_filtering_test.go
Normal 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
472
server/channels/api4/job.go
Normal 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)
|
||||
}
|
||||
16
server/channels/api4/job_local.go
Normal file
16
server/channels/api4/job_local.go
Normal 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)
|
||||
}
|
||||
957
server/channels/api4/job_test.go
Normal file
957
server/channels/api4/job_test.go
Normal 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
515
server/channels/api4/ldap.go
Normal file
515
server/channels/api4/ldap.go
Normal 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)
|
||||
}
|
||||
17
server/channels/api4/ldap_local.go
Normal file
17
server/channels/api4/ldap_local.go
Normal 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)
|
||||
}
|
||||
322
server/channels/api4/ldap_test.go
Normal file
322
server/channels/api4/ldap_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
316
server/channels/api4/license.go
Normal file
316
server/channels/api4/license.go
Normal 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))
|
||||
}
|
||||
}
|
||||
111
server/channels/api4/license_local.go
Normal file
111
server/channels/api4/license_local.go
Normal 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))
|
||||
}
|
||||
}
|
||||
739
server/channels/api4/license_test.go
Normal file
739
server/channels/api4/license_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
48
server/channels/api4/limits.go
Normal file
48
server/channels/api4/limits.go
Normal 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))
|
||||
}
|
||||
}
|
||||
215
server/channels/api4/limits_test.go
Normal file
215
server/channels/api4/limits_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
37
server/channels/api4/main_test.go
Normal file
37
server/channels/api4/main_test.go
Normal 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)
|
||||
}
|
||||
40
server/channels/api4/metrics.go
Normal file
40
server/channels/api4/metrics.go
Normal 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)
|
||||
}
|
||||
227
server/channels/api4/metrics_test.go
Normal file
227
server/channels/api4/metrics_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
57
server/channels/api4/notify_admin.go
Normal file
57
server/channels/api4/notify_admin.go
Normal 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(¬ifyAdminRequest)
|
||||
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(¬ifyAdminRequest)
|
||||
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)
|
||||
}
|
||||
166
server/channels/api4/notify_admin_test.go
Normal file
166
server/channels/api4/notify_admin_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
446
server/channels/api4/oauth.go
Normal file
446
server/channels/api4/oauth.go
Normal 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))
|
||||
}
|
||||
}
|
||||
1041
server/channels/api4/oauth_test.go
Normal file
1041
server/channels/api4/oauth_test.go
Normal file
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
Reference in New Issue
Block a user