mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
requireChannelBroadcastAccess went through RequireChannelAccess, whose DM branch checks only participant membership — a blocked user's plugin broadcast could reach the person who blocked them — and it issued a raw GetRoleByID per broadcast, bypassing the permission cache. The gate now delegates to MessageService.CanPost (extracted over checkSendPermission), so DM blocks, channel permissions, and future posting policy apply from exactly one place; fails closed when no service is wired. First brick of the permission-path unification. MemStore.GetDMRecipient gets an honest implementation so the block path is testable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
176 lines
5.9 KiB
Go
176 lines
5.9 KiB
Go
// Phase C Step 9 — plugin slash-command dispatcher.
|
|
//
|
|
// chat_command routes a slash command from a WS client to a registered plugin.
|
|
// If no plugin owns the command, an error is returned to the sender. If the
|
|
// plugin returns a Reply, it is sent only to the invoking client (ephemeral).
|
|
// If the plugin returns a Broadcast string, it is broadcast to the channel
|
|
// only after verifying the invoking client holds SEND_MESSAGES permission.
|
|
|
|
package ws
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
|
|
"github.com/owncord/server/service"
|
|
)
|
|
|
|
const MsgTypeChatCommand = "chat_command"
|
|
|
|
// maxCommandArgs is the maximum number of arguments accepted in a
|
|
// chat_command payload. This prevents a malicious client from flooding
|
|
// the plugin's allocate/dispatch ABI with thousands of strings.
|
|
const maxCommandArgs = 64
|
|
|
|
// chatCommandPayload is the client-supplied payload for a chat_command message.
|
|
type chatCommandPayload struct {
|
|
ChannelID int64 `json:"channel_id"`
|
|
Command string `json:"command"` // including leading slash, e.g. "/hello"
|
|
Args []string `json:"args"`
|
|
}
|
|
|
|
// registerPluginCommandHandler registers the chat_command V1 handler.
|
|
func registerPluginCommandHandler(r *HandlerRegistry) {
|
|
r.Register(MsgTypeChatCommand, handlePluginCommand)
|
|
}
|
|
|
|
// handlePluginCommand dispatches a slash command to the owning plugin via
|
|
// hub.pluginRegistry. Returns an error to the client when:
|
|
// - the payload is malformed,
|
|
// - the command name is empty,
|
|
// - too many arguments are supplied,
|
|
// - no plugin owns the command (unknown command),
|
|
// - the plugin returns an error reply.
|
|
func handlePluginCommand(ctx context.Context, h *Hub, c *Client, reqID string, payload json.RawMessage) {
|
|
var p chatCommandPayload
|
|
if err := json.Unmarshal(payload, &p); err != nil {
|
|
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid chat_command payload"))
|
|
return
|
|
}
|
|
|
|
cmd := strings.TrimSpace(p.Command)
|
|
if cmd == "" {
|
|
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "command must not be empty"))
|
|
return
|
|
}
|
|
|
|
if len(p.Args) > maxCommandArgs {
|
|
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, fmt.Sprintf("too many command arguments (max %d)", maxCommandArgs)))
|
|
return
|
|
}
|
|
|
|
if h.pluginRegistry == nil {
|
|
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, fmt.Sprintf("unknown command: %s (no plugins loaded)", cmd)))
|
|
return
|
|
}
|
|
|
|
result, handled := h.pluginRegistry.DispatchCommand(ctx, c.userID, p.ChannelID, cmd, p.Args)
|
|
if !handled {
|
|
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, fmt.Sprintf("unknown command: %s", cmd)))
|
|
return
|
|
}
|
|
|
|
if result == nil {
|
|
// Plugin acknowledged with no output.
|
|
return
|
|
}
|
|
|
|
if result.Reply != "" {
|
|
// Ephemeral reply — sent only to the invoking client.
|
|
c.sendMsg(buildCommandReply(reqID, result.Reply))
|
|
}
|
|
|
|
if result.Broadcast != "" && p.ChannelID != 0 {
|
|
// Verify the invoking client can post to this channel before broadcasting
|
|
// the plugin result to all channel members. Mirrors the normal send path:
|
|
// non-DM channels require READ_MESSAGES|SEND_MESSAGES (so a user cannot
|
|
// post into a channel they cannot read), and DM channels are validated by
|
|
// participant membership rather than role permissions.
|
|
if !h.requireChannelBroadcastAccess(c, p.ChannelID) {
|
|
return
|
|
}
|
|
// Channel broadcast — visible to everyone in the channel.
|
|
msg := buildCommandBroadcast(p.ChannelID, c.userID, cmd, result.Broadcast)
|
|
h.BroadcastToChannel(p.ChannelID, msg)
|
|
slog.Info("plugin command broadcast", "cmd", cmd, "channel_id", p.ChannelID, "user_id", c.userID)
|
|
}
|
|
}
|
|
|
|
// requireChannelBroadcastAccess reports whether the client may post to
|
|
// channelID, by delegating to the SAME service-layer check a real message
|
|
// send runs (MessageService.CanPost: cached channel permissions; DM
|
|
// membership AND DM blocks). The previous RequireChannelAccess route skipped
|
|
// the block check in its DM branch — a blocked user's plugin broadcast could
|
|
// reach the person who blocked them — and issued a raw GetRoleByID per
|
|
// broadcast, bypassing the permission cache. On failure it sends an error to
|
|
// the client and returns false.
|
|
func (h *Hub) requireChannelBroadcastAccess(c *Client, channelID int64) bool {
|
|
if c.user == nil {
|
|
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "not authenticated"))
|
|
return false
|
|
}
|
|
if h.messageSvc == nil {
|
|
// No service wired (bare test hub) — fail closed rather than allow
|
|
// an ungated broadcast.
|
|
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "broadcast gate unavailable"))
|
|
return false
|
|
}
|
|
if err := h.messageSvc.CanPost(c.userID, channelID); err != nil {
|
|
if errors.Is(err, service.ErrNotFound) {
|
|
c.sendMsg(buildErrorMsg(ErrCodeNotFound, "channel not found"))
|
|
return false
|
|
}
|
|
slog.Warn("ws plugin broadcast permission denied",
|
|
"user_id", c.userID, "channel_id", channelID, "err", err)
|
|
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "missing permission to post in this channel"))
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// buildCommandReply builds an ephemeral command_reply envelope.
|
|
func buildCommandReply(reqID, text string) []byte {
|
|
type payload struct {
|
|
Text string `json:"text"`
|
|
}
|
|
type envelope struct {
|
|
Type string `json:"type"`
|
|
ReqID string `json:"req_id,omitempty"`
|
|
Payload payload `json:"payload"`
|
|
}
|
|
raw, _ := json.Marshal(envelope{
|
|
Type: "command_reply",
|
|
ReqID: reqID,
|
|
Payload: payload{Text: text},
|
|
})
|
|
return raw
|
|
}
|
|
|
|
// buildCommandBroadcast builds a plugin_broadcast envelope sent to a channel.
|
|
func buildCommandBroadcast(channelID, userID int64, cmd, text string) []byte {
|
|
type payload struct {
|
|
ChannelID int64 `json:"channel_id"`
|
|
UserID int64 `json:"user_id"`
|
|
Command string `json:"command"`
|
|
Text string `json:"text"`
|
|
}
|
|
type envelope struct {
|
|
Type string `json:"type"`
|
|
Payload payload `json:"payload"`
|
|
}
|
|
raw, _ := json.Marshal(envelope{
|
|
Type: "plugin_broadcast",
|
|
Payload: payload{
|
|
ChannelID: channelID,
|
|
UserID: userID,
|
|
Command: cmd,
|
|
Text: text,
|
|
},
|
|
})
|
|
return raw
|
|
}
|