diff --git a/package.json b/package.json index e709662020..745f82d7bc 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "gemini-scrollbar": "github:matrix-org/gemini-scrollbar#91e1e566", "gfm.css": "^1.1.1", "glob": "^5.0.14", + "glob-to-regexp": "^0.4.1", "highlight.js": "^9.15.8", "is-ip": "^2.0.0", "isomorphic-fetch": "^2.2.1", diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index e909f49159..770f4723ef 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -389,6 +389,8 @@ "Call invitation": "Call invitation", "Messages sent by bot": "Messages sent by bot", "When rooms are upgraded": "When rooms are upgraded", + "My Ban List": "My Ban List", + "This is your list of users/servers you have blocked - don't leave the room!": "This is your list of users/servers you have blocked - don't leave the room!", "Active call (%(roomName)s)": "Active call (%(roomName)s)", "unknown caller": "unknown caller", "Incoming voice call from %(name)s": "Incoming voice call from %(name)s", diff --git a/src/mjolnir/BanList.js b/src/mjolnir/BanList.js new file mode 100644 index 0000000000..6ebc0a7e36 --- /dev/null +++ b/src/mjolnir/BanList.js @@ -0,0 +1,98 @@ +/* +Copyright 2019 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. +*/ + +// Inspiration largely taken from Mjolnir itself + +import {ListRule, RECOMMENDATION_BAN, recommendationToStable} from "./ListRule"; +import MatrixClientPeg from "../MatrixClientPeg"; + +export const RULE_USER = "m.room.rule.user"; +export const RULE_ROOM = "m.room.rule.room"; +export const RULE_SERVER = "m.room.rule.server"; + +export const USER_RULE_TYPES = [RULE_USER, "org.matrix.mjolnir.rule.user"]; +export const ROOM_RULE_TYPES = [RULE_ROOM, "org.matrix.mjolnir.rule.room"]; +export const SERVER_RULE_TYPES = [RULE_SERVER, "org.matrix.mjolnir.rule.server"]; +export const ALL_RULE_TYPES = [...USER_RULE_TYPES, ...ROOM_RULE_TYPES, ...SERVER_RULE_TYPES]; + +export function ruleTypeToStable(rule: string, unstable = true): string { + if (USER_RULE_TYPES.includes(rule)) return unstable ? USER_RULE_TYPES[USER_RULE_TYPES.length - 1] : RULE_USER; + if (ROOM_RULE_TYPES.includes(rule)) return unstable ? ROOM_RULE_TYPES[ROOM_RULE_TYPES.length - 1] : RULE_ROOM; + if (SERVER_RULE_TYPES.includes(rule)) return unstable ? SERVER_RULE_TYPES[SERVER_RULE_TYPES.length - 1] : RULE_SERVER; + return null; +} + +export class BanList { + _rules: ListRule[] = []; + _roomId: string; + + constructor(roomId: string) { + this._roomId = roomId; + this.updateList(); + } + + get roomId(): string { + return this._roomId; + } + + get serverRules(): ListRule[] { + return this._rules.filter(r => r.kind === RULE_SERVER); + } + + get userRules(): ListRule[] { + return this._rules.filter(r => r.kind === RULE_USER); + } + + get roomRules(): ListRule[] { + return this._rules.filter(r => r.kind === RULE_ROOM); + } + + banEntity(kind: string, entity: string, reason: string): Promise { + return MatrixClientPeg.get().sendStateEvent(this._roomId, ruleTypeToStable(kind, true), { + entity: entity, + reason: reason, + recommendation: recommendationToStable(RECOMMENDATION_BAN, true), + }, "rule:" + entity); + } + + unbanEntity(kind: string, entity: string): Promise { + // Empty state event is effectively deleting it. + return MatrixClientPeg.get().sendStateEvent(this._roomId, ruleTypeToStable(kind, true), {}, "rule:" + entity); + } + + updateList() { + this._rules = []; + + const room = MatrixClientPeg.get().getRoom(this._roomId); + if (!room) return; + + for (const eventType of ALL_RULE_TYPES) { + const events = room.currentState.getStateEvents(eventType, undefined); + for (const ev of events) { + if (!ev['state_key']) continue; + + const kind = ruleTypeToStable(eventType, false); + + const entity = ev.getContent()['entity']; + const recommendation = ev.getContent()['recommendation']; + const reason = ev.getContent()['reason']; + if (!entity || !recommendation || !reason) continue; + + this._rules.push(new ListRule(entity, recommendation, reason, kind)); + } + } + } +} diff --git a/src/mjolnir/ListRule.js b/src/mjolnir/ListRule.js new file mode 100644 index 0000000000..d33248d24c --- /dev/null +++ b/src/mjolnir/ListRule.js @@ -0,0 +1,63 @@ +/* +Copyright 2019 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 {MatrixGlob} from "../utils/MatrixGlob"; + +// Inspiration largely taken from Mjolnir itself + +export const RECOMMENDATION_BAN = "m.ban"; +export const RECOMMENDATION_BAN_TYPES = [RECOMMENDATION_BAN, "org.matrix.mjolnir.ban"]; + +export function recommendationToStable(recommendation: string, unstable = true): string { + if (RECOMMENDATION_BAN_TYPES.includes(recommendation)) return unstable ? RECOMMENDATION_BAN_TYPES[RECOMMENDATION_BAN_TYPES.length - 1] : RECOMMENDATION_BAN; + return null; +} + +export class ListRule { + _glob: MatrixGlob; + _entity: string; + _action: string; + _reason: string; + _kind: string; + + constructor(entity: string, action: string, reason: string, kind: string) { + this._glob = new MatrixGlob(entity); + this._entity = entity; + this._action = recommendationToStable(action, false); + this._reason = reason; + this._kind = kind; + } + + get entity(): string { + return this._entity; + } + + get reason(): string { + return this._reason; + } + + get kind(): string { + return this._kind; + } + + get recommendation(): string { + return this._action; + } + + isMatch(entity: string): boolean { + return this._glob.test(entity); + } +} diff --git a/src/mjolnir/Mjolnir.js b/src/mjolnir/Mjolnir.js new file mode 100644 index 0000000000..a12534592d --- /dev/null +++ b/src/mjolnir/Mjolnir.js @@ -0,0 +1,122 @@ +/* +Copyright 2019 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 MatrixClientPeg from "../MatrixClientPeg"; +import {ALL_RULE_TYPES, BanList} from "./BanList"; +import SettingsStore, {SettingLevel} from "../settings/SettingsStore"; +import {_t} from "../languageHandler"; + +// TODO: Move this and related files to the js-sdk or something once finalized. + +export class Mjolnir { + static _instance: Mjolnir = null; + + _lists: BanList[] = []; + _roomIds: string[] = []; + _mjolnirWatchRef = null; + + constructor() { + } + + start() { + this._updateLists(SettingsStore.getValue("mjolnirRooms")); + this._mjolnirWatchRef = SettingsStore.watchSetting("mjolnirRooms", null, this._onListsChanged.bind(this)); + + MatrixClientPeg.get().on("RoomState.events", this._onEvent.bind(this)); + } + + stop() { + SettingsStore.unwatchSetting(this._mjolnirWatchRef); + MatrixClientPeg.get().removeListener("RoomState.events", this._onEvent.bind(this)); + } + + async getOrCreatePersonalList(): Promise { + let personalRoomId = SettingsStore.getValue("mjolnirPersonalRoom"); + if (!personalRoomId) { + const resp = await MatrixClientPeg.get().createRoom({ + name: _t("My Ban List"), + topic: _t("This is your list of users/servers you have blocked - don't leave the room!"), + preset: "private_chat" + }); + personalRoomId = resp['room_id']; + SettingsStore.setValue("mjolnirPersonalRoom", null, SettingLevel.ACCOUNT, personalRoomId); + SettingsStore.setValue("mjolnirRooms", null, SettingLevel.ACCOUNT, [personalRoomId, ...this._roomIds]); + } + if (!personalRoomId) { + throw new Error("Error finding a room ID to use"); + } + + let list = this._lists.find(b => b.roomId === personalRoomId); + if (!list) list = new BanList(personalRoomId); + // we don't append the list to the tracked rooms because it should already be there. + // we're just trying to get the caller some utility access to the list + + return list; + } + + _onEvent(event) { + if (!this._roomIds.includes(event.getRoomId())) return; + if (!ALL_RULE_TYPES.includes(event.getType())) return; + + this._updateLists(this._roomIds); + } + + _onListsChanged(settingName, roomId, atLevel, newValue) { + // We know that ban lists are only recorded at one level so we don't need to re-eval them + this._updateLists(newValue); + } + + _updateLists(listRoomIds: string[]) { + this._lists = []; + this._roomIds = listRoomIds || []; + if (!listRoomIds) return; + + for (const roomId of listRoomIds) { + // Creating the list updates it + this._lists.push(new BanList(roomId)); + } + } + + isServerBanned(serverName: string): boolean { + for (const list of this._lists) { + for (const rule of list.serverRules) { + if (rule.isMatch(serverName)) { + return true; + } + } + } + return false; + } + + isUserBanned(userId: string): boolean { + for (const list of this._lists) { + for (const rule of list.userRules) { + if (rule.isMatch(userId)) { + return true; + } + } + } + return false; + } + + static sharedInstance(): Mjolnir { + if (!Mjolnir._instance) { + Mjolnir._instance = new Mjolnir(); + } + return Mjolnir._instance; + } +} + diff --git a/src/utils/MatrixGlob.js b/src/utils/MatrixGlob.js new file mode 100644 index 0000000000..cf55040625 --- /dev/null +++ b/src/utils/MatrixGlob.js @@ -0,0 +1,54 @@ +/* +Copyright 2019 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 * as globToRegexp from "glob-to-regexp"; + +// Taken with permission from matrix-bot-sdk: +// https://github.com/turt2live/matrix-js-bot-sdk/blob/eb148c2ecec7bf3ade801d73deb43df042d55aef/src/MatrixGlob.ts + +/** + * Represents a common Matrix glob. This is commonly used + * for server ACLs and similar functions. + */ +export class MatrixGlob { + _regex: RegExp; + + /** + * Creates a new Matrix Glob + * @param {string} glob The glob to convert. Eg: "*.example.org" + */ + constructor(glob: string) { + const globRegex = globToRegexp(glob, { + extended: false, + globstar: false, + }); + + // We need to convert `?` manually because globToRegexp's extended mode + // does more than we want it to. + const replaced = globRegex.toString().replace(/\\\?/g, "."); + this._regex = new RegExp(replaced.substring(1, replaced.length - 1)); + } + + /** + * Tests the glob against a value, returning true if it matches. + * @param {string} val The value to test. + * @returns {boolean} True if the value matches the glob, false otherwise. + */ + test(val: string): boolean { + return this._regex.test(val); + } + +} diff --git a/yarn.lock b/yarn.lock index aa0a06e588..a2effb975c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3674,6 +3674,11 @@ glob-to-regexp@^0.3.0: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab" integrity sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs= +glob-to-regexp@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" + integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== + glob@7.1.2: version "7.1.2" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15"