You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-07-30 04:23:07 +03:00
Base support for MSC3847: Ignore invites with policy rooms (#2626)
* Base support for MSC3847: Ignore invites with policy rooms Type: enhancement * Base support for MSC3847: Ignore invites with policy rooms Type: enhancement * WIP: Applying feedback * WIP: Applying feedback * WIP: CI linter gives me different errors, weird * WIP: A little more linting
This commit is contained in:
@ -36,9 +36,14 @@ import { ReceiptType } from "../../src/@types/read_receipts";
|
|||||||
import * as testUtils from "../test-utils/test-utils";
|
import * as testUtils from "../test-utils/test-utils";
|
||||||
import { makeBeaconInfoContent } from "../../src/content-helpers";
|
import { makeBeaconInfoContent } from "../../src/content-helpers";
|
||||||
import { M_BEACON_INFO } from "../../src/@types/beacon";
|
import { M_BEACON_INFO } from "../../src/@types/beacon";
|
||||||
import { ContentHelpers, Room } from "../../src";
|
import { ContentHelpers, EventTimeline, Room } from "../../src";
|
||||||
import { supportsMatrixCall } from "../../src/webrtc/call";
|
import { supportsMatrixCall } from "../../src/webrtc/call";
|
||||||
import { makeBeaconEvent } from "../test-utils/beacon";
|
import { makeBeaconEvent } from "../test-utils/beacon";
|
||||||
|
import {
|
||||||
|
IGNORE_INVITES_ACCOUNT_EVENT_KEY,
|
||||||
|
POLICIES_ACCOUNT_EVENT_TYPE,
|
||||||
|
PolicyScope,
|
||||||
|
} from "../../src/models/invites-ignorer";
|
||||||
|
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
|
|
||||||
@ -1412,4 +1417,301 @@ describe("MatrixClient", function() {
|
|||||||
expect(client.crypto.encryptAndSendToDevices).toHaveBeenLastCalledWith(deviceInfos, payload);
|
expect(client.crypto.encryptAndSendToDevices).toHaveBeenLastCalledWith(deviceInfos, payload);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("support for ignoring invites", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mockup `getAccountData`/`setAccountData`.
|
||||||
|
const dataStore = new Map();
|
||||||
|
client.setAccountData = function(eventType, content) {
|
||||||
|
dataStore.set(eventType, content);
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
client.getAccountData = function(eventType) {
|
||||||
|
const data = dataStore.get(eventType);
|
||||||
|
return new MatrixEvent({
|
||||||
|
content: data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mockup `createRoom`/`getRoom`/`joinRoom`, including state.
|
||||||
|
const rooms = new Map();
|
||||||
|
client.createRoom = function(options = {}) {
|
||||||
|
const roomId = options["_roomId"] || `!room-${rooms.size}:example.org`;
|
||||||
|
const state = new Map();
|
||||||
|
const room = {
|
||||||
|
roomId,
|
||||||
|
_options: options,
|
||||||
|
_state: state,
|
||||||
|
getUnfilteredTimelineSet: function() {
|
||||||
|
return {
|
||||||
|
getLiveTimeline: function() {
|
||||||
|
return {
|
||||||
|
getState: function(direction) {
|
||||||
|
expect(direction).toBe(EventTimeline.FORWARDS);
|
||||||
|
return {
|
||||||
|
getStateEvents: function(type) {
|
||||||
|
const store = state.get(type) || {};
|
||||||
|
return Object.keys(store).map(key => store[key]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
rooms.set(roomId, room);
|
||||||
|
return Promise.resolve({ room_id: roomId });
|
||||||
|
};
|
||||||
|
client.getRoom = function(roomId) {
|
||||||
|
return rooms.get(roomId);
|
||||||
|
};
|
||||||
|
client.joinRoom = function(roomId) {
|
||||||
|
return this.getRoom(roomId) || this.createRoom({ _roomId: roomId });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mockup state events
|
||||||
|
client.sendStateEvent = function(roomId, type, content) {
|
||||||
|
const room = this.getRoom(roomId);
|
||||||
|
const state: Map<string, any> = room._state;
|
||||||
|
let store = state.get(type);
|
||||||
|
if (!store) {
|
||||||
|
store = {};
|
||||||
|
state.set(type, store);
|
||||||
|
}
|
||||||
|
const eventId = `$event-${Math.random()}:example.org`;
|
||||||
|
store[eventId] = {
|
||||||
|
getId: function() {
|
||||||
|
return eventId;
|
||||||
|
},
|
||||||
|
getRoomId: function() {
|
||||||
|
return roomId;
|
||||||
|
},
|
||||||
|
getContent: function() {
|
||||||
|
return content;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { event_id: eventId };
|
||||||
|
};
|
||||||
|
client.redactEvent = function(roomId, eventId) {
|
||||||
|
const room = this.getRoom(roomId);
|
||||||
|
const state: Map<string, any> = room._state;
|
||||||
|
for (const store of state.values()) {
|
||||||
|
delete store[eventId];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should initialize and return the same `target` consistently", async () => {
|
||||||
|
const target1 = await client.ignoredInvites.getOrCreateTargetRoom();
|
||||||
|
const target2 = await client.ignoredInvites.getOrCreateTargetRoom();
|
||||||
|
expect(target1).toBeTruthy();
|
||||||
|
expect(target1).toBe(target2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should initialize and return the same `sources` consistently", async () => {
|
||||||
|
const sources1 = await client.ignoredInvites.getOrCreateSourceRooms();
|
||||||
|
const sources2 = await client.ignoredInvites.getOrCreateSourceRooms();
|
||||||
|
expect(sources1).toBeTruthy();
|
||||||
|
expect(sources1).toHaveLength(1);
|
||||||
|
expect(sources1).toEqual(sources2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should initially not reject any invite", async () => {
|
||||||
|
const rule = await client.ignoredInvites.getRuleForInvite({
|
||||||
|
sender: "@foobar:example.org",
|
||||||
|
roomId: "!snafu:somewhere.org",
|
||||||
|
});
|
||||||
|
expect(rule).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invites once we have added a matching rule in the target room (scope: user)", async () => {
|
||||||
|
await client.ignoredInvites.addRule(PolicyScope.User, "*:example.org", "just a test");
|
||||||
|
|
||||||
|
// We should reject this invite.
|
||||||
|
const ruleMatch = await client.ignoredInvites.getRuleForInvite({
|
||||||
|
sender: "@foobar:example.org",
|
||||||
|
roomId: "!snafu:somewhere.org",
|
||||||
|
});
|
||||||
|
expect(ruleMatch).toBeTruthy();
|
||||||
|
expect(ruleMatch.getContent()).toMatchObject({
|
||||||
|
recommendation: "m.ban",
|
||||||
|
reason: "just a test",
|
||||||
|
});
|
||||||
|
|
||||||
|
// We should let these invites go through.
|
||||||
|
const ruleWrongServer = await client.ignoredInvites.getRuleForInvite({
|
||||||
|
sender: "@foobar:somewhere.org",
|
||||||
|
roomId: "!snafu:somewhere.org",
|
||||||
|
});
|
||||||
|
expect(ruleWrongServer).toBeFalsy();
|
||||||
|
|
||||||
|
const ruleWrongServerRoom = await client.ignoredInvites.getRuleForInvite({
|
||||||
|
sender: "@foobar:somewhere.org",
|
||||||
|
roomId: "!snafu:example.org",
|
||||||
|
});
|
||||||
|
expect(ruleWrongServerRoom).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invites once we have added a matching rule in the target room (scope: server)", async () => {
|
||||||
|
const REASON = `Just a test ${Math.random()}`;
|
||||||
|
await client.ignoredInvites.addRule(PolicyScope.Server, "example.org", REASON);
|
||||||
|
|
||||||
|
// We should reject these invites.
|
||||||
|
const ruleSenderMatch = await client.ignoredInvites.getRuleForInvite({
|
||||||
|
sender: "@foobar:example.org",
|
||||||
|
roomId: "!snafu:somewhere.org",
|
||||||
|
});
|
||||||
|
expect(ruleSenderMatch).toBeTruthy();
|
||||||
|
expect(ruleSenderMatch.getContent()).toMatchObject({
|
||||||
|
recommendation: "m.ban",
|
||||||
|
reason: REASON,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ruleRoomMatch = await client.ignoredInvites.getRuleForInvite({
|
||||||
|
sender: "@foobar:somewhere.org",
|
||||||
|
roomId: "!snafu:example.org",
|
||||||
|
});
|
||||||
|
expect(ruleRoomMatch).toBeTruthy();
|
||||||
|
expect(ruleRoomMatch.getContent()).toMatchObject({
|
||||||
|
recommendation: "m.ban",
|
||||||
|
reason: REASON,
|
||||||
|
});
|
||||||
|
|
||||||
|
// We should let these invites go through.
|
||||||
|
const ruleWrongServer = await client.ignoredInvites.getRuleForInvite({
|
||||||
|
sender: "@foobar:somewhere.org",
|
||||||
|
roomId: "!snafu:somewhere.org",
|
||||||
|
});
|
||||||
|
expect(ruleWrongServer).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invites once we have added a matching rule in the target room (scope: room)", async () => {
|
||||||
|
const REASON = `Just a test ${Math.random()}`;
|
||||||
|
const BAD_ROOM_ID = "!bad:example.org";
|
||||||
|
const GOOD_ROOM_ID = "!good:example.org";
|
||||||
|
await client.ignoredInvites.addRule(PolicyScope.Room, BAD_ROOM_ID, REASON);
|
||||||
|
|
||||||
|
// We should reject this invite.
|
||||||
|
const ruleSenderMatch = await client.ignoredInvites.getRuleForInvite({
|
||||||
|
sender: "@foobar:example.org",
|
||||||
|
roomId: BAD_ROOM_ID,
|
||||||
|
});
|
||||||
|
expect(ruleSenderMatch).toBeTruthy();
|
||||||
|
expect(ruleSenderMatch.getContent()).toMatchObject({
|
||||||
|
recommendation: "m.ban",
|
||||||
|
reason: REASON,
|
||||||
|
});
|
||||||
|
|
||||||
|
// We should let these invites go through.
|
||||||
|
const ruleWrongRoom = await client.ignoredInvites.getRuleForInvite({
|
||||||
|
sender: BAD_ROOM_ID,
|
||||||
|
roomId: GOOD_ROOM_ID,
|
||||||
|
});
|
||||||
|
expect(ruleWrongRoom).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invites once we have added a matching rule in a non-target source room", async () => {
|
||||||
|
const NEW_SOURCE_ROOM_ID = "!another-source:example.org";
|
||||||
|
|
||||||
|
// Make sure that everything is initialized.
|
||||||
|
await client.ignoredInvites.getOrCreateSourceRooms();
|
||||||
|
await client.joinRoom(NEW_SOURCE_ROOM_ID);
|
||||||
|
await client.ignoredInvites.addSource(NEW_SOURCE_ROOM_ID);
|
||||||
|
|
||||||
|
// Add a rule in the new source room.
|
||||||
|
await client.sendStateEvent(NEW_SOURCE_ROOM_ID, PolicyScope.User, {
|
||||||
|
entity: "*:example.org",
|
||||||
|
reason: "just a test",
|
||||||
|
recommendation: "m.ban",
|
||||||
|
});
|
||||||
|
|
||||||
|
// We should reject this invite.
|
||||||
|
const ruleMatch = await client.ignoredInvites.getRuleForInvite({
|
||||||
|
sender: "@foobar:example.org",
|
||||||
|
roomId: "!snafu:somewhere.org",
|
||||||
|
});
|
||||||
|
expect(ruleMatch).toBeTruthy();
|
||||||
|
expect(ruleMatch.getContent()).toMatchObject({
|
||||||
|
recommendation: "m.ban",
|
||||||
|
reason: "just a test",
|
||||||
|
});
|
||||||
|
|
||||||
|
// We should let these invites go through.
|
||||||
|
const ruleWrongServer = await client.ignoredInvites.getRuleForInvite({
|
||||||
|
sender: "@foobar:somewhere.org",
|
||||||
|
roomId: "!snafu:somewhere.org",
|
||||||
|
});
|
||||||
|
expect(ruleWrongServer).toBeFalsy();
|
||||||
|
|
||||||
|
const ruleWrongServerRoom = await client.ignoredInvites.getRuleForInvite({
|
||||||
|
sender: "@foobar:somewhere.org",
|
||||||
|
roomId: "!snafu:example.org",
|
||||||
|
});
|
||||||
|
expect(ruleWrongServerRoom).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not reject invites anymore once we have removed a rule", async () => {
|
||||||
|
await client.ignoredInvites.addRule(PolicyScope.User, "*:example.org", "just a test");
|
||||||
|
|
||||||
|
// We should reject this invite.
|
||||||
|
const ruleMatch = await client.ignoredInvites.getRuleForInvite({
|
||||||
|
sender: "@foobar:example.org",
|
||||||
|
roomId: "!snafu:somewhere.org",
|
||||||
|
});
|
||||||
|
expect(ruleMatch).toBeTruthy();
|
||||||
|
expect(ruleMatch.getContent()).toMatchObject({
|
||||||
|
recommendation: "m.ban",
|
||||||
|
reason: "just a test",
|
||||||
|
});
|
||||||
|
|
||||||
|
// After removing the invite, we shouldn't reject it anymore.
|
||||||
|
await client.ignoredInvites.removeRule(ruleMatch);
|
||||||
|
const ruleMatch2 = await client.ignoredInvites.getRuleForInvite({
|
||||||
|
sender: "@foobar:example.org",
|
||||||
|
roomId: "!snafu:somewhere.org",
|
||||||
|
});
|
||||||
|
expect(ruleMatch2).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add new rules in the target room, rather than any other source room", async () => {
|
||||||
|
const NEW_SOURCE_ROOM_ID = "!another-source:example.org";
|
||||||
|
|
||||||
|
// Make sure that everything is initialized.
|
||||||
|
await client.ignoredInvites.getOrCreateSourceRooms();
|
||||||
|
await client.joinRoom(NEW_SOURCE_ROOM_ID);
|
||||||
|
const newSourceRoom = client.getRoom(NEW_SOURCE_ROOM_ID);
|
||||||
|
|
||||||
|
// Fetch the list of sources and check that we do not have the new room yet.
|
||||||
|
const policies = await client.getAccountData(POLICIES_ACCOUNT_EVENT_TYPE.name).getContent();
|
||||||
|
expect(policies).toBeTruthy();
|
||||||
|
const ignoreInvites = policies[IGNORE_INVITES_ACCOUNT_EVENT_KEY.name];
|
||||||
|
expect(ignoreInvites).toBeTruthy();
|
||||||
|
expect(ignoreInvites.sources).toBeTruthy();
|
||||||
|
expect(ignoreInvites.sources).not.toContain(NEW_SOURCE_ROOM_ID);
|
||||||
|
|
||||||
|
// Add a source.
|
||||||
|
const added = await client.ignoredInvites.addSource(NEW_SOURCE_ROOM_ID);
|
||||||
|
expect(added).toBe(true);
|
||||||
|
const added2 = await client.ignoredInvites.addSource(NEW_SOURCE_ROOM_ID);
|
||||||
|
expect(added2).toBe(false);
|
||||||
|
|
||||||
|
// Fetch the list of sources and check that we have added the new room.
|
||||||
|
const policies2 = await client.getAccountData(POLICIES_ACCOUNT_EVENT_TYPE.name).getContent();
|
||||||
|
expect(policies2).toBeTruthy();
|
||||||
|
const ignoreInvites2 = policies2[IGNORE_INVITES_ACCOUNT_EVENT_KEY.name];
|
||||||
|
expect(ignoreInvites2).toBeTruthy();
|
||||||
|
expect(ignoreInvites2.sources).toBeTruthy();
|
||||||
|
expect(ignoreInvites2.sources).toContain(NEW_SOURCE_ROOM_ID);
|
||||||
|
|
||||||
|
// Add a rule.
|
||||||
|
const eventId = await client.ignoredInvites.addRule(PolicyScope.User, "*:example.org", "just a test");
|
||||||
|
|
||||||
|
// Check where it shows up.
|
||||||
|
const targetRoomId = ignoreInvites2.target;
|
||||||
|
const targetRoom = client.getRoom(targetRoomId);
|
||||||
|
expect(targetRoom._state.get(PolicyScope.User)[eventId]).toBeTruthy();
|
||||||
|
expect(newSourceRoom._state.get(PolicyScope.User)?.[eventId]).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -197,6 +197,7 @@ import { Thread, THREAD_RELATION_TYPE } from "./models/thread";
|
|||||||
import { MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon";
|
import { MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon";
|
||||||
import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue";
|
import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue";
|
||||||
import { ToDeviceBatch } from "./models/ToDeviceMessage";
|
import { ToDeviceBatch } from "./models/ToDeviceMessage";
|
||||||
|
import { IgnoredInvites } from "./models/invites-ignorer";
|
||||||
|
|
||||||
export type Store = IStore;
|
export type Store = IStore;
|
||||||
|
|
||||||
@ -954,6 +955,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
|||||||
|
|
||||||
private toDeviceMessageQueue: ToDeviceMessageQueue;
|
private toDeviceMessageQueue: ToDeviceMessageQueue;
|
||||||
|
|
||||||
|
// A manager for determining which invites should be ignored.
|
||||||
|
public readonly ignoredInvites: IgnoredInvites;
|
||||||
|
|
||||||
constructor(opts: IMatrixClientCreateOpts) {
|
constructor(opts: IMatrixClientCreateOpts) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
@ -1135,6 +1139,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
|||||||
room.setUnreadNotificationCount(NotificationCountType.Highlight, highlightCount);
|
room.setUnreadNotificationCount(NotificationCountType.Highlight, highlightCount);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.ignoredInvites = new IgnoredInvites(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
359
src/models/invites-ignorer.ts
Normal file
359
src/models/invites-ignorer.ts
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022 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 { UnstableValue } from "matrix-events-sdk";
|
||||||
|
|
||||||
|
import { MatrixClient } from "../client";
|
||||||
|
import { EventTimeline, MatrixEvent, Preset } from "../matrix";
|
||||||
|
import { globToRegexp } from "../utils";
|
||||||
|
import { Room } from "./room";
|
||||||
|
|
||||||
|
/// The event type storing the user's individual policies.
|
||||||
|
///
|
||||||
|
/// Exported for testing purposes.
|
||||||
|
export const POLICIES_ACCOUNT_EVENT_TYPE = new UnstableValue("m.policies", "org.matrix.msc3847.policies");
|
||||||
|
|
||||||
|
/// The key within the user's individual policies storing the user's ignored invites.
|
||||||
|
///
|
||||||
|
/// Exported for testing purposes.
|
||||||
|
export const IGNORE_INVITES_ACCOUNT_EVENT_KEY = new UnstableValue("m.ignore.invites",
|
||||||
|
"org.matrix.msc3847.ignore.invites");
|
||||||
|
|
||||||
|
/// The types of recommendations understood.
|
||||||
|
enum PolicyRecommendation {
|
||||||
|
Ban = "m.ban",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The various scopes for policies.
|
||||||
|
*/
|
||||||
|
export enum PolicyScope {
|
||||||
|
/**
|
||||||
|
* The policy deals with an individual user, e.g. reject invites
|
||||||
|
* from this user.
|
||||||
|
*/
|
||||||
|
User = "m.policy.user",
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The policy deals with a room, e.g. reject invites towards
|
||||||
|
* a specific room.
|
||||||
|
*/
|
||||||
|
Room = "m.policy.room",
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The policy deals with a server, e.g. reject invites from
|
||||||
|
* this server.
|
||||||
|
*/
|
||||||
|
Server = "m.policy.server",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A container for ignored invites.
|
||||||
|
*
|
||||||
|
* # Performance
|
||||||
|
*
|
||||||
|
* This implementation is extremely naive. It expects that we are dealing
|
||||||
|
* with a very short list of sources (e.g. only one). If real-world
|
||||||
|
* applications turn out to require longer lists, we may need to rework
|
||||||
|
* our data structures.
|
||||||
|
*/
|
||||||
|
export class IgnoredInvites {
|
||||||
|
constructor(
|
||||||
|
private readonly client: MatrixClient,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new rule.
|
||||||
|
*
|
||||||
|
* @param scope The scope for this rule.
|
||||||
|
* @param entity The entity covered by this rule. Globs are supported.
|
||||||
|
* @param reason A human-readable reason for introducing this new rule.
|
||||||
|
* @return The event id for the new rule.
|
||||||
|
*/
|
||||||
|
public async addRule(scope: PolicyScope, entity: string, reason: string): Promise<string> {
|
||||||
|
const target = await this.getOrCreateTargetRoom();
|
||||||
|
const response = await this.client.sendStateEvent(target.roomId, scope, {
|
||||||
|
entity,
|
||||||
|
reason,
|
||||||
|
recommendation: PolicyRecommendation.Ban,
|
||||||
|
});
|
||||||
|
return response.event_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a rule.
|
||||||
|
*/
|
||||||
|
public async removeRule(event: MatrixEvent) {
|
||||||
|
await this.client.redactEvent(event.getRoomId()!, event.getId()!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new room to the list of sources. If the user isn't a member of the
|
||||||
|
* room, attempt to join it.
|
||||||
|
*
|
||||||
|
* @param roomId A valid room id. If this room is already in the list
|
||||||
|
* of sources, it will not be duplicated.
|
||||||
|
* @return `true` if the source was added, `false` if it was already present.
|
||||||
|
* @throws If `roomId` isn't the id of a room that the current user is already
|
||||||
|
* member of or can join.
|
||||||
|
*
|
||||||
|
* # Safety
|
||||||
|
*
|
||||||
|
* This method will rewrite the `Policies` object in the user's account data.
|
||||||
|
* This rewrite is inherently racy and could overwrite or be overwritten by
|
||||||
|
* other concurrent rewrites of the same object.
|
||||||
|
*/
|
||||||
|
public async addSource(roomId: string): Promise<boolean> {
|
||||||
|
// We attempt to join the room *before* calling
|
||||||
|
// `await this.getOrCreateSourceRooms()` to decrease the duration
|
||||||
|
// of the racy section.
|
||||||
|
await this.client.joinRoom(roomId);
|
||||||
|
// Race starts.
|
||||||
|
const sources = (await this.getOrCreateSourceRooms())
|
||||||
|
.map(room => room.roomId);
|
||||||
|
if (sources.includes(roomId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
sources.push(roomId);
|
||||||
|
await this.withIgnoreInvitesPolicies(ignoreInvitesPolicies => {
|
||||||
|
ignoreInvitesPolicies.sources = sources;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Race ends.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find out whether an invite should be ignored.
|
||||||
|
*
|
||||||
|
* @param sender The user id for the user who issued the invite.
|
||||||
|
* @param roomId The room to which the user is invited.
|
||||||
|
* @returns A rule matching the entity, if any was found, `null` otherwise.
|
||||||
|
*/
|
||||||
|
public async getRuleForInvite({ sender, roomId }: {
|
||||||
|
sender: string;
|
||||||
|
roomId: string;
|
||||||
|
}): Promise<Readonly<MatrixEvent | null>> {
|
||||||
|
// In this implementation, we perform a very naive lookup:
|
||||||
|
// - search in each policy room;
|
||||||
|
// - turn each (potentially glob) rule entity into a regexp.
|
||||||
|
//
|
||||||
|
// Real-world testing will tell us whether this is performant enough.
|
||||||
|
// In the (unfortunately likely) case it isn't, there are several manners
|
||||||
|
// in which we could optimize this:
|
||||||
|
// - match several entities per go;
|
||||||
|
// - pre-compile each rule entity into a regexp;
|
||||||
|
// - pre-compile entire rooms into a single regexp.
|
||||||
|
const policyRooms = await this.getOrCreateSourceRooms();
|
||||||
|
const senderServer = sender.split(":")[1];
|
||||||
|
const roomServer = roomId.split(":")[1];
|
||||||
|
for (const room of policyRooms) {
|
||||||
|
const state = room.getUnfilteredTimelineSet().getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||||
|
|
||||||
|
for (const { scope, entities } of [
|
||||||
|
{ scope: PolicyScope.Room, entities: [roomId] },
|
||||||
|
{ scope: PolicyScope.User, entities: [sender] },
|
||||||
|
{ scope: PolicyScope.Server, entities: [senderServer, roomServer] },
|
||||||
|
]) {
|
||||||
|
const events = state.getStateEvents(scope);
|
||||||
|
for (const event of events) {
|
||||||
|
const content = event.getContent();
|
||||||
|
if (content?.recommendation != PolicyRecommendation.Ban) {
|
||||||
|
// Ignoring invites only looks at `m.ban` recommendations.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const glob = content?.entity;
|
||||||
|
if (!glob) {
|
||||||
|
// Invalid event.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let regexp: RegExp;
|
||||||
|
try {
|
||||||
|
regexp = new RegExp(globToRegexp(glob, false));
|
||||||
|
} catch (ex) {
|
||||||
|
// Assume invalid event.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const entity of entities) {
|
||||||
|
if (entity && regexp.test(entity)) {
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No match.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the target room, i.e. the room in which any new rule should be written.
|
||||||
|
*
|
||||||
|
* If there is no target room setup, a target room is created.
|
||||||
|
*
|
||||||
|
* Note: This method is public for testing reasons. Most clients should not need
|
||||||
|
* to call it directly.
|
||||||
|
*
|
||||||
|
* # Safety
|
||||||
|
*
|
||||||
|
* This method will rewrite the `Policies` object in the user's account data.
|
||||||
|
* This rewrite is inherently racy and could overwrite or be overwritten by
|
||||||
|
* other concurrent rewrites of the same object.
|
||||||
|
*/
|
||||||
|
public async getOrCreateTargetRoom(): Promise<Room> {
|
||||||
|
const ignoreInvitesPolicies = this.getIgnoreInvitesPolicies();
|
||||||
|
let target = ignoreInvitesPolicies.target;
|
||||||
|
// Validate `target`. If it is invalid, trash out the current `target`
|
||||||
|
// and create a new room.
|
||||||
|
if (typeof target !== "string") {
|
||||||
|
target = null;
|
||||||
|
}
|
||||||
|
if (target) {
|
||||||
|
// Check that the room exists and is valid.
|
||||||
|
const room = this.client.getRoom(target);
|
||||||
|
if (room) {
|
||||||
|
return room;
|
||||||
|
} else {
|
||||||
|
target = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// We need to create our own policy room for ignoring invites.
|
||||||
|
target = (await this.client.createRoom({
|
||||||
|
name: "Individual Policy Room",
|
||||||
|
preset: Preset.PrivateChat,
|
||||||
|
})).room_id;
|
||||||
|
await this.withIgnoreInvitesPolicies(ignoreInvitesPolicies => {
|
||||||
|
ignoreInvitesPolicies.target = target;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Since we have just called `createRoom`, `getRoom` should not be `null`.
|
||||||
|
return this.client.getRoom(target)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of source rooms, i.e. the rooms from which rules need to be read.
|
||||||
|
*
|
||||||
|
* If no source rooms are setup, the target room is used as sole source room.
|
||||||
|
*
|
||||||
|
* Note: This method is public for testing reasons. Most clients should not need
|
||||||
|
* to call it directly.
|
||||||
|
*
|
||||||
|
* # Safety
|
||||||
|
*
|
||||||
|
* This method will rewrite the `Policies` object in the user's account data.
|
||||||
|
* This rewrite is inherently racy and could overwrite or be overwritten by
|
||||||
|
* other concurrent rewrites of the same object.
|
||||||
|
*/
|
||||||
|
public async getOrCreateSourceRooms(): Promise<Room[]> {
|
||||||
|
const ignoreInvitesPolicies = this.getIgnoreInvitesPolicies();
|
||||||
|
let sources = ignoreInvitesPolicies.sources;
|
||||||
|
|
||||||
|
// Validate `sources`. If it is invalid, trash out the current `sources`
|
||||||
|
// and create a new list of sources from `target`.
|
||||||
|
let hasChanges = false;
|
||||||
|
if (!Array.isArray(sources)) {
|
||||||
|
// `sources` could not be an array.
|
||||||
|
hasChanges = true;
|
||||||
|
sources = [];
|
||||||
|
}
|
||||||
|
let sourceRooms: Room[] = sources
|
||||||
|
// `sources` could contain non-string / invalid room ids
|
||||||
|
.filter(roomId => typeof roomId === "string")
|
||||||
|
.map(roomId => this.client.getRoom(roomId))
|
||||||
|
.filter(room => !!room);
|
||||||
|
if (sourceRooms.length != sources.length) {
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
if (sourceRooms.length == 0) {
|
||||||
|
// `sources` could be empty (possibly because we've removed
|
||||||
|
// invalid content)
|
||||||
|
const target = await this.getOrCreateTargetRoom();
|
||||||
|
hasChanges = true;
|
||||||
|
sourceRooms = [target];
|
||||||
|
}
|
||||||
|
if (hasChanges) {
|
||||||
|
// Reload `policies`/`ignoreInvitesPolicies` in case it has been changed
|
||||||
|
// during or by our call to `this.getTargetRoom()`.
|
||||||
|
await this.withIgnoreInvitesPolicies(ignoreInvitesPolicies => {
|
||||||
|
ignoreInvitesPolicies.sources = sources;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return sourceRooms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the `IGNORE_INVITES_POLICIES` object from account data.
|
||||||
|
*
|
||||||
|
* If both an unstable prefix version and a stable prefix version are available,
|
||||||
|
* it will return the stable prefix version preferentially.
|
||||||
|
*
|
||||||
|
* The result is *not* validated but is guaranteed to be a non-null object.
|
||||||
|
*
|
||||||
|
* @returns A non-null object.
|
||||||
|
*/
|
||||||
|
private getIgnoreInvitesPolicies(): {[key: string]: any} {
|
||||||
|
return this.getPoliciesAndIgnoreInvitesPolicies().ignoreInvitesPolicies;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modify in place the `IGNORE_INVITES_POLICIES` object from account data.
|
||||||
|
*/
|
||||||
|
private async withIgnoreInvitesPolicies(cb: (ignoreInvitesPolicies: {[key: string]: any}) => void) {
|
||||||
|
const { policies, ignoreInvitesPolicies } = this.getPoliciesAndIgnoreInvitesPolicies();
|
||||||
|
cb(ignoreInvitesPolicies);
|
||||||
|
policies[IGNORE_INVITES_ACCOUNT_EVENT_KEY.name] = ignoreInvitesPolicies;
|
||||||
|
await this.client.setAccountData(POLICIES_ACCOUNT_EVENT_TYPE.name, policies);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As `getIgnoreInvitesPolicies` but also return the `POLICIES_ACCOUNT_EVENT_TYPE`
|
||||||
|
* object.
|
||||||
|
*/
|
||||||
|
private getPoliciesAndIgnoreInvitesPolicies():
|
||||||
|
{policies: {[key: string]: any}, ignoreInvitesPolicies: {[key: string]: any}} {
|
||||||
|
let policies = {};
|
||||||
|
for (const key of [POLICIES_ACCOUNT_EVENT_TYPE.name, POLICIES_ACCOUNT_EVENT_TYPE.altName]) {
|
||||||
|
if (!key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value = this.client.getAccountData(key)?.getContent();
|
||||||
|
if (value) {
|
||||||
|
policies = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ignoreInvitesPolicies = {};
|
||||||
|
let hasIgnoreInvitesPolicies = false;
|
||||||
|
for (const key of [IGNORE_INVITES_ACCOUNT_EVENT_KEY.name, IGNORE_INVITES_ACCOUNT_EVENT_KEY.altName]) {
|
||||||
|
if (!key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value = policies[key];
|
||||||
|
if (value && typeof value == "object") {
|
||||||
|
ignoreInvitesPolicies = value;
|
||||||
|
hasIgnoreInvitesPolicies = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasIgnoreInvitesPolicies) {
|
||||||
|
policies[IGNORE_INVITES_ACCOUNT_EVENT_KEY.name] = ignoreInvitesPolicies;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { policies, ignoreInvitesPolicies };
|
||||||
|
}
|
||||||
|
}
|
@ -341,7 +341,7 @@ export function escapeRegExp(string: string): string {
|
|||||||
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function globToRegexp(glob: string, extended?: any): string {
|
export function globToRegexp(glob: string, extended = false): string {
|
||||||
// From
|
// From
|
||||||
// https://github.com/matrix-org/synapse/blob/abbee6b29be80a77e05730707602f3bbfc3f38cb/synapse/push/__init__.py#L132
|
// https://github.com/matrix-org/synapse/blob/abbee6b29be80a77e05730707602f3bbfc3f38cb/synapse/push/__init__.py#L132
|
||||||
// Because micromatch is about 130KB with dependencies,
|
// Because micromatch is about 130KB with dependencies,
|
||||||
@ -349,7 +349,7 @@ export function globToRegexp(glob: string, extended?: any): string {
|
|||||||
const replacements: ([RegExp, string | ((substring: string, ...args: any[]) => string) ])[] = [
|
const replacements: ([RegExp, string | ((substring: string, ...args: any[]) => string) ])[] = [
|
||||||
[/\\\*/g, '.*'],
|
[/\\\*/g, '.*'],
|
||||||
[/\?/g, '.'],
|
[/\?/g, '.'],
|
||||||
extended !== false && [
|
!extended && [
|
||||||
/\\\[(!|)(.*)\\]/g,
|
/\\\[(!|)(.*)\\]/g,
|
||||||
(_match: string, neg: string, pat: string) => [
|
(_match: string, neg: string, pat: string) => [
|
||||||
'[',
|
'[',
|
||||||
|
Reference in New Issue
Block a user