From 96d1b30012bc265d3b4224b62a44a5328b73b59c Mon Sep 17 00:00:00 2001 From: David Teller Date: Wed, 12 Jan 2022 11:27:33 +0100 Subject: [PATCH] MSC3531: Hiding messages during moderation (#2041) --- src/@types/event.ts | 10 ++ src/event-mapper.ts | 2 +- src/models/event.ts | 155 ++++++++++++++++++++++++++++ src/models/room-state.ts | 16 +-- src/models/room.ts | 217 ++++++++++++++++++++++++++++++++++++++- src/sync.ts | 1 + 6 files changed, 390 insertions(+), 11 deletions(-) diff --git a/src/@types/event.ts b/src/@types/event.ts index 4d3fbb788..1d03e23c4 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -177,6 +177,16 @@ export const UNSTABLE_ELEMENT_FUNCTIONAL_USERS = new UnstableValue( "io.element.functional_members", "io.element.functional_members"); +/** + * A type of message that affects visibility of a message, + * as per https://github.com/matrix-org/matrix-doc/pull/3531 + * + * @experimental + */ +export const EVENT_VISIBILITY_CHANGE_TYPE = new UnstableValue( + "m.visibility", + "org.matrix.msc3531.visibility"); + export interface IEncryptedFile { url: string; mimetype?: string; diff --git a/src/event-mapper.ts b/src/event-mapper.ts index e0a5e421b..9b9384860 100644 --- a/src/event-mapper.ts +++ b/src/event-mapper.ts @@ -41,7 +41,7 @@ export function eventMapperFor(client: MatrixClient, options: MapperOpts): Event } } if (!preventReEmit) { - client.reEmitter.reEmit(event, ["Event.replaced"]); + client.reEmitter.reEmit(event, ["Event.replaced", "Event.visibilityChange"]); } return event; } diff --git a/src/models/event.ts b/src/models/event.ts index df0ad5aec..9d6cca6a9 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -28,6 +28,7 @@ import { EventType, MsgType, RelationType, + EVENT_VISIBILITY_CHANGE_TYPE, } from "../@types/event"; import { Crypto, IEventDecryptionResult } from "../crypto"; import { deepSortedObjectEntries } from "../utils"; @@ -125,6 +126,35 @@ export interface IEventRelation { key?: string; } +export interface IVisibilityEventRelation extends IEventRelation { + visibility: "visible" | "hidden"; + reason?: string; +} + +/** + * When an event is a visibility change event, as per MSC3531, + * the visibility change implied by the event. + */ +export interface IVisibilityChange { + /** + * If `true`, the target event should be made visible. + * Otherwise, it should be hidden. + */ + visible: boolean; + + /** + * The event id affected. + */ + eventId: string; + + /** + * Optionally, a human-readable reason explaining why + * the event was hidden. Ignored if the event was made + * visible. + */ + reason: string | null; +} + export interface IClearEvent { room_id?: string; type: string; @@ -143,6 +173,30 @@ export interface IDecryptOptions { isRetry?: boolean; } +/** + * Message hiding, as specified by https://github.com/matrix-org/matrix-doc/pull/3531. + */ +export type MessageVisibility = IMessageVisibilityHidden | IMessageVisibilityVisible; +/** + * Variant of `MessageVisibility` for the case in which the message should be displayed. + */ +export interface IMessageVisibilityVisible { + readonly visible: true; +} +/** + * Variant of `MessageVisibility` for the case in which the message should be hidden. + */ +export interface IMessageVisibilityHidden { + readonly visible: false; + /** + * Optionally, a human-readable reason to show to the user indicating why the + * message has been hidden (e.g. "Message Pending Moderation"). + */ + readonly reason: string | null; +} +// A singleton implementing `IMessageVisibilityVisible`. +const MESSAGE_VISIBLE: IMessageVisibilityVisible = Object.freeze({ visible: true }); + export class MatrixEvent extends EventEmitter { private pushActions: IActionsObject = null; private _replacingEvent: MatrixEvent = null; @@ -150,6 +204,12 @@ export class MatrixEvent extends EventEmitter { private _isCancelled = false; private clearEvent?: IClearEvent; + /* Message hiding, as specified by https://github.com/matrix-org/matrix-doc/pull/3531. + + Note: We're returning this object, so any value stored here MUST be frozen. + */ + private visibility: MessageVisibility = MESSAGE_VISIBLE; + /* curve25519 key which we believe belongs to the sender of the event. See * getSenderKey() */ @@ -923,6 +983,53 @@ export class MatrixEvent extends EventEmitter { this.event.unsigned.redacted_because = redactionEvent.event as IEvent; } + /** + * Change the visibility of an event, as per https://github.com/matrix-org/matrix-doc/pull/3531 . + * + * @fires module:models/event.MatrixEvent#"Event.visibilityChange" if `visibilityEvent` + * caused a change in the actual visibility of this event, either by making it + * visible (if it was hidden), by making it hidden (if it was visible) or by + * changing the reason (if it was hidden). + * @param visibilityEvent event holding a hide/unhide payload, or nothing + * if the event is being reset to its original visibility (presumably + * by a visibility event being redacted). + */ + public applyVisibilityEvent(visibilityChange?: IVisibilityChange): void { + const visible = visibilityChange ? visibilityChange.visible : true; + const reason = visibilityChange ? visibilityChange.reason : null; + let change = false; + if (this.visibility.visible !== visibilityChange.visible) { + change = true; + } else if (!this.visibility.visible && this.visibility["reason"] !== reason) { + change = true; + } + if (change) { + if (visible) { + this.visibility = MESSAGE_VISIBLE; + } else { + this.visibility = Object.freeze({ + visible: false, + reason: reason, + }); + } + if (change) { + this.emit("Event.visibilityChange", this, visible); + } + } + } + + /** + * Return instructions to display or hide the message. + * + * @returns Instructions determining whether the message + * should be displayed. + */ + public messageVisibility(): MessageVisibility { + // Note: We may return `this.visibility` without fear, as + // this is a shallow frozen object. + return this.visibility; + } + /** * Update the content of an event in the same way it would be by the server * if it were redacted before it was sent to us @@ -992,6 +1099,54 @@ export class MatrixEvent extends EventEmitter { return this.getType() === EventType.RoomRedaction; } + /** + * Return the visibility change caused by this event, + * as per https://github.com/matrix-org/matrix-doc/pull/3531. + * + * @returns If the event is a well-formed visibility change event, + * an instance of `IVisibilityChange`, otherwise `null`. + */ + public asVisibilityChange(): IVisibilityChange | null { + if (!EVENT_VISIBILITY_CHANGE_TYPE.matches(this.getType())) { + // Not a visibility change event. + return null; + } + const relation = this.getRelation(); + if (!relation || relation.rel_type != "m.reference") { + // Ill-formed, ignore this event. + return null; + } + const eventId = relation.event_id; + if (!eventId) { + // Ill-formed, ignore this event. + return null; + } + const content = this.getWireContent(); + const visible = !!content.visible; + const reason = content.reason; + if (reason && typeof reason != "string") { + // Ill-formed, ignore this event. + return null; + } + // Well-formed visibility change event. + return { + visible, + reason, + eventId, + }; + } + + /** + * Check if this event alters the visibility of another event, + * as per https://github.com/matrix-org/matrix-doc/pull/3531. + * + * @returns {boolean} True if this event alters the visibility + * of another event. + */ + public isVisibilityEvent(): boolean { + return EVENT_VISIBILITY_CHANGE_TYPE.matches(this.getType()); + } + /** * Get the (decrypted, if necessary) redaction event JSON * if event was redacted diff --git a/src/models/room-state.ts b/src/models/room-state.ts index 03ff37096..e1fa98270 100644 --- a/src/models/room-state.ts +++ b/src/models/room-state.ts @@ -618,14 +618,14 @@ export class RoomState extends EventEmitter { } /** - * Returns true if the given MatrixClient has permission to send a state - * event of type `stateEventType` into this room. - * @param {string} stateEventType The type of state events to test - * @param {MatrixClient} cli The client to test permission for - * @return {boolean} true if the given client should be permitted to send - * the given type of state event into this room, - * according to the room's state. - */ + * Returns true if the given MatrixClient has permission to send a state + * event of type `stateEventType` into this room. + * @param {string} stateEventType The type of state events to test + * @param {MatrixClient} cli The client to test permission for + * @return {boolean} true if the given client should be permitted to send + * the given type of state event into this room, + * according to the room's state. + */ public mayClientSendStateEvent(stateEventType: EventType | string, cli: MatrixClient): boolean { if (cli.isGuest()) { return false; diff --git a/src/models/room.ts b/src/models/room.ts index d4d6843fc..0f35211a2 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -30,7 +30,10 @@ import { RoomMember } from "./room-member"; import { IRoomSummary, RoomSummary } from "./room-summary"; import { logger } from '../logger'; import { ReEmitter } from '../ReEmitter'; -import { EventType, RoomCreateTypeField, RoomType, UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "../@types/event"; +import { + EventType, RoomCreateTypeField, RoomType, UNSTABLE_ELEMENT_FUNCTIONAL_USERS, + EVENT_VISIBILITY_CHANGE_TYPE, +} from "../@types/event"; import { IRoomVersionsCapability, MatrixClient, PendingEventOrdering, RoomVersionStability } from "../client"; import { GuestAccess, HistoryVisibility, JoinRule, ResizeMethod } from "../@types/partials"; import { Filter } from "../filter"; @@ -105,6 +108,22 @@ interface IReceiptContent { type Receipts = Record>; +// When inserting a visibility event affecting event `eventId`, we +// need to scan through existing visibility events for `eventId`. +// In theory, this could take an unlimited amount of time if: +// +// - the visibility event was sent by a moderator; and +// - `eventId` already has many visibility changes (usually, it should +// be 2 or less); and +// - for some reason, the visibility changes are received out of order +// (usually, this shouldn't happen at all). +// +// For this reason, we limit the number of events to scan through, +// expecting that a broken visibility change for a single event in +// an extremely uncommon case (possibly a DoS) is a small +// price to pay to keep matrix-js-sdk responsive. +const MAX_NUMBER_OF_VISIBILITY_EVENTS_TO_SCAN_THROUGH = 30; + export enum NotificationCountType { Highlight = "highlight", Total = "total", @@ -193,6 +212,24 @@ export class Room extends EventEmitter { */ public threads = new Map(); + /** + * A mapping of eventId to all visibility changes to apply + * to the event, by chronological order, as per + * https://github.com/matrix-org/matrix-doc/pull/3531 + * + * # Invariants + * + * - within each list, all events are classed by + * chronological order; + * - all events are events such that + * `asVisibilityEvent()` returns a non-null `IVisibilityChange`; + * - within each list with key `eventId`, all events + * are in relation to `eventId`. + * + * @experimental + */ + private visibilityEvents = new Map(); + /** * Construct a new Room. * @@ -253,7 +290,9 @@ export class Room extends EventEmitter { // all our per-room timeline sets. the first one is the unfiltered ones; // the subsequent ones are the filtered ones in no particular order. this.timelineSets = [new EventTimelineSet(this, opts)]; - this.reEmitter.reEmit(this.getUnfilteredTimelineSet(), ["Room.timeline", "Room.timelineReset"]); + this.reEmitter.reEmit(this.getUnfilteredTimelineSet(), [ + "Room.timeline", "Room.timelineReset", + ]); this.fixUpLegacyTimelineFields(); @@ -1409,8 +1448,26 @@ export class Room extends EventEmitter { // 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. + + // Remove any visibility change on this event. + this.visibilityEvents.delete(redactId); + + // 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); + } } + // Implement MSC3531: hiding messages. + if (event.isVisibilityEvent()) { + // This event changes the visibility of another event, record + // the visibility change, inform clients if necessary. + this.applyNewVisibilityEvent(event); + } + // If any pending visibility change is waiting for this (older) event, + this.applyPendingVisibilityEvents(event); + if (event.getUnsigned().transaction_id) { const existingEvent = this.txnToEvent[event.getUnsigned().transaction_id]; if (existingEvent) { @@ -2292,6 +2349,161 @@ export class Room extends EventEmitter { return "Empty room"; } } + + /** + * When we receive a new visibility change event: + * + * - store this visibility change alongside the timeline, in case we + * later need to apply it to an event that we haven't received yet; + * - if we have already received the event whose visibility has changed, + * patch it to reflect the visibility change and inform listeners. + */ + private applyNewVisibilityEvent(event: MatrixEvent): void { + const visibilityChange = event.asVisibilityChange(); + if (!visibilityChange) { + // The event is ill-formed. + return; + } + + // Ignore visibility change events that are not emitted by moderators. + const userId = event.getSender(); + if (!userId) { + return; + } + const isPowerSufficient = + ( + EVENT_VISIBILITY_CHANGE_TYPE.name + && this.currentState.maySendStateEvent(EVENT_VISIBILITY_CHANGE_TYPE.name, userId) + ) + || ( + EVENT_VISIBILITY_CHANGE_TYPE.altName + && this.currentState.maySendStateEvent(EVENT_VISIBILITY_CHANGE_TYPE.altName, userId) + ); + if (!isPowerSufficient) { + // Powerlevel is insufficient. + return; + } + + // Record this change in visibility. + // If the event is not in our timeline and we only receive it later, + // we may need to apply the visibility change at a later date. + + const visibilityEventsOnOriginalEvent = this.visibilityEvents.get(visibilityChange.eventId); + if (visibilityEventsOnOriginalEvent) { + // It would be tempting to simply erase the latest visibility change + // but we need to record all of the changes in case the latest change + // is ever redacted. + // + // In practice, linear scans through `visibilityEvents` should be fast. + // However, to protect against a potential DoS attack, we limit the + // number of iterations in this loop. + let index = visibilityEventsOnOriginalEvent.length - 1; + const min = Math.max(0, + visibilityEventsOnOriginalEvent.length - MAX_NUMBER_OF_VISIBILITY_EVENTS_TO_SCAN_THROUGH); + for (; index >= min; --index) { + const target = visibilityEventsOnOriginalEvent[index]; + if (target.getTs() < event.getTs()) { + break; + } + } + if (index === -1) { + visibilityEventsOnOriginalEvent.unshift(event); + } else { + visibilityEventsOnOriginalEvent.splice(index + 1, 0, event); + } + } else { + this.visibilityEvents.set(visibilityChange.eventId, [event]); + } + + // Finally, let's check if the event is already in our timeline. + // If so, we need to patch it and inform listeners. + + const originalEvent = this.findEventById(visibilityChange.eventId); + if (!originalEvent) { + return; + } + originalEvent.applyVisibilityEvent(visibilityChange); + } + + private redactVisibilityChangeEvent(event: MatrixEvent) { + // Sanity checks. + if (!event.isVisibilityEvent) { + throw new Error("expected a visibility change event"); + } + const relation = event.getRelation(); + const originalEventId = relation.event_id; + const visibilityEventsOnOriginalEvent = this.visibilityEvents.get(originalEventId); + if (!visibilityEventsOnOriginalEvent) { + // No visibility changes on the original event. + // In particular, this change event was not recorded, + // most likely because it was ill-formed. + return; + } + const index = visibilityEventsOnOriginalEvent.findIndex(change => change.getId() === event.getId()); + if (index === -1) { + // This change event was not recorded, most likely because + // it was ill-formed. + return; + } + // Remove visibility change. + visibilityEventsOnOriginalEvent.splice(index, 1); + + // If we removed the latest visibility change event, propagate changes. + if (index === visibilityEventsOnOriginalEvent.length) { + const originalEvent = this.findEventById(originalEventId); + if (!originalEvent) { + return; + } + if (index === 0) { + // We have just removed the only visibility change event. + this.visibilityEvents.delete(originalEventId); + originalEvent.applyVisibilityEvent(); + } else { + const newEvent = visibilityEventsOnOriginalEvent[visibilityEventsOnOriginalEvent.length - 1]; + const newVisibility = newEvent.asVisibilityChange(); + if (!newVisibility) { + // Event is ill-formed. + // This breaks our invariant. + throw new Error("at this stage, visibility changes should be well-formed"); + } + originalEvent.applyVisibilityEvent(newVisibility); + } + } + } + + /** + * When we receive an event whose visibility has been altered by + * a (more recent) visibility change event, patch the event in + * place so that clients now not to display it. + * + * @param event Any matrix event. If this event has at least one a + * pending visibility change event, apply the latest visibility + * change event. + */ + private applyPendingVisibilityEvents(event: MatrixEvent): void { + const visibilityEvents = this.visibilityEvents.get(event.getId()); + if (!visibilityEvents || visibilityEvents.length == 0) { + // No pending visibility change in store. + return; + } + const visibilityEvent = visibilityEvents[visibilityEvents.length - 1]; + const visibilityChange = visibilityEvent.asVisibilityChange(); + if (!visibilityChange) { + return; + } + if (visibilityChange.visible) { + // Events are visible by default, no need to apply a visibility change. + // Note that we need to keep the visibility changes in `visibilityEvents`, + // in case we later fetch an older visibility change event that is superseded + // by `visibilityChange`. + } + if (visibilityEvent.getTs() < event.getTs()) { + // Something is wrong, the visibility change cannot happen before the + // event. Presumably an ill-formed event. + return; + } + event.applyVisibilityEvent(visibilityChange); + } } /** @@ -2460,3 +2672,4 @@ function memberNamesToRoomName(names: string[], count = (names.length + 1)) { * @param {string} membership The new membership value * @param {string} prevMembership The previous membership value */ + diff --git a/src/sync.ts b/src/sync.ts index 88d12f9ef..61f23187f 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -201,6 +201,7 @@ export class SyncApi { "Room.accountData", "Room.myMembership", "Room.replaceEvent", + "Room.visibilityChange", ]); this.registerStateListeners(room); return room;