mirror of
https://github.com/mattermost/mattermost.git
synced 2026-08-27 05:37:15 -05:00
* MM-66162: harden GET /sharedchannels/{id}/remotes error path and add WS guard
Two small follow-ups on top of the existing fix that returns 200 with an
empty list when a channel is not shared.
Server: getSharedChannelRemotes built its 500 AppError with nil params,
so the i18n template "Could not fetch status for secure connections:
{{.Error}}." rendered as "<no value>" in logs and response bodies when a
genuinely unexpected error occurred. Pass map[string]any{"Error":
err.Error()} so the underlying error surfaces, matching the pattern
already used by the slash command caller in
channels/app/slashcommands/command_share.go.
Webapp: handleSharedChannelRemoteUpdatedEvent dispatched
fetchChannelRemotes unconditionally on every shared_channel_remote_updated
WS event, including for channels the local state already knows are not
shared. The server publishes this event from the onInvite callback after
a remote accepts a channel invitation, by which point the channel has
already been flipped to shared and the channel_updated event that did so
has already fired, so the local channel should reflect shared=true when
this event arrives. If it does not, the event is not actionable and we
skip the fetch.
Adds jest coverage for the four cases of the new guard.
* add regression test for stale shared channel state
Pins the DB inconsistency scenario suggested in the original ticket
investigation: Channel.shared is true and a SharedChannelRemote row
exists, but the SharedChannels row is missing. The endpoint should
return 200 with an empty list rather than surfacing the stale state
as an internal server error. Also tightens the existing
TestGetSharedChannelRemotes by asserting the status code on the
success path.
360 lines
11 KiB
Go
360 lines
11 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package api4
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/mattermost/mattermost/server/public/model"
|
|
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
|
)
|
|
|
|
func (api *API) InitSharedChannels() {
|
|
api.BaseRoutes.SharedChannels.Handle("/{team_id:[A-Za-z0-9]+}", api.APISessionRequired(getSharedChannels)).Methods(http.MethodGet)
|
|
api.BaseRoutes.SharedChannels.Handle("/remote_info/{remote_id:[A-Za-z0-9]+}", api.APISessionRequired(getRemoteClusterInfo)).Methods(http.MethodGet)
|
|
api.BaseRoutes.SharedChannels.Handle("/{channel_id:[A-Za-z0-9]+}/remotes", api.APISessionRequired(getSharedChannelRemotes)).Methods(http.MethodGet)
|
|
api.BaseRoutes.SharedChannels.Handle("/users/{user_id:[A-Za-z0-9]+}/can_dm/{other_user_id:[A-Za-z0-9]+}", api.APISessionRequired(canUserDirectMessage)).Methods(http.MethodGet)
|
|
|
|
api.BaseRoutes.SharedChannelRemotes.Handle("", api.APISessionRequired(getSharedChannelRemotesByRemoteCluster)).Methods(http.MethodGet)
|
|
api.BaseRoutes.ChannelForRemote.Handle("/invite", api.APISessionRequired(inviteRemoteClusterToChannel)).Methods(http.MethodPost)
|
|
api.BaseRoutes.ChannelForRemote.Handle("/uninvite", api.APISessionRequired(uninviteRemoteClusterToChannel)).Methods(http.MethodPost)
|
|
}
|
|
|
|
func getSharedChannels(c *Context, w http.ResponseWriter, r *http.Request) {
|
|
c.RequireTeamId()
|
|
if c.Err != nil {
|
|
return
|
|
}
|
|
|
|
// make sure remote cluster service is enabled.
|
|
if _, appErr := c.App.GetRemoteClusterService(); appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
|
|
// make sure user has access to the team.
|
|
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) {
|
|
c.SetPermissionError(model.PermissionViewTeam)
|
|
return
|
|
}
|
|
|
|
opts := model.SharedChannelFilterOpts{
|
|
TeamId: c.Params.TeamId,
|
|
}
|
|
|
|
// only return channels the user is a member of, unless they are a shared channels manager.
|
|
if !c.App.HasPermissionTo(c.AppContext.Session().UserId, model.PermissionManageSharedChannels) {
|
|
opts.MemberId = c.AppContext.Session().UserId
|
|
}
|
|
|
|
channels, appErr := c.App.GetSharedChannels(c.Params.Page, c.Params.PerPage, opts)
|
|
if appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
|
|
b, err := json.Marshal(channels)
|
|
if err != nil {
|
|
c.SetJSONEncodingError(err)
|
|
return
|
|
}
|
|
|
|
if _, err := w.Write(b); err != nil {
|
|
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
|
}
|
|
}
|
|
|
|
func getRemoteClusterInfo(c *Context, w http.ResponseWriter, r *http.Request) {
|
|
c.RequireRemoteId()
|
|
if c.Err != nil {
|
|
return
|
|
}
|
|
|
|
// make sure remote cluster service is enabled.
|
|
if _, appErr := c.App.GetRemoteClusterService(); appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
|
|
// GetRemoteClusterForUser will only return a remote if the user is a member of at
|
|
// least one channel shared by the remote. All other cases return error.
|
|
rc, appErr := c.App.GetRemoteClusterForUser(c.Params.RemoteId, c.AppContext.Session().UserId, c.Params.IncludeDeleted)
|
|
if appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
|
|
remoteInfo := rc.ToRemoteClusterInfo()
|
|
|
|
b, err := json.Marshal(remoteInfo)
|
|
if err != nil {
|
|
c.SetJSONEncodingError(err)
|
|
return
|
|
}
|
|
if _, err := w.Write(b); err != nil {
|
|
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
|
}
|
|
}
|
|
|
|
func getSharedChannelRemotesByRemoteCluster(c *Context, w http.ResponseWriter, r *http.Request) {
|
|
c.RequireRemoteId()
|
|
if c.Err != nil {
|
|
return
|
|
}
|
|
|
|
c.RequirePermissionToManageSecureConnectionsOrSharedChannels()
|
|
if c.Err != nil {
|
|
return
|
|
}
|
|
|
|
// make sure remote cluster service is enabled.
|
|
if _, appErr := c.App.GetRemoteClusterService(); appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
|
|
if _, appErr := c.App.GetRemoteCluster(c.Params.RemoteId, true); appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
|
|
filter := model.SharedChannelRemoteFilterOpts{
|
|
RemoteId: c.Params.RemoteId,
|
|
IncludeUnconfirmed: c.Params.IncludeUnconfirmed,
|
|
ExcludeConfirmed: c.Params.ExcludeConfirmed,
|
|
ExcludeHome: c.Params.ExcludeHome,
|
|
ExcludeRemote: c.Params.ExcludeRemote,
|
|
IncludeDeleted: c.Params.IncludeDeleted,
|
|
}
|
|
sharedChannelRemotes, err := c.App.GetSharedChannelRemotes(c.Params.Page, c.Params.PerPage, filter)
|
|
if err != nil {
|
|
c.Err = model.NewAppError("getSharedChannelRemotesByRemoteCluster", "api.shared_channel.get_shared_channel_remotes_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
return
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(sharedChannelRemotes); err != nil {
|
|
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
|
}
|
|
}
|
|
|
|
func inviteRemoteClusterToChannel(c *Context, w http.ResponseWriter, r *http.Request) {
|
|
c.RequireRemoteId()
|
|
if c.Err != nil {
|
|
return
|
|
}
|
|
|
|
c.RequireChannelId()
|
|
if c.Err != nil {
|
|
return
|
|
}
|
|
|
|
c.RequirePermissionToManageSharedChannels()
|
|
if c.Err != nil {
|
|
return
|
|
}
|
|
|
|
// make sure remote cluster service is enabled.
|
|
if _, appErr := c.App.GetRemoteClusterService(); appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
|
|
if _, appErr := c.App.GetRemoteCluster(c.Params.RemoteId, false); appErr != nil {
|
|
c.SetInvalidRemoteIdError(c.Params.RemoteId)
|
|
return
|
|
}
|
|
|
|
if _, appErr := c.App.GetChannel(c.AppContext, c.Params.ChannelId); appErr != nil {
|
|
c.SetInvalidURLParam("channel_id")
|
|
return
|
|
}
|
|
|
|
auditRec := c.MakeAuditRecord(model.AuditEventInviteRemoteClusterToChannel, model.AuditStatusFail)
|
|
defer c.LogAuditRec(auditRec)
|
|
model.AddEventParameterToAuditRec(auditRec, "remote_id", c.Params.RemoteId)
|
|
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
|
|
model.AddEventParameterToAuditRec(auditRec, "user_id", c.AppContext.Session().UserId)
|
|
|
|
if err := c.App.InviteRemoteToChannel(c.Params.ChannelId, c.Params.RemoteId, c.AppContext.Session().UserId, true); err != nil {
|
|
if appErr, ok := err.(*model.AppError); ok {
|
|
c.Err = appErr
|
|
} else {
|
|
c.Err = model.NewAppError("inviteRemoteClusterToChannel", "api.shared_channel.invite_remote_to_channel_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
return
|
|
}
|
|
|
|
auditRec.Success()
|
|
ReturnStatusOK(w)
|
|
}
|
|
|
|
func uninviteRemoteClusterToChannel(c *Context, w http.ResponseWriter, r *http.Request) {
|
|
c.RequireRemoteId()
|
|
if c.Err != nil {
|
|
return
|
|
}
|
|
|
|
c.RequireChannelId()
|
|
if c.Err != nil {
|
|
return
|
|
}
|
|
|
|
c.RequirePermissionToManageSharedChannels()
|
|
if c.Err != nil {
|
|
return
|
|
}
|
|
|
|
// make sure remote cluster service is enabled.
|
|
if _, appErr := c.App.GetRemoteClusterService(); appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
|
|
if _, appErr := c.App.GetRemoteCluster(c.Params.RemoteId, false); appErr != nil {
|
|
c.SetInvalidRemoteIdError(c.Params.RemoteId)
|
|
return
|
|
}
|
|
|
|
if _, appErr := c.App.GetChannel(c.AppContext, c.Params.ChannelId); appErr != nil {
|
|
c.SetInvalidURLParam("channel_id")
|
|
return
|
|
}
|
|
|
|
auditRec := c.MakeAuditRecord(model.AuditEventUninviteRemoteClusterToChannel, model.AuditStatusFail)
|
|
defer c.LogAuditRec(auditRec)
|
|
model.AddEventParameterToAuditRec(auditRec, "remote_id", c.Params.RemoteId)
|
|
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
|
|
|
|
hasRemote, err := c.App.HasRemote(c.Params.ChannelId, c.Params.RemoteId)
|
|
if err != nil {
|
|
c.Err = model.NewAppError("uninviteRemoteClusterToChannel", "api.shared_channel.has_remote_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
return
|
|
}
|
|
|
|
// if the channel is not shared with the remote, we return early
|
|
if !hasRemote {
|
|
auditRec.Success()
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
|
|
if err := c.App.UninviteRemoteFromChannel(c.Params.ChannelId, c.Params.RemoteId); err != nil {
|
|
c.Err = model.NewAppError("uninviteRemoteClusterToChannel", "api.shared_channel.uninvite_remote_to_channel_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
return
|
|
}
|
|
|
|
auditRec.Success()
|
|
ReturnStatusOK(w)
|
|
}
|
|
|
|
// getSharedChannelRemotes returns info about remote clusters for a shared channel
|
|
func getSharedChannelRemotes(c *Context, w http.ResponseWriter, r *http.Request) {
|
|
c.RequireChannelId()
|
|
if c.Err != nil {
|
|
return
|
|
}
|
|
|
|
// make sure remote cluster service is enabled.
|
|
if _, appErr := c.App.GetRemoteClusterService(); appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
|
|
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok {
|
|
c.SetPermissionError(model.PermissionReadChannel)
|
|
return
|
|
}
|
|
|
|
// Get the remotes status (return empty if channel is not yet shared)
|
|
remoteStatuses, err := c.App.GetSharedChannelRemotesStatus(c.Params.ChannelId)
|
|
if err != nil {
|
|
if errors.Is(err, model.ErrChannelNotShared) {
|
|
remoteStatuses = []*model.SharedChannelRemoteStatus{}
|
|
} else {
|
|
c.Err = model.NewAppError("getSharedChannelRemotes", "api.command_share.fetch_remote_status.error", map[string]any{"Error": err.Error()}, "", http.StatusInternalServerError).Wrap(err)
|
|
return
|
|
}
|
|
}
|
|
|
|
// For each remote status, get the RemoteClusterInfo
|
|
remoteInfos := make([]*model.RemoteClusterInfo, 0, len(remoteStatuses))
|
|
for _, status := range remoteStatuses {
|
|
// Use GetRemoteCluster to get the full remote cluster
|
|
remoteCluster, appErr := c.App.GetRemoteCluster(status.RemoteId, false)
|
|
if appErr == nil && remoteCluster != nil {
|
|
info := remoteCluster.ToRemoteClusterInfo()
|
|
remoteInfos = append(remoteInfos, &info)
|
|
} else {
|
|
// If we can't find the detailed info, create a basic RemoteClusterInfo from the status
|
|
remoteInfos = append(remoteInfos, &model.RemoteClusterInfo{
|
|
RemoteId: status.RemoteId,
|
|
Name: status.DisplayName,
|
|
DisplayName: status.DisplayName,
|
|
LastPingAt: status.LastPingAt,
|
|
SiteURL: status.SiteURL,
|
|
})
|
|
}
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(remoteInfos); err != nil {
|
|
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
|
}
|
|
}
|
|
|
|
func canUserDirectMessage(c *Context, w http.ResponseWriter, r *http.Request) {
|
|
c.RequireUserId().RequireOtherUserId()
|
|
if c.Err != nil {
|
|
return
|
|
}
|
|
|
|
// Check if the user can see the other user at all
|
|
canSee, err := c.App.UserCanSeeOtherUser(c.AppContext, c.Params.UserId, c.Params.OtherUserId)
|
|
if err != nil {
|
|
c.Err = err
|
|
return
|
|
}
|
|
if !canSee {
|
|
result := map[string]bool{"can_dm": false}
|
|
if err := json.NewEncoder(w).Encode(result); err != nil {
|
|
c.Logger.Warn("Error encoding JSON response", mlog.Err(err))
|
|
}
|
|
return
|
|
}
|
|
|
|
canDM := true
|
|
|
|
// Get shared channel sync service for remote user checks
|
|
scs := c.App.Srv().GetSharedChannelSyncService()
|
|
if scs != nil {
|
|
otherUser, otherErr := c.App.GetUser(c.Params.OtherUserId)
|
|
if otherErr != nil {
|
|
canDM = false
|
|
} else {
|
|
originalRemoteId := otherUser.GetOriginalRemoteID()
|
|
|
|
// Check if the other user is from a remote cluster
|
|
if otherUser.IsRemote() {
|
|
// If original remote ID is unknown, fall back to current RemoteId as best guess
|
|
if originalRemoteId == model.UserOriginalRemoteIdUnknown {
|
|
originalRemoteId = otherUser.GetRemoteID()
|
|
}
|
|
|
|
// For DMs, we require a direct connection to the ORIGINAL remote cluster
|
|
isDirectlyConnected := scs.IsRemoteClusterDirectlyConnected(originalRemoteId)
|
|
|
|
if !isDirectlyConnected {
|
|
canDM = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
result := map[string]bool{"can_dm": canDM}
|
|
if err := json.NewEncoder(w).Encode(result); err != nil {
|
|
c.Logger.Warn("Error encoding JSON response", mlog.Err(err))
|
|
}
|
|
}
|