diff --git a/server/channels/app/plugin_hooks_test.go b/server/channels/app/plugin_hooks_test.go index 54ae6e5418..9a6fbc6b0c 100644 --- a/server/channels/app/plugin_hooks_test.go +++ b/server/channels/app/plugin_hooks_test.go @@ -1550,3 +1550,82 @@ func TestHookNotificationWillBePushed(t *testing.T) { }) } } + +func TestHookMessagesWillBeConsumed(t *testing.T) { + setupPlugin := func(t *testing.T, th *TestHelper) { + var mockAPI plugintest.API + mockAPI.On("LoadPluginConfiguration", mock.Anything).Return(nil) + mockAPI.On("LogDebug", "message").Return(nil) + + tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) MessagesWillBeConsumed(posts []*model.Post) []*model.Post { + for _, post := range posts { + post.Message = "mwbc_plugin:" + post.Message + } + return posts + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `}, th.App, func(*model.Manifest) plugin.API { return &mockAPI }) + t.Cleanup(tearDown) + } + + t.Run("feature flag disabled", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CONSUMEPOSTHOOK", "false") + defer os.Unsetenv("MM_FEATUREFLAGS_CONSUMEPOSTHOOK") + + th := Setup(t).InitBasic() + t.Cleanup(th.TearDown) + + setupPlugin(t, th) + + newPost := &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "message", + CreateAt: model.GetMillis() - 10000, + } + _, err := th.App.CreatePost(th.Context, newPost, th.BasicChannel, false, true) + require.Nil(t, err) + + post, err := th.App.GetSinglePost(newPost.Id, true) + require.Nil(t, err) + assert.Equal(t, "message", post.Message) + }) + + t.Run("feature flag enabled", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CONSUMEPOSTHOOK", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CONSUMEPOSTHOOK") + + th := Setup(t).InitBasic() + t.Cleanup(th.TearDown) + + setupPlugin(t, th) + + newPost := &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "message", + CreateAt: model.GetMillis() - 10000, + } + _, err := th.App.CreatePost(th.Context, newPost, th.BasicChannel, false, true) + require.Nil(t, err) + + post, err := th.App.GetSinglePost(newPost.Id, true) + require.Nil(t, err) + assert.Equal(t, "mwbc_plugin:message", post.Message) + }) +} diff --git a/server/channels/app/post.go b/server/channels/app/post.go index 0ec24baf96..98179c14e0 100644 --- a/server/channels/app/post.go +++ b/server/channels/app/post.go @@ -386,6 +386,8 @@ func (a *App) CreatePost(c request.CTX, post *model.Post, channel *model.Channel // so we just return the one that was passed with post rpost = a.PreparePostForClient(c, rpost, true, false, false) + a.applyPostWillBeConsumedHook(&rpost) + if rpost.RootId != "" { if appErr := a.ResolvePersistentNotification(c, parentPostList.Posts[post.RootId], rpost.UserId); appErr != nil { return nil, appErr @@ -870,6 +872,8 @@ func (a *App) GetPostsPage(options model.GetPostsOptions) (*model.PostList, *mod return nil, appErr } + a.applyPostsWillBeConsumedHook(postList.Posts) + return postList, nil } @@ -889,6 +893,8 @@ func (a *App) GetPosts(channelID string, offset int, limit int) (*model.PostList return nil, appErr } + a.applyPostsWillBeConsumedHook(postList.Posts) + return postList, nil } @@ -906,6 +912,8 @@ func (a *App) GetPostsSince(options model.GetPostsSinceOptions) (*model.PostList return nil, appErr } + a.applyPostsWillBeConsumedHook(postList.Posts) + return postList, nil } @@ -929,6 +937,8 @@ func (a *App) GetSinglePost(postID string, includeDeleted bool) (*model.Post, *m return nil, model.NewAppError("GetSinglePost", "app.post.cloud.get.app_error", nil, "", http.StatusForbidden) } + a.applyPostWillBeConsumedHook(&post) + return post, nil } @@ -959,6 +969,8 @@ func (a *App) GetPostThread(postID string, opts model.GetPostsOptions, userID st return nil, appErr } + a.applyPostsWillBeConsumedHook(posts.Posts) + return posts, nil } @@ -972,6 +984,8 @@ func (a *App) GetFlaggedPosts(userID string, offset int, limit int) (*model.Post return nil, appErr } + a.applyPostsWillBeConsumedHook(postList.Posts) + return postList, nil } @@ -985,6 +999,8 @@ func (a *App) GetFlaggedPostsForTeam(userID, teamID string, offset int, limit in return nil, appErr } + a.applyPostsWillBeConsumedHook(postList.Posts) + return postList, nil } @@ -998,6 +1014,8 @@ func (a *App) GetFlaggedPostsForChannel(userID, channelID string, offset int, li return nil, appErr } + a.applyPostsWillBeConsumedHook(postList.Posts) + return postList, nil } @@ -1034,6 +1052,8 @@ func (a *App) GetPermalinkPost(c request.CTX, postID string, userID string) (*mo return nil, appErr } + a.applyPostsWillBeConsumedHook(list.Posts) + return list, nil } @@ -1062,6 +1082,8 @@ func (a *App) GetPostsBeforePost(options model.GetPostsOptions) (*model.PostList return nil, appErr } + a.applyPostsWillBeConsumedHook(postList.Posts) + return postList, nil } @@ -1090,6 +1112,8 @@ func (a *App) GetPostsAfterPost(options model.GetPostsOptions) (*model.PostList, return nil, appErr } + a.applyPostsWillBeConsumedHook(postList.Posts) + return postList, nil } @@ -1126,6 +1150,8 @@ func (a *App) GetPostsAroundPost(before bool, options model.GetPostsOptions) (*m return nil, appErr } + a.applyPostsWillBeConsumedHook(postList.Posts) + return postList, nil } @@ -1135,6 +1161,8 @@ func (a *App) GetPostAfterTime(channelID string, time int64, collapsedThreads bo return nil, model.NewAppError("GetPostAfterTime", "app.post.get_post_after_time.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } + a.applyPostWillBeConsumedHook(&post) + return post, nil } @@ -2249,3 +2277,37 @@ func (a *App) GetPostInfo(c request.CTX, postID string) (*model.PostInfo, *model } return &info, nil } + +func (a *App) applyPostsWillBeConsumedHook(posts map[string]*model.Post) { + if !a.Config().FeatureFlags.ConsumePostHook { + return + } + + postsSlice := make([]*model.Post, 0, len(posts)) + + for _, post := range posts { + postsSlice = append(postsSlice, post.ForPlugin()) + } + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + postReplacements := hooks.MessagesWillBeConsumed(postsSlice) + for _, postReplacement := range postReplacements { + posts[postReplacement.Id] = postReplacement + } + return true + }, plugin.MessagesWillBeConsumedID) +} + +func (a *App) applyPostWillBeConsumedHook(post **model.Post) { + if !a.Config().FeatureFlags.ConsumePostHook { + return + } + + ps := []*model.Post{*post} + a.ch.RunMultiHook(func(hooks plugin.Hooks) bool { + rp := hooks.MessagesWillBeConsumed(ps) + if len(rp) > 0 { + (*post) = rp[0] + } + return true + }, plugin.MessagesWillBeConsumedID) +} diff --git a/server/public/model/feature_flags.go b/server/public/model/feature_flags.go index bceb4daa38..04f4ba9b74 100644 --- a/server/public/model/feature_flags.go +++ b/server/public/model/feature_flags.go @@ -43,6 +43,8 @@ type FeatureFlags struct { EnableExportDirectDownload bool StreamlinedMarketplace bool + + ConsumePostHook bool } func (f *FeatureFlags) SetDefaults() { @@ -58,6 +60,7 @@ func (f *FeatureFlags) SetDefaults() { f.CloudReverseTrial = false f.EnableExportDirectDownload = false f.StreamlinedMarketplace = true + f.ConsumePostHook = false } // ToMap returns the feature flags as a map[string]string diff --git a/server/public/plugin/client_rpc.go b/server/public/plugin/client_rpc.go index c3d5f50394..c31aefc7a3 100644 --- a/server/public/plugin/client_rpc.go +++ b/server/public/plugin/client_rpc.go @@ -698,6 +698,43 @@ func (s *hooksRPCServer) MessageWillBeUpdated(args *Z_MessageWillBeUpdatedArgs, return nil } +// MessagesWillBeConsumed is in this file because of the difficulty of identifying which fields need special behaviour. +// The special behaviour needed is decoding the returned post into the original one to avoid the unintentional removal +// of fields by older plugins. +func init() { + hookNameToId["MessagesWillBeConsumed"] = MessagesWillBeConsumedID +} + +type Z_MessagesWillBeConsumedArgs struct { + A []*model.Post +} + +type Z_MessagesWillBeConsumedReturns struct { + A []*model.Post +} + +func (g *hooksRPCClient) MessagesWillBeConsumed(posts []*model.Post) []*model.Post { + _args := &Z_MessagesWillBeConsumedArgs{posts} + _returns := &Z_MessagesWillBeConsumedReturns{} + if g.implemented[MessagesWillBeConsumedID] { + if err := g.client.Call("Plugin.MessagesWillBeConsumed", _args, _returns); err != nil { + g.log.Error("RPC call MessagesWillBeConsumed to plugin failed.", mlog.Err(err)) + } + } + return _returns.A +} + +func (s *hooksRPCServer) MessagesWillBeConsumed(args *Z_MessagesWillBeConsumedArgs, returns *Z_MessagesWillBeConsumedReturns) error { + if hook, ok := s.impl.(interface { + MessagesWillBeConsumed(posts []*model.Post) []*model.Post + }); ok { + returns.A = hook.MessagesWillBeConsumed(args.A) + } else { + return encodableError(fmt.Errorf("hook MessagesWillBeConsumed called but not implemented")) + } + return nil +} + type Z_LogDebugArgs struct { A string B []any diff --git a/server/public/plugin/hooks.go b/server/public/plugin/hooks.go index 138f466774..cb210a5762 100644 --- a/server/public/plugin/hooks.go +++ b/server/public/plugin/hooks.go @@ -53,6 +53,7 @@ const ( NotificationWillBePushedID = 35 UserHasBeenDeactivatedID = 36 MessageHasBeenDeletedID = 37 + MessagesWillBeConsumedID = 38 TotalHooksID = iota ) @@ -170,6 +171,15 @@ type Hooks interface { // Minimum server version: 5.2 MessageHasBeenUpdated(c *Context, newPost, oldPost *model.Post) + // MessagesWillBeConsumed is invoked when a message is requested by a client before it is returned + // to the client + // + // Note that this method will be called for posts created by plugins, including the plugin that + // created the post. + // + // Minimum server version: 9.3 + MessagesWillBeConsumed(posts []*model.Post) []*model.Post + // MessageHasBeenDeleted is invoked after the message has been deleted from the database. // Note that this method will be called for posts deleted by plugins, including the plugin that // deleted the post. diff --git a/server/public/plugin/hooks_timer_layer_generated.go b/server/public/plugin/hooks_timer_layer_generated.go index 373386dc7e..c9782089bc 100644 --- a/server/public/plugin/hooks_timer_layer_generated.go +++ b/server/public/plugin/hooks_timer_layer_generated.go @@ -113,6 +113,13 @@ func (hooks *hooksTimerLayer) MessageHasBeenUpdated(c *Context, newPost, oldPost hooks.recordTime(startTime, "MessageHasBeenUpdated", true) } +func (hooks *hooksTimerLayer) MessagesWillBeConsumed(posts []*model.Post) []*model.Post { + startTime := timePkg.Now() + _returnsA := hooks.hooksImpl.MessagesWillBeConsumed(posts) + hooks.recordTime(startTime, "MessagesWillBeConsumed", true) + return _returnsA +} + func (hooks *hooksTimerLayer) MessageHasBeenDeleted(c *Context, post *model.Post) { startTime := timePkg.Now() hooks.hooksImpl.MessageHasBeenDeleted(c, post) diff --git a/server/public/plugin/interface_generator/main.go b/server/public/plugin/interface_generator/main.go index c34380a186..15361cd960 100644 --- a/server/public/plugin/interface_generator/main.go +++ b/server/public/plugin/interface_generator/main.go @@ -32,6 +32,7 @@ var excludedPluginHooks = []string{ "LogWarn", "MessageWillBePosted", "MessageWillBeUpdated", + "MessagesWillBeConsumed", "OnActivate", "PluginHTTP", "ServeHTTP", diff --git a/server/public/plugin/plugintest/hooks.go b/server/public/plugin/plugintest/hooks.go index 6af55e7b80..c8bb0230b7 100644 --- a/server/public/plugin/plugintest/hooks.go +++ b/server/public/plugin/plugintest/hooks.go @@ -198,6 +198,22 @@ func (_m *Hooks) MessageWillBeUpdated(c *plugin.Context, newPost *model.Post, ol return r0, r1 } +// MessagesWillBeConsumed provides a mock function with given fields: posts +func (_m *Hooks) MessagesWillBeConsumed(posts []*model.Post) []*model.Post { + ret := _m.Called(posts) + + var r0 []*model.Post + if rf, ok := ret.Get(0).(func([]*model.Post) []*model.Post); ok { + r0 = rf(posts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.Post) + } + } + + return r0 +} + // NotificationWillBePushed provides a mock function with given fields: pushNotification, userID func (_m *Hooks) NotificationWillBePushed(pushNotification *model.PushNotification, userID string) (*model.PushNotification, string) { ret := _m.Called(pushNotification, userID) diff --git a/server/public/plugin/product_hooks_generated.go b/server/public/plugin/product_hooks_generated.go index ab359a6454..9bbd85f9be 100644 --- a/server/public/plugin/product_hooks_generated.go +++ b/server/public/plugin/product_hooks_generated.go @@ -50,6 +50,10 @@ type MessageHasBeenUpdatedIFace interface { MessageHasBeenUpdated(c *Context, newPost, oldPost *model.Post) } +type MessagesWillBeConsumedIFace interface { + MessagesWillBeConsumed(posts []*model.Post) []*model.Post +} + type MessageHasBeenDeletedIFace interface { MessageHasBeenDeleted(c *Context, post *model.Post) } @@ -224,6 +228,15 @@ func NewAdapter(productHooks any) (*HooksAdapter, error) { return nil, errors.New("hook has MessageHasBeenUpdated method but does not implement plugin.MessageHasBeenUpdated interface") } + // Assessing the type of the productHooks if it individually implements MessagesWillBeConsumed interface. + tt = reflect.TypeOf((*MessagesWillBeConsumedIFace)(nil)).Elem() + + if ft.Implements(tt) { + a.implemented[MessagesWillBeConsumedID] = struct{}{} + } else if _, ok := ft.MethodByName("MessagesWillBeConsumed"); ok { + return nil, errors.New("hook has MessagesWillBeConsumed method but does not implement plugin.MessagesWillBeConsumed interface") + } + // Assessing the type of the productHooks if it individually implements MessageHasBeenDeleted interface. tt = reflect.TypeOf((*MessageHasBeenDeletedIFace)(nil)).Elem() @@ -488,6 +501,15 @@ func (a *HooksAdapter) MessageHasBeenUpdated(c *Context, newPost, oldPost *model } +func (a *HooksAdapter) MessagesWillBeConsumed(posts []*model.Post) []*model.Post { + if _, ok := a.implemented[MessagesWillBeConsumedID]; !ok { + panic("product hooks must implement MessagesWillBeConsumed") + } + + return a.productHooks.(MessagesWillBeConsumedIFace).MessagesWillBeConsumed(posts) + +} + func (a *HooksAdapter) MessageHasBeenDeleted(c *Context, post *model.Post) { if _, ok := a.implemented[MessageHasBeenDeletedID]; !ok { panic("product hooks must implement MessageHasBeenDeleted")