diff --git a/src/matrix-to.js b/src/matrix-to.js index b750dff6d6..d997be12f9 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. - const serverCandidates = pickServerCandidates(roomId); - return `${permalinkBase}${encodeServerCandidates(serverCandidates)}`; +// 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); + } + + 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) {