333 lines
11 KiB
JavaScript
333 lines
11 KiB
JavaScript
|
|
/*
|
||
|
|
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 { EventTimeline } from "./event-timeline.js";
|
||
|
|
import { Preset } from "../@types/partials.js";
|
||
|
|
import { globToRegexp } from "../utils.js";
|
||
|
|
import { EventType } from "../@types/event.js";
|
||
|
|
import { IGNORE_INVITES_ACCOUNT_EVENT_KEY, POLICIES_ACCOUNT_EVENT_TYPE, PolicyRecommendation, PolicyScope } from "./invites-ignorer-types.js";
|
||
|
|
export { IGNORE_INVITES_ACCOUNT_EVENT_KEY, POLICIES_ACCOUNT_EVENT_TYPE, PolicyRecommendation, PolicyScope };
|
||
|
|
const scopeToEventTypeMap = {
|
||
|
|
[PolicyScope.User]: EventType.PolicyRuleUser,
|
||
|
|
[PolicyScope.Room]: EventType.PolicyRuleRoom,
|
||
|
|
[PolicyScope.Server]: EventType.PolicyRuleServer
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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(client) {
|
||
|
|
this.client = client;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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.
|
||
|
|
* @returns The event id for the new rule.
|
||
|
|
*/
|
||
|
|
async addRule(scope, entity, reason) {
|
||
|
|
const target = await this.getOrCreateTargetRoom();
|
||
|
|
const response = await this.client.sendStateEvent(target.roomId, scopeToEventTypeMap[scope], {
|
||
|
|
entity,
|
||
|
|
reason,
|
||
|
|
recommendation: PolicyRecommendation.Ban
|
||
|
|
});
|
||
|
|
return response.event_id;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Remove a rule.
|
||
|
|
*/
|
||
|
|
async removeRule(event) {
|
||
|
|
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.
|
||
|
|
* @returns `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.
|
||
|
|
*/
|
||
|
|
async addSource(roomId) {
|
||
|
|
// 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 params
|
||
|
|
* @param params.sender - The user id for the user who issued the invite.
|
||
|
|
* @param params.roomId - The room to which the user is invited.
|
||
|
|
* @returns A rule matching the entity, if any was found, `null` otherwise.
|
||
|
|
*/
|
||
|
|
async getRuleForInvite({
|
||
|
|
sender,
|
||
|
|
roomId
|
||
|
|
}) {
|
||
|
|
// 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(scopeToEventTypeMap[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;
|
||
|
|
try {
|
||
|
|
regexp = new RegExp(globToRegexp(glob));
|
||
|
|
} catch {
|
||
|
|
// 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.
|
||
|
|
*/
|
||
|
|
async getOrCreateTargetRoom() {
|
||
|
|
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.
|
||
|
|
*/
|
||
|
|
async getOrCreateSourceRooms() {
|
||
|
|
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 = 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.
|
||
|
|
*/
|
||
|
|
getIgnoreInvitesPolicies() {
|
||
|
|
return this.getPoliciesAndIgnoreInvitesPolicies().ignoreInvitesPolicies;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Modify in place the `IGNORE_INVITES_POLICIES` object from account data.
|
||
|
|
*/
|
||
|
|
async withIgnoreInvitesPolicies(cb) {
|
||
|
|
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.
|
||
|
|
*/
|
||
|
|
getPoliciesAndIgnoreInvitesPolicies() {
|
||
|
|
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
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
//# sourceMappingURL=invites-ignorer.js.map
|