From 3c5280119357e3742811fd724601d0bef01bcb29 Mon Sep 17 00:00:00 2001 From: David Meza Date: Fri, 1 Sep 2017 08:53:55 -0500 Subject: Config to make town square read only (#7140) * Be able to make Town Square read-only (Disable typing messages for non admins). * Do not emit UserTypingEvent when TownSquareIsReadOnly and is Town Square. * Add unit tests for TownSquareIsReadOnly config value and logic. * Add TownSquareIsReadOnly to System console>Policy. Added Telemetry. * Add control for TownSquareIsReadOnly=true only for License Enterprise Edition E10 & E20. * Update en.json * Update en.json * Update policy_settings.jsx * Change config value from TownSquareIsReadOnly to ExperimentalTownSquareIsReadOnly. * Refactored to simplify. Avoid code repeat and multiple db calls. --- api/context.go | 2 +- api/post_test.go | 39 ++++++++++++++++++++++++++++++ api/webhook_test.go | 25 ++++++++++++++++++- app/diagnostics.go | 1 + app/post.go | 51 ++++++++++++++++++++++++++++++--------- app/webhook.go | 5 ++++ config/default.json | 3 ++- i18n/en.json | 4 +++ model/config.go | 6 +++++ utils/config.go | 1 + webapp/actions/global_actions.jsx | 7 ++++++ 11 files changed, 130 insertions(+), 14 deletions(-) diff --git a/api/context.go b/api/context.go index fe3448ebd..9f09540e6 100644 --- a/api/context.go +++ b/api/context.go @@ -282,7 +282,7 @@ func (c *Context) LogError(err *model.AppError) { // filter out endless reconnects if c.Path == "/api/v3/users/websocket" && err.StatusCode == 401 || err.Id == "web.check_browser_compatibility.app_error" { c.LogDebug(err) - } else { + } else if err.Id != "api.post.create_post.town_square_read_only" { l4g.Error(utils.TDefault("api.context.log.error"), c.Path, err.Where, err.StatusCode, c.RequestId, c.Session.UserId, c.IpAddress, err.SystemMessage(utils.TDefault), err.DetailedError) } diff --git a/api/post_test.go b/api/post_test.go index c31439c82..18bf22f5c 100644 --- a/api/post_test.go +++ b/api/post_test.go @@ -22,6 +22,12 @@ import ( ) func TestCreatePost(t *testing.T) { + adm := Setup().InitSystemAdmin() + AdminClient := adm.SystemAdminClient + adminTeam := adm.SystemAdminTeam + adminUser := adm.CreateUser(adm.SystemAdminClient) + LinkUserToTeam(adminUser, adminTeam) + th := Setup().InitBasic() Client := th.BasicClient team := th.BasicTeam @@ -142,6 +148,39 @@ func TestCreatePost(t *testing.T) { t.Fatal("should've attached all 3 files to post") } } + + isLicensed := utils.IsLicensed() + license := utils.License() + disableTownSquareReadOnly := utils.Cfg.TeamSettings.ExperimentalTownSquareIsReadOnly + defer func() { + utils.Cfg.TeamSettings.ExperimentalTownSquareIsReadOnly = disableTownSquareReadOnly + utils.SetIsLicensed(isLicensed) + utils.SetLicense(license) + utils.SetDefaultRolesBasedOnConfig() + }() + *utils.Cfg.TeamSettings.ExperimentalTownSquareIsReadOnly = true + utils.SetDefaultRolesBasedOnConfig() + utils.SetIsLicensed(true) + utils.SetLicense(&model.License{Features: &model.Features{}}) + utils.License().Features.SetDefaults() + + defaultChannel := store.Must(app.Srv.Store.Channel().GetByName(team.Id, model.DEFAULT_CHANNEL, true)).(*model.Channel) + defaultPost := &model.Post{ + ChannelId: defaultChannel.Id, + Message: "Default Channel Post", + } + if _, err = Client.CreatePost(defaultPost); err == nil { + t.Fatal("should have failed -- ExperimentalTownSquareIsReadOnly is true and it's a read only channel") + } + + adminDefaultChannel := store.Must(app.Srv.Store.Channel().GetByName(adminTeam.Id, model.DEFAULT_CHANNEL, true)).(*model.Channel) + adminDefaultPost := &model.Post{ + ChannelId: adminDefaultChannel.Id, + Message: "Admin Default Channel Post", + } + if _, err = AdminClient.CreatePost(adminDefaultPost); err != nil { + t.Fatal("should not have failed -- ExperimentalTownSquareIsReadOnly is true and admin can post to channel") + } } func TestCreatePostWithCreateAt(t *testing.T) { diff --git a/api/webhook_test.go b/api/webhook_test.go index 93d596bb1..c84aee992 100644 --- a/api/webhook_test.go +++ b/api/webhook_test.go @@ -956,7 +956,7 @@ func TestRegenOutgoingHookToken(t *testing.T) { } func TestIncomingWebhooks(t *testing.T) { - th := Setup().InitSystemAdmin() + th := Setup().InitBasic().InitSystemAdmin() Client := th.SystemAdminClient team := th.SystemAdminTeam channel1 := th.CreateChannel(Client, team) @@ -1004,6 +1004,29 @@ func TestIncomingWebhooks(t *testing.T) { t.Fatal(err) } + if _, err := th.BasicClient.DoPost(url, fmt.Sprintf("{\"text\":\"this is a test\", \"channel\":\"%s\"}", model.DEFAULT_CHANNEL), "application/json"); err != nil { + t.Fatal("should not have failed -- ExperimentalTownSquareIsReadOnly is false and it's not a read only channel") + } + + isLicensed := utils.IsLicensed() + license := utils.License() + disableTownSquareReadOnly := utils.Cfg.TeamSettings.ExperimentalTownSquareIsReadOnly + defer func() { + utils.Cfg.TeamSettings.ExperimentalTownSquareIsReadOnly = disableTownSquareReadOnly + utils.SetIsLicensed(isLicensed) + utils.SetLicense(license) + utils.SetDefaultRolesBasedOnConfig() + }() + *utils.Cfg.TeamSettings.ExperimentalTownSquareIsReadOnly = true + utils.SetDefaultRolesBasedOnConfig() + utils.SetIsLicensed(true) + utils.SetLicense(&model.License{Features: &model.Features{}}) + utils.License().Features.SetDefaults() + + if _, err := th.BasicClient.DoPost(url, fmt.Sprintf("{\"text\":\"this is a test\", \"channel\":\"%s\"}", model.DEFAULT_CHANNEL), "application/json"); err == nil { + t.Fatal("should have failed -- ExperimentalTownSquareIsReadOnly is true and it's a read only channel") + } + attachmentPayload := `{ "text": "this is a test", "attachments": [ diff --git a/app/diagnostics.go b/app/diagnostics.go index 84d11054b..f05d90bec 100644 --- a/app/diagnostics.go +++ b/app/diagnostics.go @@ -243,6 +243,7 @@ func trackConfig() { "isdefault_custom_description_text": isDefault(*utils.Cfg.TeamSettings.CustomDescriptionText, model.TEAM_SETTINGS_DEFAULT_CUSTOM_DESCRIPTION_TEXT), "isdefault_user_status_away_timeout": isDefault(*utils.Cfg.TeamSettings.UserStatusAwayTimeout, model.TEAM_SETTINGS_DEFAULT_USER_STATUS_AWAY_TIMEOUT), "restrict_private_channel_manage_members": *utils.Cfg.TeamSettings.RestrictPrivateChannelManageMembers, + "experimental_town_square_is_read_only": *utils.Cfg.TeamSettings.ExperimentalTownSquareIsReadOnly, }) SendDiagnostic(TRACK_CONFIG_CLIENT_REQ, map[string]interface{}{ diff --git a/app/post.go b/app/post.go index c852a90d2..3845e1006 100644 --- a/app/post.go +++ b/app/post.go @@ -44,6 +44,29 @@ func CreatePostAsUser(post *model.Post) (*model.Post, *model.AppError) { err.StatusCode = http.StatusBadRequest } + if err.Id == "api.post.create_post.town_square_read_only" { + uchan := Srv.Store.User().Get(post.UserId) + var user *model.User + if result := <-uchan; result.Err != nil { + return nil, result.Err + } else { + user = result.Data.(*model.User) + } + + T := utils.GetUserTranslations(user.Locale) + SendEphemeralPost( + post.UserId, + &model.Post{ + ChannelId: channel.Id, + ParentId: post.ParentId, + RootId: post.RootId, + UserId: post.UserId, + Message: T("api.post.create_post.town_square_read_only"), + CreateAt: model.GetMillis() + 1, + }, + ) + } + return nil, err } else { // Update the LastViewAt only if the post does not have from_webhook prop set (eg. Zapier app) @@ -82,6 +105,21 @@ func CreatePost(post *model.Post, channel *model.Channel, triggerWebhooks bool) pchan = Srv.Store.Post().Get(post.RootId) } + uchan := Srv.Store.User().Get(post.UserId) + var user *model.User + if result := <-uchan; result.Err != nil { + return nil, result.Err + } else { + user = result.Data.(*model.User) + } + + if utils.IsLicensed() && *utils.Cfg.TeamSettings.ExperimentalTownSquareIsReadOnly && + !post.IsSystemMessage() && + channel.Name == model.DEFAULT_CHANNEL && + !CheckIfRolesGrantPermission(user.GetRoles(), model.PERMISSION_MANAGE_SYSTEM.Id) { + return nil, model.NewLocAppError("createPost", "api.post.create_post.town_square_read_only", nil, "") + } + // Verify the parent/child relationships are correct var parentPostList *model.PostList if pchan != nil { @@ -139,21 +177,19 @@ func CreatePost(post *model.Post, channel *model.Channel, triggerWebhooks bool) } } - if err := handlePostEvents(rpost, channel, triggerWebhooks, parentPostList); err != nil { + if err := handlePostEvents(rpost, user, channel, triggerWebhooks, parentPostList); err != nil { return nil, err } return rpost, nil } -func handlePostEvents(post *model.Post, channel *model.Channel, triggerWebhooks bool, parentPostList *model.PostList) *model.AppError { +func handlePostEvents(post *model.Post, user *model.User, channel *model.Channel, triggerWebhooks bool, parentPostList *model.PostList) *model.AppError { var tchan store.StoreChannel if len(channel.TeamId) > 0 { tchan = Srv.Store.Team().Get(channel.TeamId) } - uchan := Srv.Store.User().Get(post.UserId) - var team *model.Team if tchan != nil { if result := <-tchan; result.Err != nil { @@ -169,13 +205,6 @@ func handlePostEvents(post *model.Post, channel *model.Channel, triggerWebhooks InvalidateCacheForChannel(channel) InvalidateCacheForChannelPosts(channel.Id) - var user *model.User - if result := <-uchan; result.Err != nil { - return result.Err - } else { - user = result.Data.(*model.User) - } - if _, err := SendNotifications(post, team, channel, user, parentPostList); err != nil { return err } diff --git a/app/webhook.go b/app/webhook.go index ce154ff70..cf4f156a2 100644 --- a/app/webhook.go +++ b/app/webhook.go @@ -520,6 +520,11 @@ func HandleIncomingWebhook(hookId string, req *model.IncomingWebhookRequest) *mo } } + if utils.IsLicensed() && *utils.Cfg.TeamSettings.ExperimentalTownSquareIsReadOnly && + channel.Name == model.DEFAULT_CHANNEL { + return model.NewLocAppError("HandleIncomingWebhook", "api.post.create_post.town_square_read_only", nil, "") + } + if channel.Type != model.CHANNEL_OPEN && !HasPermissionToChannel(hook.UserId, channel.Id, model.PERMISSION_READ_CHANNEL) { return model.NewAppError("HandleIncomingWebhook", "web.incoming_webhook.permissions.app_error", nil, "", http.StatusForbidden) } diff --git a/config/default.json b/config/default.json index 1c772c4ff..1d08fd7cf 100644 --- a/config/default.json +++ b/config/default.json @@ -74,7 +74,8 @@ "UserStatusAwayTimeout": 300, "MaxChannelsPerTeam": 2000, "MaxNotificationsPerChannel": 1000, - "TeammateNameDisplay": "username" + "TeammateNameDisplay": "username", + "ExperimentalTownSquareIsReadOnly": false }, "ClientRequirements": { "AndroidLatestVersion": "", diff --git a/i18n/en.json b/i18n/en.json index 794424aff..febcb9d9c 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1629,6 +1629,10 @@ "id": "api.post.create_post.root_id.app_error", "translation": "Invalid RootId parameter" }, + { + "id": "api.post.create_post.town_square_read_only", + "translation": "This channel is read-only. Only members with permission can post here." + }, { "id": "api.post.create_webhook_post.creating.app_error", "translation": "Error creating post" diff --git a/model/config.go b/model/config.go index 65608c9a5..58b3da4d1 100644 --- a/model/config.go +++ b/model/config.go @@ -354,6 +354,7 @@ type TeamSettings struct { MaxChannelsPerTeam *int64 MaxNotificationsPerChannel *int64 TeammateNameDisplay *string + ExperimentalTownSquareIsReadOnly *bool } type ClientRequirements struct { @@ -824,6 +825,11 @@ func (o *Config) SetDefaults() { *o.TeamSettings.MaxNotificationsPerChannel = 1000 } + if o.TeamSettings.ExperimentalTownSquareIsReadOnly == nil { + o.TeamSettings.ExperimentalTownSquareIsReadOnly = new(bool) + *o.TeamSettings.ExperimentalTownSquareIsReadOnly = false + } + if o.EmailSettings.EnableSignInWithEmail == nil { o.EmailSettings.EnableSignInWithEmail = new(bool) diff --git a/utils/config.go b/utils/config.go index 7183ef92b..642abfdf0 100644 --- a/utils/config.go +++ b/utils/config.go @@ -518,6 +518,7 @@ func getClientConfig(c *model.Config) map[string]string { if IsLicensed() { License := License() + props["ExperimentalTownSquareIsReadOnly"] = strconv.FormatBool(*c.TeamSettings.ExperimentalTownSquareIsReadOnly) if *License.Features.CustomBrand { props["EnableCustomBrand"] = strconv.FormatBool(*c.TeamSettings.EnableCustomBrand) diff --git a/webapp/actions/global_actions.jsx b/webapp/actions/global_actions.jsx index a67d1b751..025e56f7d 100644 --- a/webapp/actions/global_actions.jsx +++ b/webapp/actions/global_actions.jsx @@ -443,6 +443,13 @@ export function emitLocalUserTypingEvent(channelId, parentId) { const t = Date.now(); const membersInChannel = ChannelStore.getStats(channelId).member_count; + if (global.mm_license.IsLicensed === 'true' && global.mm_config.ExperimentalTownSquareIsReadOnly === 'true') { + const channel = ChannelStore.getChannelById(channelId); + if (channel && ChannelStore.isDefault(channel)) { + return; + } + } + if (((t - lastTimeTypingSent) > global.window.mm_config.TimeBetweenUserTypingUpdatesMilliseconds) && membersInChannel < global.window.mm_config.MaxNotificationsPerChannel && global.window.mm_config.EnableUserTypingMessages === 'true') { WebSocketClient.userTyping(channelId, parentId); lastTimeTypingSent = t; -- cgit v1.2.3-1-g7c22