banlist.ts (19859B)
1 import { MatrixClient, MatrixGlob, RoomCreateOptions } from "matrix-bot-sdk"; 2 import { Config } from "../index"; 3 import short from "short-uuid"; 4 5 export enum EntityType { 6 /// `entity` is to be parsed as a glob of users IDs 7 RULE_USER = "m.policy.rule.user", 8 9 /// `entity` is to be parsed as a glob of room IDs/aliases 10 RULE_ROOM = "m.policy.rule.room", 11 12 /// `entity` is to be parsed as a glob of server names 13 RULE_SERVER = "m.policy.rule.server", 14 } 15 16 export const RULE_USER = EntityType.RULE_USER; 17 export const RULE_ROOM = EntityType.RULE_ROOM; 18 export const RULE_SERVER = EntityType.RULE_SERVER; 19 20 // README! The order here matters for determining whether a type is obsolete, most recent should be first. 21 // These are the current and historical types for each type of rule which were used while MSC2313 was being developed 22 // and were left as an artifact for some time afterwards. 23 // Most rules (as of writing) will have the prefix `m.room.rule.*` as this has been in use for roughly 2 years. 24 export const USER_RULE_TYPES = [RULE_USER, "m.room.rule.user", "org.matrix.mjolnir.rule.user"]; 25 export const ROOM_RULE_TYPES = [RULE_ROOM, "m.room.rule.room", "org.matrix.mjolnir.rule.room"]; 26 export const SERVER_RULE_TYPES = [RULE_SERVER, "m.room.rule.server", "org.matrix.mjolnir.rule.server"]; 27 export const ALL_RULE_TYPES = [...USER_RULE_TYPES, ...ROOM_RULE_TYPES, ...SERVER_RULE_TYPES]; 28 29 export enum Recommendation { 30 /// The rule recommends a "ban". 31 /// 32 /// The actual semantics for this "ban" may vary, e.g. room ban, 33 /// server ban, ignore user, etc. To determine the semantics for 34 /// this "ban", clients need to take into account the context for 35 /// the list, e.g. how the rule was imported. 36 Ban = "m.ban", 37 38 /// The rule specifies an "opinion", as a number in [-100, +100], 39 /// where -100 represents a user who is considered absolutely toxic 40 /// by whoever issued this ListRule and +100 represents a user who 41 /// is considered absolutely absolutely perfect by whoever issued 42 /// this ListRule. 43 Opinion = "org.matrix.msc3845.opinion", 44 45 /** 46 * This is a rule that recommends allowing a user to participate. 47 * Used for the construction of allow lists. 48 */ 49 Allow = "org.matrix.mjolnir.allow", 50 } 51 52 interface PolicyStateEvent { 53 type: string, 54 content: { 55 entity: string, 56 reason: string, 57 recommendation: Recommendation, 58 opinion?: number, 59 }, 60 event_id: string, 61 state_key: string, 62 } 63 64 /** 65 * All variants of recommendation `m.ban` 66 */ 67 const RECOMMENDATION_BAN_VARIANTS = [ 68 // Stable 69 Recommendation.Ban, 70 // Unstable prefix, for compatibility. 71 "org.matrix.mjolnir.ban" 72 ]; 73 74 /** 75 * All variants of recommendation `m.opinion` 76 */ 77 const RECOMMENDATION_OPINION_VARIANTS: string[] = [ 78 // Unstable 79 Recommendation.Opinion 80 ]; 81 82 const RECOMMENDATION_ALLOW_VARIANTS: string[] = [ 83 // Unstable 84 Recommendation.Allow 85 ] 86 87 export const OPINION_MIN = -100; 88 export const OPINION_MAX = +100; 89 90 /** 91 * Representation of a rule within a Policy List. 92 */ 93 export abstract class ListRule { 94 /** 95 * A glob for `entity`. 96 */ 97 private glob: MatrixGlob; 98 constructor( 99 /** 100 * The event source for the rule. 101 */ 102 public readonly sourceEvent: PolicyStateEvent, 103 /** 104 * The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain. 105 */ 106 public readonly entity: string, 107 /** 108 * A human-readable reason for this rule, for audit purposes. 109 */ 110 public readonly reason: string, 111 /** 112 * The type of entity for this rule, e.g. user, server domain, etc. 113 */ 114 public readonly kind: EntityType, 115 /** 116 * The recommendation for this rule, e.g. "ban" or "opinion", or `null` 117 * if the recommendation is one that Mjölnir doesn't understand. 118 */ 119 public readonly recommendation: Recommendation | null) { 120 this.glob = new MatrixGlob(entity); 121 } 122 123 /** 124 * Determine whether this rule should apply to a given entity. 125 */ 126 public isMatch(entity: string): boolean { 127 return this.glob.test(entity); 128 } 129 130 /** 131 * @returns Whether the entity in he rule represents a Matrix glob (and not a literal). 132 */ 133 public isGlob(): boolean { 134 return /[*?]/.test(this.entity); 135 } 136 137 /** 138 * Validate and parse an event into a ListRule. 139 * 140 * @param event An *untrusted* event. 141 * @returns null if the ListRule is invalid or not recognized by Mjölnir. 142 */ 143 public static parse(event: PolicyStateEvent): ListRule | null { 144 // Parse common fields. 145 // If a field is ill-formed, discard the rule. 146 const content = event['content']; 147 if (!content || typeof content !== "object") { 148 return null; 149 } 150 const entity = content['entity']; 151 if (!entity || typeof entity !== "string") { 152 return null; 153 } 154 const recommendation = content['recommendation']; 155 if (!recommendation || typeof recommendation !== "string") { 156 return null; 157 } 158 159 const reason = content['reason'] || '<no reason>'; 160 if (typeof reason !== "string") { 161 return null; 162 } 163 164 const type = event['type']; 165 let kind; 166 if (USER_RULE_TYPES.includes(type)) { 167 kind = EntityType.RULE_USER; 168 } else if (ROOM_RULE_TYPES.includes(type)) { 169 kind = EntityType.RULE_ROOM; 170 } else if (SERVER_RULE_TYPES.includes(type)) { 171 kind = EntityType.RULE_SERVER; 172 } else { 173 return null; 174 } 175 176 // From this point, we may need specific fields. 177 if (RECOMMENDATION_BAN_VARIANTS.includes(recommendation)) { 178 return new ListRuleBan(event, entity, reason, kind); 179 } else if (RECOMMENDATION_OPINION_VARIANTS.includes(recommendation)) { 180 const opinion = content['opinion']; 181 if (!Number.isInteger(opinion)) { 182 return null; 183 } 184 return new ListRuleOpinion(event, entity, reason, kind, opinion); 185 } else if (RECOMMENDATION_ALLOW_VARIANTS.includes(recommendation)) { 186 return new ListRuleAllow(event, entity, reason, kind); 187 } else { 188 // As long as the `recommendation` is defined, we assume 189 // that the rule is correct, just unknown. 190 return new ListRuleUnknown(event, entity, reason, kind, content); 191 } 192 } 193 } 194 195 /** 196 * A rule representing a "ban". 197 */ 198 export class ListRuleBan extends ListRule { 199 constructor( 200 /** 201 * The event source for the rule. 202 */ 203 sourceEvent: PolicyStateEvent, 204 /** 205 * The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain. 206 */ 207 entity: string, 208 /** 209 * A human-readable reason for this rule, for audit purposes. 210 */ 211 reason: string, 212 /** 213 * The type of entity for this rule, e.g. user, server domain, etc. 214 */ 215 kind: EntityType, 216 ) { 217 super(sourceEvent, entity, reason, kind, Recommendation.Ban) 218 } 219 } 220 221 /** 222 * A rule representing an "allow". 223 */ 224 export class ListRuleAllow extends ListRule { 225 constructor( 226 /** 227 * The event source for the rule. 228 */ 229 sourceEvent: PolicyStateEvent, 230 /** 231 * The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain. 232 */ 233 entity: string, 234 /** 235 * A human-readable reason for this rule, for audit purposes. 236 */ 237 reason: string, 238 /** 239 * The type of entity for this rule, e.g. user, server domain, etc. 240 */ 241 kind: EntityType, 242 ) { 243 super(sourceEvent, entity, reason, kind, Recommendation.Allow) 244 } 245 } 246 247 /** 248 * A rule representing an "opinion" 249 */ 250 export class ListRuleOpinion extends ListRule { 251 constructor( 252 /** 253 * The event source for the rule. 254 */ 255 sourceEvent: PolicyStateEvent, 256 /** 257 * The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain. 258 */ 259 entity: string, 260 /** 261 * A human-readable reason for this rule, for audit purposes. 262 */ 263 reason: string, 264 /** 265 * The type of entity for this rule, e.g. user, server domain, etc. 266 */ 267 kind: EntityType, 268 /** 269 * A number in [-100, +100] where -100 represents the worst possible opinion 270 * on the entity (e.g. toxic user or community) and +100 represents the best 271 * possible opinion on the entity (e.g. pillar of the community). 272 */ 273 public readonly opinion: number | undefined 274 ) { 275 super(sourceEvent, entity, reason, kind, Recommendation.Opinion); 276 if (!Number.isInteger(opinion)) { 277 throw new TypeError(`The opinion must be an integer, got ${opinion ?? 'undefined'}`); 278 } 279 if ((opinion ?? 0) < OPINION_MIN || (opinion ?? 0) > OPINION_MAX) { 280 throw new TypeError(`The opinion must be within [-100, +100], got ${opinion ?? 'undefined'}`); 281 } 282 } 283 } 284 285 /** 286 * Any list rule that we do not understand. 287 */ 288 export class ListRuleUnknown extends ListRule { 289 constructor( 290 /** 291 * The event source for the rule. 292 */ 293 sourceEvent: PolicyStateEvent, 294 /** 295 * The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain. 296 */ 297 entity: string, 298 /** 299 * A human-readable reason for this rule, for audit purposes. 300 */ 301 reason: string, 302 /** 303 * The type of entity for this rule, e.g. user, server domain, etc. 304 */ 305 kind: EntityType, 306 /** 307 * The event used to create the rule. 308 */ 309 public readonly content: unknown, 310 ) { 311 super(sourceEvent, entity, reason, kind, null); 312 } 313 } 314 315 /* Soom of this code is taken from. (Not copied but based upon. This is needed for compat reasons.) */ 316 export default class BanListHandler { 317 private readonly uuidGen = short(short.constants.cookieBase90); 318 constructor(private readonly client: MatrixClient, private readonly config: Config) { } 319 320 /** 321 * This is used to annotate state events we store with the rule they are associated with. 322 * If we refactor this, it is important to also refactor any listeners to 'PolicyList.update' 323 * which may assume `ListRule`s that are removed will be identital (Object.is) to when they were added. 324 * If you are adding new listeners, you should check the source event_id of the rule. 325 */ 326 private static readonly EVENT_RULE_ANNOTATION_KEY = 'org.matrix.mjolnir.annotation.rule'; 327 328 public static async createPolicyRoom(client: MatrixClient): Promise<string> { 329 const powerLevels: { [key: string]: number | object } = { 330 "ban": 50, 331 "events": { 332 "m.room.name": 100, 333 "m.room.power_levels": 100, 334 }, 335 "events_default": 50, // non-default 336 "invite": 0, 337 "kick": 50, 338 "notifications": { 339 "room": 20, 340 }, 341 "redact": 50, 342 "state_default": 50, 343 "users": { 344 [await client.getUserId()]: 100, 345 }, 346 "users_default": 0, 347 }; 348 // Support for MSC3784. 349 const roomOptions: RoomCreateOptions = { 350 creation_content: { 351 type: "support.feline.policy.lists.msc.v1" 352 }, 353 preset: "public_chat", 354 355 power_level_content_override: powerLevels, 356 } 357 const listRoomId = await client.createRoom(roomOptions); 358 return listRoomId 359 } 360 361 // Adds a rule to the ban list and bans the user. 362 public async banUser(userId: string, reason: string): Promise<void> { 363 await this.addRule(userId, reason, RULE_USER, Recommendation.Ban); 364 365 // Get all rooms the user is in 366 const rooms = await this.client.getJoinedRooms(); 367 for (const room of rooms) { 368 // Get all members in the room 369 const members = await this.client.getJoinedRoomMembers(room); 370 for (const member of members) { 371 // If the member is the user we want to ban 372 if (member == userId) { 373 // Ban the user 374 // TODO: allow setting a reason 375 await this.client.banUser(room, member, reason); 376 377 // Redact all messages the user sent 378 // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 379 const messages = await this.client.doRequest("GET", `/_matrix/client/v3/rooms/${room}/messages?dir=b&limit=100`); 380 // Load more if we have more than 100 messages 381 // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 382 if (messages.chunk.length == 100) { 383 // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/restrict-template-expressions 384 const moreMessages = await this.client.doRequest("GET", `/_matrix/client/v3/rooms/${room}/messages?dir=b&limit=100&from=${messages.end}`); 385 // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call 386 messages.chunk = messages.chunk.concat(moreMessages.chunk); 387 } 388 // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 389 for (const message of messages.chunk) { 390 // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access 391 await this.client.redactEvent(room, message.event_id); 392 } 393 } 394 } 395 } 396 } 397 398 // This kicks the user. It doesn't actually add a rule to the ban list. 399 public async kickUser(userId: string, reason: string): Promise<void> { 400 // Get all rooms the user is in 401 const rooms = await this.client.getJoinedRooms(); 402 for (const room of rooms) { 403 // Get all members in the room 404 const members = await this.client.getJoinedRoomMembers(room); 405 for (const member of members) { 406 // If the member is the user we want to ban 407 if (member == userId) { 408 // Ban the user 409 // TODO: allow setting a reason 410 await this.client.kickUser(room, member, reason); 411 412 // Redact all messages the user sent 413 // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 414 const messages = await this.client.doRequest("GET", `/_matrix/client/v3/rooms/${room}/messages?dir=b&limit=100`); 415 // Load more if we have more than 100 messages 416 // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 417 if (messages.chunk.length == 100) { 418 // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/restrict-template-expressions 419 const moreMessages = await this.client.doRequest("GET", `/_matrix/client/v3/rooms/${room}/messages?dir=b&limit=100&from=${messages.end}`); 420 // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call 421 messages.chunk = messages.chunk.concat(moreMessages.chunk); 422 } 423 // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 424 for (const message of messages.chunk) { 425 // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access 426 await this.client.redactEvent(room, message.event_id); 427 } 428 } 429 } 430 } 431 } 432 433 private async addRule(userId: string, reason: string, kind: string, recommendation: Recommendation): Promise<void> { 434 await this.client.sendStateEvent(this.config.banlistRoom, kind, this.uuidGen.new(), { 435 entity: userId, 436 reason: reason, 437 recommendation: recommendation, 438 }); 439 } 440 441 /** 442 * Return all the active rules of a given kind. 443 * @param kind e.g. RULE_SERVER (m.policy.rule.server). Rule types are always normalised when they are interned into the PolicyList. 444 * @param recommendation A specific recommendation to filter for e.g. `m.ban`. Please remember recommendation varients are normalized. 445 * @returns The active ListRules for the ban list of that kind. 446 */ 447 public async rulesOfKind(roomId: string, kind: string, recommendation?: Recommendation): Promise<ListRule[]> { 448 const rules: ListRule[] = [] 449 // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 450 const stateKeyMap = await this.client.getRoomStateEvent(roomId, kind, ""); 451 if (stateKeyMap) { 452 // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call 453 for (const event of stateKeyMap.values()) { 454 // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment 455 const rule = event[BanListHandler.EVENT_RULE_ANNOTATION_KEY]; 456 // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 457 if (rule && rule.kind === kind) { 458 if (recommendation === undefined) { 459 rules.push(rule as ListRuleUnknown); 460 // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 461 } else if (rule.recommendation === recommendation) { 462 // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 463 switch (rule.recommendation) { 464 case Recommendation.Ban: 465 rules.push(rule as ListRuleBan); 466 break; 467 case Recommendation.Allow: 468 rules.push(rule as ListRuleAllow); 469 break; 470 case Recommendation.Opinion: 471 rules.push(rule as ListRuleOpinion); 472 break; 473 default: 474 rules.push(rule as ListRuleUnknown); 475 break; 476 } 477 } 478 } 479 } 480 } 481 return rules; 482 } 483 484 public async serverRules(roomId: string): Promise<ListRule[]> { 485 return this.rulesOfKind(roomId, RULE_SERVER); 486 } 487 488 public async userRules(roomId: string): Promise<ListRule[]> { 489 return this.rulesOfKind(roomId, RULE_USER); 490 } 491 492 public async roomRules(roomId: string): Promise<ListRule[]> { 493 return this.rulesOfKind(roomId, RULE_ROOM); 494 } 495 496 public async allRules(roomId: string): Promise<ListRule[]> { 497 return [...await this.serverRules(roomId), ...await this.userRules(roomId), ...await this.roomRules(roomId)]; 498 } 499 }