manual cherrypick: [MM-67130] Fix permalink preview permissions (#34909) (#35114)

* manual cherrypick: [MM-67130] Fix permalink preview permissions (#34909)

* remove permalink embeds when user loses access to orginating channel

* remove posts & embeds on team_leave event; simplify preview index.ts

* cleanup

* remove dead code

* more dead code elimination

* linter
Этот коммит содержится в:
Christopher Poile
2026-01-29 15:41:37 -05:00
коммит произвёл GitHub
родитель f894103741
Коммит f626d98799
3 изменённых файлов: 315 добавлений и 17 удалений

Просмотреть файл

@@ -918,7 +918,8 @@ export function handleLeaveTeamEvent(msg) {
const currentUser = getCurrentUser(state);
if (currentUser.id === msg.data.user_id) {
dispatch({type: TeamTypes.LEAVE_TEAM, data: {id: msg.data.team_id}});
// Include channel IDs so reducers can clean up posts/embeds for those channels
dispatch({type: TeamTypes.LEAVE_TEAM, data: {id: msg.data.team_id, channelIds: channels}});
// if they are on the team being removed redirect them to default team
if (getCurrentTeamId(state) === msg.data.team_id) {

Просмотреть файл

@@ -6,6 +6,7 @@ import type {Post, PostOrderBlock} from '@mattermost/types/posts';
import {
ChannelTypes,
PostTypes,
TeamTypes,
ThreadTypes,
CloudTypes,
} from 'mattermost-redux/action_types';
@@ -538,6 +539,265 @@ describe('posts', () => {
});
}
describe('when a channel is left (LEAVE_CHANNEL)', () => {
it('MM-67130 should remove permalink embed referencing the left channel', () => {
const state = deepFreeze({
post1: {
id: 'post1',
channel_id: 'channel2',
metadata: {
embeds: [{
type: 'permalink',
data: {
post_id: 'linked_post',
channel_id: 'channel1',
post: {id: 'linked_post', message: 'secret message'},
},
}],
},
},
post2: {id: 'post2', channel_id: 'channel2'},
});
const nextState = reducers.handlePosts(state, {
type: ChannelTypes.LEAVE_CHANNEL,
data: {
id: 'channel1',
viewArchivedChannels: false,
},
});
expect(nextState).not.toBe(state);
expect(nextState.post2).toBe(state.post2);
expect(nextState.post1.metadata.embeds).toHaveLength(0);
});
it('MM-67130 should not modify posts with embeds referencing other channels', () => {
const state = deepFreeze({
post1: {
id: 'post1',
channel_id: 'channel2',
metadata: {
embeds: [{
type: 'permalink',
data: {
post_id: 'linked_post',
channel_id: 'channel3',
post: {id: 'linked_post', message: 'other message'},
},
}],
},
},
});
const nextState = reducers.handlePosts(state, {
type: ChannelTypes.LEAVE_CHANNEL,
data: {
id: 'channel1',
viewArchivedChannels: false,
},
});
expect(nextState).toBe(state);
});
it('MM-67130 should handle posts with multiple embeds, only removing affected permalinks', () => {
const state = deepFreeze({
post1: {
id: 'post1',
channel_id: 'channel2',
metadata: {
embeds: [
{
type: 'opengraph',
url: 'https://example.com',
},
{
type: 'permalink',
data: {
post_id: 'linked_post1',
channel_id: 'channel1',
post: {id: 'linked_post1', message: 'secret'},
},
},
{
type: 'permalink',
data: {
post_id: 'linked_post2',
channel_id: 'channel3',
post: {id: 'linked_post2', message: 'keep this'},
},
},
],
},
},
});
const nextState = reducers.handlePosts(state, {
type: ChannelTypes.LEAVE_CHANNEL,
data: {
id: 'channel1',
viewArchivedChannels: false,
},
});
expect(nextState).not.toBe(state);
expect(nextState.post1.metadata.embeds).toHaveLength(2);
expect(nextState.post1.metadata.embeds[0]).toBe(state.post1.metadata.embeds[0]); // opengraph preserved
expect(nextState.post1.metadata.embeds[1]).toBe(state.post1.metadata.embeds[2]); // channel3 permalink preserved
});
});
describe(`leaving a team (${TeamTypes.LEAVE_TEAM})`, () => {
it('MM-67130 should remove posts from channels in the left team', () => {
// Team A: channel_teamA (user stays here)
// Team B: channel_teamB (user leaves this team)
const state = deepFreeze({
post1: {id: 'post1', channel_id: 'channel_teamA'},
post2: {id: 'post2', channel_id: 'channel_teamB'},
post3: {id: 'post3', channel_id: 'channel_teamB'},
});
const nextState = reducers.handlePosts(state, {
type: TeamTypes.LEAVE_TEAM,
data: {
id: 'teamB',
channelIds: ['channel_teamB'],
},
});
expect(nextState).not.toBe(state);
expect(nextState.post1).toBe(state.post1);
expect(nextState.post2).toBeUndefined();
expect(nextState.post3).toBeUndefined();
});
it('MM-67130 should remove permalink embeds referencing channels in the left team', () => {
// Scenario: User is on Team A and Team B
// - Post in Team A's channel has a permalink to a post in Team B's channel
// - When user leaves Team B, the permalink embed should be removed
const state = deepFreeze({
post_in_teamA: {
id: 'post_in_teamA',
channel_id: 'channel_teamA',
metadata: {
embeds: [{
type: 'permalink',
data: {
post_id: 'secret_post',
channel_id: 'channel_teamB',
post: {id: 'secret_post', message: 'secret message from Team B'},
},
}],
},
},
other_post: {id: 'other_post', channel_id: 'channel_teamA'},
});
const nextState = reducers.handlePosts(state, {
type: TeamTypes.LEAVE_TEAM,
data: {
id: 'teamB',
channelIds: ['channel_teamB'],
},
});
expect(nextState).not.toBe(state);
expect(nextState.other_post).toBe(state.other_post);
expect(nextState.post_in_teamA.metadata.embeds).toHaveLength(0);
});
it('MM-67130 should handle leaving team with multiple channels', () => {
// Team B has multiple channels, all should be cleaned up
const state = deepFreeze({
post_in_teamA: {
id: 'post_in_teamA',
channel_id: 'channel_teamA',
metadata: {
embeds: [
{
type: 'permalink',
data: {
post_id: 'post1',
channel_id: 'channel_teamB_1',
post: {id: 'post1', message: 'secret 1'},
},
},
{
type: 'permalink',
data: {
post_id: 'post2',
channel_id: 'channel_teamB_2',
post: {id: 'post2', message: 'secret 2'},
},
},
{
type: 'permalink',
data: {
post_id: 'post3',
channel_id: 'channel_teamA',
post: {id: 'post3', message: 'keep this'},
},
},
],
},
},
post_teamB_1: {id: 'post_teamB_1', channel_id: 'channel_teamB_1'},
post_teamB_2: {id: 'post_teamB_2', channel_id: 'channel_teamB_2'},
});
const nextState = reducers.handlePosts(state, {
type: TeamTypes.LEAVE_TEAM,
data: {
id: 'teamB',
channelIds: ['channel_teamB_1', 'channel_teamB_2'],
},
});
expect(nextState).not.toBe(state);
// Posts from Team B channels should be removed
expect(nextState.post_teamB_1).toBeUndefined();
expect(nextState.post_teamB_2).toBeUndefined();
// Only the permalink to Team A's channel should remain
expect(nextState.post_in_teamA.metadata.embeds).toHaveLength(1);
expect(nextState.post_in_teamA.metadata.embeds[0].data.channel_id).toBe('channel_teamA');
});
it('MM-67130 should handle LEAVE_TEAM with no channelIds (no-op)', () => {
const state = deepFreeze({
post1: {id: 'post1', channel_id: 'channel1'},
});
// channelIds not provided (e.g., from other dispatch sites)
const nextState = reducers.handlePosts(state, {
type: TeamTypes.LEAVE_TEAM,
data: {
id: 'teamB',
},
});
expect(nextState).toBe(state);
});
it('MM-67130 should handle LEAVE_TEAM with empty channelIds array', () => {
const state = deepFreeze({
post1: {id: 'post1', channel_id: 'channel1'},
});
const nextState = reducers.handlePosts(state, {
type: TeamTypes.LEAVE_TEAM,
data: {
id: 'teamB',
channelIds: [],
},
});
expect(nextState).toBe(state);
});
});
describe(`follow a post/thread (${ThreadTypes.FOLLOW_CHANGED_THREAD})`, () => {
test.each([[true], [false]])('should set is_following to %s', (following) => {
const state = deepFreeze({

Просмотреть файл

@@ -20,7 +20,7 @@ import type {
} from '@mattermost/types/utilities';
import type {MMReduxAction} from 'mattermost-redux/action_types';
import {ChannelTypes, PostTypes, UserTypes, ThreadTypes, CloudTypes} from 'mattermost-redux/action_types';
import {ChannelTypes, PostTypes, UserTypes, ThreadTypes, CloudTypes, TeamTypes} from 'mattermost-redux/action_types';
import {Posts} from 'mattermost-redux/constants';
import {comparePosts, isPermalink, shouldUpdatePost} from 'mattermost-redux/utils/post_utils';
@@ -156,6 +156,52 @@ export function nextPostsReplies(state: {[x in Post['id']]: number} = {}, action
}
}
// Helper function to remove posts and permalink embeds for a set of channel IDs.
function removePostsAndEmbedsForChannels(state: IDMappedObjects<Post>, channelIds: Set<string>): IDMappedObjects<Post> {
let postModified = false;
const nextState = {...state};
for (const post of Object.values(state)) {
// Remove posts from the channels
if (channelIds.has(post.channel_id)) {
Reflect.deleteProperty(nextState, post.id);
postModified = true;
continue;
}
// Remove permalink embeds referencing those channels (matches server behavior)
if (post.metadata?.embeds?.length) {
const newEmbeds: PostEmbed[] = [];
let embedRemoved = false;
for (const embed of post.metadata.embeds) {
if (embed.type === 'permalink' && embed.data && channelIds.has((embed.data as PostPreviewMetadata).channel_id)) {
embedRemoved = true;
} else {
newEmbeds.push(embed);
}
}
if (embedRemoved) {
nextState[post.id] = {
...nextState[post.id],
metadata: {
...nextState[post.id].metadata,
embeds: newEmbeds,
},
};
postModified = true;
}
}
}
if (!postModified) {
return state;
}
return nextState;
}
export function handlePosts(state: IDMappedObjects<Post> = {}, action: MMReduxAction) {
switch (action.type) {
case PostTypes.RECEIVED_POST:
@@ -288,24 +334,15 @@ export function handlePosts(state: IDMappedObjects<Post> = {}, action: MMReduxAc
}
const channelId = action.data.id;
return removePostsAndEmbedsForChannels(state, new Set([channelId]));
}
let postDeleted = false;
// Remove any posts in the deleted channel
const nextState = {...state};
for (const post of Object.values(state)) {
if (post.channel_id === channelId) {
Reflect.deleteProperty(nextState, post.id);
postDeleted = true;
}
}
if (!postDeleted) {
// Nothing changed
case TeamTypes.LEAVE_TEAM: {
const channelIds: string[] = action.data.channelIds || [];
if (channelIds.length === 0) {
return state;
}
return nextState;
return removePostsAndEmbedsForChannels(state, new Set(channelIds));
}
case ThreadTypes.FOLLOW_CHANGED_THREAD: {