From c2791b947036c7d6fee80da9e55f4c69f12a031f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 21 Feb 2019 15:36:37 +0100 Subject: [PATCH 01/23] refactor pickServerCandidates into statefull class so we don't need to iterate over all members for every permalink --- src/matrix-to.js | 305 +++++++++++++++++++++++++++++++---------------- 1 file changed, 203 insertions(+), 102 deletions(-) diff --git a/src/matrix-to.js b/src/matrix-to.js index b750dff6d67..d997be12f91 100644 --- a/src/matrix-to.js +++ b/src/matrix-to.js @@ -25,17 +25,211 @@ export const baseUrl = `https://${host}`; // to add to permalinks. The servers are appended as ?via=example.org const MAX_SERVER_CANDIDATES = 3; -export function makeEventPermalink(roomId, eventId) { - const permalinkBase = `${baseUrl}/#/${roomId}/${eventId}`; - // If the roomId isn't actually a room ID, don't try to list the servers. - // Aliases are already routable, and don't need extra information. - if (roomId[0] !== '!') return permalinkBase; +// Permalinks can have servers appended to them so that the user +// receiving them can have a fighting chance at joining the room. +// These servers are called "candidates" at this point because +// it is unclear whether they are going to be useful to actually +// join in the future. +// +// We pick 3 servers based on the following criteria: +// +// Server 1: The highest power level user in the room, provided +// they are at least PL 50. We don't calculate "what is a moderator" +// here because it is less relevant for the vast majority of rooms. +// We also want to ensure that we get an admin or high-ranking mod +// as they are less likely to leave the room. If no user happens +// to meet this criteria, we'll pick the most popular server in the +// room. +// +// Server 2: The next most popular server in the room (in user +// distribution). This cannot be the same as Server 1. If no other +// servers are available then we'll only return Server 1. +// +// Server 3: The next most popular server by user distribution. This +// has the same rules as Server 2, with the added exception that it +// must be unique from Server 1 and 2. + +// Rationale for popular servers: It's hard to get rid of people when +// they keep flocking in from a particular server. Sure, the server could +// be ACL'd in the future or for some reason be evicted from the room +// however an event like that is unlikely the larger the room gets. If +// the server is ACL'd at the time of generating the link however, we +// shouldn't pick them. We also don't pick IP addresses. + +// Note: we don't pick the server the room was created on because the +// homeserver should already be using that server as a last ditch attempt +// and there's less of a guarantee that the server is a resident server. +// Instead, we actively figure out which servers are likely to be residents +// in the future and try to use those. + +// Note: Users receiving permalinks that happen to have all 3 potential +// servers fail them (in terms of joining) are somewhat expected to hunt +// down the person who gave them the link to ask for a participating server. +// The receiving user can then manually append the known-good server to +// the list and magically have the link work. + +export class RoomPermaLinkCreator { + constructor(room) { + this._room = room; + this._highestPlUserId = null; + this._populationMap = null; + this._bannedHostsRegexps = null; + this._allowedHostsRegexps = null; + this._serverCandidates = null; + + this.onPowerlevel = this.onPowerlevel.bind(this); + this.onMembership = this.onMembership.bind(this); + this.onRoomState = this.onRoomState.bind(this); + } - const serverCandidates = pickServerCandidates(roomId); - return `${permalinkBase}${encodeServerCandidates(serverCandidates)}`; + load() { + this._updateAllowedServers(); + this._updatePopulationMap(); + this._updateServerCandidates(); + } + + start() { + this.load(); + this._room.on("RoomMember.membership", this.onMembership); + this._room.on("RoomMember.powerLevel", this.onPowerlevel); + this._room.on("RoomState.events", this.onRoomState); + } + + stop() { + this._room.off("RoomMember.membership", this.onMembership); + this._room.off("RoomMember.powerLevel", this.onPowerlevel); + this._room.off("RoomState.events", this.onRoomState); + } + + forEvent(eventId) { + const roomId = this._room.roomId; + const permalinkBase = `${baseUrl}/#/${roomId}/${eventId}`; + + // If the roomId isn't actually a room ID, don't try to list the servers. + // Aliases are already routable, and don't need extra information. + if (roomId[0] !== '!') return permalinkBase; + return `${permalinkBase}${encodeServerCandidates(this._serverCandidates)}`; + } + + forRoom() { + const roomId = this._room.roomId; + const permalinkBase = `${baseUrl}/#/${roomId}`; + return `${permalinkBase}${encodeServerCandidates(this._serverCandidates)}`; + } + + onRoomState(event) { + if (event.getType() === "m.room.server_acl") { + this._updateAllowedServers(); + this._updatePopulationMap(); + this._updateServerCandidates(); + } + } + + onMembership(evt, member, oldMembership) { + const userId = member.userId; + const membership = member.membership; + const serverName = getServerName(userId); + const hasJoined = oldMembership !== "join" && membership === "join"; + const hasLeft = oldMembership === "join" && membership !== "join"; + + if (hasLeft) { + this._populationMap[serverName]--; + } else if (hasJoined) { + this._populationMap[serverName]++; + } + + this._updateHighestPlUser(); + this._updateServerCandidates(); + } + + onPowerlevel() { + this._updateHighestPlUser(); + this._updateServerCandidates(); + } + + _updateHighestPlUser() { + const plEvent = this._room.currentState.getStateEvents("m.room.power_levels", ""); + const content = plEvent.getContent(); + if (content) { + const users = content.users; + if (users) { + const entries = Object.entries(users); + const allowedEntries = entries.filter(([userId]) => { + const member = this._room.getMember(userId); + if (!member || member.membership !== "join") { + return false; + } + const serverName = getServerName(userId); + return !isHostnameIpAddress(serverName) && + !isHostInRegex(serverName, this._bannedHostsRegexps) && + isHostInRegex(serverName, this._allowedHostsRegexps); + }); + const maxEntry = allowedEntries.reduce((max, entry) => { + return (entry[1] > max[1]) ? entry : max; + }, [null, 0]); + const [userId, powerLevel] = maxEntry; + // object wasn't empty, and max entry wasn't a demotion from the default + if (userId !== null && powerLevel > (content.users_default || 0)) { + this._highestPlUserId = userId; + return; + } + } + } + this._highestPlUserId = null; + } + + _updateAllowedServers() { + const bannedHostsRegexps = []; + let allowedHostsRegexps = [new RegExp(".*")]; // default allow everyone + if (this._room.currentState) { + const aclEvent = this._room.currentState.getStateEvents("m.room.server_acl", ""); + if (aclEvent && aclEvent.getContent()) { + const getRegex = (hostname) => new RegExp("^" + utils.globToRegexp(hostname, false) + "$"); + + const denied = aclEvent.getContent().deny || []; + denied.forEach(h => bannedHostsRegexps.push(getRegex(h))); + + const allowed = aclEvent.getContent().allow || []; + allowedHostsRegexps = []; // we don't want to use the default rule here + allowed.forEach(h => allowedHostsRegexps.push(getRegex(h))); + } + } + this._bannedHostsRegexps = bannedHostsRegexps; + this._allowedHostsRegexps = allowedHostsRegexps; + } + + _updatePopulationMap() { + const populationMap: {[server:string]:number} = {}; + for (const member of this._room.getJoinedMembers()) { + const serverName = getServerName(member.userId); + if (!populationMap[serverName]) { + populationMap[serverName] = 0; + } + populationMap[serverName]++; + } + this._populationMap = populationMap; + } + + _updateServerCandidates() { + let candidates = []; + if (this._highestPlUserId) { + candidates.push(getServerName(this._highestPlUserId)); + } + + const serversByPopulation = Object.keys(this._populationMap) + .sort((a, b) => this._populationMap[b] - this._populationMap[a]) + .filter(a => !candidates.includes(a) && !isHostnameIpAddress(a) + && !isHostInRegex(a, this._bannedHostsRegexps) && isHostInRegex(a, this._allowedHostsRegexps)); + + const remainingServers = serversByPopulation.slice(0, MAX_SERVER_CANDIDATES - candidates.length); + candidates = candidates.concat(remainingServers); + + this._serverCandidates = candidates; + } } + export function makeUserPermalink(userId) { return `${baseUrl}/#/${userId}`; } @@ -60,101 +254,8 @@ export function encodeServerCandidates(candidates) { return `?via=${candidates.map(c => encodeURIComponent(c)).join("&via=")}`; } -export function pickServerCandidates(roomId) { - const client = MatrixClientPeg.get(); - const room = client.getRoom(roomId); - if (!room) return []; - - // Permalinks can have servers appended to them so that the user - // receiving them can have a fighting chance at joining the room. - // These servers are called "candidates" at this point because - // it is unclear whether they are going to be useful to actually - // join in the future. - // - // We pick 3 servers based on the following criteria: - // - // Server 1: The highest power level user in the room, provided - // they are at least PL 50. We don't calculate "what is a moderator" - // here because it is less relevant for the vast majority of rooms. - // We also want to ensure that we get an admin or high-ranking mod - // as they are less likely to leave the room. If no user happens - // to meet this criteria, we'll pick the most popular server in the - // room. - // - // Server 2: The next most popular server in the room (in user - // distribution). This cannot be the same as Server 1. If no other - // servers are available then we'll only return Server 1. - // - // Server 3: The next most popular server by user distribution. This - // has the same rules as Server 2, with the added exception that it - // must be unique from Server 1 and 2. - - // Rationale for popular servers: It's hard to get rid of people when - // they keep flocking in from a particular server. Sure, the server could - // be ACL'd in the future or for some reason be evicted from the room - // however an event like that is unlikely the larger the room gets. If - // the server is ACL'd at the time of generating the link however, we - // shouldn't pick them. We also don't pick IP addresses. - - // Note: we don't pick the server the room was created on because the - // homeserver should already be using that server as a last ditch attempt - // and there's less of a guarantee that the server is a resident server. - // Instead, we actively figure out which servers are likely to be residents - // in the future and try to use those. - - // Note: Users receiving permalinks that happen to have all 3 potential - // servers fail them (in terms of joining) are somewhat expected to hunt - // down the person who gave them the link to ask for a participating server. - // The receiving user can then manually append the known-good server to - // the list and magically have the link work. - - const bannedHostsRegexps = []; - let allowedHostsRegexps = [new RegExp(".*")]; // default allow everyone - if (room.currentState) { - const aclEvent = room.currentState.getStateEvents("m.room.server_acl", ""); - if (aclEvent && aclEvent.getContent()) { - const getRegex = (hostname) => new RegExp("^" + utils.globToRegexp(hostname, false) + "$"); - - const denied = aclEvent.getContent().deny || []; - denied.forEach(h => bannedHostsRegexps.push(getRegex(h))); - - const allowed = aclEvent.getContent().allow || []; - allowedHostsRegexps = []; // we don't want to use the default rule here - allowed.forEach(h => allowedHostsRegexps.push(getRegex(h))); - } - } - - const populationMap: {[server:string]:number} = {}; - const highestPlUser = {userId: null, powerLevel: 0, serverName: null}; - - for (const member of room.getJoinedMembers()) { - const serverName = member.userId.split(":").splice(1).join(":"); - if (member.powerLevel > highestPlUser.powerLevel && !isHostnameIpAddress(serverName) - && !isHostInRegex(serverName, bannedHostsRegexps) && isHostInRegex(serverName, allowedHostsRegexps)) { - highestPlUser.userId = member.userId; - highestPlUser.powerLevel = member.powerLevel; - highestPlUser.serverName = serverName; - } - - if (!populationMap[serverName]) populationMap[serverName] = 0; - populationMap[serverName]++; - } - - const candidates = []; - if (highestPlUser.powerLevel >= 50) candidates.push(highestPlUser.serverName); - - const beforePopulation = candidates.length; - const serversByPopulation = Object.keys(populationMap) - .sort((a, b) => populationMap[b] - populationMap[a]) - .filter(a => !candidates.includes(a) && !isHostnameIpAddress(a) - && !isHostInRegex(a, bannedHostsRegexps) && isHostInRegex(a, allowedHostsRegexps)); - for (let i = beforePopulation; i < MAX_SERVER_CANDIDATES; i++) { - const idx = i - beforePopulation; - if (idx >= serversByPopulation.length) break; - candidates.push(serversByPopulation[idx]); - } - - return candidates; +function getServerName(userId) { + return userId.split(":").splice(1).join(":"); } function getHostnameFromMatrixDomain(domain) { From d3f0b609f593718c01432fd56797541c6754df6f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 21 Feb 2019 15:37:52 +0100 Subject: [PATCH 02/23] use URL instead of creating new a element --- src/matrix-to.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/matrix-to.js b/src/matrix-to.js index d997be12f91..c9040e04af3 100644 --- a/src/matrix-to.js +++ b/src/matrix-to.js @@ -260,12 +260,7 @@ function getServerName(userId) { function getHostnameFromMatrixDomain(domain) { if (!domain) return null; - - // The hostname might have a port, so we convert it to a URL and - // split out the real hostname. - const parser = document.createElement('a'); - parser.href = "https://" + domain; - return parser.hostname; + return new URL(`https://${domain}`).hostname; } function isHostInRegex(hostname, regexps) { From d4c6198fa31b2ee968bb92f5d929d303fc7d4062 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 21 Feb 2019 15:38:09 +0100 Subject: [PATCH 03/23] update copyright --- src/matrix-to.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix-to.js b/src/matrix-to.js index c9040e04af3..1c94a9e78cb 100644 --- a/src/matrix-to.js +++ b/src/matrix-to.js @@ -1,5 +1,5 @@ /* -Copyright 2017 New Vector Ltd +Copyright 2019 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From 8d929add1885d23c95a06c6ef096b6074c605778 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 21 Feb 2019 15:38:17 +0100 Subject: [PATCH 04/23] delegate to class when needing a roomId permalink --- src/matrix-to.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/matrix-to.js b/src/matrix-to.js index 1c94a9e78cb..b3fe10a215f 100644 --- a/src/matrix-to.js +++ b/src/matrix-to.js @@ -241,8 +241,11 @@ export function makeRoomPermalink(roomId) { // Aliases are already routable, and don't need extra information. if (roomId[0] !== '!') return permalinkBase; - const serverCandidates = pickServerCandidates(roomId); - return `${permalinkBase}${encodeServerCandidates(serverCandidates)}`; + const client = MatrixClientPeg.get(); + const room = client.getRoom(roomId); + const permaLinkCreator = new RoomPermaLinkCreator(room); + permaLinkCreator.load(); + return permaLinkCreator.forRoom(); } export function makeGroupPermalink(groupId) { From 29be28919da4dc4f4edb8aa3cab4924f63c01875 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 21 Feb 2019 15:44:20 +0100 Subject: [PATCH 05/23] create permalink creator in roomview and pass it on to the event tiles --- src/components/structures/MessagePanel.js | 1 + src/components/structures/RoomView.js | 13 +++++++++++++ src/components/structures/TimelinePanel.js | 1 + src/components/views/rooms/EventTile.js | 6 ++++-- src/components/views/rooms/SearchResultTile.js | 1 + src/matrix-to.js | 6 +++--- 6 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index dd3d92913c7..a6c43ecc8e3 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -525,6 +525,7 @@ module.exports = React.createClass({ eventSendStatus={mxEv.status} tileShape={this.props.tileShape} isTwelveHour={this.props.isTwelveHour} + permaLinkCreator={this.props.permaLinkCreator} last={last} isSelectedEvent={highlight} /> , ); diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 85f3afdfa59..54ef6be558f 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -30,6 +30,7 @@ import Promise from 'bluebird'; import filesize from 'filesize'; const classNames = require("classnames"); import { _t } from '../../languageHandler'; +import {RoomPermaLinkCreator} from "../../matrix-to"; const MatrixClientPeg = require("../../MatrixClientPeg"); const ContentMessages = require("../../ContentMessages"); @@ -441,6 +442,11 @@ module.exports = React.createClass({ RoomScrollStateStore.setScrollState(this.state.roomId, this._getScrollState()); } + // stop tracking room changes to format permalinks + if (this.state.permaLinkCreator) { + this.state.permaLinkCreator.stop(); + } + if (this.refs.roomView) { // disconnect the D&D event listeners from the room view. This // is really just for hygiene - we're going to be @@ -652,6 +658,11 @@ module.exports = React.createClass({ this._loadMembersIfJoined(room); this._calculateRecommendedVersion(room); this._updateE2EStatus(room); + if (!this.state.permaLinkCreator) { + const permaLinkCreator = new RoomPermaLinkCreator(room); + permaLinkCreator.start(); + this.setState({permaLinkCreator}); + } }, _calculateRecommendedVersion: async function(room) { @@ -1219,6 +1230,7 @@ module.exports = React.createClass({ searchResult={result} searchHighlights={this.state.searchHighlights} resultLink={resultLink} + permaLinkCreator={this.state.permaLinkCreator} onWidgetLoad={onWidgetLoad} />); } return ret; @@ -1826,6 +1838,7 @@ module.exports = React.createClass({ showUrlPreview = {this.state.showUrlPreview} className="mx_RoomView_messagePanel" membersLoaded={this.state.membersLoaded} + permaLinkCreator={this.state.permaLinkCreator} />); let topUnreadMessagesBar = null; diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 9fe83c2c2d0..1dc9162ef12 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -1202,6 +1202,7 @@ var TimelinePanel = React.createClass({ return (