diff --git a/services/matrix/matrix.service.js b/services/matrix/matrix.service.js index a6199a35ec697..4c9b78114ede1 100644 --- a/services/matrix/matrix.service.js +++ b/services/matrix/matrix.service.js @@ -2,11 +2,20 @@ const Joi = require('joi') const BaseJsonService = require('../base-json') +const errors = require('../errors') + +const queryParamSchema = Joi.object({ + server_fqdn: Joi.string().hostname(), +}).required() const matrixRegisterSchema = Joi.object({ access_token: Joi.string().required(), }).required() +const matrixAliasLookupSchema = Joi.object({ + room_id: Joi.string().required(), +}) + const matrixStateSchema = Joi.array() .items( Joi.object({ @@ -31,15 +40,36 @@ const documentation = ` +
+ Some Matrix homeservers don't hold a server name matching where they live (e.g. if the homeserver example.com that created the room alias #mysuperroom:example.com lives at matrix.example.com). +
+ If that is the case of the homeserver that created the room alias used for generating the badge, you will need to add the server's FQDN (fully qualified domain name) as a query parameter. +
+ The final badge URL should then look something like this /matrix/mysuperroom:example.com.svg?server_fqdn=matrix.example.com.

` module.exports = class Matrix extends BaseJsonService { + async retrieveAccessToken({ host }) { + let auth + try { + auth = await this.registerAccount({ host, guest: true }) + } catch (e) { + if (e.prettyMessage === 'guests not allowed') { + // attempt fallback method + auth = await this.registerAccount({ host, guest: false }) + } else throw e + } + + return auth.access_token + } + async registerAccount({ host, guest }) { return this._requestJson({ url: `https://${host}/_matrix/client/r0/register`, @@ -59,27 +89,59 @@ module.exports = class Matrix extends BaseJsonService { errorMessages: { 401: 'auth failed', 403: 'guests not allowed', - 429: 'rate limited by rooms host', + 429: 'rate limited by remote server', }, }) } - async fetch({ host, roomId }) { - let auth - try { - auth = await this.registerAccount({ host, guest: true }) - } catch (e) { - if (e.prettyMessage === 'guests not allowed') { - // attempt fallback method - auth = await this.registerAccount({ host, guest: false }) - } else throw e + async lookupRoomAlias({ host, roomAlias, accessToken }) { + return this._requestJson({ + url: `https://${host}/_matrix/client/r0/directory/room/${encodeURIComponent( + `#${roomAlias}` + )}`, + schema: matrixAliasLookupSchema, + options: { + qs: { + access_token: accessToken, + }, + }, + errorMessages: { + 401: 'bad auth token', + 404: 'room not found', + 429: 'rate limited by remote server', + }, + }) + } + + async fetch({ roomAlias, serverFQDN }) { + let host + if (serverFQDN === undefined) { + const splitAlias = roomAlias.split(':') + // A room alias can either be in the form #localpart:server or + // #localpart:server:port. + switch (splitAlias.length) { + case 2: + host = splitAlias[1] + break + case 3: + host = `${splitAlias[1]}:${splitAlias[2]}` + break + default: + throw new errors.InvalidParameter({ prettyMessage: 'invalid alias' }) + } + } else { + host = serverFQDN } + const accessToken = await this.retrieveAccessToken({ host }) + const lookup = await this.lookupRoomAlias({ host, roomAlias, accessToken }) const data = await this._requestJson({ - url: `https://${host}/_matrix/client/r0/rooms/${roomId}/state`, + url: `https://${host}/_matrix/client/r0/rooms/${encodeURIComponent( + lookup.room_id + )}/state`, schema: matrixStateSchema, options: { qs: { - access_token: auth.access_token, + access_token: accessToken, }, }, errorMessages: { @@ -109,11 +171,12 @@ module.exports = class Matrix extends BaseJsonService { } } - async handle({ roomId, host, authServer }) { - const members = await this.fetch({ - host, - roomId: `${roomId}:${host}`, - }) + async handle({ roomAlias }, queryParams) { + const { server_fqdn: serverFQDN } = this.constructor._validateQueryParams( + queryParams, + queryParamSchema + ) + const members = await this.fetch({ roomAlias, serverFQDN }) return this.constructor.render({ members }) } @@ -128,8 +191,9 @@ module.exports = class Matrix extends BaseJsonService { static get route() { return { base: 'matrix', - format: '([^/]+)/([^/]+)', - capture: ['roomId', 'host'], + format: '([^/]+)', + capture: ['roomAlias'], + queryParams: ['server_fqdn'], } } @@ -137,8 +201,16 @@ module.exports = class Matrix extends BaseJsonService { return [ { title: 'Matrix', - exampleUrl: '!ltIjvaLydYAWZyihee/matrix.org', - pattern: ':roomId/:host', + namedParams: { roomAlias: 'twim:matrix.org' }, + pattern: ':roomAlias', + staticExample: this.render({ members: 42 }), + documentation, + }, + { + title: 'Matrix', + namedParams: { roomAlias: 'twim:matrix.org' }, + queryParams: { server_fqdn: 'matrix.org' }, + pattern: ':roomAlias', staticExample: this.render({ members: 42 }), documentation, }, diff --git a/services/matrix/matrix.tester.js b/services/matrix/matrix.tester.js index 225b452577840..1bbf5915a597a 100644 --- a/services/matrix/matrix.tester.js +++ b/services/matrix/matrix.tester.js @@ -1,14 +1,12 @@ 'use strict' const Joi = require('joi') -const ServiceTester = require('../service-tester') const { colorScheme } = require('../test-helpers') -const t = new ServiceTester({ id: 'matrix', title: 'Matrix' }) -module.exports = t +const t = (module.exports = require('../create-service-tester')()) t.create('get room state as guest') - .get('/ROOM/DUMMY.dumb.json?style=_shields_test') + .get('/ALIAS:DUMMY.dumb.json?style=_shields_test') .intercept(nock => nock('https://DUMMY.dumb/') .post('/_matrix/client/r0/register?kind=guest') @@ -18,7 +16,18 @@ t.create('get room state as guest') access_token: 'TOKEN', }) ) - .get('/_matrix/client/r0/rooms/ROOM:DUMMY.dumb/state?access_token=TOKEN') + .get( + '/_matrix/client/r0/directory/room/%23ALIAS%3ADUMMY.dumb?access_token=TOKEN' + ) + .reply( + 200, + JSON.stringify({ + room_id: 'ROOM:DUMMY.dumb', + }) + ) + .get( + '/_matrix/client/r0/rooms/ROOM%3ADUMMY.dumb/state?access_token=TOKEN' + ) .reply( 200, JSON.stringify([ @@ -68,14 +77,14 @@ t.create('get room state as guest') }) t.create('get room state as member (backup method)') - .get('/ROOM/DUMMY.dumb.json?style=_shields_test') + .get('/ALIAS:DUMMY.dumb.json?style=_shields_test') .intercept(nock => nock('https://DUMMY.dumb/') .post('/_matrix/client/r0/register?kind=guest') .reply( 403, JSON.stringify({ - errcode: 'M_GUEST_ACCESS_FORBIDDEN', // i think this is the right one + errcode: 'M_GUEST_ACCESS_FORBIDDEN', error: 'Guest access not allowed', }) ) @@ -86,7 +95,18 @@ t.create('get room state as member (backup method)') access_token: 'TOKEN', }) ) - .get('/_matrix/client/r0/rooms/ROOM:DUMMY.dumb/state?access_token=TOKEN') + .get( + '/_matrix/client/r0/directory/room/%23ALIAS%3ADUMMY.dumb?access_token=TOKEN' + ) + .reply( + 200, + JSON.stringify({ + room_id: 'ROOM:DUMMY.dumb', + }) + ) + .get( + '/_matrix/client/r0/rooms/ROOM%3ADUMMY.dumb/state?access_token=TOKEN' + ) .reply( 200, JSON.stringify([ @@ -136,7 +156,7 @@ t.create('get room state as member (backup method)') }) t.create('bad server or connection') - .get('/ROOM/DUMMY.dumb.json?style=_shields_test') + .get('/ALIAS:DUMMY.dumb.json?style=_shields_test') .networkOff() .expectJSON({ name: 'chat', @@ -144,8 +164,8 @@ t.create('bad server or connection') colorB: colorScheme.lightgray, }) -t.create('invalid room') - .get('/ROOM/DUMMY.dumb.json?style=_shields_test') +t.create('non-world readable room') + .get('/ALIAS:DUMMY.dumb.json?style=_shields_test') .intercept(nock => nock('https://DUMMY.dumb/') .post('/_matrix/client/r0/register?kind=guest') @@ -155,7 +175,18 @@ t.create('invalid room') access_token: 'TOKEN', }) ) - .get('/_matrix/client/r0/rooms/ROOM:DUMMY.dumb/state?access_token=TOKEN') + .get( + '/_matrix/client/r0/directory/room/%23ALIAS%3ADUMMY.dumb?access_token=TOKEN' + ) + .reply( + 200, + JSON.stringify({ + room_id: 'ROOM:DUMMY.dumb', + }) + ) + .get( + '/_matrix/client/r0/rooms/ROOM%3ADUMMY.dumb/state?access_token=TOKEN' + ) .reply( 403, JSON.stringify({ @@ -171,7 +202,7 @@ t.create('invalid room') }) t.create('invalid token') - .get('/ROOM/DUMMY.dumb.json?style=_shields_test') + .get('/ALIAS:DUMMY.dumb.json?style=_shields_test') .intercept(nock => nock('https://DUMMY.dumb/') .post('/_matrix/client/r0/register?kind=guest') @@ -181,7 +212,9 @@ t.create('invalid token') access_token: 'TOKEN', }) ) - .get('/_matrix/client/r0/rooms/ROOM:DUMMY.dumb/state?access_token=TOKEN') + .get( + '/_matrix/client/r0/directory/room/%23ALIAS%3ADUMMY.dumb?access_token=TOKEN' + ) .reply( 401, JSON.stringify({ @@ -197,7 +230,7 @@ t.create('invalid token') }) t.create('unknown request') - .get('/ROOM/DUMMY.dumb.json?style=_shields_test') + .get('/ALIAS:DUMMY.dumb.json?style=_shields_test') .intercept(nock => nock('https://DUMMY.dumb/') .post('/_matrix/client/r0/register?kind=guest') @@ -207,7 +240,18 @@ t.create('unknown request') access_token: 'TOKEN', }) ) - .get('/_matrix/client/r0/rooms/ROOM:DUMMY.dumb/state?access_token=TOKEN') + .get( + '/_matrix/client/r0/directory/room/%23ALIAS%3ADUMMY.dumb?access_token=TOKEN' + ) + .reply( + 200, + JSON.stringify({ + room_id: 'ROOM:DUMMY.dumb', + }) + ) + .get( + '/_matrix/client/r0/rooms/ROOM%3ADUMMY.dumb/state?access_token=TOKEN' + ) .reply( 400, JSON.stringify({ @@ -222,8 +266,189 @@ t.create('unknown request') colorB: colorScheme.lightgray, }) +t.create('unknown alias') + .get('/ALIAS:DUMMY.dumb.json?style=_shields_test') + .intercept(nock => + nock('https://DUMMY.dumb/') + .post('/_matrix/client/r0/register?kind=guest') + .reply( + 200, + JSON.stringify({ + access_token: 'TOKEN', + }) + ) + .get( + '/_matrix/client/r0/directory/room/%23ALIAS%3ADUMMY.dumb?access_token=TOKEN' + ) + .reply( + 404, + JSON.stringify({ + errcode: 'M_NOT_FOUND', + error: 'Room alias #ALIAS%3ADUMMY.dumb not found.', + }) + ) + ) + .expectJSON({ + name: 'chat', + value: 'room not found', + colorB: colorScheme.red, + }) + +t.create('invalid alias') + .get('/ALIASDUMMY.dumb.json?style=_shields_test') + .expectJSON({ + name: 'chat', + value: 'invalid alias', + colorB: colorScheme.red, + }) + +t.create('server uses a custom port') + .get('/ALIAS:DUMMY.dumb:5555.json?style=_shields_test') + .intercept(nock => + nock('https://DUMMY.dumb:5555/') + .post('/_matrix/client/r0/register?kind=guest') + .reply( + 200, + JSON.stringify({ + access_token: 'TOKEN', + }) + ) + .get( + '/_matrix/client/r0/directory/room/%23ALIAS%3ADUMMY.dumb%3A5555?access_token=TOKEN' + ) + .reply( + 200, + JSON.stringify({ + room_id: 'ROOM:DUMMY.dumb:5555', + }) + ) + .get( + '/_matrix/client/r0/rooms/ROOM%3ADUMMY.dumb%3A5555/state?access_token=TOKEN' + ) + .reply( + 200, + JSON.stringify([ + { + // valid user 1 + type: 'm.room.member', + sender: '@user1:DUMMY.dumb:5555', + state_key: '@user1:DUMMY.dumb:5555', + content: { + membership: 'join', + }, + }, + { + // valid user 2 + type: 'm.room.member', + sender: '@user2:DUMMY.dumb:5555', + state_key: '@user2:DUMMY.dumb:5555', + content: { + membership: 'join', + }, + }, + { + // should exclude banned/invited/left members + type: 'm.room.member', + sender: '@user3:DUMMY.dumb:5555', + state_key: '@user3:DUMMY.dumb:5555', + content: { + membership: 'leave', + }, + }, + { + // exclude events like the room name + type: 'm.room.name', + sender: '@user4:DUMMY.dumb:5555', + state_key: '@user4:DUMMY.dumb:5555', + content: { + membership: 'fake room', + }, + }, + ]) + ) + ) + .expectJSON({ + name: 'chat', + value: '2 users', + colorB: colorScheme.brightgreen, + }) + +t.create('specify the homeserver fqdn') + .get( + '/ALIAS:DUMMY.dumb.json?style=_shields_test&server_fqdn=matrix.DUMMY.dumb' + ) + .intercept(nock => + nock('https://matrix.DUMMY.dumb/') + .post('/_matrix/client/r0/register?kind=guest') + .reply( + 200, + JSON.stringify({ + access_token: 'TOKEN', + }) + ) + .get( + '/_matrix/client/r0/directory/room/%23ALIAS%3ADUMMY.dumb?access_token=TOKEN' + ) + .reply( + 200, + JSON.stringify({ + room_id: 'ROOM:DUMMY.dumb', + }) + ) + .get( + '/_matrix/client/r0/rooms/ROOM%3ADUMMY.dumb/state?access_token=TOKEN' + ) + .reply( + 200, + JSON.stringify([ + { + // valid user 1 + type: 'm.room.member', + sender: '@user1:DUMMY.dumb', + state_key: '@user1:DUMMY.dumb', + content: { + membership: 'join', + }, + }, + { + // valid user 2 + type: 'm.room.member', + sender: '@user2:DUMMY.dumb', + state_key: '@user2:DUMMY.dumb', + content: { + membership: 'join', + }, + }, + { + // should exclude banned/invited/left members + type: 'm.room.member', + sender: '@user3:DUMMY.dumb', + state_key: '@user3:DUMMY.dumb', + content: { + membership: 'leave', + }, + }, + { + // exclude events like the room name + type: 'm.room.name', + sender: '@user4:DUMMY.dumb', + state_key: '@user4:DUMMY.dumb', + content: { + membership: 'fake room', + }, + }, + ]) + ) + ) + .expectJSON({ + name: 'chat', + value: '2 users', + colorB: colorScheme.brightgreen, + }) + t.create('test on real matrix room for API compliance') - .get('/!ltIjvaLydYAWZyihee/matrix.org.json?style=_shields_test') + .get('/twim:matrix.org.json?style=_shields_test') + .timeout(10000) .expectJSONTypes( Joi.object().keys({ name: 'chat',