Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions api/v4/source/sharedchannels.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,88 @@
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"

"/api/v4/sharedchannels/{channel_id}/remotes":
get:
tags:
- shared channels
summary: Get remote clusters for a shared channel
description: |
Gets the remote clusters information for a shared channel.

__Minimum server version__: 10.11

##### Permissions
Must be authenticated and have the `read_channel` permission for the channel.
operationId: GetSharedChannelRemotes
parameters:
- name: channel_id
in: path
description: Channel GUID
required: true
schema:
type: string
responses:
"200":
description: Remote clusters retrieval successful
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/RemoteClusterInfo"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"

"/api/v4/sharedchannels/users/{user_id}/can_dm/{other_user_id}":
get:
tags:
- shared channels
summary: Check if user can DM another user in shared channels context
description: |
Checks if a user can send direct messages to another user, considering shared channel restrictions.
This is specifically for shared channels where DMs require direct connections between clusters.

__Minimum server version__: 10.11

##### Permissions
Must be authenticated and have permission to view the user.
operationId: CanUserDirectMessage
parameters:
- name: user_id
in: path
description: User GUID
required: true
schema:
type: string
- name: other_user_id
in: path
description: Other user GUID
required: true
schema:
type: string
responses:
"200":
description: DM permission check successful
content:
application/json:
schema:
type: object
properties:
can_dm:
type: boolean
description: Whether the user can send DMs to the other user
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
55 changes: 55 additions & 0 deletions server/channels/api4/shared_channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ 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)
Expand Down Expand Up @@ -294,3 +295,57 @@ func getSharedChannelRemotes(c *Context, w http.ResponseWriter, r *http.Request)
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))
}
}
9 changes: 9 additions & 0 deletions server/channels/app/shared_channel_service_iface.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type SharedChannelServiceIFace interface {
CheckChannelIsShared(channelID string) error
CheckCanInviteToSharedChannel(channelId string) error
HandleMembershipChange(channelID, userID string, isAdd bool, remoteID string)
IsRemoteClusterDirectlyConnected(remoteId string) bool
TransformMentionsOnReceiveForTesting(ctx request.CTX, post *model.Post, targetChannel *model.Channel, rc *model.RemoteCluster, mentionTransforms map[string]string)
}

Expand Down Expand Up @@ -100,3 +101,11 @@ func (mrcs *mockSharedChannelService) HandleMembershipChange(channelID, userID s
mrcs.SharedChannelServiceIFace.HandleMembershipChange(channelID, userID, isAdd, remoteID)
}
}

func (mrcs *mockSharedChannelService) IsRemoteClusterDirectlyConnected(remoteId string) bool {
if mrcs.SharedChannelServiceIFace != nil {
return mrcs.SharedChannelServiceIFace.IsRemoteClusterDirectlyConnected(remoteId)
}
// Default behavior for mock: Local server is always connected
return remoteId == ""
}
73 changes: 73 additions & 0 deletions server/channels/app/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"database/sql"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
Expand All @@ -26,6 +27,7 @@ import (
"github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
"github.com/mattermost/mattermost/server/v8/einterfaces"
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
"github.com/mattermost/mattermost/server/v8/platform/services/sharedchannel"
)

func TestCreateOAuthUser(t *testing.T) {
Expand Down Expand Up @@ -2411,3 +2413,74 @@ func TestGetUsersForReporting(t *testing.T) {
require.NotNil(t, userReports)
})
}

// Helper functions for remote user testing
func setupRemoteClusterTest(t *testing.T) (*TestHelper, store.Store) {
os.Setenv("MM_FEATUREFLAGS_ENABLESHAREDCHANNELSDMS", "true")
t.Cleanup(func() { os.Unsetenv("MM_FEATUREFLAGS_ENABLESHAREDCHANNELSDMS") })
th := setupSharedChannels(t).InitBasic()
t.Cleanup(th.TearDown)
return th, th.App.Srv().Store()
}

func createTestRemoteCluster(t *testing.T, th *TestHelper, ss store.Store, name, siteURL string, confirmed bool) *model.RemoteCluster {
cluster := &model.RemoteCluster{
RemoteId: model.NewId(),
Name: name,
SiteURL: siteURL,
CreateAt: model.GetMillis(),
LastPingAt: model.GetMillis(),
Token: model.NewId(),
CreatorId: th.BasicUser.Id,
}
if confirmed {
cluster.RemoteToken = model.NewId()
}
savedCluster, err := ss.RemoteCluster().Save(cluster)
require.NoError(t, err)
return savedCluster
}

func createRemoteUser(t *testing.T, th *TestHelper, remoteCluster *model.RemoteCluster) *model.User {
user := th.CreateUser()
user.RemoteId = &remoteCluster.RemoteId
updatedUser, appErr := th.App.UpdateUser(th.Context, user, false)
require.Nil(t, appErr)
return updatedUser
}

func ensureRemoteClusterConnected(t *testing.T, ss store.Store, cluster *model.RemoteCluster, connected bool) {
if connected {
cluster.SiteURL = "https://example.com"
cluster.RemoteToken = model.NewId()
cluster.LastPingAt = model.GetMillis()
} else {
cluster.SiteURL = model.SiteURLPending + "example.com"
cluster.RemoteToken = ""
}
_, err := ss.RemoteCluster().Update(cluster)
require.NoError(t, err)
}

// TestRemoteUserDirectChannelCreation tests direct channel creation with remote users
func TestRemoteUserDirectChannelCreation(t *testing.T) {
th, ss := setupRemoteClusterTest(t)

connectedRC := createTestRemoteCluster(t, th, ss, "connected-cluster", "https://example-connected.com", true)

user1 := createRemoteUser(t, th, connectedRC)

t.Run("Can create DM with user from connected remote", func(t *testing.T) {
ensureRemoteClusterConnected(t, ss, connectedRC, true)

scs := th.App.Srv().GetSharedChannelSyncService()
service, ok := scs.(*sharedchannel.Service)
require.True(t, ok)
require.True(t, service.IsRemoteClusterDirectlyConnected(connectedRC.RemoteId))

channel, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, user1.Id)
assert.NotNil(t, channel)
assert.Nil(t, appErr)
assert.Equal(t, model.ChannelTypeDirect, channel.Type)
})
}
11 changes: 11 additions & 0 deletions server/channels/web/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,17 @@ func (c *Context) RequireUserId() *Context {
return c
}

func (c *Context) RequireOtherUserId() *Context {
if c.Err != nil {
return c
}

if !model.IsValidId(c.Params.OtherUserId) {
c.SetInvalidURLParam("other_user_id")
}
return c
}

func (c *Context) RequireTeamId() *Context {
if c.Err != nil {
return c
Expand Down
2 changes: 2 additions & 0 deletions server/channels/web/params.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const (

type Params struct {
UserId string
OtherUserId string
TeamId string
InviteId string
TokenId string
Expand Down Expand Up @@ -129,6 +130,7 @@ func ParamsFromRequest(r *http.Request) *Params {
query := r.URL.Query()

params.UserId = props["user_id"]
params.OtherUserId = props["other_user_id"]
params.TeamId = props["team_id"]
params.CategoryId = props["category_id"]
params.InviteId = props["invite_id"]
Expand Down
4 changes: 0 additions & 4 deletions server/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -250,10 +250,6 @@ exclude (
github.com/willf/bitset v1.2.0
)

// Prevent from being upgraded because this library has a minimum requirement
// of Go 1.24.
replace github.com/ledongthuc/pdf => github.com/ledongthuc/pdf v0.0.0-20240201131950-da5b75280b06

// Also prevent tablewriter from being upgraded because the downstream dependency
// jaytaylor/html2text does not have a go.mod file which makes it bump to the latest
// version always. Tablewriter has made breaking changes to its latest release.
Expand Down
4 changes: 2 additions & 2 deletions server/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -405,8 +405,8 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/ledongthuc/pdf v0.0.0-20240201131950-da5b75280b06 h1:kacRlPN7EN++tVpGUorNGPn/4DnB7/DfTY82AOn6ccU=
github.com/ledongthuc/pdf v0.0.0-20240201131950-da5b75280b06/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 h1:QwWKgMY28TAXaDl+ExRDqGQltzXqN/xypdKP86niVn8=
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
github.com/levigross/exp-html v0.0.0-20120902181939-8df60c69a8f5 h1:W7p+m/AECTL3s/YR5RpQ4hz5SjNeKzZBl1q36ws12s0=
github.com/levigross/exp-html v0.0.0-20120902181939-8df60c69a8f5/go.mod h1:QMe2wuKJ0o7zIVE8AqiT8rd8epmm6WDIZ2wyuBqYPzM=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
Expand Down
2 changes: 1 addition & 1 deletion server/platform/services/docextractor/pdf_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func TestPdfEmptyFile(t *testing.T) {

func TestPdfFile(t *testing.T) {
extractor := pdfExtractor{}
contentText := "This is a simple document that contains some text."
contentText := "\nThis is a simple document that contains some text."
content, err := testutils.ReadTestFile("sample-doc.pdf")
require.NoError(t, err)
extractedText, err := extractor.Extract("sample-doc.pdf", bytes.NewReader(content))
Expand Down
23 changes: 23 additions & 0 deletions server/platform/services/sharedchannel/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,29 @@ func (scs *Service) postUnshareNotification(channelID string, creatorID string,
}
}

// IsRemoteClusterDirectlyConnected checks if a remote cluster has a direct connection to the current server
func (scs *Service) IsRemoteClusterDirectlyConnected(remoteId string) bool {
if remoteId == "" {
return true // Local server is always "directly connected"
}

// Check if the remote cluster exists and confirmed
rc, err := scs.server.GetStore().RemoteCluster().Get(remoteId, false)
if err != nil {
return false
}

isConfirmed := rc.IsConfirmed()
hasCreator := rc.CreatorId != ""

// For a direct connection, the remote cluster must be confirmed AND have a creator
// (someone on this server initiated or accepted the connection)
// Remote clusters known only through synthetic users won't have a creator
directConnection := isConfirmed && hasCreator

return directConnection
}

// OnReceiveSyncMessageForTesting is a wrapper to expose onReceiveSyncMessage for testing purposes
// isGlobalUserSyncEnabled checks if the global user sync feature is enabled
func (scs *Service) isGlobalUserSyncEnabled() bool {
Expand Down
8 changes: 8 additions & 0 deletions server/platform/services/sharedchannel/sync_recv.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,15 @@ func (scs *Service) upsertSyncUser(c request.CTX, user *model.User, channel *mod
var userSaved *model.User
if euser == nil {
// new user. Make sure the remoteID is correct and insert the record
// Preserve original remote ID before overwriting RemoteId
originalRemoteId := user.GetRemoteID()
user.RemoteId = model.NewPointer(rc.RemoteId)
if user.Props == nil || user.Props[model.UserPropsKeyOriginalRemoteId] == "" {
if originalRemoteId == "" {
originalRemoteId = rc.RemoteId // If no original RemoteId, use current sync sender
}
user.SetProp(model.UserPropsKeyOriginalRemoteId, originalRemoteId)
}
if userSaved, err = scs.insertSyncUser(c, user, channel, rc); err != nil {
return nil, err
}
Expand Down
6 changes: 4 additions & 2 deletions server/public/model/shared_channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import (
)

const (
UserPropsKeyRemoteUsername = "RemoteUsername"
UserPropsKeyRemoteEmail = "RemoteEmail"
UserPropsKeyRemoteUsername = "RemoteUsername"
UserPropsKeyRemoteEmail = "RemoteEmail"
UserPropsKeyOriginalRemoteId = "OriginalRemoteId"
UserOriginalRemoteIdUnknown = "UNKNOWN"
)

var (
Expand Down
Loading
Loading