1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-08-09 10:22:46 +03:00

Redact on ban: Client implementation (#4867)

* First pass implementation

* fix naming/docs

* apply lint

* Add test for existing behaviour

* Add happy path tests

* Fix bug identified by tests

* ... and this is why we add negative tests too

* Add some sanity tests

* Apply linter
This commit is contained in:
Travis Ralston
2025-06-10 08:28:02 -06:00
committed by GitHub
parent d438e25f87
commit cdece6cb9f
2 changed files with 287 additions and 50 deletions

View File

@@ -0,0 +1,188 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Direction, type MatrixClient, MatrixEvent, Room } from "../../../src";
import type { MockedObject } from "jest-mock";
const CREATOR_USER_ID = "@creator:example.org";
const MODERATOR_USER_ID = "@moderator:example.org";
describe("Room", () => {
function createMockClient(): MatrixClient {
return {
supportsThreads: jest.fn().mockReturnValue(true),
decryptEventIfNeeded: jest.fn().mockReturnThis(),
getUserId: jest.fn().mockReturnValue(CREATOR_USER_ID),
} as unknown as MockedObject<MatrixClient>;
}
function createEvent(eventId: string): MatrixEvent {
return new MatrixEvent({
type: "m.room.message",
content: {
body: eventId, // we do this for ease of use, not practicality
},
event_id: eventId,
sender: CREATOR_USER_ID,
});
}
function createRedaction(redactsEventId: string): MatrixEvent {
return new MatrixEvent({
type: "m.room.redaction",
redacts: redactsEventId,
event_id: "$redacts_" + redactsEventId.substring(1),
sender: CREATOR_USER_ID,
});
}
function getNonStateMainTimelineLiveEvents(room: Room): Array<MatrixEvent> {
return room
.getLiveTimeline()
.getEvents()
.filter((e) => !e.isState());
}
it("should apply redactions locally", async () => {
const mockClient = createMockClient();
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
const messageEvent = createEvent("$message_event");
// Set up the room
await room.addLiveEvents([messageEvent], { addToState: false });
let timeline = getNonStateMainTimelineLiveEvents(room);
expect(timeline.length).toEqual(1);
expect(timeline[0].getId()).toEqual(messageEvent.getId());
expect(timeline[0].isRedacted()).toEqual(false); // "should never happen"
// Now redact
const redactionEvent = createRedaction(messageEvent.getId()!);
await room.addLiveEvents([redactionEvent], { addToState: false });
timeline = getNonStateMainTimelineLiveEvents(room);
expect(timeline.length).toEqual(2);
expect(timeline[0].getId()).toEqual(messageEvent.getId());
expect(timeline[0].isRedacted()).toEqual(true); // test case
expect(timeline[1].getId()).toEqual(redactionEvent.getId());
expect(timeline[1].isRedacted()).toEqual(false); // "should never happen"
});
describe("MSC4293: Redact on ban", () => {
async function setupRoom(andGrantPermissions: boolean): Promise<{ room: Room; messageEvents: MatrixEvent[] }> {
const mockClient = createMockClient();
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
// Pre-populate room
const messageEvents: MatrixEvent[] = [];
for (let i = 0; i < 3; i++) {
messageEvents.push(createEvent(`$message_${i}`));
}
await room.addLiveEvents(messageEvents, { addToState: false });
if (andGrantPermissions) {
room.getLiveTimeline().getState(Direction.Forward)!.maySendRedactionForEvent = (ev, userId) => {
return true;
};
}
return { room, messageEvents };
}
function createRedactOnMembershipChange(
targetUserId: string,
senderUserId: string,
membership: string,
): MatrixEvent {
return new MatrixEvent({
type: "m.room.member",
state_key: targetUserId,
content: {
"membership": membership,
"org.matrix.msc4293.redact_events": true,
},
sender: senderUserId,
});
}
function expectRedacted(messageEvents: MatrixEvent[], room: Room, shouldAllBeRedacted: boolean) {
const actualEvents = getNonStateMainTimelineLiveEvents(room).filter((e) =>
messageEvents.find((e2) => e2.getId() === e.getId()),
);
expect(actualEvents.length).toEqual(messageEvents.length);
const redactedEvents = actualEvents.filter((e) => e.isRedacted());
if (shouldAllBeRedacted) {
expect(redactedEvents.length).toEqual(messageEvents.length);
} else {
expect(redactedEvents.length).toEqual(0);
}
}
it("should apply on ban", async () => {
const { room, messageEvents } = await setupRoom(true);
const banEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "ban");
await room.addLiveEvents([banEvent], { addToState: true });
expectRedacted(messageEvents, room, true);
});
it("should apply on kick", async () => {
const { room, messageEvents } = await setupRoom(true);
const kickEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "leave");
await room.addLiveEvents([kickEvent], { addToState: true });
expectRedacted(messageEvents, room, true);
});
it("should not apply if the user doesn't have permission to redact", async () => {
const { room, messageEvents } = await setupRoom(false); // difference from other tests here
const banEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "ban");
await room.addLiveEvents([banEvent], { addToState: true });
expectRedacted(messageEvents, room, false);
});
it("should not apply to self-leaves", async () => {
const { room, messageEvents } = await setupRoom(true);
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "leave");
await room.addLiveEvents([leaveEvent], { addToState: true });
expectRedacted(messageEvents, room, false);
});
it("should not apply to invites", async () => {
const { room, messageEvents } = await setupRoom(true);
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "invite");
await room.addLiveEvents([leaveEvent], { addToState: true });
expectRedacted(messageEvents, room, false);
});
it("should not apply to joins", async () => {
const { room, messageEvents } = await setupRoom(true);
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "join");
await room.addLiveEvents([leaveEvent], { addToState: true });
expectRedacted(messageEvents, room, false);
});
it("should not apply to knocks", async () => {
const { room, messageEvents } = await setupRoom(true);
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "knock");
await room.addLiveEvents([leaveEvent], { addToState: true });
expectRedacted(messageEvents, room, false);
});
});
});

View File

@@ -17,15 +17,15 @@ limitations under the License.
import { M_POLL_START, type Optional } from "matrix-events-sdk";
import {
EventTimelineSet,
DuplicateStrategy,
type IAddLiveEventOptions,
EventTimelineSet,
type EventTimelineSetHandlerMap,
type IAddLiveEventOptions,
} from "./event-timeline-set.ts";
import { Direction, EventTimeline } from "./event-timeline.ts";
import { getHttpUriForMxc } from "../content-repo.ts";
import { removeElement } from "../utils.ts";
import { normalize, noUnsafeEventProps } from "../utils.ts";
import * as utils from "../utils.ts";
import { normalize, noUnsafeEventProps, removeElement } from "../utils.ts";
import {
type IEvent,
type IThreadBundledRelationship,
@@ -35,17 +35,17 @@ import {
} from "./event.ts";
import { EventStatus } from "./event-status.ts";
import { RoomMember } from "./room-member.ts";
import { type IRoomSummary, type Hero, RoomSummary } from "./room-summary.ts";
import { type Hero, type IRoomSummary, RoomSummary } from "./room-summary.ts";
import { logger } from "../logger.ts";
import { TypedReEmitter } from "../ReEmitter.ts";
import {
EVENT_VISIBILITY_CHANGE_TYPE,
EventType,
RelationType,
RoomCreateTypeField,
RoomType,
UNSTABLE_ELEMENT_FUNCTIONAL_USERS,
EVENT_VISIBILITY_CHANGE_TYPE,
RelationType,
UNSIGNED_THREAD_ID_FIELD,
UNSTABLE_ELEMENT_FUNCTIONAL_USERS,
} from "../@types/event.ts";
import { type MatrixClient, PendingEventOrdering } from "../client.ts";
import { type GuestAccess, type HistoryVisibility, type JoinRule, type ResizeMethod } from "../@types/partials.ts";
@@ -53,12 +53,12 @@ import { Filter, type IFilterDefinition } from "../filter.ts";
import { type RoomState, RoomStateEvent, type RoomStateEventHandlerMap } from "./room-state.ts";
import { BeaconEvent, type BeaconEventHandlerMap } from "./beacon.ts";
import {
FILTER_RELATED_BY_REL_TYPES,
FILTER_RELATED_BY_SENDERS,
Thread,
THREAD_RELATION_TYPE,
ThreadEvent,
type ThreadEventHandlerMap as ThreadHandlerMap,
FILTER_RELATED_BY_REL_TYPES,
THREAD_RELATION_TYPE,
FILTER_RELATED_BY_SENDERS,
ThreadFilterType,
} from "./thread.ts";
import {
@@ -74,7 +74,6 @@ import { ReadReceipt, synthesizeReceipt } from "./read-receipt.ts";
import { isPollEvent, Poll, PollEvent } from "./poll.ts";
import { RoomReceipts } from "./room-receipts.ts";
import { compareEventOrdering } from "./compare-event-ordering.ts";
import * as utils from "../utils.ts";
import { KnownMembership, type Membership } from "../@types/membership.ts";
import { type Capabilities, type IRoomVersionsCapability, RoomVersionStability } from "../serverCapabilities.ts";
import { type MSC4186Hero } from "../sliding-sync.ts";
@@ -2469,7 +2468,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* Adds events to a thread's timeline. Will fire "Thread.update"
*/
public processThreadedEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void {
events.forEach(this.applyRedaction);
events.forEach(this.tryApplyRedaction);
const eventsByThread: { [threadId: string]: MatrixEvent[] } = {};
for (const event of events) {
@@ -2580,15 +2579,19 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
return thread;
}
private applyRedaction = (event: MatrixEvent): void => {
if (event.isRedaction()) {
const redactId = event.event.redacts;
// if we know about this event, redact its contents now.
const redactedEvent = redactId ? this.findEventById(redactId) : undefined;
if (redactedEvent) {
/**
* Applies an event as a redaction of another event, regardless of whether the redacting
* event is actually a redaction.
*
* Callers should use tryApplyRedaction instead.
*
* @param redactionEvent The event which redacts an event.
* @param redactedEvent The event being redacted.
* @private
*/
private applyEventAsRedaction(redactionEvent: MatrixEvent, redactedEvent: MatrixEvent): void {
const threadRootId = redactedEvent.threadRootId;
redactedEvent.makeRedacted(event, this);
redactedEvent.makeRedacted(redactionEvent, this);
// If this is in the current state, replace it with the redacted version
if (redactedEvent.isState()) {
@@ -2601,7 +2604,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
}
this.emit(RoomEvent.Redaction, event, this, threadRootId);
this.emit(RoomEvent.Redaction, redactionEvent, this, threadRootId);
// TODO: we stash user displaynames (among other things) in
// RoomMember objects which are then attached to other events
@@ -2610,25 +2613,71 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
// they are based on are changed.
// Remove any visibility change on this event.
this.visibilityEvents.delete(redactId!);
this.visibilityEvents.delete(redactedEvent.getId()!);
// If this event is a visibility change event, remove it from the
// list of visibility changes and update any event affected by it.
if (redactedEvent.isVisibilityEvent()) {
this.redactVisibilityChangeEvent(event);
this.redactVisibilityChangeEvent(redactionEvent);
}
}
private tryApplyRedaction = (event: MatrixEvent): void => {
// FIXME: apply redactions to notification list
// NB: We continue to add the redaction event to the timeline so
// clients can say "so and so redacted an event" if they wish to. Also
// this may be needed to trigger an update.
// NB: We continue to add the redaction event to the timeline at the
// end of this function so clients can say "so and so redacted an event"
// if they wish to. Also this may be needed to trigger an update.
if (event.isRedaction()) {
const redactId = event.event.redacts;
// if we know about this event, redact its contents now.
const redactedEvent = redactId ? this.findEventById(redactId) : undefined;
if (redactedEvent) {
this.applyEventAsRedaction(event, redactedEvent);
}
} else if (event.getType() === EventType.RoomMember) {
const membership = event.getContent()["membership"];
if (
membership !== KnownMembership.Ban &&
!(membership === KnownMembership.Leave && event.getStateKey() !== event.getSender())
) {
// Not a ban or kick, therefore not a membership event we care about here.
return;
}
const redactEvents = event.getContent()["org.matrix.msc4293.redact_events"];
if (redactEvents !== true) {
// Invalid or not set - nothing to redact.
return;
}
const state = this.getLiveTimeline().getState(Direction.Forward)!;
if (!state.maySendRedactionForEvent(event, event.getSender()!)) {
// If the sender can't redact the membership event, then they won't be able to
// redact any of the target's events either, so skip.
return;
}
// The redaction is possible, so let's find all the events and apply it.
const events = this.getTimelineSets()
.map((s) => s.getTimelines())
.reduce((p, c) => {
p.push(...c);
return p;
}, [])
.map((t) => t.getEvents().filter((e) => e.getSender() === event.getStateKey()))
.reduce((p, c) => {
p.push(...c);
return c;
}, []);
for (const toRedact of events) {
this.applyEventAsRedaction(event, toRedact);
}
}
};
private processLiveEvent(event: MatrixEvent): void {
this.applyRedaction(event);
this.tryApplyRedaction(event);
// Implement MSC3531: hiding messages.
if (event.isVisibilityEvent()) {