Remote Cluster Service
- provides ability for multiple Mattermost cluster instances to create a trusted connection with each other and exchange messages
- trusted connections are managed via slash commands (for now)
- facilitates features requiring inter-cluster communication, such as Shared Channels
Shared Channels Service
- provides ability to shared channels between one or more Mattermost cluster instances (using trusted connection)
- sharing/unsharing of channels is managed via slash commands (for now)
Этот коммит содержится в:
Doug Lauder
2021-04-01 13:44:56 -04:00
коммит произвёл GitHub
родитель ff980266ac
Коммит 02196e04fa
137 изменённых файлов: 15137 добавлений и 262 удалений

292
app/slashcommands/command_remote.go Обычный файл
Просмотреть файл

@@ -0,0 +1,292 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"encoding/base64"
"errors"
"fmt"
"strings"
"github.com/mattermost/mattermost-server/v5/app"
"github.com/mattermost/mattermost-server/v5/model"
"github.com/mattermost/mattermost-server/v5/shared/i18n"
)
const (
AvailableRemoteActions = "invite, accept, remove, status"
)
type RemoteProvider struct {
}
const (
CommandTriggerRemote = "remote"
)
func init() {
app.RegisterCommandProvider(&RemoteProvider{})
}
func (rp *RemoteProvider) GetTrigger() string {
return CommandTriggerRemote
}
func (rp *RemoteProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
remote := model.NewAutocompleteData(rp.GetTrigger(), "[action]", T("api.command_remote.remote_add_remove.help", map[string]interface{}{"Actions": AvailableRemoteActions}))
invite := model.NewAutocompleteData("invite", "", T("api.command_remote.invite.help"))
invite.AddNamedTextArgument("password", T("api.command_remote.invite_password.help"), T("api.command_remote.invite_password.hint"), "", true)
invite.AddNamedTextArgument("name", T("api.command_remote.name.help"), T("api.command_remote.name.hint"), "", true)
accept := model.NewAutocompleteData("accept", "", T("api.command_remote.accept.help"))
accept.AddNamedTextArgument("password", T("api.command_remote.invite_password.help"), T("api.command_remote.invite_password.hint"), "", true)
accept.AddNamedTextArgument("name", T("api.command_remote.name.help"), T("api.command_remote.name.hint"), "", true)
accept.AddNamedTextArgument("invite", T("api.command_remote.invitation.help"), T("api.command_remote.invitation.hint"), "", true)
remove := model.NewAutocompleteData("remove", "", T("api.command_remote.remove.help"))
remove.AddNamedDynamicListArgument("remoteId", T("api.command_remote.remove_remote_id.help"), "builtin:remote", true)
status := model.NewAutocompleteData("status", "", T("api.command_remote.status.help"))
remote.AddCommand(invite)
remote.AddCommand(accept)
remote.AddCommand(remove)
remote.AddCommand(status)
return &model.Command{
Trigger: rp.GetTrigger(),
AutoComplete: true,
AutoCompleteDesc: T("api.command_remote.desc"),
AutoCompleteHint: T("api.command_remote.hint"),
DisplayName: T("api.command_remote.name"),
AutocompleteData: remote,
}
}
func (rp *RemoteProvider) DoCommand(a *app.App, args *model.CommandArgs, message string) *model.CommandResponse {
if !a.HasPermissionTo(args.UserId, model.PERMISSION_MANAGE_SHARED_CHANNELS) {
return responsef(args.T("api.command_remote.permission_required", map[string]interface{}{"Permission": "manage_shared_channels"}))
}
margs := parseNamedArgs(args.Command)
action, ok := margs[ActionKey]
if !ok {
return responsef(args.T("api.command_remote.missing_command", map[string]interface{}{"Actions": AvailableRemoteActions}))
}
switch action {
case "invite":
return rp.doInvite(a, args, margs)
case "accept":
return rp.doAccept(a, args, margs)
case "remove":
return rp.doRemove(a, args, margs)
case "status":
return rp.doStatus(a, args, margs)
}
return responsef(args.T("api.command_remote.unknown_action", map[string]interface{}{"Action": action}))
}
func (rp *RemoteProvider) GetAutoCompleteListItems(a *app.App, commandArgs *model.CommandArgs, arg *model.AutocompleteArg, parsed, toBeParsed string) ([]model.AutocompleteListItem, error) {
if !a.HasPermissionTo(commandArgs.UserId, model.PERMISSION_MANAGE_SHARED_CHANNELS) {
return nil, errors.New("You require `manage_shared_channels` permission to manage remote clusters.")
}
if arg.Name == "remoteId" && strings.Contains(parsed, " remove ") {
return getRemoteClusterAutocompleteListItems(a, true)
}
return nil, fmt.Errorf("`%s` is not a dynamic argument", arg.Name)
}
// doInvite creates and displays an encrypted invite that can be used by a remote site to establish a simple trust.
func (rp *RemoteProvider) doInvite(a *app.App, args *model.CommandArgs, margs map[string]string) *model.CommandResponse {
password := margs["password"]
if password == "" {
return responsef(args.T("api.command_remote.missing_empty", map[string]interface{}{"Arg": "password"}))
}
name := margs["name"]
if name == "" {
return responsef(args.T("api.command_remote.missing_empty", map[string]interface{}{"Arg": "name"}))
}
url := a.GetSiteURL()
if url == "" {
return responsef(args.T("api.command_remote.site_url_not_set"))
}
rc := &model.RemoteCluster{
DisplayName: name,
Token: model.NewId(),
CreatorId: args.UserId,
}
rcSaved, appErr := a.AddRemoteCluster(rc)
if appErr != nil {
return responsef(args.T("api.command_remote.add_remote.error", map[string]interface{}{"Error": appErr.Error()}))
}
// Display the encrypted invitation
invite := &model.RemoteClusterInvite{
RemoteId: rcSaved.RemoteId,
RemoteTeamId: args.TeamId,
SiteURL: url,
Token: rcSaved.Token,
}
encrypted, err := invite.Encrypt(password)
if err != nil {
return responsef(args.T("api.command_remote.encrypt_invitation.error", map[string]interface{}{"Error": err.Error()}))
}
encoded := base64.URLEncoding.EncodeToString(encrypted)
return responsef("##### " + args.T("api.command_remote.invitation_created") + "\n" +
args.T("api.command_remote.invite_summary", map[string]interface{}{"Command": "/remote accept", "Invitation": encoded, "SiteURL": invite.SiteURL}))
}
// doAccept accepts an invitation generated by a remote site.
func (rp *RemoteProvider) doAccept(a *app.App, args *model.CommandArgs, margs map[string]string) *model.CommandResponse {
password := margs["password"]
if password == "" {
return responsef(args.T("api.command_remote.missing_empty", map[string]interface{}{"Arg": "password"}))
}
name := margs["name"]
if name == "" {
return responsef(args.T("api.command_remote.missing_empty", map[string]interface{}{"Arg": "name"}))
}
blob := margs["invite"]
if blob == "" {
return responsef(args.T("api.command_remote.missing_empty", map[string]interface{}{"Arg": "invite"}))
}
// invite is encoded as base64 and encrypted
decoded, err := base64.URLEncoding.DecodeString(blob)
if err != nil {
return responsef(args.T("api.command_remote.decode_invitation.error", map[string]interface{}{"Error": err.Error()}))
}
invite := &model.RemoteClusterInvite{}
err = invite.Decrypt(decoded, password)
if err != nil {
return responsef(args.T("api.command_remote.incorrect_password.error", map[string]interface{}{"Error": err.Error()}))
}
rcs, _ := a.GetRemoteClusterService()
if rcs == nil {
return responsef(args.T("api.command_remote.service_not_enabled"))
}
url := a.GetSiteURL()
if url == "" {
return responsef(args.T("api.command_remote.site_url_not_set"))
}
rc, err := rcs.AcceptInvitation(invite, name, args.UserId, args.TeamId, url)
if err != nil {
return responsef(args.T("api.command_remote.accept_invitation.error", map[string]interface{}{"Error": err.Error()}))
}
return responsef("##### " + args.T("api.command_remote.accept_invitation", map[string]interface{}{"SiteURL": rc.SiteURL}))
}
// doRemove removes a remote cluster from the database, effectively revoking the trust relationship.
func (rp *RemoteProvider) doRemove(a *app.App, args *model.CommandArgs, margs map[string]string) *model.CommandResponse {
id, ok := margs["remoteId"]
if !ok {
return responsef(args.T("api.command_remote.missing_empty", map[string]interface{}{"Arg": "remoteId"}))
}
deleted, err := a.DeleteRemoteCluster(id)
if err != nil {
responsef(args.T("api.command_remote.remove_remote.error", map[string]interface{}{"Error": err.Error()}))
}
result := "removed"
if !deleted {
result = "**NOT FOUND**"
}
return responsef("##### " + args.T("api.command_remote.cluster_removed", map[string]interface{}{"RemoteId": id, "Result": result}))
}
// doStatus displays connection status for all remote clusters.
func (rp *RemoteProvider) doStatus(a *app.App, args *model.CommandArgs, _ map[string]string) *model.CommandResponse {
list, err := a.GetAllRemoteClusters(model.RemoteClusterQueryFilter{})
if err != nil {
responsef(args.T("api.command_remote.fetch_status.error", map[string]interface{}{"Error": err.Error()}))
}
if len(list) == 0 {
return responsef("** " + args.T("api.command_remote.remotes_not_found") + " **")
}
var sb strings.Builder
fmt.Fprintf(&sb, args.T("api.command_remote.remote_table_header")+"| \n")
fmt.Fprintf(&sb, "| ---- | -------- | ---------- | :-------------: | :----: | ---------- |\n")
for _, rc := range list {
accepted := ":white_check_mark:"
if rc.SiteURL == "" {
accepted = ":x:"
}
online := ":white_check_mark:"
if !isOnline(rc.LastPingAt) {
online = ":skull_and_crossbones:"
}
lastPing := formatTimestamp(model.GetTimeForMillis(rc.LastPingAt))
fmt.Fprintf(&sb, "| %s | %s | %s | %s | %s | %s |\n", rc.DisplayName, rc.SiteURL, rc.RemoteId, accepted, online, lastPing)
}
return responsef(sb.String())
}
func isOnline(lastPing int64) bool {
return lastPing > model.GetMillis()-model.RemoteOfflineAfterMillis
}
func getRemoteClusterAutocompleteListItems(a *app.App, includeOffline bool) ([]model.AutocompleteListItem, error) {
filter := model.RemoteClusterQueryFilter{
ExcludeOffline: !includeOffline,
}
clusters, err := a.GetAllRemoteClusters(filter)
if err != nil || len(clusters) == 0 {
return []model.AutocompleteListItem{}, nil
}
list := make([]model.AutocompleteListItem, 0, len(clusters))
for _, rc := range clusters {
item := model.AutocompleteListItem{
Item: rc.RemoteId,
HelpText: fmt.Sprintf("%s (%s)", rc.DisplayName, rc.SiteURL)}
list = append(list, item)
}
return list, nil
}
func getRemoteClusterAutocompleteListItemsNotInChannel(a *app.App, channelId string, includeOffline bool) ([]model.AutocompleteListItem, error) {
filter := model.RemoteClusterQueryFilter{
ExcludeOffline: !includeOffline,
NotInChannel: channelId,
}
all, err := a.GetAllRemoteClusters(filter)
if err != nil || len(all) == 0 {
return []model.AutocompleteListItem{}, nil
}
list := make([]model.AutocompleteListItem, 0, len(all))
for _, rc := range all {
item := model.AutocompleteListItem{
Item: rc.RemoteId,
HelpText: fmt.Sprintf("%s (%s)", rc.DisplayName, rc.SiteURL)}
list = append(list, item)
}
return list, nil
}

347
app/slashcommands/command_share.go Обычный файл
Просмотреть файл

@@ -0,0 +1,347 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"errors"
"fmt"
"strings"
"github.com/mattermost/mattermost-server/v5/app"
"github.com/mattermost/mattermost-server/v5/model"
"github.com/mattermost/mattermost-server/v5/shared/i18n"
)
type ShareProvider struct {
}
const (
CommandTriggerShare = "share"
AvailableShareActions = "share_channel, unshare_channel, invite_remove, uninvite_remote, status"
)
func init() {
app.RegisterCommandProvider(&ShareProvider{})
}
func (sp *ShareProvider) GetTrigger() string {
return CommandTriggerShare
}
func (sp *ShareProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
share := model.NewAutocompleteData(CommandTriggerShare, "[action]", T("api.command_share.available_actions", map[string]interface{}{"Actions": AvailableShareActions}))
shareChannel := model.NewAutocompleteData("share_channel", "", T("api.command_share.share_current"))
shareChannel.AddNamedTextArgument("readonly", T("api.command_share.share_read_only.help"), T("api.command_share.share_read_only.hint"), "Y|N|y|n", false)
shareChannel.AddNamedTextArgument("name", T("api.command_share.channel_name.help"), T("api.command_share.channel_name.hint"), "", false)
shareChannel.AddNamedTextArgument("displayname", T("api.command_share.channel_display_name.help"), T("api.command_share.channel_display_name.hint"), "", false)
shareChannel.AddNamedTextArgument("purpose", T("api.command_share.channel_purpose.help"), T("api.command_share.channel_purpose.hint"), "", false)
shareChannel.AddNamedTextArgument("header", T("api.command_share.channel_header.help"), T("api.command_share.channel_header.hint"), "", false)
unshareChannel := model.NewAutocompleteData("unshare_channel", "", T("api.command_share.unshare_channel.help"))
unshareChannel.AddNamedTextArgument("are_you_sure", T("api.command_share.unshare_confirmation.help"), T("api.command_share.unshare_confirmation.hint"), "Y|N|y|n", true)
inviteRemote := model.NewAutocompleteData("invite_remote", "", T("api.command_share.invite_remote.help"))
inviteRemote.AddNamedDynamicListArgument("remoteId", T("api.command_share.remote_id.help"), "builtin:share", true)
inviteRemote.AddNamedTextArgument("description", T("api.command_share.description_invite.help"), T("api.command_share.description_invite.hint"), "", false)
unInviteRemote := model.NewAutocompleteData("uninvite_remote", "", T("api.command_share.uninvite_remote.help"))
unInviteRemote.AddNamedDynamicListArgument("remoteId", T("api.command_share.uninvite_remote_id.help"), "builtin:share", true)
status := model.NewAutocompleteData("status", "", T("api.command_share.channel_status.help"))
share.AddCommand(shareChannel)
share.AddCommand(unshareChannel)
share.AddCommand(inviteRemote)
share.AddCommand(unInviteRemote)
share.AddCommand(status)
return &model.Command{
Trigger: CommandTriggerShare,
AutoComplete: true,
AutoCompleteDesc: T("api.command_share.desc"),
AutoCompleteHint: T("api.command_share.hint"),
DisplayName: T("api.command_share.name"),
AutocompleteData: share,
}
}
func (sp *ShareProvider) GetAutoCompleteListItems(a *app.App, commandArgs *model.CommandArgs, arg *model.AutocompleteArg, parsed, toBeParsed string) ([]model.AutocompleteListItem, error) {
switch {
case strings.Contains(parsed, " share_channel "):
return sp.getAutoCompleteShareChannel(a, commandArgs, arg)
case strings.Contains(parsed, " invite_remote "):
return sp.getAutoCompleteInviteRemote(a, commandArgs, arg)
case strings.Contains(parsed, " uninvite_remote "):
return sp.getAutoCompleteUnInviteRemote(a, commandArgs, arg)
}
return nil, errors.New("invalid action")
}
func (sp *ShareProvider) getAutoCompleteShareChannel(a *app.App, commandArgs *model.CommandArgs, arg *model.AutocompleteArg) ([]model.AutocompleteListItem, error) {
channel, err := a.GetChannel(commandArgs.ChannelId)
if err != nil {
return nil, err
}
var item model.AutocompleteListItem
switch arg.Name {
case "name":
item = model.AutocompleteListItem{
Item: channel.Name,
HelpText: channel.DisplayName,
}
case "displayname":
item = model.AutocompleteListItem{
Item: channel.DisplayName,
HelpText: channel.Name,
}
default:
return nil, fmt.Errorf("%s not a dynamic argument", arg.Name)
}
return []model.AutocompleteListItem{item}, nil
}
func (sp *ShareProvider) getAutoCompleteInviteRemote(a *app.App, commandArgs *model.CommandArgs, arg *model.AutocompleteArg) ([]model.AutocompleteListItem, error) {
switch arg.Name {
case "remoteId":
return getRemoteClusterAutocompleteListItemsNotInChannel(a, commandArgs.ChannelId, true)
default:
return nil, fmt.Errorf("%s not a dynamic argument", arg.Name)
}
}
func (sp *ShareProvider) getAutoCompleteUnInviteRemote(a *app.App, _ *model.CommandArgs, arg *model.AutocompleteArg) ([]model.AutocompleteListItem, error) {
switch arg.Name {
case "remoteId":
return getRemoteClusterAutocompleteListItems(a, true)
default:
return nil, fmt.Errorf("%s not a dynamic argument", arg.Name)
}
}
func (sp *ShareProvider) DoCommand(a *app.App, args *model.CommandArgs, message string) *model.CommandResponse {
if !a.HasPermissionTo(args.UserId, model.PERMISSION_MANAGE_SHARED_CHANNELS) {
return responsef(args.T("api.command_share.permission_required", map[string]interface{}{"Permission": "manage_shared_channels"}))
}
if a.Srv().GetSharedChannelSyncService() == nil {
return responsef(args.T("api.command_share.service_disabled"))
}
if a.Srv().GetRemoteClusterService() == nil {
return responsef(args.T("api.command_remote.service_disabled"))
}
margs := parseNamedArgs(args.Command)
action, ok := margs[ActionKey]
if !ok {
return responsef(args.T("api.command_share.missing_action", map[string]interface{}{"Actions": AvailableShareActions}))
}
switch action {
case "share_channel":
return sp.doShareChannel(a, args, margs)
case "unshare_channel":
return sp.doUnshareChannel(a, args, margs)
case "invite_remote":
return sp.doInviteRemote(a, args, margs)
case "uninvite_remote":
return sp.doUninviteRemote(a, args, margs)
case "status":
return sp.doStatus(a, args, margs)
}
return responsef(args.T("api.command_share.unknown_action", map[string]interface{}{"Action": action, "Actions": AvailableShareActions}))
}
func (sp *ShareProvider) doShareChannel(a *app.App, args *model.CommandArgs, margs map[string]string) *model.CommandResponse {
// check that channel exists.
channel, errApp := a.GetChannel(args.ChannelId)
if errApp != nil {
return responsef(args.T("api.command_share.share_channel.error", map[string]interface{}{"Error": errApp.Error()}))
}
if name := margs["name"]; name == "" {
margs["name"] = channel.Name
}
if name := margs["displayname"]; name == "" {
margs["displayname"] = channel.DisplayName
}
if name := margs["purpose"]; name == "" {
margs["purpose"] = channel.Purpose
}
if name := margs["header"]; name == "" {
margs["header"] = channel.Header
}
if _, ok := margs["readonly"]; !ok {
margs["readonly"] = "N"
}
readonly, err := parseBool(margs["readonly"])
if err != nil {
return responsef(args.T("api.command_share.invalid_value.error", map[string]interface{}{"Arg": "readonly", "Error": err.Error()}))
}
sc := &model.SharedChannel{
ChannelId: args.ChannelId,
TeamId: args.TeamId,
Home: true,
ReadOnly: readonly,
ShareName: margs["name"],
ShareDisplayName: margs["displayname"],
SharePurpose: margs["purpose"],
ShareHeader: margs["header"],
CreatorId: args.UserId,
}
if _, err := a.SaveSharedChannel(sc); err != nil {
return responsef(args.T("api.command_share.share_channel.error", map[string]interface{}{"Error": err.Error()}))
}
notifyClientsForChannelUpdate(a, sc)
return responsef("##### " + args.T("api.command_share.channel_shared"))
}
func (sp *ShareProvider) doUnshareChannel(a *app.App, args *model.CommandArgs, margs map[string]string) *model.CommandResponse {
if _, ok := margs["are_you_sure"]; !ok {
margs["are_you_sure"] = "N"
}
sure, err := parseBool(margs["are_you_sure"])
if err != nil || !sure {
return responsef(args.T("api.command_share.shared_channel_not_deleted", map[string]interface{}{"Arg": "are_you_sure", "Expected": "Y"}))
}
sc, appErr := a.GetSharedChannel(args.ChannelId)
if appErr != nil {
return responsef(args.T("api.command_share.shared_channel_unshare.error", map[string]interface{}{"Error": appErr.Error()}))
}
deleted, err := a.DeleteSharedChannel(args.ChannelId)
if err != nil {
return responsef(args.T("api.command_share.shared_channel_unshare.error", map[string]interface{}{"Error": err.Error()}))
}
if !deleted {
return responsef(args.T("api.command_share.not_shared_channel_unshare"))
}
notifyClientsForChannelUpdate(a, sc)
return responsef("##### " + args.T("api.command_share.shared_channel_unavailable"))
}
func (sp *ShareProvider) doInviteRemote(a *app.App, args *model.CommandArgs, margs map[string]string) (resp *model.CommandResponse) {
remoteId, ok := margs["remoteId"]
if !ok || remoteId == "" {
return responsef(args.T("api.command_share.must_specify_valid_remote"))
}
hasRemote, err := a.HasRemote(args.ChannelId, remoteId)
if err != nil {
return responsef(args.T("api.command_share.fetch_remote.error", map[string]interface{}{"Error": err.Error()}))
}
if hasRemote {
return responsef(args.T("api.command_share.remote_already_invited"))
}
// Check if channel is shared or not.
hasChan, err := a.HasSharedChannel(args.ChannelId)
if err != nil {
return responsef(args.T("api.command_share.check_channel_exist.error", map[string]interface{}{"Error": err.Error()}))
}
if !hasChan {
// If it doesn't exist, then create it.
resp2 := sp.doShareChannel(a, args, margs)
// We modify the outgoing response by prepending the text
// from the shareChannel response.
defer func() {
resp.Text = resp2.Text + "\n" + resp.Text
}()
}
// don't allow invitation to shared channel originating from remote.
// (also blocks cyclic invitations)
if err := a.CheckCanInviteToSharedChannel(args.ChannelId); err != nil {
return responsef(args.T("api.command_share.channel_invite_not_home.error"))
}
rc, appErr := a.GetRemoteCluster(remoteId)
if appErr != nil {
return responsef(args.T("api.command_share.remote_id_invalid.error", map[string]interface{}{"Error": appErr.Error()}))
}
channel, errApp := a.GetChannel(args.ChannelId)
if errApp != nil {
return responsef(args.T("api.command_share.channel_invite.error", map[string]interface{}{"Name": rc.DisplayName, "Error": errApp.Error()}))
}
// send channel invite to remote cluster
if err := a.Srv().GetSharedChannelSyncService().SendChannelInvite(channel, args.UserId, margs["description"], rc); err != nil {
return responsef(args.T("api.command_share.channel_invite.error", map[string]interface{}{"Name": rc.DisplayName, "Error": err.Error()}))
}
return responsef("##### " + args.T("api.command_share.invitation_sent", map[string]interface{}{"Name": rc.DisplayName, "SiteURL": rc.SiteURL}))
}
func (sp *ShareProvider) doUninviteRemote(a *app.App, args *model.CommandArgs, margs map[string]string) *model.CommandResponse {
remoteId, ok := margs["remoteId"]
if !ok || remoteId == "" {
return responsef(args.T("api.command_share.remote_not_valid"))
}
scr, err := a.GetSharedChannelRemoteByIds(args.ChannelId, remoteId)
if err != nil || scr.ChannelId != args.ChannelId {
return responsef(args.T("api.command_share.channel_remote_id_not_exists", map[string]interface{}{"RemoteId": remoteId}))
}
deleted, err := a.DeleteSharedChannelRemote(scr.Id)
if err != nil || !deleted {
return responsef(args.T("api.command_share.could_not_uninvite.error", map[string]interface{}{"RemoteId": remoteId, "Error": err.Error()}))
}
return responsef("##### " + args.T("api.command_share.remote_uninvited", map[string]interface{}{"RemoteId": remoteId}))
}
func (sp *ShareProvider) doStatus(a *app.App, args *model.CommandArgs, _ map[string]string) *model.CommandResponse {
statuses, err := a.GetSharedChannelRemotesStatus(args.ChannelId)
if err != nil {
return responsef(args.T("api.command_share.fetch_remote_status.error", map[string]interface{}{"Error": err.Error()}))
}
if len(statuses) == 0 {
return responsef(args.T("api.command_share.no_remote_invited"))
}
var sb strings.Builder
fmt.Fprintf(&sb, args.T("api.command_share.channel_status_id", map[string]interface{}{"ChannelId": statuses[0].ChannelId})+"\n\n")
fmt.Fprintf(&sb, args.T("api.command_share.remote_table_header")+" \n")
fmt.Fprintf(&sb, "| ------ | ------- | ----------- | -------- | -------------- | ------ | --------- | \n")
for _, status := range statuses {
online := ":white_check_mark:"
if !isOnline(status.LastPingAt) {
online = ":skull_and_crossbones:"
}
lastSync := formatTimestamp(model.GetTimeForMillis(status.NextSyncAt))
fmt.Fprintf(&sb, "| %s | %s | %s | %t | %t | %s | %s |\n",
status.DisplayName, status.SiteURL, status.Description,
status.ReadOnly, status.IsInviteAccepted, online, lastSync)
}
return responsef(sb.String())
}
func notifyClientsForChannelUpdate(a *app.App, sharedChannel *model.SharedChannel) {
messageWs := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_CHANNEL_CONVERTED, sharedChannel.TeamId, "", "", nil)
messageWs.Add("channel_id", sharedChannel.ChannelId)
a.Publish(messageWs)
}

92
app/slashcommands/command_share_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,92 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost-server/v5/testlib"
"github.com/mattermost/mattermost-server/v5/app"
"github.com/mattermost/mattermost-server/v5/services/remotecluster"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/v5/model"
)
func TestShareProviderDoCommand(t *testing.T) {
t.Run("share command sends a websocket channel converted event", func(t *testing.T) {
th := setup(t).initBasic()
defer th.tearDown()
th.addPermissionToRole(model.PERMISSION_MANAGE_SHARED_CHANNELS.Id, th.BasicUser.Roles)
mockSyncService := app.NewMockSharedChannelService(nil)
th.Server.SetSharedChannelSyncService(mockSyncService)
mockRemoteCluster, err := remotecluster.NewRemoteClusterService(th.Server)
require.NoError(t, err)
th.Server.SetRemoteClusterService(mockRemoteCluster)
testCluster := &testlib.FakeClusterInterface{}
th.Server.Cluster = testCluster
commandProvider := ShareProvider{}
channel := th.CreateChannel(th.BasicTeam, WithShared(false))
args := &model.CommandArgs{
T: func(s string, args ...interface{}) string { return s },
ChannelId: channel.Id,
UserId: th.BasicUser.Id,
TeamId: th.BasicTeam.Id,
Command: "/share share_channel",
}
response := commandProvider.DoCommand(th.App, args, "")
require.Equal(t, "##### "+args.T("api.command_share.channel_shared"), response.Text)
channelConvertedMessages := testCluster.SelectMessages(func(msg *model.ClusterMessage) bool {
event := model.WebSocketEventFromJson(strings.NewReader(msg.Data))
return event != nil && event.EventType() == model.WEBSOCKET_EVENT_CHANNEL_CONVERTED
})
assert.Len(t, channelConvertedMessages, 1)
})
t.Run("unshare command sends a websocket channel converted event", func(t *testing.T) {
th := setup(t).initBasic()
defer th.tearDown()
th.addPermissionToRole(model.PERMISSION_MANAGE_SHARED_CHANNELS.Id, th.BasicUser.Roles)
mockSyncService := app.NewMockSharedChannelService(nil)
th.Server.SetSharedChannelSyncService(mockSyncService)
mockRemoteCluster, err := remotecluster.NewRemoteClusterService(th.Server)
require.NoError(t, err)
th.Server.SetRemoteClusterService(mockRemoteCluster)
testCluster := &testlib.FakeClusterInterface{}
th.Server.Cluster = testCluster
commandProvider := ShareProvider{}
channel := th.CreateChannel(th.BasicTeam, WithShared(true))
args := &model.CommandArgs{
T: func(s string, args ...interface{}) string { return s },
ChannelId: channel.Id,
UserId: th.BasicUser.Id,
TeamId: th.BasicTeam.Id,
Command: "/share unshare_channel --are_you_sure Y",
}
response := commandProvider.DoCommand(th.App, args, "")
require.Equal(t, "##### "+args.T("api.command_share.shared_channel_unavailable"), response.Text)
channelConvertedMessages := testCluster.SelectMessages(func(msg *model.ClusterMessage) bool {
event := model.WebSocketEventFromJson(strings.NewReader(msg.Data))
return event != nil && event.EventType() == model.WEBSOCKET_EVENT_CHANNEL_CONVERTED
})
require.Len(t, channelConvertedMessages, 1)
})
}

Просмотреть файл

@@ -226,15 +226,23 @@ func (th *TestHelper) createUserOrGuest(guest bool) *model.User {
return user
}
func (th *TestHelper) CreateChannel(team *model.Team) *model.Channel {
return th.createChannel(team, model.CHANNEL_OPEN)
type ChannelOption func(*model.Channel)
func WithShared(v bool) ChannelOption {
return func(channel *model.Channel) {
channel.Shared = model.NewBool(v)
}
}
func (th *TestHelper) CreateChannel(team *model.Team, options ...ChannelOption) *model.Channel {
return th.createChannel(team, model.CHANNEL_OPEN, options...)
}
func (th *TestHelper) createPrivateChannel(team *model.Team) *model.Channel {
return th.createChannel(team, model.CHANNEL_PRIVATE)
}
func (th *TestHelper) createChannel(team *model.Team, channelType string) *model.Channel {
func (th *TestHelper) createChannel(team *model.Team, channelType string, options ...ChannelOption) *model.Channel {
id := model.NewId()
channel := &model.Channel{
@@ -245,11 +253,32 @@ func (th *TestHelper) createChannel(team *model.Team, channelType string) *model
CreatorId: th.BasicUser.Id,
}
for _, option := range options {
option(channel)
}
utils.DisableDebugLogForTest()
var err *model.AppError
if channel, err = th.App.CreateChannel(channel, true); err != nil {
panic(err)
}
if channel.IsShared() {
id := model.NewId()
_, err := th.App.SaveSharedChannel(&model.SharedChannel{
ChannelId: channel.Id,
TeamId: channel.TeamId,
Home: false,
ReadOnly: false,
ShareName: "shared-" + id,
ShareDisplayName: "shared-" + id,
CreatorId: th.BasicUser.Id,
RemoteId: model.NewId(),
})
if err != nil {
panic(err)
}
}
utils.EnableDebugLogForTest()
return channel
}

88
app/slashcommands/util.go Обычный файл
Просмотреть файл

@@ -0,0 +1,88 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"fmt"
"strings"
"time"
"github.com/mattermost/mattermost-server/v5/model"
)
const (
ActionKey = "-action"
)
// responsef creates an ephemeral command response using printf syntax.
func responsef(format string, args ...interface{}) *model.CommandResponse {
return &model.CommandResponse{
ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL,
Text: fmt.Sprintf(format, args...),
Type: model.POST_DEFAULT,
}
}
// parseNamedArgs parses a command string into a map of arguments. It is assumed the
// command string is of the form `<action> --arg1 value1 ...` Supports empty values.
// Arg names are limited to [0-9a-zA-Z_].
func parseNamedArgs(cmd string) map[string]string {
m := make(map[string]string)
split := strings.Fields(cmd)
// check for optional action
if len(split) >= 2 && !strings.HasPrefix(split[1], "--") {
m[ActionKey] = split[1] // prefix with hyphen to avoid collision with arg named "action"
}
for i := 0; i < len(split); i++ {
if !strings.HasPrefix(split[i], "--") {
continue
}
var val string
arg := trimSpaceAndQuotes(strings.Trim(split[i], "-"))
if i < len(split)-1 && !strings.HasPrefix(split[i+1], "--") {
val = trimSpaceAndQuotes(split[i+1])
}
if arg != "" {
m[arg] = val
}
}
return m
}
func trimSpaceAndQuotes(s string) string {
trimmed := strings.TrimSpace(s)
trimmed = strings.TrimPrefix(trimmed, "\"")
trimmed = strings.TrimPrefix(trimmed, "'")
trimmed = strings.TrimSuffix(trimmed, "\"")
trimmed = strings.TrimSuffix(trimmed, "'")
return trimmed
}
func parseBool(s string) (bool, error) {
switch strings.ToLower(s) {
case "1", "t", "true", "yes", "y":
return true, nil
case "0", "f", "false", "no", "n":
return false, nil
}
return false, fmt.Errorf("cannot parse '%s' as a boolean", s)
}
func formatTimestamp(ts time.Time) string {
if !isToday(ts) {
return ts.Format("Jan 2 15:04:05 MST 2006")
}
date := ts.Format("15:04:05 MST 2006")
return fmt.Sprintf("today %s", date)
}
func isToday(ts time.Time) bool {
now := time.Now()
year, month, day := ts.Date()
nowYear, nowMonth, nowDay := now.Date()
return year == nowYear && month == nowMonth && day == nowDay
}

40
app/slashcommands/util_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,40 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseNamedArgs(t *testing.T) {
data := []struct {
name string
s string
m map[string]string
}{
{"empty", "", map[string]string{}},
{"gibberish", "ifu3ue-h29f8", map[string]string{}},
{"action only", "remote status", map[string]string{ActionKey: "status"}},
{"no action", "remote --arg1 val1 --arg2 val2", map[string]string{"arg1": "val1", "arg2": "val2"}},
{"command only", "remote", map[string]string{}},
{"trailing empty arg", "remote add --arg1 val1 --arg2", map[string]string{ActionKey: "add", "arg1": "val1", "arg2": ""}},
{"leading empty arg", "remote add --arg1 --arg2 val2", map[string]string{ActionKey: "add", "arg1": "", "arg2": "val2"}},
{"weird", "-- -- -- --", map[string]string{}},
{"hyphen before action", "remote -- add", map[string]string{}},
{"trailing hyphen", "remote add -- ", map[string]string{ActionKey: "add"}},
{"hyphen in val", "remote add --arg1 val-1 ", map[string]string{ActionKey: "add", "arg1": "val-1"}},
{"quote prefix and suffix", "remote add --arg1 \"val-1\"", map[string]string{ActionKey: "add", "arg1": "val-1"}},
{"quote embedded", "remote add --arg1 O'Brien", map[string]string{ActionKey: "add", "arg1": "O'Brien"}},
{"quote prefix, suffix, and embedded", "remote add --arg1 \"O'Brien\"", map[string]string{ActionKey: "add", "arg1": "O'Brien"}},
{"empty quotes", "remote add --arg1 \"\"", map[string]string{ActionKey: "add", "arg1": ""}},
}
for _, tt := range data {
m := parseNamedArgs(tt.s)
assert.NotNil(t, m)
assert.Equal(t, tt.m, m, tt.name)
}
}