diff --git a/karma.conf.js b/karma.conf.js index 4d699599cb..41ddbdf249 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -199,12 +199,25 @@ module.exports = function (config) { 'matrix-react-sdk': path.resolve('test/skinned-sdk.js'), 'sinon': 'sinon/pkg/sinon.js', + + // To make webpack happy + // Related: https://github.com/request/request/issues/1529 + // (there's no mock available for fs, so we fake a mock by using + // an in-memory version of fs) + "fs": "memfs", }, modules: [ path.resolve('./test'), "node_modules" ], }, + node: { + // Because webpack is made of fail + // https://github.com/request/request/issues/1529 + // Note: 'mock' is the new 'empty' + net: 'mock', + tls: 'mock' + }, devtool: 'inline-source-map', externals: { // Don't try to bundle electron: leave it as a commonjs dependency diff --git a/package.json b/package.json index dabaefe0ad..03311a50e3 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "lodash": "^4.13.1", "lolex": "2.3.2", "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", + "memfs": "^2.10.1", "optimist": "^0.6.1", "pako": "^1.0.5", "prop-types": "^15.5.8", diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 180a348434..d95d5cd652 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -64,6 +64,9 @@ const LoggedInView = React.createClass({ teamToken: PropTypes.string, + // Used by the RoomView to handle joining rooms + viaServers: PropTypes.arrayOf(PropTypes.string), + // and lots and lots of other stuff. }, @@ -389,6 +392,7 @@ const LoggedInView = React.createClass({ onRegistered={this.props.onRegistered} thirdPartyInvite={this.props.thirdPartyInvite} oobData={this.props.roomOobData} + viaServers={this.props.viaServers} eventPixelOffset={this.props.initialEventPixelOffset} key={this.props.currentRoomId || 'roomview'} disabled={this.props.middleDisabled} diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 75f2e400b1..f3f188df81 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -840,6 +840,7 @@ export default React.createClass({ page_type: PageTypes.RoomView, thirdPartyInvite: roomInfo.third_party_invite, roomOobData: roomInfo.oob_data, + viaServers: roomInfo.via_servers, }; if (roomInfo.room_alias) { @@ -1489,9 +1490,21 @@ export default React.createClass({ inviterName: params.inviter_name, }; + // on our URLs there might be a ?via=matrix.org or similar to help + // joins to the room succeed. We'll pass these through as an array + // to other levels. If there's just one ?via= then params.via is a + // single string. If someone does something like ?via=one.com&via=two.com + // then params.via is an array of strings. + let via = []; + if (params.via) { + if (typeof(params.via) === 'string') via = [params.via]; + else via = params.via; + } + const payload = { action: 'view_room', event_id: eventId, + via_servers: via, // If an event ID is given in the URL hash, notify RoomViewStore to mark // it as highlighted, which will propagate to RoomView and highlight the // associated EventTile. diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index e20ac54006..53644af78f 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -88,6 +88,9 @@ module.exports = React.createClass({ // is the RightPanel collapsed? collapsedRhs: PropTypes.bool, + + // Servers the RoomView can use to try and assist joins + viaServers: PropTypes.arrayOf(PropTypes.string), }, getInitialState: function() { @@ -833,7 +836,7 @@ module.exports = React.createClass({ action: 'do_after_sync_prepared', deferred_action: { action: 'join_room', - opts: { inviteSignUrl: signUrl }, + opts: { inviteSignUrl: signUrl, viaServers: this.props.viaServers }, }, }); @@ -875,7 +878,7 @@ module.exports = React.createClass({ this.props.thirdPartyInvite.inviteSignUrl : undefined; dis.dispatch({ action: 'join_room', - opts: { inviteSignUrl: signUrl }, + opts: { inviteSignUrl: signUrl, viaServers: this.props.viaServers }, }); return Promise.resolve(); }); diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index f32026511b..772eb70dde 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -1298,7 +1298,7 @@ module.exports = React.createClass({ // If the olmVersion is not defined then either crypto is disabled, or // we are using a version old version of olm. We assume the former. let olmVersionString = ""; - if (olmVersion !== undefined) { + if (olmVersion) { olmVersionString = `${olmVersion[0]}.${olmVersion[1]}.${olmVersion[2]}`; } diff --git a/src/matrix-to.js b/src/matrix-to.js index 90b0a66090..944446c4cc 100644 --- a/src/matrix-to.js +++ b/src/matrix-to.js @@ -14,11 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ +import MatrixClientPeg from "./MatrixClientPeg"; + export const host = "matrix.to"; export const baseUrl = `https://${host}`; +// The maximum number of servers to pick when working out which servers +// to add to permalinks. The servers are appended as ?via=example.org +const MAX_SERVER_CANDIDATES = 3; + export function makeEventPermalink(roomId, eventId) { - return `${baseUrl}/#/${roomId}/${eventId}`; + const serverCandidates = pickServerCandidates(roomId); + return `${baseUrl}/#/${roomId}/${eventId}?${encodeServerCandidates(serverCandidates)}`; } export function makeUserPermalink(userId) { @@ -26,9 +33,92 @@ export function makeUserPermalink(userId) { } export function makeRoomPermalink(roomId) { - return `${baseUrl}/#/${roomId}`; + const serverCandidates = pickServerCandidates(roomId); + return `${baseUrl}/#/${roomId}?${encodeServerCandidates(serverCandidates)}`; } export function makeGroupPermalink(groupId) { return `${baseUrl}/#/${groupId}`; } + +export function encodeServerCandidates(candidates) { + if (!candidates) return ''; + 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. + + // 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 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) { + 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)); + for (let i = beforePopulation; i <= MAX_SERVER_CANDIDATES; i++) { + const idx = i - beforePopulation; + if (idx >= serversByPopulation.length) break; + candidates.push(serversByPopulation[idx]); + } + + return candidates; +} diff --git a/test/matrix-to-test.js b/test/matrix-to-test.js new file mode 100644 index 0000000000..d199588b9a --- /dev/null +++ b/test/matrix-to-test.js @@ -0,0 +1,231 @@ +/* +Copyright 2018 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. +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 expect from 'expect'; +import peg from '../src/MatrixClientPeg'; +import {pickServerCandidates} from "../src/matrix-to"; +import * as testUtils from "./test-utils"; + + +describe('matrix-to', function() { + let sandbox; + + beforeEach(function() { + testUtils.beforeEach(this); + sandbox = testUtils.stubClient(); + peg.get().credentials = { userId: "@test:example.com" }; + }); + + afterEach(function() { + sandbox.restore(); + }); + + it('should pick no candidate servers when the room is not found', function() { + peg.get().getRoom = () => null; + const pickedServers = pickServerCandidates("!somewhere:example.org"); + expect(pickedServers).toExist(); + expect(pickedServers.length).toBe(0); + }); + + it('should pick no candidate servers when the room has no members', function() { + peg.get().getRoom = () => { + return { + getJoinedMembers: () => [], + }; + }; + const pickedServers = pickServerCandidates("!somewhere:example.org"); + expect(pickedServers).toExist(); + expect(pickedServers.length).toBe(0); + }); + + it('should pick a candidate server for the highest power level user in the room', function() { + peg.get().getRoom = () => { + return { + getJoinedMembers: () => [ + { + userId: "@alice:pl_50", + powerLevel: 50, + }, + { + userId: "@alice:pl_75", + powerLevel: 75, + }, + { + userId: "@alice:pl_95", + powerLevel: 95, + }, + ], + }; + }; + const pickedServers = pickServerCandidates("!somewhere:example.org"); + expect(pickedServers).toExist(); + expect(pickedServers.length).toBe(3); + expect(pickedServers[0]).toBe("pl_95"); + // we don't check the 2nd and 3rd servers because that is done by the next test + }); + + it('should pick candidate servers based on user population', function() { + peg.get().getRoom = () => { + return { + getJoinedMembers: () => [ + { + userId: "@alice:first", + powerLevel: 0, + }, + { + userId: "@bob:first", + powerLevel: 0, + }, + { + userId: "@charlie:first", + powerLevel: 0, + }, + { + userId: "@alice:second", + powerLevel: 0, + }, + { + userId: "@bob:second", + powerLevel: 0, + }, + { + userId: "@charlie:third", + powerLevel: 0, + }, + ], + }; + }; + const pickedServers = pickServerCandidates("!somewhere:example.org"); + expect(pickedServers).toExist(); + expect(pickedServers.length).toBe(3); + expect(pickedServers[0]).toBe("first"); + expect(pickedServers[1]).toBe("second"); + expect(pickedServers[2]).toBe("third"); + }); + + it('should pick prefer candidate servers with higher power levels', function() { + peg.get().getRoom = () => { + return { + getJoinedMembers: () => [ + { + userId: "@alice:first", + powerLevel: 100, + }, + { + userId: "@alice:second", + powerLevel: 0, + }, + { + userId: "@bob:second", + powerLevel: 0, + }, + { + userId: "@charlie:third", + powerLevel: 0, + }, + ], + }; + }; + const pickedServers = pickServerCandidates("!somewhere:example.org"); + expect(pickedServers).toExist(); + expect(pickedServers.length).toBe(3); + expect(pickedServers[0]).toBe("first"); + expect(pickedServers[1]).toBe("second"); + expect(pickedServers[2]).toBe("third"); + }); + + it('should work with IPv4 hostnames', function() { + peg.get().getRoom = () => { + return { + getJoinedMembers: () => [ + { + userId: "@alice:127.0.0.1", + powerLevel: 100, + }, + ], + }; + }; + const pickedServers = pickServerCandidates("!somewhere:example.org"); + expect(pickedServers).toExist(); + expect(pickedServers.length).toBe(1); + expect(pickedServers[0]).toBe("127.0.0.1"); + }); + + it('should work with IPv6 hostnames', function() { + peg.get().getRoom = () => { + return { + getJoinedMembers: () => [ + { + userId: "@alice:[::1]", + powerLevel: 100, + }, + ], + }; + }; + const pickedServers = pickServerCandidates("!somewhere:example.org"); + expect(pickedServers).toExist(); + expect(pickedServers.length).toBe(1); + expect(pickedServers[0]).toBe("[::1]"); + }); + + it('should work with IPv4 hostnames with ports', function() { + peg.get().getRoom = () => { + return { + getJoinedMembers: () => [ + { + userId: "@alice:127.0.0.1:8448", + powerLevel: 100, + }, + ], + }; + }; + const pickedServers = pickServerCandidates("!somewhere:example.org"); + expect(pickedServers).toExist(); + expect(pickedServers.length).toBe(1); + expect(pickedServers[0]).toBe("127.0.0.1:8448"); + }); + + it('should work with IPv6 hostnames with ports', function() { + peg.get().getRoom = () => { + return { + getJoinedMembers: () => [ + { + userId: "@alice:[::1]:8448", + powerLevel: 100, + }, + ], + }; + }; + const pickedServers = pickServerCandidates("!somewhere:example.org"); + expect(pickedServers).toExist(); + expect(pickedServers.length).toBe(1); + expect(pickedServers[0]).toBe("[::1]:8448"); + }); + + it('should work with hostnames with ports', function() { + peg.get().getRoom = () => { + return { + getJoinedMembers: () => [ + { + userId: "@alice:example.org:8448", + powerLevel: 100, + }, + ], + }; + }; + const pickedServers = pickServerCandidates("!somewhere:example.org"); + expect(pickedServers).toExist(); + expect(pickedServers.length).toBe(1); + expect(pickedServers[0]).toBe("example.org:8448"); + }); +});