mirror of
https://github.com/hanzoai/team.git
synced 2026-09-30 01:57:35 +00:00
* MM-68952: Resolve public channel mentions for non-members under Compliance Channel mention name resolution reused HasPermissionToReadChannel, a content-read check that returns false for non-members of a public channel when Compliance Monitoring is enabled (MM-45272) or when the channel is on another team (MM-66791). As a result, the channel_mentions post prop was stripped per-viewer (since #34235), and the webapp fell back to rendering the raw (anonymized) channel slug instead of a clickable link. Introduce HasPermissionToResolveChannelMention, which exposes only a public channel display name and link (not content) and is therefore independent of ComplianceSettings, while still requiring team membership for public channels (blocks cross-team disclosure) and channel membership for private/DM/GM channels. Switch the three mention call sites (FillInPostProps, sanitizeChannelMentionsForUser, channelMentionsBroadcastHook) to the new helper. HasPermissionToReadChannel and all content-read paths are unchanged. * MM-68952: Add author-side and E2E coverage for channel mention resolution Add a Go test (TestFillInPostPropsChannelMentionResolution) that exercises the author-side persistence of the channel_mentions prop in FillInPostProps. It locks in the new behavior: an author who is a team member but not a member of a referenced public channel now persists the mention prop even when Compliance Monitoring is enabled, while public channels on other teams and private channels the author is not in are still dropped. Add a Playwright spec (channel_mention_resolution.spec.ts) with a license-free cross-team case (a public channel mention stays unresolved for a viewer not on the channel's team) and a license-gated case (with Compliance enabled, a team member who is not in the channel sees the resolved mention link). * MM-68952: Tighten channel mention test assertions and fix lint Use strings.Builder when assembling the test message to avoid the golangci-lint stringsbuilder (modernize) warning about string += string in a loop. Tighten the cross-team public and private non-member cases to assert.Nil on the resolved mentions so the contract requires no persisted channel_mentions map rather than merely an empty one. --------- Co-authored-by: Hanzo Dev <[email protected]>
596 lines
20 KiB
Go
596 lines
20 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package app
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"slices"
|
|
|
|
"github.com/mattermost/mattermost/server/public/model"
|
|
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
|
"github.com/mattermost/mattermost/server/public/shared/request"
|
|
"github.com/mattermost/mattermost/server/v8/channels/app/platform"
|
|
"github.com/mattermost/mattermost/server/v8/channels/store"
|
|
"github.com/pkg/errors"
|
|
)
|
|
|
|
const (
|
|
broadcastAddMentions = "add_mentions"
|
|
broadcastAddFollowers = "add_followers"
|
|
broadcastPostedAck = "posted_ack"
|
|
broadcastPermalink = "permalink"
|
|
broadcastChannelMentions = "channel_mentions"
|
|
broadcastBurnOnRead = "burn_on_read"
|
|
broadcastBurnOnReadReaction = "burn_on_read_reaction"
|
|
broadcastAbacFiles = "abac_files"
|
|
broadcastOnlyChannelAdmins = "only_channel_admins"
|
|
)
|
|
|
|
func (s *Server) makeBroadcastHooks() map[string]platform.BroadcastHook {
|
|
return map[string]platform.BroadcastHook{
|
|
broadcastAddMentions: &addMentionsBroadcastHook{},
|
|
broadcastAddFollowers: &addFollowersBroadcastHook{},
|
|
broadcastPostedAck: &postedAckBroadcastHook{},
|
|
broadcastPermalink: &permalinkBroadcastHook{},
|
|
broadcastChannelMentions: &channelMentionsBroadcastHook{},
|
|
broadcastBurnOnRead: &burnOnReadBroadcastHook{},
|
|
broadcastBurnOnReadReaction: &burnOnReadReactionBroadcastHook{},
|
|
broadcastAbacFiles: &abacFilesBroadcastHook{},
|
|
broadcastOnlyChannelAdmins: &onlyChannelAdminsBroadcastHook{},
|
|
}
|
|
}
|
|
|
|
type addMentionsBroadcastHook struct{}
|
|
|
|
func (h *addMentionsBroadcastHook) Process(msg *platform.HookedWebSocketEvent, webConn *platform.WebConn, args map[string]any) error {
|
|
mentions, err := getTypedArg[model.StringArray](args, "mentions")
|
|
if err != nil {
|
|
return errors.Wrap(err, "Invalid mentions value passed to addMentionsBroadcastHook")
|
|
}
|
|
|
|
if len(mentions) > 0 && slices.Contains(mentions, webConn.UserId) {
|
|
// Note that the client expects this field to be stringified
|
|
msg.Add("mentions", model.ArrayToJSON([]string{webConn.UserId}))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func useAddMentionsHook(message *model.WebSocketEvent, mentionedUsers model.StringArray) {
|
|
message.GetBroadcast().AddHook(broadcastAddMentions, map[string]any{
|
|
"mentions": mentionedUsers,
|
|
})
|
|
}
|
|
|
|
type addFollowersBroadcastHook struct{}
|
|
|
|
func (h *addFollowersBroadcastHook) Process(msg *platform.HookedWebSocketEvent, webConn *platform.WebConn, args map[string]any) error {
|
|
followers, err := getTypedArg[model.StringArray](args, "followers")
|
|
if err != nil {
|
|
return errors.Wrap(err, "Invalid followers value passed to addFollowersBroadcastHook")
|
|
}
|
|
|
|
if len(followers) > 0 && slices.Contains(followers, webConn.UserId) {
|
|
// Note that the client expects this field to be stringified
|
|
msg.Add("followers", model.ArrayToJSON([]string{webConn.UserId}))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func useAddFollowersHook(message *model.WebSocketEvent, followers model.StringArray) {
|
|
message.GetBroadcast().AddHook(broadcastAddFollowers, map[string]any{
|
|
"followers": followers,
|
|
})
|
|
}
|
|
|
|
type postedAckBroadcastHook struct{}
|
|
|
|
func usePostedAckHook(message *model.WebSocketEvent, postedUserId string, channelType model.ChannelType, usersToNotify []string) {
|
|
message.GetBroadcast().AddHook(broadcastPostedAck, map[string]any{
|
|
"posted_user_id": postedUserId,
|
|
"channel_type": channelType,
|
|
"users": usersToNotify,
|
|
})
|
|
}
|
|
|
|
func (h *postedAckBroadcastHook) Process(msg *platform.HookedWebSocketEvent, webConn *platform.WebConn, args map[string]any) error {
|
|
// Don't ACK unless we say to explicitly
|
|
if !(webConn.PostedAck && webConn.Active.Load()) {
|
|
return nil
|
|
}
|
|
|
|
postedUserId, err := getTypedArg[string](args, "posted_user_id")
|
|
if err != nil {
|
|
return errors.Wrap(err, "Invalid posted_user_id value passed to postedAckBroadcastHook")
|
|
}
|
|
|
|
// Don't ACK your own posts
|
|
if postedUserId == webConn.UserId {
|
|
return nil
|
|
}
|
|
|
|
// Add if we have mentions or followers
|
|
// This works since we currently do have an order for broadcast hooks, but this probably should be reworked going forward
|
|
if msg.Get("followers") != nil || msg.Get("mentions") != nil {
|
|
msg.Add("should_ack", true)
|
|
incrementWebsocketCounter(webConn)
|
|
return nil
|
|
}
|
|
|
|
channelType, err := getTypedArg[model.ChannelType](args, "channel_type")
|
|
if err != nil {
|
|
return errors.Wrap(err, "Invalid channel_type value passed to postedAckBroadcastHook")
|
|
}
|
|
|
|
// Always ACK direct channels
|
|
if channelType == model.ChannelTypeDirect {
|
|
msg.Add("should_ack", true)
|
|
incrementWebsocketCounter(webConn)
|
|
return nil
|
|
}
|
|
|
|
users, err := getTypedArg[model.StringArray](args, "users")
|
|
if err != nil {
|
|
return errors.Wrap(err, "Invalid users value passed to postedAckBroadcastHook")
|
|
}
|
|
|
|
if len(users) > 0 && slices.Contains(users, webConn.UserId) {
|
|
msg.Add("should_ack", true)
|
|
incrementWebsocketCounter(webConn)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func usePermalinkHook(message *model.WebSocketEvent, previewChannel *model.Channel, permalinkPreviewedPost *model.PreviewPost, previewProp string) {
|
|
message.GetBroadcast().AddHook(broadcastPermalink, map[string]any{
|
|
"preview_channel": previewChannel,
|
|
"permalink_previewed_post": permalinkPreviewedPost,
|
|
"preview_prop": previewProp,
|
|
})
|
|
}
|
|
|
|
func useBurnOnReadHook(message *model.WebSocketEvent, authorID string, revealedPostJSON, postJSON string) {
|
|
message.GetBroadcast().AddHook(broadcastBurnOnRead, map[string]any{
|
|
"author_id": authorID,
|
|
"post_json": postJSON,
|
|
"revealed_post_json": revealedPostJSON,
|
|
})
|
|
}
|
|
|
|
type permalinkBroadcastHook struct{}
|
|
|
|
// Process adds the post metadata from usePermalinkHook to the websocket event
|
|
// if the user has access to the containing channel.
|
|
// This hook reads from the current post in the message
|
|
// (which may have been modified by other hooks)
|
|
// and adds permalink metadata to it.
|
|
func (h *permalinkBroadcastHook) Process(msg *platform.HookedWebSocketEvent, webConn *platform.WebConn, args map[string]any) error {
|
|
previewChannel, err := getTypedArg[*model.Channel](args, "preview_channel")
|
|
if err != nil {
|
|
return errors.Wrap(err, "Invalid preview_channel value passed to permalinkBroadcastHook")
|
|
}
|
|
|
|
rctx := request.EmptyContext(webConn.Platform.Log())
|
|
ok, isMember := webConn.Suite.HasPermissionToReadChannel(rctx, webConn.UserId, previewChannel)
|
|
if !ok {
|
|
// Do nothing.
|
|
// In this case, the sanitized post is already attached to the ws event.
|
|
return nil
|
|
}
|
|
|
|
// Get the current post from the message (may have been modified by other hooks)
|
|
post, err := getPostFromMessage(msg)
|
|
if err != nil {
|
|
return errors.Wrap(err, "permalinkBroadcastHook failed to get post from message")
|
|
}
|
|
|
|
// Add permalink metadata to the post
|
|
permalinkPreviewedPost, err := getTypedArg[*model.PreviewPost](args, "permalink_previewed_post")
|
|
if err != nil {
|
|
return errors.Wrap(err, "Invalid permalink_previewed_post value passed to permalinkBroadcastHook")
|
|
}
|
|
|
|
previewProp, err := getTypedArg[string](args, "preview_prop")
|
|
if err != nil {
|
|
return errors.Wrap(err, "Invalid preview_prop value passed to permalinkBroadcastHook")
|
|
}
|
|
|
|
// Strip files from the referenced post for recipients denied download access.
|
|
// Clone to avoid mutating the shared hook arg across concurrent recipients.
|
|
// Fail-secure: if the session is nil we cannot evaluate permissions, so strip files.
|
|
embedData := permalinkPreviewedPost
|
|
if permalinkPreviewedPost.Post != nil &&
|
|
(len(permalinkPreviewedPost.Post.FileIds) > 0 ||
|
|
(permalinkPreviewedPost.Post.Metadata != nil && len(permalinkPreviewedPost.Post.Metadata.Files) > 0)) {
|
|
session := webConn.GetSession()
|
|
stripFiles := true
|
|
if session != nil {
|
|
rctxWithSession := rctx.WithSession(session)
|
|
stripFiles = !webConn.Suite.HasPermissionToFileAction(rctxWithSession, webConn.UserId, session.Roles, permalinkPreviewedPost.Post.ChannelId, model.AccessControlPolicyActionDownloadFileAttachment)
|
|
}
|
|
if stripFiles {
|
|
postCopy := permalinkPreviewedPost.Post.Clone()
|
|
if postCopy.Metadata == nil {
|
|
postCopy.Metadata = &model.PostMetadata{}
|
|
}
|
|
actualCount := len(postCopy.Metadata.Files)
|
|
if actualCount == 0 {
|
|
actualCount = len(postCopy.FileIds)
|
|
}
|
|
postCopy.Metadata.RedactedFileCount = actualCount
|
|
postCopy.Metadata.Files = nil
|
|
postCopy.FileIds = model.StringArray{}
|
|
previewCopy := *permalinkPreviewedPost
|
|
previewCopy.Post = postCopy
|
|
embedData = &previewCopy
|
|
}
|
|
}
|
|
|
|
post.AddProp(model.PostPropsPreviewedPost, previewProp)
|
|
post.Metadata.Embeds = append(post.Metadata.Embeds, &model.PostEmbed{Type: model.PostEmbedPermalink, Data: embedData})
|
|
|
|
// Marshal and write back
|
|
updatedPostJSON, err := post.ToJSON()
|
|
if err != nil {
|
|
return errors.Wrap(err, "Failed to marshal post in permalinkBroadcastHook")
|
|
}
|
|
msg.Add("post", updatedPostJSON)
|
|
|
|
auditRec := webConn.Suite.MakeAuditRecord(rctx, model.AuditEventWebsocketPost, model.AuditStatusSuccess)
|
|
defer webConn.Suite.LogAuditRec(rctx, auditRec, nil)
|
|
model.AddEventParameterToAuditRec(auditRec, "channel_id", previewChannel.Id)
|
|
model.AddEventParameterToAuditRec(auditRec, "user_id", webConn.UserId)
|
|
model.AddEventParameterToAuditRec(auditRec, "source", "permalinkBroadcastHook")
|
|
|
|
if !isMember {
|
|
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func useChannelMentionsHook(message *model.WebSocketEvent, channelMentions map[string]any) {
|
|
message.GetBroadcast().AddHook(broadcastChannelMentions, map[string]any{
|
|
"channel_mentions": channelMentions,
|
|
})
|
|
}
|
|
|
|
type channelMentionsBroadcastHook struct{}
|
|
|
|
// Process filters channel mentions from the post based on the recipient's permissions.
|
|
// This hook works with the current post in the message (which may have been modified by other hooks)
|
|
// and filters channel mentions so that only channels the recipient can read are included.
|
|
func (h *channelMentionsBroadcastHook) Process(msg *platform.HookedWebSocketEvent, webConn *platform.WebConn, args map[string]any) error {
|
|
channelMentions, err := getTypedArg[map[string]any](args, "channel_mentions")
|
|
if err != nil {
|
|
return errors.Wrap(err, "Invalid channel_mentions value passed to channelMentionsBroadcastHook")
|
|
}
|
|
|
|
// If no channel mentions, nothing to filter
|
|
if len(channelMentions) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Get the current post from the message (might have been modified by other hooks like permalink)
|
|
post, err := getPostFromMessage(msg)
|
|
if err != nil {
|
|
return errors.Wrap(err, "channelMentionsBroadcastHook failed to get post from message")
|
|
}
|
|
|
|
// Filter channel mentions based on recipient's permissions
|
|
rctx := request.EmptyContext(webConn.Platform.Log())
|
|
filteredMentions := make(map[string]any)
|
|
|
|
for channelName, channelInfo := range channelMentions {
|
|
// Extract channel ID from the channel info map
|
|
channelInfoMap, ok := channelInfo.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
channelID, ok := channelInfoMap["id"].(string)
|
|
if !ok || channelID == "" {
|
|
continue
|
|
}
|
|
|
|
// Resolve the mention if the recipient may see the channel's name/link.
|
|
channel, appErr := webConn.Platform.Store.Channel().Get(channelID, true)
|
|
if appErr != nil {
|
|
// If we can't get the channel, don't include the mention
|
|
continue
|
|
}
|
|
|
|
if webConn.Suite.HasPermissionToResolveChannelMention(rctx, webConn.UserId, channel) {
|
|
filteredMentions[channelName] = channelInfo
|
|
}
|
|
}
|
|
|
|
// Update the post with filtered channel mentions
|
|
if len(filteredMentions) > 0 {
|
|
post.AddProp(model.PostPropsChannelMentions, filteredMentions)
|
|
} else {
|
|
post.DelProp(model.PostPropsChannelMentions)
|
|
}
|
|
|
|
updatedPostJSON, err := post.ToJSON()
|
|
if err != nil {
|
|
return errors.Wrap(err, "Failed to marshal post in channelMentionsBroadcastHook")
|
|
}
|
|
|
|
msg.Add("post", updatedPostJSON)
|
|
|
|
return nil
|
|
}
|
|
|
|
type burnOnReadBroadcastHook struct{}
|
|
|
|
func (h *burnOnReadBroadcastHook) Process(msg *platform.HookedWebSocketEvent, webConn *platform.WebConn, args map[string]any) error {
|
|
userID := webConn.UserId
|
|
authorID, err := getTypedArg[string](args, "author_id")
|
|
if err != nil {
|
|
return errors.Wrap(err, "Invalid author_id value passed to burnOnReadBroadcastHook")
|
|
}
|
|
if userID == authorID {
|
|
postJSON, tErr := getTypedArg[string](args, "revealed_post_json")
|
|
if tErr != nil {
|
|
return errors.Wrap(tErr, "Invalid revealed_post_json value passed to burnOnReadBroadcastHook")
|
|
}
|
|
msg.Add("post", postJSON)
|
|
return nil
|
|
}
|
|
|
|
postJSON, err := getTypedArg[string](args, "post_json")
|
|
if err != nil {
|
|
return errors.Wrap(err, "Invalid post_json value passed to burnOnReadBroadcastHook")
|
|
}
|
|
|
|
var post model.Post
|
|
err = json.Unmarshal([]byte(postJSON), &post)
|
|
if err != nil {
|
|
return errors.Wrap(err, "Invalid post value passed to burnOnReadBroadcastHook")
|
|
}
|
|
post.Metadata.Embeds = []*model.PostEmbed{}
|
|
post.Metadata.Emojis = []*model.Emoji{}
|
|
post.Metadata.Reactions = []*model.Reaction{}
|
|
post.Metadata.Files = nil
|
|
post.FileIds = model.StringArray{}
|
|
postJSON, err = post.ToJSON()
|
|
if err != nil {
|
|
return errors.Wrap(err, "Invalid post value passed to burnOnReadBroadcastHook")
|
|
}
|
|
|
|
msg.Add("post", postJSON)
|
|
|
|
return nil
|
|
}
|
|
|
|
type burnOnReadReactionBroadcastHook struct{}
|
|
|
|
func (h *burnOnReadReactionBroadcastHook) Process(msg *platform.HookedWebSocketEvent, webConn *platform.WebConn, args map[string]any) error {
|
|
userID := webConn.UserId
|
|
authorID, err := getTypedArg[string](args, "author_id")
|
|
if err != nil {
|
|
return errors.Wrap(err, "Invalid author_id value passed to burnOnReadReactionBroadcastHook")
|
|
}
|
|
|
|
// If user is the author, they can always see reactions
|
|
if userID == authorID {
|
|
return nil
|
|
}
|
|
|
|
postID, err := getTypedArg[string](args, "post_id")
|
|
if err != nil {
|
|
return errors.Wrap(err, "Invalid post_id value passed to burnOnReadReactionBroadcastHook")
|
|
}
|
|
|
|
// Check if user has a valid read receipt
|
|
rctx := request.EmptyContext(webConn.Platform.Log())
|
|
receipt, err := webConn.Platform.Store.ReadReceipt().Get(rctx, postID, userID)
|
|
if err != nil && !store.IsErrNotFound(err) {
|
|
return errors.Wrap(err, "Failed to get read receipt in burnOnReadReactionBroadcastHook")
|
|
}
|
|
|
|
// If no receipt or receipt expired, remove reaction data
|
|
if receipt == nil || receipt.ExpireAt < model.GetMillis() {
|
|
msg.Event().Reject()
|
|
return nil
|
|
}
|
|
|
|
// User has valid receipt, allow the reaction event
|
|
return nil
|
|
}
|
|
|
|
func useBurnOnReadReactionHook(message *model.WebSocketEvent, authorID, postID string) {
|
|
message.GetBroadcast().AddHook(broadcastBurnOnReadReaction, map[string]any{
|
|
"author_id": authorID,
|
|
"post_id": postID,
|
|
})
|
|
}
|
|
|
|
type abacFilesBroadcastHook struct{}
|
|
|
|
func useAbacFilesHook(message *model.WebSocketEvent, channelID string, fileCount int) {
|
|
message.GetBroadcast().AddHook(broadcastAbacFiles, map[string]any{
|
|
"channel_id": channelID,
|
|
"file_count": fileCount,
|
|
})
|
|
}
|
|
|
|
// Process strips file metadata for recipients denied the download_file_attachment action.
|
|
// Once a deny decision is made, all subsequent errors strip defensively (fail closed).
|
|
// Errors before the permission check allow pass-through (no decision made yet).
|
|
func (h *abacFilesBroadcastHook) Process(msg *platform.HookedWebSocketEvent, webConn *platform.WebConn, args map[string]any) error {
|
|
channelID, err := getTypedArg[string](args, "channel_id")
|
|
if err != nil {
|
|
return errors.Wrap(err, "Invalid channel_id value passed to abacFilesBroadcastHook")
|
|
}
|
|
|
|
fileCount, err := getTypedArg[int](args, "file_count")
|
|
if err != nil {
|
|
return errors.Wrap(err, "Invalid file_count value passed to abacFilesBroadcastHook")
|
|
}
|
|
|
|
// The ABAC evaluator resolves role from rctx.Session(), not Subject.Role.
|
|
// Fail-secure: if the session is nil we cannot evaluate permissions, so strip files.
|
|
session := webConn.GetSession()
|
|
if session != nil {
|
|
rctx := request.EmptyContext(webConn.Platform.Log()).WithSession(session)
|
|
if webConn.Suite.HasPermissionToFileAction(rctx, webConn.UserId, session.Roles, channelID, model.AccessControlPolicyActionDownloadFileAttachment) {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
post, postErr := getPostFromMessage(msg)
|
|
if postErr != nil {
|
|
mlog.Warn("abacFilesBroadcastHook: failed to deserialise post, stripping defensively",
|
|
mlog.String("user_id", webConn.UserId),
|
|
mlog.Err(postErr),
|
|
)
|
|
return h.stripFilesFromMessage(msg, fileCount)
|
|
}
|
|
|
|
if post.Metadata == nil {
|
|
post.Metadata = &model.PostMetadata{}
|
|
}
|
|
|
|
// Use live count; fall back to registered arg if a prior hook already cleared Files.
|
|
actualCount := len(post.Metadata.Files)
|
|
if actualCount == 0 {
|
|
actualCount = len(post.FileIds)
|
|
}
|
|
if actualCount == 0 {
|
|
actualCount = fileCount
|
|
}
|
|
|
|
post.Metadata.RedactedFileCount = actualCount
|
|
post.Metadata.Files = nil
|
|
post.FileIds = model.StringArray{}
|
|
|
|
updatedPostJSON, jsonErr := post.ToJSON()
|
|
if jsonErr != nil {
|
|
mlog.Warn("abacFilesBroadcastHook: failed to marshal post, stripping defensively",
|
|
mlog.String("user_id", webConn.UserId),
|
|
mlog.Err(jsonErr),
|
|
)
|
|
return h.stripFilesFromMessage(msg, actualCount)
|
|
}
|
|
|
|
msg.Add("post", updatedPostJSON)
|
|
return nil
|
|
}
|
|
|
|
// stripFilesFromMessage is the fallback strip path when the post cannot be fully reconstructed.
|
|
func (h *abacFilesBroadcastHook) stripFilesFromMessage(msg *platform.HookedWebSocketEvent, redactedCount int) error {
|
|
post, err := getPostFromMessage(msg)
|
|
if err != nil {
|
|
mlog.Warn("abacFilesBroadcastHook: failed to parse post in fallback strip; rejecting event", mlog.Err(err))
|
|
msg.Event().Reject()
|
|
return nil
|
|
}
|
|
if post.Metadata == nil {
|
|
post.Metadata = &model.PostMetadata{}
|
|
}
|
|
post.Metadata.RedactedFileCount = redactedCount
|
|
post.Metadata.Files = nil
|
|
post.FileIds = model.StringArray{}
|
|
if postJSON, jsonErr := post.ToJSON(); jsonErr == nil {
|
|
msg.Add("post", postJSON)
|
|
} else {
|
|
mlog.Warn("abacFilesBroadcastHook: failed to marshal post in fallback strip; rejecting event", mlog.Err(jsonErr))
|
|
msg.Event().Reject()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// onlyChannelAdminsBroadcastHook narrows a channel-scoped broadcast to the
|
|
// channel-admin subset of the channel's members. The hook arg
|
|
// `channel_admin_user_ids` is the precomputed list of admin user ids at publish
|
|
// time; recipients not in that set have the event rejected.
|
|
//
|
|
// Pair with `Broadcast{ChannelId: channelId}` so the platform's existing
|
|
// channel-member fan-out is the outer bound and this hook simply filters
|
|
// non-admin members out.
|
|
type onlyChannelAdminsBroadcastHook struct{}
|
|
|
|
func useOnlyChannelAdminsHook(message *model.WebSocketEvent, channelAdminUserIds []string) {
|
|
message.GetBroadcast().AddHook(broadcastOnlyChannelAdmins, map[string]any{
|
|
"channel_admin_user_ids": model.StringArray(channelAdminUserIds),
|
|
})
|
|
}
|
|
|
|
func (h *onlyChannelAdminsBroadcastHook) Process(msg *platform.HookedWebSocketEvent, webConn *platform.WebConn, args map[string]any) error {
|
|
adminUserIDs, err := getTypedArg[model.StringArray](args, "channel_admin_user_ids")
|
|
if err != nil {
|
|
return errors.Wrap(err, "Invalid channel_admin_user_ids value passed to onlyChannelAdminsBroadcastHook")
|
|
}
|
|
|
|
if !slices.Contains(adminUserIDs, webConn.UserId) {
|
|
msg.Event().Reject()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func incrementWebsocketCounter(wc *platform.WebConn) {
|
|
if wc.Platform.Metrics() == nil {
|
|
return
|
|
}
|
|
|
|
if !(wc.Platform.Config().FeatureFlags.NotificationMonitoring && *wc.Platform.Config().MetricsSettings.EnableNotificationMetrics) {
|
|
return
|
|
}
|
|
|
|
wc.Platform.Metrics().IncrementNotificationCounter(model.NotificationTypeWebsocket, model.NotificationNoPlatform)
|
|
}
|
|
|
|
// getPostFromMessage extracts and unmarshals the current post from the WebSocket message.
|
|
// This is used by hooks that need to read and modify the post in the message.
|
|
func getPostFromMessage(msg *platform.HookedWebSocketEvent) (*model.Post, error) {
|
|
currentPostData := msg.Get("post")
|
|
if currentPostData == nil {
|
|
return nil, errors.New("No post found in message")
|
|
}
|
|
|
|
currentPostJSON, ok := currentPostData.(string)
|
|
if !ok {
|
|
return nil, errors.New("Invalid post type in message")
|
|
}
|
|
|
|
var post model.Post
|
|
err := json.Unmarshal([]byte(currentPostJSON), &post)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "Failed to unmarshal post")
|
|
}
|
|
|
|
return &post, nil
|
|
}
|
|
|
|
// getTypedArg returns a correctly typed hook argument with the given key, reinterpreting the type using JSON encoding
|
|
// if necessary. This is needed because broadcast hook args are JSON encoded in a multi-server environment, and any
|
|
// type information is lost because those types aren't known at decode time.
|
|
func getTypedArg[T any](args map[string]any, key string) (T, error) {
|
|
var value T
|
|
|
|
untyped, ok := args[key]
|
|
if !ok {
|
|
return value, fmt.Errorf("No argument found with key: %s", key)
|
|
}
|
|
|
|
// If the value is already correct, just return it
|
|
if typed, ok := untyped.(T); ok {
|
|
return typed, nil
|
|
}
|
|
|
|
// Marshal and unmarshal the data with the correct typing information
|
|
buf, err := json.Marshal(untyped)
|
|
if err != nil {
|
|
return value, err
|
|
}
|
|
|
|
err = json.Unmarshal(buf, &value)
|
|
return value, err
|
|
}
|