Feature: Wrangler (#23602)
* Migrate feature/wrangler to mono-repo * Add wrangler files * Fix linters, types, etc * Fix snapshots * Fix playwright * Fix pipelines * Fix more pipeline * Fixes for pipelines * More changes for pipeline * Fix types * Add support for a feature flag, but leave it defaulted on for spinwick usage for now * Update snapshot * fix js error when removing last value of multiselect, support CSV marshaling to string array for textsetting * Fix linter * Remove TODO * Remove another TODO * fix tests * Fix i18n * Add server tests * Fix linter * Fix linter * Use proper icon for dot menu * Update snapshot * Add Cypress UI tests for various entrypoints to move thread modal, split SCSS out from forward post into its own thing * clean up * fix linter * More cleanup * Revert files to master * Fix linter for e2e tests * Make ForwardPostChannelSelect channel types configurable with a prop * Add missing return * Fixes from PR feedback * First batch of PR Feedback * Another batch of PR changes * Fix linter * Update snapshots * Wrangler system messages are translated to each user's locale * Initially translate Wrangler into system locale rather than initiating user * More fixes for PR Feedback * Fix some server tests * More updates with master. Fixes around pipelines. Enforce Enterprise license on front/back end * Add tests for dot_menu * More pipeline fixes * Fix e2etests prettier * Update cypress tests, change occurrences of 'Wrangler' with 'Move Thread' * Fix linter * Remove enterprise lock * A couple more occurrences of wrangler strings, and one more enterprise lock * Fix server tests * Fix i18n * Fix e2e linter * Feature flag shouldn't be on by default * Enable move threads feature in smoke tests (#25657) * enable move threads feature * add @prod tag * Fix move_thread_from_public_channel e2e test * Fix e2e style --------- Co-authored-by: Mattermost Build <build@mattermost.com> Co-authored-by: yasserfaraazkhan <attitude3cena.yf@gmail.com>
Этот коммит содержится в:
@@ -44,6 +44,8 @@ func (api *API) InitPost() {
|
||||
|
||||
api.BaseRoutes.PostForUser.Handle("/ack", api.APISessionRequired(acknowledgePost)).Methods("POST")
|
||||
api.BaseRoutes.PostForUser.Handle("/ack", api.APISessionRequired(unacknowledgePost)).Methods("DELETE")
|
||||
|
||||
api.BaseRoutes.Post.Handle("/move", api.APISessionRequired(moveThread)).Methods("POST")
|
||||
}
|
||||
|
||||
func createPost(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1128,6 +1130,83 @@ func unacknowledgePost(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
ReturnStatusOK(w)
|
||||
}
|
||||
|
||||
func moveThread(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequirePostId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !c.App.Config().FeatureFlags.MoveThreadsEnabled {
|
||||
c.Err = model.NewAppError("moveThread", "api.post.move_thread.disabled.app_error", nil, "", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
var moveThreadParams model.MoveThreadParams
|
||||
if jsonErr := json.NewDecoder(r.Body).Decode(&moveThreadParams); jsonErr != nil {
|
||||
c.SetInvalidParamWithErr("post", jsonErr)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord("moveThread", audit.Fail)
|
||||
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
|
||||
audit.AddEventParameter(auditRec, "original_post_id", c.Params.PostId)
|
||||
audit.AddEventParameter(auditRec, "to_channel_id", moveThreadParams.ChannelId)
|
||||
|
||||
user, err := c.App.GetUser(c.AppContext.Session().UserId)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
// If there are no configured PermittedWranglerRoles, skip the check
|
||||
userHasRole := len(c.App.Config().WranglerSettings.PermittedWranglerRoles) == 0
|
||||
for _, role := range c.App.Config().WranglerSettings.PermittedWranglerRoles {
|
||||
if user.IsInRole(role) {
|
||||
userHasRole = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Sysadmins are always permitted
|
||||
if !userHasRole && !user.IsSystemAdmin() {
|
||||
c.Err = model.NewAppError("moveThread", "api.post.move_thread.no_permission", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
userHasEmailDomain := len(c.App.Config().WranglerSettings.AllowedEmailDomain) == 0
|
||||
for _, domain := range c.App.Config().WranglerSettings.AllowedEmailDomain {
|
||||
if user.EmailDomain() == domain {
|
||||
userHasEmailDomain = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !userHasEmailDomain && !user.IsSystemAdmin() {
|
||||
c.Err = model.NewAppError("moveThread", "api.post.move_thread.no_permission", nil, fmt.Sprintf("User: %+v", user), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
sourcePost, err := c.App.GetPostIfAuthorized(c.AppContext, c.Params.PostId, c.AppContext.Session(), false)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
if err.Id == "app.post.cloud.get.app_error" {
|
||||
w.Header().Set(model.HeaderFirstInaccessiblePostTime, "1")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err = c.App.MoveThread(c.AppContext, c.Params.PostId, sourcePost.ChannelId, moveThreadParams.ChannelId, user)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
|
||||
ReturnStatusOK(w)
|
||||
}
|
||||
|
||||
func getFileInfosForPost(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequirePostId()
|
||||
if c.Err != nil {
|
||||
|
||||
@@ -730,6 +730,239 @@ func TestCreatePostWithOutgoingHook_no_content_type(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestMoveThread(t *testing.T) {
|
||||
os.Setenv("MM_FEATUREFLAGS_MOVETHREADSENABLED", "true")
|
||||
defer os.Unsetenv("MM_FEATUREFLAGS_MOVETHREADSENABLED")
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
client := th.Client
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
basicUser1 := th.BasicUser
|
||||
basicUser2 := th.BasicUser2
|
||||
basicUser3 := th.CreateUser()
|
||||
|
||||
// Create a new public channel to move the post to
|
||||
publicChannel, resp, err := client.CreateChannel(ctx, &model.Channel{
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Name: "test-public-channel",
|
||||
DisplayName: "Test Public Channel",
|
||||
Type: model.ChannelTypeOpen,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, publicChannel)
|
||||
|
||||
// Create a new private channel to move the post to
|
||||
privateChannel, resp, err := client.CreateChannel(ctx, &model.Channel{
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Name: "test-private-channel",
|
||||
DisplayName: "Test Private Channel",
|
||||
Type: model.ChannelTypePrivate,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, privateChannel)
|
||||
|
||||
// Create a new direct message channel to move the post to
|
||||
dmChannel, resp, err := client.CreateDirectChannel(ctx, basicUser1.Id, basicUser2.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, dmChannel)
|
||||
|
||||
// Create a new group message channel to move the post to
|
||||
gmChannel, resp, err := client.CreateGroupChannel(ctx, []string{basicUser1.Id, basicUser2.Id, basicUser3.Id})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, gmChannel)
|
||||
t.Run("Move to public channel", func(t *testing.T) {
|
||||
// Create a new post to move
|
||||
post := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "test post",
|
||||
}
|
||||
newPost, resp, err := client.CreatePost(ctx, post)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, newPost)
|
||||
|
||||
// Move the post to the public channel
|
||||
moveThreadParams := &model.MoveThreadParams{
|
||||
ChannelId: publicChannel.Id,
|
||||
}
|
||||
resp, err = client.MoveThread(ctx, newPost.Id, moveThreadParams)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Check that the post was moved to the public channel
|
||||
posts, resp, err := client.GetPostsForChannel(ctx, publicChannel.Id, 0, 100, "", true, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, posts)
|
||||
// There should be 2 posts, the system join message for the user who moved it joining the channel, and the post we moved
|
||||
require.Equal(t, 2, len(posts.Posts))
|
||||
require.Equal(t, newPost.Message, posts.Posts[posts.Order[0]].Message)
|
||||
})
|
||||
|
||||
t.Run("Move to private channel", func(t *testing.T) {
|
||||
// Create a new post to move
|
||||
post := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "test post",
|
||||
}
|
||||
newPost, resp, err := client.CreatePost(ctx, post)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, newPost)
|
||||
|
||||
// Move the post to the private channel
|
||||
moveThreadParams := &model.MoveThreadParams{
|
||||
ChannelId: privateChannel.Id,
|
||||
}
|
||||
resp, err = client.MoveThread(ctx, newPost.Id, moveThreadParams)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Check that the post was moved to the private channel
|
||||
posts, resp, err := client.GetPostsForChannel(ctx, privateChannel.Id, 0, 100, "", true, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, posts)
|
||||
// There should be 2 posts, the system join message for the user who moved it joining the channel, and the post we moved
|
||||
require.Equal(t, 2, len(posts.Posts))
|
||||
require.Equal(t, newPost.Message, posts.Posts[posts.Order[0]].Message)
|
||||
})
|
||||
|
||||
t.Run("Move to direct message channel", func(t *testing.T) {
|
||||
// Create a new post to move
|
||||
post := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "test post",
|
||||
}
|
||||
newPost, resp, err := client.CreatePost(ctx, post)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, newPost)
|
||||
|
||||
// Move the post to the direct message channel
|
||||
moveThreadParams := &model.MoveThreadParams{
|
||||
ChannelId: dmChannel.Id,
|
||||
}
|
||||
resp, err = client.MoveThread(ctx, newPost.Id, moveThreadParams)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Check that the post was moved to the direct message channel
|
||||
posts, resp, err := client.GetPostsForChannel(ctx, dmChannel.Id, 0, 100, "", true, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, posts)
|
||||
// There should be 1 post, the post we moved
|
||||
require.Equal(t, 1, len(posts.Posts))
|
||||
require.Equal(t, newPost.Message, posts.Posts[posts.Order[0]].Message)
|
||||
})
|
||||
|
||||
t.Run("Move to group message channel", func(t *testing.T) {
|
||||
// Create a new post to move
|
||||
post := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "test post",
|
||||
}
|
||||
newPost, resp, err := client.CreatePost(ctx, post)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, newPost)
|
||||
|
||||
// Move the post to the group message channel
|
||||
moveThreadParams := &model.MoveThreadParams{
|
||||
ChannelId: gmChannel.Id,
|
||||
}
|
||||
resp, err = client.MoveThread(ctx, newPost.Id, moveThreadParams)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Check that the post was moved to the group message channel
|
||||
posts, resp, err := client.GetPostsForChannel(ctx, gmChannel.Id, 0, 100, "", true, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, posts)
|
||||
// There should be 1 post, the post we moved
|
||||
require.Equal(t, 1, len(posts.Posts))
|
||||
require.Equal(t, newPost.Message, posts.Posts[posts.Order[0]].Message)
|
||||
})
|
||||
|
||||
t.Run("Move thread with more than one post", func(t *testing.T) {
|
||||
// Create a new public channel to move the post to
|
||||
pChannel, resp, err := client.CreateChannel(ctx, &model.Channel{
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Name: "test-public-channel2",
|
||||
DisplayName: "Test Public Channel",
|
||||
Type: model.ChannelTypeOpen,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, pChannel)
|
||||
// Create a new post to use as the root post
|
||||
rootPost := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "root post",
|
||||
}
|
||||
rootPost, resp, err = client.CreatePost(ctx, rootPost)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, rootPost)
|
||||
|
||||
// Create a new post to move
|
||||
post := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "test post",
|
||||
RootId: rootPost.Id,
|
||||
}
|
||||
newPost, resp, err := client.CreatePost(ctx, post)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, newPost)
|
||||
|
||||
// Create another post in the thread
|
||||
post = &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "test post 2",
|
||||
RootId: rootPost.Id,
|
||||
}
|
||||
newPost2, resp, err := client.CreatePost(ctx, post)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, newPost2)
|
||||
|
||||
// Move the thread to the public channel
|
||||
moveThreadParams := &model.MoveThreadParams{
|
||||
ChannelId: pChannel.Id,
|
||||
}
|
||||
resp, err = client.MoveThread(ctx, rootPost.Id, moveThreadParams)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Check that the thread was moved to the public channel
|
||||
posts, resp, err := client.GetPostsForChannel(ctx, pChannel.Id, 0, 100, "", false, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, posts)
|
||||
// There should be 3 posts, the system join message for the user who moved it joining the channel, and the two posts in the thread
|
||||
// require.Equal(t, 3, len(posts.Posts))
|
||||
fmt.Println(posts.Order)
|
||||
for _, p := range posts.Order {
|
||||
fmt.Println(posts.Posts[p].Id)
|
||||
fmt.Println(posts.Posts[p].Message)
|
||||
}
|
||||
require.Equal(t, "This thread was moved from another channel", posts.Posts[posts.Order[0]].Message)
|
||||
require.Equal(t, newPost2.Message, posts.Posts[posts.Order[1]].Message)
|
||||
require.Equal(t, newPost.Message, posts.Posts[posts.Order[2]].Message)
|
||||
require.Equal(t, rootPost.Message, posts.Posts[posts.Order[3]].Message)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreatePostPublic(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
@@ -398,6 +398,10 @@ type AppIface interface {
|
||||
ValidateUserPermissionsOnChannels(c request.CTX, userId string, channelIds []string) []string
|
||||
// VerifyPlugin checks that the given signature corresponds to the given plugin and matches a trusted certificate.
|
||||
VerifyPlugin(plugin, signature io.ReadSeeker) *model.AppError
|
||||
// validateMoveOrCopy performs validation on a provided post list to determine
|
||||
// if all permissions are in place to allow the for the posts to be moved or
|
||||
// copied.
|
||||
ValidateMoveOrCopy(c request.CTX, wpl *model.WranglerPostList, originalChannel *model.Channel, targetChannel *model.Channel, user *model.User) error
|
||||
AccountMigration() einterfaces.AccountMigrationInterface
|
||||
ActivateMfa(userID, token string) *model.AppError
|
||||
ActiveSearchBackend() string
|
||||
@@ -478,6 +482,7 @@ type AppIface interface {
|
||||
Config() *model.Config
|
||||
ConvertGroupMessageToChannel(c request.CTX, convertedByUserId string, gmConversionRequest *model.GroupMessageConversionRequestBody) (*model.Channel, *model.AppError)
|
||||
CopyFileInfos(rctx request.CTX, userID string, fileIDs []string) ([]string, *model.AppError)
|
||||
CopyWranglerPostlist(c request.CTX, wpl *model.WranglerPostList, targetChannel *model.Channel) (*model.Post, *model.AppError)
|
||||
CreateChannel(c request.CTX, channel *model.Channel, addMember bool) (*model.Channel, *model.AppError)
|
||||
CreateChannelWithUser(c request.CTX, channel *model.Channel, userID string) (*model.Channel, *model.AppError)
|
||||
CreateCommand(cmd *model.Command) (*model.Command, *model.AppError)
|
||||
@@ -918,6 +923,7 @@ type AppIface interface {
|
||||
MigrateIdLDAP(c request.CTX, toAttribute string) *model.AppError
|
||||
MoveCommand(team *model.Team, command *model.Command) *model.AppError
|
||||
MoveFile(oldPath, newPath string) *model.AppError
|
||||
MoveThread(c request.CTX, postID string, sourceChannelID, channelID string, user *model.User) *model.AppError
|
||||
NewPluginAPI(c request.CTX, manifest *model.Manifest) plugin.API
|
||||
Notification() einterfaces.NotificationInterface
|
||||
NotificationsLog() *mlog.Logger
|
||||
|
||||
@@ -1884,6 +1884,28 @@ func (a *OpenTracingAppLayer) CopyFileInfos(rctx request.CTX, userID string, fil
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) CopyWranglerPostlist(c request.CTX, wpl *model.WranglerPostList, targetChannel *model.Channel) (*model.Post, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CopyWranglerPostlist")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store().SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store().SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.CopyWranglerPostlist(c, wpl, targetChannel)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) CreateBot(c request.CTX, bot *model.Bot) (*model.Bot, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CreateBot")
|
||||
@@ -12664,6 +12686,28 @@ func (a *OpenTracingAppLayer) MoveFile(oldPath string, newPath string) *model.Ap
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) MoveThread(c request.CTX, postID string, sourceChannelID string, channelID string, user *model.User) *model.AppError {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.MoveThread")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store().SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store().SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0 := a.app.MoveThread(c, postID, sourceChannelID, channelID, user)
|
||||
|
||||
if resultVar0 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar0))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) NewPluginAPI(c request.CTX, manifest *model.Manifest) plugin.API {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.NewPluginAPI")
|
||||
@@ -18590,6 +18634,28 @@ func (a *OpenTracingAppLayer) ValidateDesktopToken(token string, expiryTime int6
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) ValidateMoveOrCopy(c request.CTX, wpl *model.WranglerPostList, originalChannel *model.Channel, targetChannel *model.Channel, user *model.User) error {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ValidateMoveOrCopy")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store().SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store().SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0 := a.app.ValidateMoveOrCopy(c, wpl, originalChannel, targetChannel, user)
|
||||
|
||||
if resultVar0 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar0))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) ValidateUserPermissionsOnChannels(c request.CTX, userId string, channelIds []string) []string {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ValidateUserPermissionsOnChannels")
|
||||
|
||||
@@ -2349,3 +2349,255 @@ func (a *App) applyPostWillBeConsumedHook(post **model.Post) {
|
||||
return true
|
||||
}, plugin.MessagesWillBeConsumedID)
|
||||
}
|
||||
|
||||
func makePostLink(siteURL, teamName, postID string) string {
|
||||
return fmt.Sprintf("%s/%s/pl/%s", siteURL, teamName, postID)
|
||||
}
|
||||
|
||||
// validateMoveOrCopy performs validation on a provided post list to determine
|
||||
// if all permissions are in place to allow the for the posts to be moved or
|
||||
// copied.
|
||||
func (a *App) ValidateMoveOrCopy(c request.CTX, wpl *model.WranglerPostList, originalChannel *model.Channel, targetChannel *model.Channel, user *model.User) error {
|
||||
if wpl.NumPosts() == 0 {
|
||||
return errors.New("The wrangler post list contains no posts")
|
||||
}
|
||||
|
||||
config := a.Config().WranglerSettings
|
||||
|
||||
switch originalChannel.Type {
|
||||
case model.ChannelTypePrivate:
|
||||
if !*config.MoveThreadFromPrivateChannelEnable {
|
||||
return errors.New("Wrangler is currently configured to not allow moving posts from private channels")
|
||||
}
|
||||
case model.ChannelTypeDirect:
|
||||
if !*config.MoveThreadFromDirectMessageChannelEnable {
|
||||
return errors.New("Wrangler is currently configured to not allow moving posts from direct message channels")
|
||||
}
|
||||
case model.ChannelTypeGroup:
|
||||
if !*config.MoveThreadFromGroupMessageChannelEnable {
|
||||
return errors.New("Wrangler is currently configured to not allow moving posts from group message channels")
|
||||
}
|
||||
}
|
||||
|
||||
if !originalChannel.IsGroupOrDirect() && !targetChannel.IsGroupOrDirect() {
|
||||
// DM and GM channels are "teamless" so it doesn't make sense to check
|
||||
// the MoveThreadToAnotherTeamEnable config when dealing with those.
|
||||
if !*config.MoveThreadToAnotherTeamEnable && targetChannel.TeamId != originalChannel.TeamId {
|
||||
return errors.New("Wrangler is currently configured to not allow moving messages to different teams")
|
||||
}
|
||||
}
|
||||
|
||||
if *config.MoveThreadMaxCount != int64(0) && *config.MoveThreadMaxCount < int64(wpl.NumPosts()) {
|
||||
return fmt.Errorf("the thread is %d posts long, but this command is configured to only move threads of up to %d posts", wpl.NumPosts(), *config.MoveThreadMaxCount)
|
||||
}
|
||||
|
||||
_, appErr := a.GetChannelMember(c, targetChannel.Id, user.Id)
|
||||
if appErr != nil {
|
||||
return fmt.Errorf("channel with ID %s doesn't exist or you are not a member", targetChannel.Id)
|
||||
}
|
||||
|
||||
_, appErr = a.GetChannelMember(c, originalChannel.Id, user.Id)
|
||||
if appErr != nil {
|
||||
return fmt.Errorf("channel with ID %s doesn't exist or you are not a member", originalChannel.Id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) CopyWranglerPostlist(c request.CTX, wpl *model.WranglerPostList, targetChannel *model.Channel) (*model.Post, *model.AppError) {
|
||||
var appErr *model.AppError
|
||||
var newRootPost *model.Post
|
||||
|
||||
if wpl.ContainsFileAttachments() {
|
||||
// The thread contains at least one attachment. To properly move the
|
||||
// thread, the files will have to be re-uploaded. This is completed
|
||||
// before any messages are moved.
|
||||
// TODO: check number of files that need to be re-uploaded or file size?
|
||||
c.Logger().Info("Wrangler is re-uploading file attachments",
|
||||
mlog.String("file_count", fmt.Sprintf("%d", wpl.FileAttachmentCount)),
|
||||
)
|
||||
|
||||
for _, post := range wpl.Posts {
|
||||
var newFileIDs []string
|
||||
var fileBytes []byte
|
||||
var oldFileInfo, newFileInfo *model.FileInfo
|
||||
for _, fileID := range post.FileIds {
|
||||
oldFileInfo, appErr = a.GetFileInfo(c, fileID)
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
fileBytes, appErr = a.GetFile(c, fileID)
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
newFileInfo, appErr = a.UploadFile(c, fileBytes, targetChannel.Id, oldFileInfo.Name)
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
newFileIDs = append(newFileIDs, newFileInfo.Id)
|
||||
}
|
||||
|
||||
post.FileIds = newFileIDs
|
||||
}
|
||||
}
|
||||
|
||||
for i, post := range wpl.Posts {
|
||||
var reactions []*model.Reaction
|
||||
|
||||
// Store reactions to be reapplied later.
|
||||
reactions, appErr = a.GetReactionsForPost(post.Id)
|
||||
if appErr != nil {
|
||||
// Reaction-based errors are logged, but do not abort
|
||||
c.Logger().Error("Failed to get reactions on original post")
|
||||
}
|
||||
|
||||
newPost := post.Clone()
|
||||
newPost = newPost.CleanPost()
|
||||
newPost.ChannelId = targetChannel.Id
|
||||
|
||||
if i == 0 {
|
||||
newPost, appErr = a.CreatePost(c, newPost, targetChannel, false, false)
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
newRootPost = newPost.Clone()
|
||||
} else {
|
||||
newPost.RootId = newRootPost.Id
|
||||
newPost, appErr = a.CreatePost(c, newPost, targetChannel, false, false)
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
}
|
||||
|
||||
for _, reaction := range reactions {
|
||||
reaction.PostId = newPost.Id
|
||||
_, appErr = a.SaveReactionForPost(c, reaction)
|
||||
if appErr != nil {
|
||||
// Reaction-based errors are logged, but do not abort
|
||||
c.Logger().Error("Failed to reapply reactions to post")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newRootPost, nil
|
||||
}
|
||||
|
||||
func (a *App) MoveThread(c request.CTX, postID string, sourceChannelID, channelID string, user *model.User) *model.AppError {
|
||||
postListResponse, appErr := a.GetPostThread(postID, model.GetPostsOptions{}, user.Id)
|
||||
if appErr != nil {
|
||||
return model.NewAppError("getPostThread", "app.post.move_thread_command.error", nil, "postID="+postID+", "+"UserId="+user.Id+"", http.StatusBadRequest)
|
||||
}
|
||||
wpl := postListResponse.BuildWranglerPostList()
|
||||
|
||||
originalChannel, appErr := a.GetChannel(c, sourceChannelID)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
targetChannel, appErr := a.GetChannel(c, channelID)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
err := a.ValidateMoveOrCopy(c, wpl, originalChannel, targetChannel, user)
|
||||
if err != nil {
|
||||
return model.NewAppError("validateMoveOrCopy", "app.post.move_thread_command.error", nil, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
|
||||
var targetTeam *model.Team
|
||||
if targetChannel.IsGroupOrDirect() {
|
||||
if !originalChannel.IsGroupOrDirect() {
|
||||
targetTeam, appErr = a.GetTeam(originalChannel.TeamId)
|
||||
}
|
||||
} else {
|
||||
targetTeam, appErr = a.GetTeam(targetChannel.TeamId)
|
||||
}
|
||||
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
if targetTeam == nil {
|
||||
return model.NewAppError("validateMoveOrCopy", "app.post.move_thread_command.error", nil, "target team is nil", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// Begin creating the new thread.
|
||||
c.Logger().Info("Wrangler is moving a thread", mlog.String("user_id", user.Id), mlog.String("original_post_id", wpl.RootPost().Id), mlog.String("original_channel_id", originalChannel.Id))
|
||||
|
||||
// To simulate the move, we first copy the original messages(s) to the
|
||||
// new channel and later delete the original messages(s).
|
||||
newRootPost, appErr := a.CopyWranglerPostlist(c, wpl, targetChannel)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
T, err := i18n.GetTranslationsBySystemLocale()
|
||||
if err != nil {
|
||||
return model.NewAppError("MoveThread", "app.post.move_thread_command.error", nil, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
ephemeralPostProps := model.StringInterface{
|
||||
"TranslationID": "app.post.move_thread.from_another_channel",
|
||||
}
|
||||
_, appErr = a.CreatePost(c, &model.Post{
|
||||
UserId: user.Id,
|
||||
Type: model.PostTypeWrangler,
|
||||
RootId: newRootPost.Id,
|
||||
ChannelId: channelID,
|
||||
Message: T("app.post.move_thread.from_another_channel"),
|
||||
Props: ephemeralPostProps,
|
||||
}, targetChannel, false, false)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
// Cleanup is handled by simply deleting the root post. Any comments/replies
|
||||
// are automatically marked as deleted for us.
|
||||
_, appErr = a.DeletePost(c, wpl.RootPost().Id, user.Id)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
c.Logger().Info("Wrangler thread move complete", mlog.String("user_id", user.Id), mlog.String("new_post_id", newRootPost.Id), mlog.String("channel_id", channelID))
|
||||
|
||||
// Translate to the system locale, webapp will attempt to render in each user's specific locale (based on the TranslationID prop) before falling back on the initiating user's locale
|
||||
ephemeralPostProps = model.StringInterface{}
|
||||
|
||||
msg := T("app.post.move_thread_command.direct_or_group.multiple_messages", model.StringInterface{"NumMessages": wpl.NumPosts()})
|
||||
ephemeralPostProps["TranslationID"] = "app.post.move_thread_command.direct_or_group.multiple_messages"
|
||||
if wpl.NumPosts() == 1 {
|
||||
msg = T("app.post.move_thread_command.direct_or_group.one_message")
|
||||
ephemeralPostProps["TranslationID"] = "app.post.move_thread_command.direct_or_group.one_message"
|
||||
}
|
||||
|
||||
if targetChannel.TeamId != "" {
|
||||
targetTeam, teamErr := a.GetTeam(targetChannel.TeamId)
|
||||
if teamErr != nil {
|
||||
return teamErr
|
||||
}
|
||||
targetName := targetTeam.Name
|
||||
newPostLink := makePostLink(*a.Config().ServiceSettings.SiteURL, targetName, newRootPost.Id)
|
||||
msg = T("app.post.move_thread_command.channel.multiple_messages", model.StringInterface{"NumMessages": wpl.NumPosts(), "Link": newPostLink})
|
||||
ephemeralPostProps["TranslationID"] = "app.post.move_thread_command.channel.multiple_messages"
|
||||
if wpl.NumPosts() == 1 {
|
||||
msg = T("app.post.move_thread_command.channel.one_message", model.StringInterface{"Link": newPostLink})
|
||||
ephemeralPostProps["TranslationID"] = "app.post.move_thread_command.channel.one_message"
|
||||
}
|
||||
ephemeralPostProps["MovedThreadPermalink"] = newPostLink
|
||||
}
|
||||
|
||||
ephemeralPostProps["NumMessages"] = wpl.NumPosts()
|
||||
|
||||
_, appErr = a.CreatePost(c, &model.Post{
|
||||
UserId: user.Id,
|
||||
Type: model.PostTypeWrangler,
|
||||
ChannelId: originalChannel.Id,
|
||||
Message: msg,
|
||||
Props: ephemeralPostProps,
|
||||
}, originalChannel, false, false)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
c.Logger().Info(msg)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3286,3 +3286,179 @@ func TestGetEditHistoryForPost(t *testing.T) {
|
||||
require.Empty(t, edits)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCopyWranglerPostlist(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
// Create a post with a file attachment
|
||||
fileBytes := []byte("file contents")
|
||||
fileInfo, err := th.App.UploadFile(th.Context, fileBytes, th.BasicChannel.Id, "file.txt")
|
||||
require.Nil(t, err)
|
||||
post := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "test message",
|
||||
UserId: th.BasicUser.Id,
|
||||
FileIds: []string{fileInfo.Id},
|
||||
}
|
||||
rootPost, err := th.App.CreatePost(th.Context, post, th.BasicChannel, false, true)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Add a reaction to the post
|
||||
reaction := &model.Reaction{
|
||||
UserId: th.BasicUser.Id,
|
||||
PostId: rootPost.Id,
|
||||
EmojiName: "smile",
|
||||
}
|
||||
_, err = th.App.SaveReactionForPost(th.Context, reaction)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Copy the post to a new channel
|
||||
targetChannel := &model.Channel{
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Name: "test-channel",
|
||||
Type: model.ChannelTypeOpen,
|
||||
}
|
||||
targetChannel, err = th.App.CreateChannel(th.Context, targetChannel, false)
|
||||
require.Nil(t, err)
|
||||
wpl := &model.WranglerPostList{
|
||||
Posts: []*model.Post{rootPost},
|
||||
FileAttachmentCount: 1,
|
||||
}
|
||||
newRootPost, err := th.App.CopyWranglerPostlist(th.Context, wpl, targetChannel)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Check that the new post has the same message and file attachment
|
||||
require.Equal(t, rootPost.Message, newRootPost.Message)
|
||||
require.Len(t, newRootPost.FileIds, 1)
|
||||
|
||||
// Check that the new post has the same reaction
|
||||
reactions, err := th.App.GetReactionsForPost(newRootPost.Id)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, reactions, 1)
|
||||
require.Equal(t, reaction.EmojiName, reactions[0].EmojiName)
|
||||
}
|
||||
|
||||
func TestValidateMoveOrCopy(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.WranglerSettings.MoveThreadFromPrivateChannelEnable = model.NewBool(true)
|
||||
cfg.WranglerSettings.MoveThreadFromDirectMessageChannelEnable = model.NewBool(true)
|
||||
cfg.WranglerSettings.MoveThreadFromGroupMessageChannelEnable = model.NewBool(true)
|
||||
cfg.WranglerSettings.MoveThreadToAnotherTeamEnable = model.NewBool(true)
|
||||
cfg.WranglerSettings.MoveThreadMaxCount = model.NewInt64(100)
|
||||
})
|
||||
|
||||
t.Run("empty post list", func(t *testing.T) {
|
||||
err := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{}, th.BasicChannel, th.BasicChannel, th.BasicUser)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "The wrangler post list contains no posts", err.Error())
|
||||
})
|
||||
|
||||
t.Run("moving from private channel with MoveThreadFromPrivateChannelEnable disabled", func(t *testing.T) {
|
||||
privateChannel := &model.Channel{
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Name: "private-channel",
|
||||
Type: model.ChannelTypePrivate,
|
||||
}
|
||||
privateChannel, err := th.App.CreateChannel(th.Context, privateChannel, false)
|
||||
require.Nil(t, err)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.WranglerSettings.MoveThreadFromPrivateChannelEnable = model.NewBool(false)
|
||||
})
|
||||
|
||||
e := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{Posts: []*model.Post{{ChannelId: privateChannel.Id}}}, privateChannel, th.BasicChannel, th.BasicUser)
|
||||
require.Error(t, e)
|
||||
require.Equal(t, "Wrangler is currently configured to not allow moving posts from private channels", e.Error())
|
||||
})
|
||||
|
||||
t.Run("moving from direct channel with MoveThreadFromDirectMessageChannelEnable disabled", func(t *testing.T) {
|
||||
directChannel, err := th.App.createDirectChannel(th.Context, th.BasicUser.Id, th.BasicUser2.Id)
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, directChannel)
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.WranglerSettings.MoveThreadFromDirectMessageChannelEnable = model.NewBool(false)
|
||||
})
|
||||
|
||||
e := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{Posts: []*model.Post{{ChannelId: directChannel.Id}}}, directChannel, th.BasicChannel, th.BasicUser)
|
||||
require.Error(t, e)
|
||||
require.Equal(t, "Wrangler is currently configured to not allow moving posts from direct message channels", e.Error())
|
||||
})
|
||||
|
||||
t.Run("moving from group channel with MoveThreadFromGroupMessageChannelEnable disabled", func(t *testing.T) {
|
||||
groupChannel := &model.Channel{
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Name: "group-channel",
|
||||
Type: model.ChannelTypeGroup,
|
||||
}
|
||||
groupChannel, err := th.App.CreateChannel(th.Context, groupChannel, false)
|
||||
require.Nil(t, err)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.WranglerSettings.MoveThreadFromGroupMessageChannelEnable = model.NewBool(false)
|
||||
})
|
||||
|
||||
e := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{Posts: []*model.Post{{ChannelId: groupChannel.Id}}}, groupChannel, th.BasicChannel, th.BasicUser)
|
||||
require.Error(t, e)
|
||||
require.Equal(t, "Wrangler is currently configured to not allow moving posts from group message channels", e.Error())
|
||||
})
|
||||
|
||||
t.Run("moving to different team with MoveThreadToAnotherTeamEnable disabled", func(t *testing.T) {
|
||||
team := &model.Team{
|
||||
Name: "testteam",
|
||||
DisplayName: "testteam",
|
||||
Type: model.TeamOpen,
|
||||
}
|
||||
|
||||
targetTeam, err := th.App.CreateTeam(th.Context, team)
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, targetTeam)
|
||||
|
||||
targetChannel := &model.Channel{
|
||||
TeamId: targetTeam.Id,
|
||||
Name: "test-channel",
|
||||
Type: model.ChannelTypeOpen,
|
||||
}
|
||||
|
||||
targetChannel, err = th.App.CreateChannel(th.Context, targetChannel, false)
|
||||
require.Nil(t, err)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.WranglerSettings.MoveThreadToAnotherTeamEnable = model.NewBool(false)
|
||||
})
|
||||
|
||||
e := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{Posts: []*model.Post{{ChannelId: th.BasicChannel.Id}}}, th.BasicChannel, targetChannel, th.BasicUser)
|
||||
require.Error(t, e)
|
||||
require.Equal(t, "Wrangler is currently configured to not allow moving messages to different teams", e.Error())
|
||||
})
|
||||
|
||||
t.Run("moving to channel user is not a member of", func(t *testing.T) {
|
||||
targetChannel := &model.Channel{
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Name: "test-channel",
|
||||
Type: model.ChannelTypePrivate,
|
||||
}
|
||||
targetChannel, err := th.App.CreateChannel(th.Context, targetChannel, false)
|
||||
require.Nil(t, err)
|
||||
|
||||
err = th.App.RemoveUserFromChannel(th.Context, th.BasicUser.Id, th.SystemAdminUser.Id, th.BasicChannel)
|
||||
require.Nil(t, err)
|
||||
|
||||
e := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{Posts: []*model.Post{{ChannelId: th.BasicChannel.Id}}}, th.BasicChannel, targetChannel, th.BasicUser)
|
||||
require.Error(t, e)
|
||||
require.Equal(t, fmt.Sprintf("channel with ID %s doesn't exist or you are not a member", targetChannel.Id), e.Error())
|
||||
})
|
||||
|
||||
t.Run("moving thread longer than MoveThreadMaxCount", func(t *testing.T) {
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.WranglerSettings.MoveThreadMaxCount = 1
|
||||
})
|
||||
|
||||
e := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{Posts: []*model.Post{{ChannelId: th.BasicChannel.Id}, {ChannelId: th.BasicChannel.Id}}}, th.BasicChannel, th.BasicChannel, th.BasicUser)
|
||||
require.Error(t, e)
|
||||
require.Equal(t, "the thread is 2 posts long, but this command is configured to only move threads of up to 1 posts", e.Error())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -142,6 +142,14 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li
|
||||
props["DelayChannelAutocomplete"] = strconv.FormatBool(*c.ExperimentalSettings.DelayChannelAutocomplete)
|
||||
props["UniqueEmojiReactionLimitPerPost"] = strconv.FormatInt(int64(*c.ServiceSettings.UniqueEmojiReactionLimitPerPost), 10)
|
||||
|
||||
props["WranglerPermittedWranglerRoles"] = strings.Join(c.WranglerSettings.PermittedWranglerRoles, ",")
|
||||
props["WranglerAllowedEmailDomain"] = strings.Join(c.WranglerSettings.AllowedEmailDomain, ",")
|
||||
props["WranglerMoveThreadMaxCount"] = strconv.FormatInt(*c.WranglerSettings.MoveThreadMaxCount, 10)
|
||||
props["WranglerMoveThreadToAnotherTeamEnable"] = strconv.FormatBool(*c.WranglerSettings.MoveThreadToAnotherTeamEnable)
|
||||
props["WranglerMoveThreadFromPrivateChannelEnable"] = strconv.FormatBool(*c.WranglerSettings.MoveThreadFromPrivateChannelEnable)
|
||||
props["WranglerMoveThreadFromDirectMessageChannelEnable"] = strconv.FormatBool(*c.WranglerSettings.MoveThreadFromDirectMessageChannelEnable)
|
||||
props["WranglerMoveThreadFromGroupMessageChannelEnable"] = strconv.FormatBool(*c.WranglerSettings.MoveThreadFromGroupMessageChannelEnable)
|
||||
|
||||
if license != nil {
|
||||
props["ExperimentalEnableAuthenticationTransfer"] = strconv.FormatBool(*c.ServiceSettings.ExperimentalEnableAuthenticationTransfer)
|
||||
|
||||
|
||||
@@ -2454,6 +2454,14 @@
|
||||
"other": "{{.Count}} images sent: {{.Filenames}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "api.post.move_thread.disabled.app_error",
|
||||
"translation": "Thread moving is disabled"
|
||||
},
|
||||
{
|
||||
"id": "api.post.move_thread.no_permission",
|
||||
"translation": "You do not have permission to move this thread."
|
||||
},
|
||||
{
|
||||
"id": "api.post.patch_post.can_not_update_post_in_deleted.error",
|
||||
"translation": "Can not update a post in a deleted channel."
|
||||
@@ -6442,6 +6450,30 @@
|
||||
"id": "app.post.marshal.app_error",
|
||||
"translation": "Failed to marshal post."
|
||||
},
|
||||
{
|
||||
"id": "app.post.move_thread.from_another_channel",
|
||||
"translation": "This thread was moved from another channel"
|
||||
},
|
||||
{
|
||||
"id": "app.post.move_thread_command.channel.multiple_messages",
|
||||
"translation": "A thread with {{.NumMessages}} messages has been moved: {{.Link}}\n"
|
||||
},
|
||||
{
|
||||
"id": "app.post.move_thread_command.channel.one_message",
|
||||
"translation": "A message has been moved: {{.Link}}\n"
|
||||
},
|
||||
{
|
||||
"id": "app.post.move_thread_command.direct_or_group.multiple_messages",
|
||||
"translation": "A thread with {{.NumMessages}} messages has been moved to a Direct/Group Message\n"
|
||||
},
|
||||
{
|
||||
"id": "app.post.move_thread_command.direct_or_group.one_message",
|
||||
"translation": "A message has been moved to a Direct/Group Message\n"
|
||||
},
|
||||
{
|
||||
"id": "app.post.move_thread_command.error",
|
||||
"translation": "Unable to remove thread"
|
||||
},
|
||||
{
|
||||
"id": "app.post.overwrite.app_error",
|
||||
"translation": "Unable to overwrite the Post."
|
||||
@@ -8894,6 +8926,10 @@
|
||||
"id": "model.config.is_valid.message_export.global_relay.smtp_username.app_error",
|
||||
"translation": "Message export job GlobalRelaySettings.SmtpUsername must be set."
|
||||
},
|
||||
{
|
||||
"id": "model.config.is_valid.move_thread.domain_invalid.app_error",
|
||||
"translation": "Invalid domain for move thread settings"
|
||||
},
|
||||
{
|
||||
"id": "model.config.is_valid.password_length.app_error",
|
||||
"translation": "Minimum password length must be a whole number greater than or equal to {{.MinLength}} and less than or equal to {{.MaxLength}}."
|
||||
|
||||
@@ -76,6 +76,7 @@ const (
|
||||
TrackConfigImageProxy = "config_image_proxy"
|
||||
TrackConfigBleve = "config_bleve"
|
||||
TrackConfigExport = "config_export"
|
||||
TrackConfigWrangler = "config_wrangler"
|
||||
TrackFeatureFlags = "config_feature_flags"
|
||||
TrackConfigProducts = "products"
|
||||
TrackPermissionsGeneral = "permissions_general"
|
||||
@@ -880,6 +881,16 @@ func (ts *TelemetryService) trackConfig() {
|
||||
"retention_days": *cfg.ExportSettings.RetentionDays,
|
||||
})
|
||||
|
||||
ts.SendTelemetry(TrackConfigWrangler, map[string]any{
|
||||
"permitted_wrangler_users": cfg.WranglerSettings.PermittedWranglerRoles,
|
||||
"allowed_email_domain": cfg.WranglerSettings.AllowedEmailDomain,
|
||||
"move_thread_max_count": cfg.WranglerSettings.MoveThreadMaxCount,
|
||||
"move_thread_to_another_team_enable": cfg.WranglerSettings.MoveThreadToAnotherTeamEnable,
|
||||
"move_thread_from_private_channel_enable": cfg.WranglerSettings.MoveThreadFromPrivateChannelEnable,
|
||||
"move_thread_from_direct_message_channel_enable": cfg.WranglerSettings.MoveThreadFromDirectMessageChannelEnable,
|
||||
"move_thread_from_group_message_channel_enable": cfg.WranglerSettings.MoveThreadFromGroupMessageChannelEnable,
|
||||
})
|
||||
|
||||
// Convert feature flags to map[string]any for sending
|
||||
flags := cfg.FeatureFlags.ToMap()
|
||||
interfaceFlags := make(map[string]any)
|
||||
|
||||
@@ -4184,6 +4184,21 @@ func (c *Client4) GetPostsBefore(ctx context.Context, channelId, postId string,
|
||||
return &list, BuildResponse(r), nil
|
||||
}
|
||||
|
||||
// MoveThread moves a thread based on provided post id, and channel id string.
|
||||
func (c *Client4) MoveThread(ctx context.Context, postId string, params *MoveThreadParams) (*Response, error) {
|
||||
js, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return nil, NewAppError("MoveThread", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
r, err := c.DoAPIPost(ctx, c.postRoute(postId)+"/move", string(js))
|
||||
if err != nil {
|
||||
return BuildResponse(r), err
|
||||
}
|
||||
defer closeBody(r)
|
||||
return BuildResponse(r), nil
|
||||
}
|
||||
|
||||
// GetPostsAroundLastUnread gets a list of posts around last unread post by a user in a channel.
|
||||
func (c *Client4) GetPostsAroundLastUnread(ctx context.Context, userId, channelId string, limitBefore, limitAfter int, collapsedThreads bool) (*PostList, *Response, error) {
|
||||
query := fmt.Sprintf("?limit_before=%v&limit_after=%v", limitBefore, limitAfter)
|
||||
|
||||
@@ -3108,6 +3108,51 @@ func (s *PluginSettings) SetDefaults(ls LogSettings) {
|
||||
}
|
||||
}
|
||||
|
||||
type WranglerSettings struct {
|
||||
PermittedWranglerRoles []string
|
||||
AllowedEmailDomain []string
|
||||
MoveThreadMaxCount *int64
|
||||
MoveThreadToAnotherTeamEnable *bool
|
||||
MoveThreadFromPrivateChannelEnable *bool
|
||||
MoveThreadFromDirectMessageChannelEnable *bool
|
||||
MoveThreadFromGroupMessageChannelEnable *bool
|
||||
}
|
||||
|
||||
func (w *WranglerSettings) SetDefaults() {
|
||||
if w.PermittedWranglerRoles == nil {
|
||||
w.PermittedWranglerRoles = make([]string, 0)
|
||||
}
|
||||
if w.AllowedEmailDomain == nil {
|
||||
w.AllowedEmailDomain = make([]string, 0)
|
||||
}
|
||||
if w.MoveThreadMaxCount == nil {
|
||||
w.MoveThreadMaxCount = NewInt64(100)
|
||||
}
|
||||
if w.MoveThreadToAnotherTeamEnable == nil {
|
||||
w.MoveThreadToAnotherTeamEnable = NewBool(false)
|
||||
}
|
||||
if w.MoveThreadFromPrivateChannelEnable == nil {
|
||||
w.MoveThreadFromPrivateChannelEnable = NewBool(false)
|
||||
}
|
||||
if w.MoveThreadFromDirectMessageChannelEnable == nil {
|
||||
w.MoveThreadFromDirectMessageChannelEnable = NewBool(false)
|
||||
}
|
||||
if w.MoveThreadFromGroupMessageChannelEnable == nil {
|
||||
w.MoveThreadFromGroupMessageChannelEnable = NewBool(false)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WranglerSettings) IsValid() *AppError {
|
||||
validDomainRegex := regexp.MustCompile(`^(([a-zA-Z]{1})|([a-zA-Z]{1}[a-zA-Z]{1})|([a-zA-Z]{1}[0-9]{1})|([0-9]{1}[a-zA-Z]{1})|([a-zA-Z0-9][a-zA-Z0-9-_]{1,61}[a-zA-Z0-9]))\.([a-zA-Z]{2,6}|[a-zA-Z0-9-]{2,30}\.[a-zA-Z]{2,3})$`)
|
||||
for _, domain := range w.AllowedEmailDomain {
|
||||
if !validDomainRegex.MatchString(domain) && domain != "localhost" {
|
||||
return NewAppError("Config.IsValid", "model.config.is_valid.move_thread.domain_invalid.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type GlobalRelayMessageExportSettings struct {
|
||||
CustomerType *string `access:"compliance_compliance_export"` // must be either A9, A10 or CUSTOM, dictates SMTP server url
|
||||
SMTPUsername *string `access:"compliance_compliance_export"`
|
||||
@@ -3405,6 +3450,7 @@ type Config struct {
|
||||
FeatureFlags *FeatureFlags `access:"*_read" json:",omitempty"`
|
||||
ImportSettings ImportSettings // telemetry: none
|
||||
ExportSettings ExportSettings
|
||||
WranglerSettings WranglerSettings
|
||||
}
|
||||
|
||||
func (o *Config) Auditable() map[string]interface{} {
|
||||
@@ -3520,6 +3566,7 @@ func (o *Config) SetDefaults() {
|
||||
}
|
||||
o.ImportSettings.SetDefaults()
|
||||
o.ExportSettings.SetDefaults()
|
||||
o.WranglerSettings.SetDefaults()
|
||||
}
|
||||
|
||||
func (o *Config) IsValid() *AppError {
|
||||
@@ -3610,6 +3657,11 @@ func (o *Config) IsValid() *AppError {
|
||||
if appErr := o.ImportSettings.isValid(); appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
if appErr := o.WranglerSettings.IsValid(); appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -143,6 +143,24 @@ func TestConfigOverwriteSignatureAlgorithm(t *testing.T) {
|
||||
require.Equal(t, *c1.SamlSettings.CanonicalAlgorithm, testAlgorithm)
|
||||
}
|
||||
|
||||
func TestWranglerSettingsIsValid(t *testing.T) {
|
||||
// // Test valid domains
|
||||
w := &WranglerSettings{
|
||||
AllowedEmailDomain: []string{"example.com", "subdomain.example.com"},
|
||||
}
|
||||
if err := w.IsValid(); err != nil {
|
||||
t.Errorf("Expected no error for valid domains, but got %v", err)
|
||||
}
|
||||
|
||||
// Test invalid domains
|
||||
w = &WranglerSettings{
|
||||
AllowedEmailDomain: []string{"example", "example..com", "example-.com", "-example.com", "example.com.", "example.com-"},
|
||||
}
|
||||
if err := w.IsValid(); err == nil {
|
||||
t.Errorf("Expected error for invalid domains, but got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigIsValidDefaultAlgorithms(t *testing.T) {
|
||||
c1 := Config{}
|
||||
c1.SetDefaults()
|
||||
|
||||
@@ -42,6 +42,8 @@ type FeatureFlags struct {
|
||||
|
||||
EnableExportDirectDownload bool
|
||||
|
||||
MoveThreadsEnabled bool
|
||||
|
||||
StreamlinedMarketplace bool
|
||||
|
||||
CloudIPFiltering bool
|
||||
@@ -62,6 +64,7 @@ func (f *FeatureFlags) SetDefaults() {
|
||||
f.OnboardingTourTips = true
|
||||
f.CloudReverseTrial = false
|
||||
f.EnableExportDirectDownload = false
|
||||
f.MoveThreadsEnabled = false
|
||||
f.StreamlinedMarketplace = true
|
||||
f.CloudIPFiltering = false
|
||||
f.ConsumePostHook = false
|
||||
|
||||
@@ -45,6 +45,7 @@ const (
|
||||
PostTypeChannelRestored = "system_channel_restored"
|
||||
PostTypeEphemeral = "system_ephemeral"
|
||||
PostTypeChangeChannelPrivacy = "system_change_chan_privacy"
|
||||
PostTypeWrangler = "system_wrangler"
|
||||
PostTypeGMConvertedToChannel = "system_gm_to_channel"
|
||||
PostTypeAddBotTeamsChannels = "add_bot_teams_channels"
|
||||
PostTypeSystemWarnMetricStatus = "warn_metric_status"
|
||||
@@ -193,6 +194,10 @@ type GetPersistentNotificationsPostsParams struct {
|
||||
PerPage int
|
||||
}
|
||||
|
||||
type MoveThreadParams struct {
|
||||
ChannelId string `json:"channel_id"`
|
||||
}
|
||||
|
||||
type SearchParameter struct {
|
||||
Terms *string `json:"terms"`
|
||||
IsOrSearch *bool `json:"is_or_search"`
|
||||
@@ -444,6 +449,7 @@ func (o *Post) IsValid(maxPostSize int) *AppError {
|
||||
PostTypeSystemWarnMetricStatus,
|
||||
PostTypeReminder,
|
||||
PostTypeMe,
|
||||
PostTypeWrangler,
|
||||
PostTypeGMConvertedToChannel:
|
||||
default:
|
||||
if !strings.HasPrefix(o.Type, PostCustomTypePrefix) {
|
||||
@@ -893,3 +899,11 @@ func (o *Post) IsUrgent() bool {
|
||||
|
||||
return *postPriority.Priority == PostPriorityUrgent
|
||||
}
|
||||
|
||||
func (o *Post) CleanPost() *Post {
|
||||
o.Id = ""
|
||||
o.CreateAt = 0
|
||||
o.UpdateAt = 0
|
||||
o.EditAt = 0
|
||||
return o
|
||||
}
|
||||
|
||||
@@ -190,3 +190,39 @@ func (o *PostList) IsChannelId(channelId string) bool {
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (o *PostList) BuildWranglerPostList() *WranglerPostList {
|
||||
wpl := &WranglerPostList{}
|
||||
|
||||
o.UniqueOrder()
|
||||
o.SortByCreateAt()
|
||||
posts := o.ToSlice()
|
||||
|
||||
if len(posts) == 0 {
|
||||
// Something was sorted wrong or an empty PostList was provided.
|
||||
return wpl
|
||||
}
|
||||
|
||||
// A separate ID key map to ensure no duplicates.
|
||||
idKeys := make(map[string]bool)
|
||||
|
||||
for i := range posts {
|
||||
p := posts[len(posts)-i-1]
|
||||
|
||||
// Add UserID to metadata if it's new.
|
||||
if _, ok := idKeys[p.UserId]; !ok {
|
||||
idKeys[p.UserId] = true
|
||||
wpl.ThreadUserIDs = append(wpl.ThreadUserIDs, p.UserId)
|
||||
}
|
||||
|
||||
wpl.FileAttachmentCount += int64(len(p.FileIds))
|
||||
|
||||
wpl.Posts = append(wpl.Posts, p)
|
||||
}
|
||||
|
||||
// Set metadata for earliest and latest posts
|
||||
wpl.EarlistPostTimestamp = wpl.RootPost().CreateAt
|
||||
wpl.LatestPostTimestamp = wpl.Posts[wpl.NumPosts()-1].CreateAt
|
||||
|
||||
return wpl
|
||||
}
|
||||
|
||||
@@ -1016,6 +1016,12 @@ type UsersWithGroupsAndCount struct {
|
||||
Count int64 `json:"total_count"`
|
||||
}
|
||||
|
||||
func (u *User) EmailDomain() string {
|
||||
at := strings.LastIndex(u.Email, "@")
|
||||
// at >= 0 holds true and this is not checked here. It holds true, because during signup we run `mail.ParseAddress(email)`
|
||||
return u.Email[at+1:]
|
||||
}
|
||||
|
||||
type UserPostStats struct {
|
||||
LastLogin int64 `json:"last_login_at,omitempty"`
|
||||
LastStatusAt *int64 `json:"last_status_at,omitempty"`
|
||||
|
||||
33
server/public/model/wrangler.go
Обычный файл
33
server/public/model/wrangler.go
Обычный файл
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package model
|
||||
|
||||
// WranglerPostList provides a list of posts along with metadata about those
|
||||
// posts.
|
||||
type WranglerPostList struct {
|
||||
Posts []*Post
|
||||
ThreadUserIDs []string
|
||||
EarlistPostTimestamp int64
|
||||
LatestPostTimestamp int64
|
||||
FileAttachmentCount int64
|
||||
}
|
||||
|
||||
// NumPosts returns the number of posts in a post list.
|
||||
func (wpl *WranglerPostList) NumPosts() int {
|
||||
return len(wpl.Posts)
|
||||
}
|
||||
|
||||
// RootPost returns the root post in a post list.
|
||||
func (wpl *WranglerPostList) RootPost() *Post {
|
||||
if wpl.NumPosts() < 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return wpl.Posts[0]
|
||||
}
|
||||
|
||||
// ContainsFileAttachments returns if the post list contains any file attachments.
|
||||
func (wpl *WranglerPostList) ContainsFileAttachments() bool {
|
||||
return wpl.FileAttachmentCount != 0
|
||||
}
|
||||
@@ -85,7 +85,7 @@ func InitTranslations(serverLocale, clientLocale string) error {
|
||||
defaultClientLocale = clientLocale
|
||||
|
||||
var err error
|
||||
T, err = getTranslationsBySystemLocale()
|
||||
T, err = GetTranslationsBySystemLocale()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ func GetTranslationFuncForDir(dir string) (TranslationFuncByLocal, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getTranslationsBySystemLocale() (TranslateFunc, error) {
|
||||
func GetTranslationsBySystemLocale() (TranslateFunc, error) {
|
||||
locale := defaultServerLocale
|
||||
if _, ok := locales[locale]; !ok {
|
||||
mlog.Warn("Failed to load system translations for selected locale, attempting to fall back to default", mlog.String("locale", locale), mlog.String("default_locale", defaultLocale))
|
||||
|
||||
Ссылка в новой задаче
Block a user