feat: implement comprehensive room validation and admin-only command access

- Add RoomExists and GetRoomMapping methods to Bridge interface
- Implement XMPP room existence checking using disco#info queries (XEP-0030)
- Add room validation in BridgeManager to prevent duplicate mappings and invalid rooms
- Enhance XMPP client with CheckRoomExists method and comprehensive logging
- Implement admin-only access control for all bridge commands
- Add user-friendly error messages with actionable troubleshooting steps
- Update doctor command with room existence testing and pre-join validation
- Add SimpleLogger implementation for standalone command usage

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Felipe M 2025-08-04 11:29:35 +02:00
parent 1f45197aa8
commit a95ca8fb76
No known key found for this signature in database
GPG key ID: 52E5D65FCF99808A
8 changed files with 454 additions and 17 deletions

View file

@ -245,6 +245,39 @@ func (m *Manager) OnChannelMappingCreated(req model.ChannelMappingRequest) error
return fmt.Errorf("bridge '%s' is not connected", req.BridgeName)
}
// NEW: Check if room already mapped to another channel
existingChannelID, err := bridge.GetRoomMapping(req.BridgeRoomID)
if err != nil {
m.logger.LogError("Failed to check room mapping", "bridge_room_id", req.BridgeRoomID, "error", err)
return fmt.Errorf("failed to check room mapping: %w", err)
}
if existingChannelID != "" {
m.logger.LogWarn("Room already mapped to another channel",
"bridge_room_id", req.BridgeRoomID,
"existing_channel_id", existingChannelID,
"requested_channel_id", req.ChannelID)
return fmt.Errorf("room '%s' is already mapped to channel '%s'", req.BridgeRoomID, existingChannelID)
}
// NEW: Check if room exists on target bridge
roomExists, err := bridge.RoomExists(req.BridgeRoomID)
if err != nil {
m.logger.LogError("Failed to check room existence", "bridge_room_id", req.BridgeRoomID, "error", err)
return fmt.Errorf("failed to check room existence: %w", err)
}
if !roomExists {
m.logger.LogWarn("Room does not exist on bridge",
"bridge_room_id", req.BridgeRoomID,
"bridge_name", req.BridgeName)
return fmt.Errorf("room '%s' does not exist on %s bridge", req.BridgeRoomID, req.BridgeName)
}
m.logger.LogDebug("Room validation passed",
"bridge_room_id", req.BridgeRoomID,
"bridge_name", req.BridgeName,
"room_exists", roomExists,
"already_mapped", false)
// Create the channel mapping on the receiving bridge
if err = bridge.CreateChannelMapping(req.ChannelID, req.BridgeRoomID); err != nil {
m.logger.LogError("Failed to create channel mapping", "channel_id", req.ChannelID, "bridge_name", req.BridgeName, "bridge_room_id", req.BridgeRoomID, "error", err)

View file

@ -255,3 +255,53 @@ func (b *mattermostBridge) DeleteChannelMapping(channelID string) error {
b.logger.LogInfo("Deleted Mattermost channel room mapping", "channel_id", channelID, "room_id", roomID)
return nil
}
// RoomExists checks if a Mattermost channel exists on the server
func (b *mattermostBridge) RoomExists(roomID string) (bool, error) {
if b.api == nil {
return false, fmt.Errorf("Mattermost API not initialized")
}
b.logger.LogDebug("Checking if Mattermost channel exists", "channel_id", roomID)
// Use the Mattermost API to check if the channel exists
channel, appErr := b.api.GetChannel(roomID)
if appErr != nil {
if appErr.StatusCode == 404 {
b.logger.LogDebug("Mattermost channel does not exist", "channel_id", roomID)
return false, nil
}
b.logger.LogError("Failed to check channel existence", "channel_id", roomID, "error", appErr)
return false, fmt.Errorf("failed to check channel existence: %w", appErr)
}
if channel == nil {
b.logger.LogDebug("Mattermost channel does not exist (nil response)", "channel_id", roomID)
return false, nil
}
b.logger.LogDebug("Mattermost channel exists", "channel_id", roomID, "channel_name", channel.Name)
return true, nil
}
// GetRoomMapping retrieves the Mattermost channel ID for a given room ID (reverse lookup)
func (b *mattermostBridge) GetRoomMapping(roomID string) (string, error) {
if b.kvstore == nil {
return "", fmt.Errorf("KV store not initialized")
}
b.logger.LogDebug("Getting channel mapping for Mattermost room", "room_id", roomID)
// Look up the channel ID using the room ID as the key
channelIDBytes, err := b.kvstore.Get(kvstore.BuildChannelMapKey("mattermost", roomID))
if err != nil {
// No mapping found is not an error, just return empty string
b.logger.LogDebug("No channel mapping found for room", "room_id", roomID)
return "", nil
}
channelID := string(channelIDBytes)
b.logger.LogDebug("Found channel mapping for room", "room_id", roomID, "channel_id", channelID)
return channelID, nil
}

View file

@ -73,6 +73,7 @@ func (b *xmppBridge) createXMPPClient(cfg *config.Configuration) *xmppClient.Cli
cfg.GetXMPPResource(),
"", // remoteID not needed for bridge user
tlsConfig,
b.logger,
)
}
@ -471,3 +472,48 @@ func (b *xmppBridge) DeleteChannelMapping(channelID string) error {
b.logger.LogInfo("Deleted channel room mapping", "channel_id", channelID, "room_jid", roomJID)
return nil
}
// RoomExists checks if an XMPP room exists on the remote service
func (b *xmppBridge) RoomExists(roomID string) (bool, error) {
if !b.connected.Load() {
return false, fmt.Errorf("not connected to XMPP server")
}
if b.xmppClient == nil {
return false, fmt.Errorf("XMPP client not initialized")
}
b.logger.LogDebug("Checking if XMPP room exists", "room_jid", roomID)
// Use the XMPP client to check room existence
exists, err := b.xmppClient.CheckRoomExists(roomID)
if err != nil {
b.logger.LogError("Failed to check room existence", "room_jid", roomID, "error", err)
return false, fmt.Errorf("failed to check room existence: %w", err)
}
b.logger.LogDebug("Room existence check completed", "room_jid", roomID, "exists", exists)
return exists, nil
}
// GetRoomMapping retrieves the Mattermost channel ID for a given XMPP room JID (reverse lookup)
func (b *xmppBridge) GetRoomMapping(roomID string) (string, error) {
if b.kvstore == nil {
return "", fmt.Errorf("KV store not initialized")
}
b.logger.LogDebug("Getting channel mapping for XMPP room", "room_jid", roomID)
// Look up the channel ID using the room JID as the key
channelIDBytes, err := b.kvstore.Get(kvstore.BuildChannelMapKey("xmpp", roomID))
if err != nil {
// No mapping found is not an error, just return empty string
b.logger.LogDebug("No channel mapping found for room", "room_jid", roomID)
return "", nil
}
channelID := string(channelIDBytes)
b.logger.LogDebug("Found channel mapping for room", "room_jid", roomID, "channel_id", channelID)
return channelID, nil
}

View file

@ -54,6 +54,14 @@ func NewCommandHandler(client *pluginapi.Client, bridgeManager pluginModel.Bridg
// ExecuteCommand hook calls this method to execute the commands that were registered in the NewCommandHandler function.
func (c *Handler) Handle(args *model.CommandArgs) (*model.CommandResponse, error) {
// Check if user is system admin for all plugin commands
if !c.isSystemAdmin(args.UserId) {
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: "❌ Only system administrators can use XMPP bridge commands.",
}, nil
}
trigger := strings.TrimPrefix(strings.Fields(args.Command)[0], "/")
switch trigger {
case xmppBridgeCommandTrigger:
@ -162,10 +170,7 @@ func (c *Handler) executeMapCommand(args *model.CommandArgs, fields []string) *m
err = c.bridgeManager.OnChannelMappingCreated(mappingReq)
if err != nil {
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: fmt.Sprintf("❌ Failed to create channel mapping: %v", err),
}
return c.formatMappingError("create", roomJID, err)
}
return &model.CommandResponse{
@ -212,10 +217,7 @@ func (c *Handler) executeUnmapCommand(args *model.CommandArgs) *model.CommandRes
err = c.bridgeManager.OnChannelMappingDeleted(deleteReq)
if err != nil {
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: fmt.Sprintf("❌ Failed to unmap channel: %v", err),
}
return c.formatMappingError("delete", roomJID, err)
}
return &model.CommandResponse{
@ -269,3 +271,85 @@ func (c *Handler) executeStatusCommand(args *model.CommandArgs) *model.CommandRe
- Use `+"`/xmppbridge unmap`"+` to unmap this channel from an XMPP room`, statusText, mappingText),
}
}
// isSystemAdmin checks if the user is a system administrator
func (c *Handler) isSystemAdmin(userID string) bool {
user, err := c.client.User.Get(userID)
if err != nil {
c.client.Log.Warn("Failed to get user for admin check", "user_id", userID, "error", err)
return false
}
return user.IsSystemAdmin()
}
// formatMappingError provides user-friendly error messages for mapping operations
func (c *Handler) formatMappingError(operation, roomJID string, err error) *model.CommandResponse {
errorMsg := err.Error()
// Handle specific error cases with user-friendly messages
switch {
case strings.Contains(errorMsg, "already mapped to channel"):
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: fmt.Sprintf(` **Room Already Mapped**
The XMPP room **%s** is already connected to another channel.
**What you can do:**
- Choose a different XMPP room that isn't already in use
- Unmap the room from the other channel first using ` + "`/xmppbridge unmap`" + `
- Use ` + "`/xmppbridge status`" + ` to check current mappings`, roomJID),
}
case strings.Contains(errorMsg, "does not exist"):
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: fmt.Sprintf(` **Room Not Found**
The XMPP room **%s** doesn't exist or isn't accessible.
**What you can do:**
- Check that the room JID is spelled correctly
- Make sure the room exists on the XMPP server
- Verify you have permission to access the room
- Contact your XMPP administrator if needed
**Example format:** room@conference.example.com`, roomJID),
}
case strings.Contains(errorMsg, "not connected"):
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: ` **Bridge Not Connected**
The XMPP bridge is currently disconnected.
**What you can do:**
- Wait a moment and try again (the bridge may be reconnecting)
- Contact your system administrator
- Use ` + "`/xmppbridge status`" + ` to check the connection status`,
}
default:
// Generic error message for unknown cases
action := "create the mapping"
if operation == "delete" {
action = "remove the mapping"
}
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: fmt.Sprintf(` **Operation Failed**
Unable to %s for room **%s**.
**What you can do:**
- Try the command again in a few moments
- Use ` + "`/xmppbridge status`" + ` to check the bridge status
- Contact your system administrator if the problem persists
**Error details:** %s`, action, roomJID, errorMsg),
}
}
}

View file

@ -133,6 +133,12 @@ type Bridge interface {
// DeleteChannelMapping removes a mapping between a Mattermost channel ID and a bridge room ID.
DeleteChannelMapping(channelID string) error
// RoomExists checks if a room/channel exists on the remote service.
RoomExists(roomID string) (bool, error)
// GetRoomMapping retrieves the Mattermost channel ID for a given room ID (reverse lookup).
GetRoomMapping(roomID string) (string, error)
// IsConnected checks if the bridge is connected to the remote service.
IsConnected() bool
}

View file

@ -153,6 +153,7 @@ func (p *Plugin) initXMPPClient() {
cfg.XMPPPassword,
cfg.GetXMPPResource(),
p.remoteID,
p.logger,
)
}

View file

@ -8,8 +8,10 @@ import (
"fmt"
"time"
"github.com/mattermost/mattermost-plugin-bridge-xmpp/server/logger"
"mellium.im/sasl"
"mellium.im/xmpp"
"mellium.im/xmpp/disco"
"mellium.im/xmpp/jid"
"mellium.im/xmpp/muc"
"mellium.im/xmpp/mux"
@ -25,6 +27,7 @@ type Client struct {
remoteID string // Plugin remote ID for metadata
serverDomain string // explicit server domain for testing
tlsConfig *tls.Config // custom TLS configuration
logger logger.Logger // Logger for debugging
// XMPP connection
session *xmpp.Session
@ -80,7 +83,7 @@ type UserProfile struct {
}
// NewClient creates a new XMPP client.
func NewClient(serverURL, username, password, resource, remoteID string) *Client {
func NewClient(serverURL, username, password, resource, remoteID string, logger logger.Logger) *Client {
ctx, cancel := context.WithCancel(context.Background())
mucClient := &muc.Client{}
mux := mux.New("jabber:client", muc.HandleClient(mucClient))
@ -91,6 +94,7 @@ func NewClient(serverURL, username, password, resource, remoteID string) *Client
password: password,
resource: resource,
remoteID: remoteID,
logger: logger,
ctx: ctx,
cancel: cancel,
mucClient: mucClient,
@ -100,8 +104,8 @@ func NewClient(serverURL, username, password, resource, remoteID string) *Client
}
// NewClientWithTLS creates a new XMPP client with custom TLS configuration.
func NewClientWithTLS(serverURL, username, password, resource, remoteID string, tlsConfig *tls.Config) *Client {
client := NewClient(serverURL, username, password, resource, remoteID)
func NewClientWithTLS(serverURL, username, password, resource, remoteID string, tlsConfig *tls.Config, logger logger.Logger) *Client {
client := NewClient(serverURL, username, password, resource, remoteID, logger)
client.tlsConfig = tlsConfig
return client
}
@ -430,3 +434,93 @@ func (c *Client) SetOnlinePresence() error {
return nil
}
// CheckRoomExists verifies if an XMPP room exists and is accessible using disco#info
func (c *Client) CheckRoomExists(roomJID string) (bool, error) {
if c.session == nil {
return false, fmt.Errorf("XMPP session not established")
}
c.logger.LogDebug("Checking room existence using disco#info", "room_jid", roomJID)
// Parse and validate the room JID
roomAddr, err := jid.Parse(roomJID)
if err != nil {
c.logger.LogError("Invalid room JID", "room_jid", roomJID, "error", err)
return false, fmt.Errorf("invalid room JID: %w", err)
}
// Set timeout for the disco query
ctx, cancel := context.WithTimeout(c.ctx, 10*time.Second)
defer cancel()
// Perform disco#info query to the room
info, err := disco.GetInfo(ctx, "", roomAddr, c.session)
if err != nil {
// Check if it's a service-unavailable or item-not-found error
if stanzaErr, ok := err.(stanza.Error); ok {
c.logger.LogDebug("Received stanza error during disco#info query",
"room_jid", roomJID,
"error_condition", string(stanzaErr.Condition),
"error_type", string(stanzaErr.Type))
switch stanzaErr.Condition {
case stanza.ServiceUnavailable, stanza.ItemNotFound:
c.logger.LogDebug("Room does not exist", "room_jid", roomJID, "condition", string(stanzaErr.Condition))
return false, nil // Room doesn't exist
case stanza.Forbidden:
c.logger.LogWarn("Access denied to room (room exists but not accessible)", "room_jid", roomJID)
return false, fmt.Errorf("access denied to room %s", roomJID)
case stanza.NotAuthorized:
c.logger.LogWarn("Not authorized to query room (room exists but not queryable)", "room_jid", roomJID)
return false, fmt.Errorf("not authorized to query room %s", roomJID)
default:
c.logger.LogError("Unexpected disco query error", "room_jid", roomJID, "condition", string(stanzaErr.Condition), "error", err)
return false, fmt.Errorf("disco query failed: %w", err)
}
}
c.logger.LogError("Disco query error", "room_jid", roomJID, "error", err)
return false, fmt.Errorf("disco query error: %w", err)
}
c.logger.LogDebug("Received disco#info response, checking for MUC features",
"room_jid", roomJID,
"features_count", len(info.Features),
"identities_count", len(info.Identity))
// Verify it's actually a MUC room by checking features
for _, feature := range info.Features {
if feature.Var == muc.NS { // "http://jabber.org/protocol/muc"
c.logger.LogDebug("Room exists and has MUC feature", "room_jid", roomJID)
return true, nil
}
}
// Check for conference identity as backup verification
for _, identity := range info.Identity {
if identity.Category == "conference" {
c.logger.LogDebug("Room exists and has conference identity", "room_jid", roomJID, "identity_type", identity.Type)
return true, nil
}
}
// Log all features and identities for debugging
c.logger.LogDebug("Room exists but doesn't appear to be a MUC room",
"room_jid", roomJID,
"features", func() []string {
var features []string
for _, f := range info.Features {
features = append(features, f.Var)
}
return features
}(),
"identities", func() []string {
var identities []string
for _, i := range info.Identity {
identities = append(identities, fmt.Sprintf("%s/%s", i.Category, i.Type))
}
return identities
}())
return false, nil
}