Merge pull request #2250 from matrix-org/travis/permalink-routing

Support routing matrix.to links to joinable rooms
This commit is contained in:
Travis Ralston 2018-10-26 14:23:30 -06:00 committed by GitHub
commit 0bd1d6b778
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 360 additions and 5 deletions

View File

@ -199,12 +199,25 @@ module.exports = function (config) {
'matrix-react-sdk': path.resolve('test/skinned-sdk.js'), 'matrix-react-sdk': path.resolve('test/skinned-sdk.js'),
'sinon': 'sinon/pkg/sinon.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: [ modules: [
path.resolve('./test'), path.resolve('./test'),
"node_modules" "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', devtool: 'inline-source-map',
externals: { externals: {
// Don't try to bundle electron: leave it as a commonjs dependency // Don't try to bundle electron: leave it as a commonjs dependency

View File

@ -76,6 +76,7 @@
"lodash": "^4.13.1", "lodash": "^4.13.1",
"lolex": "2.3.2", "lolex": "2.3.2",
"matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop",
"memfs": "^2.10.1",
"optimist": "^0.6.1", "optimist": "^0.6.1",
"pako": "^1.0.5", "pako": "^1.0.5",
"prop-types": "^15.5.8", "prop-types": "^15.5.8",

View File

@ -64,6 +64,9 @@ const LoggedInView = React.createClass({
teamToken: PropTypes.string, teamToken: PropTypes.string,
// Used by the RoomView to handle joining rooms
viaServers: PropTypes.arrayOf(PropTypes.string),
// and lots and lots of other stuff. // and lots and lots of other stuff.
}, },
@ -389,6 +392,7 @@ const LoggedInView = React.createClass({
onRegistered={this.props.onRegistered} onRegistered={this.props.onRegistered}
thirdPartyInvite={this.props.thirdPartyInvite} thirdPartyInvite={this.props.thirdPartyInvite}
oobData={this.props.roomOobData} oobData={this.props.roomOobData}
viaServers={this.props.viaServers}
eventPixelOffset={this.props.initialEventPixelOffset} eventPixelOffset={this.props.initialEventPixelOffset}
key={this.props.currentRoomId || 'roomview'} key={this.props.currentRoomId || 'roomview'}
disabled={this.props.middleDisabled} disabled={this.props.middleDisabled}

View File

@ -840,6 +840,7 @@ export default React.createClass({
page_type: PageTypes.RoomView, page_type: PageTypes.RoomView,
thirdPartyInvite: roomInfo.third_party_invite, thirdPartyInvite: roomInfo.third_party_invite,
roomOobData: roomInfo.oob_data, roomOobData: roomInfo.oob_data,
viaServers: roomInfo.via_servers,
}; };
if (roomInfo.room_alias) { if (roomInfo.room_alias) {
@ -1489,9 +1490,21 @@ export default React.createClass({
inviterName: params.inviter_name, 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 = { const payload = {
action: 'view_room', action: 'view_room',
event_id: eventId, event_id: eventId,
via_servers: via,
// If an event ID is given in the URL hash, notify RoomViewStore to mark // 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 // it as highlighted, which will propagate to RoomView and highlight the
// associated EventTile. // associated EventTile.

View File

@ -88,6 +88,9 @@ module.exports = React.createClass({
// is the RightPanel collapsed? // is the RightPanel collapsed?
collapsedRhs: PropTypes.bool, collapsedRhs: PropTypes.bool,
// Servers the RoomView can use to try and assist joins
viaServers: PropTypes.arrayOf(PropTypes.string),
}, },
getInitialState: function() { getInitialState: function() {
@ -833,7 +836,7 @@ module.exports = React.createClass({
action: 'do_after_sync_prepared', action: 'do_after_sync_prepared',
deferred_action: { deferred_action: {
action: 'join_room', 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; this.props.thirdPartyInvite.inviteSignUrl : undefined;
dis.dispatch({ dis.dispatch({
action: 'join_room', action: 'join_room',
opts: { inviteSignUrl: signUrl }, opts: { inviteSignUrl: signUrl, viaServers: this.props.viaServers },
}); });
return Promise.resolve(); return Promise.resolve();
}); });

View File

@ -1298,7 +1298,7 @@ module.exports = React.createClass({
// If the olmVersion is not defined then either crypto is disabled, or // 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. // we are using a version old version of olm. We assume the former.
let olmVersionString = "<not-enabled>"; let olmVersionString = "<not-enabled>";
if (olmVersion !== undefined) { if (olmVersion) {
olmVersionString = `${olmVersion[0]}.${olmVersion[1]}.${olmVersion[2]}`; olmVersionString = `${olmVersion[0]}.${olmVersion[1]}.${olmVersion[2]}`;
} }

View File

@ -14,11 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import MatrixClientPeg from "./MatrixClientPeg";
export const host = "matrix.to"; export const host = "matrix.to";
export const baseUrl = `https://${host}`; 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) { export function makeEventPermalink(roomId, eventId) {
return `${baseUrl}/#/${roomId}/${eventId}`; const serverCandidates = pickServerCandidates(roomId);
return `${baseUrl}/#/${roomId}/${eventId}?${encodeServerCandidates(serverCandidates)}`;
} }
export function makeUserPermalink(userId) { export function makeUserPermalink(userId) {
@ -26,9 +33,92 @@ export function makeUserPermalink(userId) {
} }
export function makeRoomPermalink(roomId) { export function makeRoomPermalink(roomId) {
return `${baseUrl}/#/${roomId}`; const serverCandidates = pickServerCandidates(roomId);
return `${baseUrl}/#/${roomId}?${encodeServerCandidates(serverCandidates)}`;
} }
export function makeGroupPermalink(groupId) { export function makeGroupPermalink(groupId) {
return `${baseUrl}/#/${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;
}

231
test/matrix-to-test.js Normal file
View File

@ -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");
});
});